Skip to content

Commit d2a5302

Browse files
committed
New methods to compare ETFs with index (benchmarks) in AssetList:
- tracking difference - tracking difference annualized - tracking error - index correlation - index beta
1 parent 87e4140 commit d2a5302

File tree

5 files changed

+190
-21
lines changed

5 files changed

+190
-21
lines changed

okama/assets.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import numpy as np
55

66
from .macro import Inflation
7-
from .helpers import Float, Frame, Rebalance, Date
7+
from .helpers import Float, Frame, Rebalance, Date, Index
88
from .settings import default_ticker, assets_namespaces
99
from .data import QueryData
1010

@@ -215,6 +215,10 @@ def currency(self):
215215

216216
@property
217217
def wealth_indexes(self) -> pd.DataFrame:
218+
"""
219+
Wealth index time series for the assets and accumulated inflation.
220+
Wealth index is obtained from the accumulated return multiplicated by the initial investments (1000).
221+
"""
218222
if hasattr(self, 'inflation'):
219223
df = pd.concat([self.ror, self.inflation_ts], axis=1, join='inner', copy='false')
220224
else:
@@ -239,25 +243,42 @@ def risk_annual(self) -> pd.Series:
239243

240244
@property
241245
def semideviation_monthly(self) -> pd.Series:
246+
"""
247+
Returns semideviation monthly values for each asset (full period).
248+
"""
242249
return Frame.get_semideviation(self.ror)
243250

244251
@property
245252
def semideviation_annual(self) -> float:
253+
"""
254+
Returns semideviation annual values for each asset (full period).
255+
"""
246256
return Frame.get_semideviation(self.returns_ts) * 12 ** 0.5
247257

248258
def get_var_historic(self, level: int = 5) -> pd.Series:
259+
"""
260+
Calculates historic VAR for the assets (full period).
261+
VAR levels could be set by level attribute (integer).
262+
"""
249263
return Frame.get_var_historic(self.ror, level)
250264

251265
def get_cvar_historic(self, level: int = 5) -> pd.Series:
266+
"""
267+
Calculates historic CVAR for the assets (full period).
268+
CVAR levels could be set by level attribute (integer).
269+
"""
252270
return Frame.get_cvar_historic(self.ror, level)
253271

254272
@property
255273
def drawdowns(self) -> pd.DataFrame:
274+
"""
275+
Calculates drawdowns time series for the assets.
276+
"""
256277
return Frame.get_drawdowns(self.ror)
257278

258279
def get_cagr(self, period: Union[str, int, None] = None) -> pd.Series:
259280
"""
260-
Calculates Compound Annual Growth Rate for a given period:
281+
Calculates Compound Annual Growth Rate (CAGR) for a given period:
261282
None: full time
262283
'YTD': Year To Date compound rate of return (formally not a CAGR)
263284
Integer: several years
@@ -286,6 +307,9 @@ def get_cagr(self, period: Union[str, int, None] = None) -> pd.Series:
286307

287308
@property
288309
def annual_return_ts(self) -> pd.DataFrame:
310+
"""
311+
Calculates annual rate of return time series for the assets.
312+
"""
289313
return Frame.get_annual_return_ts_from_monthly(self.ror)
290314

291315
def describe(self, years: tuple = (1, 5, 10), tickers: bool = True) -> pd.DataFrame:
@@ -394,6 +418,9 @@ def describe(self, years: tuple = (1, 5, 10), tickers: bool = True) -> pd.DataFr
394418

395419
@property
396420
def mean_return(self) -> pd.Series:
421+
"""
422+
Calculates mean return (arithmetic mean) for the assets.
423+
"""
397424
if hasattr(self, 'inflation'):
398425
df = pd.concat([self.ror, self.inflation_ts], axis=1, join='inner', copy='false')
399426
else:
@@ -404,7 +431,7 @@ def mean_return(self) -> pd.Series:
404431
@property
405432
def real_mean_return(self) -> pd.Series:
406433
"""
407-
Calculates real mean return (arithmetic mean).
434+
Calculates real mean return (arithmetic mean) for the assets.
408435
"""
409436
if hasattr(self, 'inflation'):
410437
df = pd.concat([self.ror, self.inflation_ts], axis=1, join='inner', copy='false')
@@ -533,6 +560,53 @@ def get_dividend_mean_growth_rate(self, period=5) -> pd.Series:
533560
raise TypeError(f'{period} is not a valid value for period')
534561
return mean_growth_rate
535562

563+
# index methods
564+
@property
565+
def tracking_difference(self):
566+
"""
567+
Returns tracking difference for the rate of return of assets.
568+
Assets are compared with the index or another benchmark.
569+
Index should be in the first position (first column).
570+
"""
571+
accumulated_return = Frame.get_wealth_indexes(self.ror) # we don't need inflation here
572+
return Index.tracking_difference(accumulated_return)
573+
574+
@property
575+
def tracking_difference_annualized(self):
576+
"""
577+
Annualizes the values of tracking difference time series.
578+
Annual values are available for periods of more than 12 months.
579+
Returns for less than 12 months can't be annualized.
580+
"""
581+
return Index.tracking_difference_annualized(self.tracking_difference)
582+
583+
@property
584+
def tracking_error(self):
585+
"""
586+
Returns tracking error for the rate of return time series of assets.
587+
Assets are compared with the index or another benchmark.
588+
Index should be in the first position (first column).
589+
"""
590+
return Index.tracking_error(self.ror)
591+
592+
@property
593+
def index_corr(self):
594+
"""
595+
Returns the accumulated correlation with the index (or benchmark) time series for the assets.
596+
Index should be in the first position (first column).
597+
The period should be at least 12 months.
598+
"""
599+
return Index.cov_cor(self.ror, fn='corr')
600+
601+
@property
602+
def index_beta(self):
603+
"""
604+
Returns beta coefficient time series for the assets.
605+
Index (or benchmark) should be in the first position (first column).
606+
The period should be at least 12 months.
607+
"""
608+
return Index.beta(self.ror)
609+
536610

537611
class Portfolio:
538612
"""

okama/frontier_reb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ def gmv_annual_values(self) -> Tuple[float]:
163163
@property
164164
def max_return(self) -> dict:
165165
"""
166-
Returns the weights and risk / CAGR of the maximum return portfolio.
166+
Returns the weights and risk / CAGR of the maximum return portfolio point.
167167
"""
168168
ror = self.ror
169169
period = self.reb_period

okama/helpers.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ def get_cvar_historic(ror: Union[pd.DataFrame, pd.Series], level: int = 5) -> Un
226226
"""
227227
Computes the Conditional VaR (CVaR) of Series or DataFrame at a specified level.
228228
"""
229+
if not isinstance(level, int):
230+
raise TypeError("Level should be an integer.")
229231
if isinstance(ror, pd.Series) or isinstance(ror, pd.DataFrame):
230232
is_beyond = ror <= ror.quantile(level / 100) # mask: return is less than quantile
231233
return -ror[is_beyond].mean()
@@ -303,6 +305,25 @@ def rebalanced_portfolio_return_ts(weights: list, ror: pd.DataFrame, *, period:
303305
# ror.sort_index(ascending=True, inplace=True)
304306
return ror
305307

308+
@staticmethod
309+
def create_fn_list_ror_ts(ror: pd.DataFrame, *, period: str = 'Y') -> list:
310+
"""
311+
Returns a list of functions of weights.
312+
"""
313+
# Frame.weights_sum_is_one(weights)
314+
initial_inv = 1000
315+
fn_list = []
316+
for x in ror.resample(period):
317+
def ror_list_fn(weights, y=x):
318+
df = y[1] # select ror part of the grouped data
319+
inv_period_spread = np.asarray(weights) * initial_inv # rebalancing
320+
assets_wealth_indexes = inv_period_spread * (1 + df).cumprod()
321+
wealth_index_local = assets_wealth_indexes.sum(axis=1)
322+
ror_local = wealth_index_local.pct_change()
323+
return ror_local
324+
fn_list.append(ror_list_fn)
325+
return fn_list
326+
306327

307328
class Date:
308329
@staticmethod
@@ -320,3 +341,72 @@ def subtract_years(dt: pd.Timestamp, years: int) -> pd.Timestamp:
320341
raise TypeError('The period should be integer')
321342
return dt
322343

344+
345+
class Index:
346+
@staticmethod
347+
def tracking_difference(accumulated_return: pd.DataFrame) -> pd.DataFrame:
348+
"""
349+
Returns tracking difference for a rate of return time series.
350+
Assets are compared with the index or another benchmark.
351+
Index should be in the first position (first column).
352+
"""
353+
if accumulated_return.shape[1] < 2:
354+
raise ValueError('At least 2 symbols should be provided to calculate Tracking Difference.')
355+
initial_value = accumulated_return.iloc[0]
356+
difference = accumulated_return.subtract(accumulated_return.iloc[:, 0], axis=0) / initial_value
357+
difference.drop(difference.columns[0], axis=1, inplace=True) # drop the first column (stock index data)
358+
return difference
359+
360+
@staticmethod
361+
def tracking_difference_annualized(tracking_diff: pd.DataFrame) -> pd.DataFrame:
362+
"""
363+
Annualizes the values of tracking difference time series.
364+
Annual values are available for periods of more than 12 months.
365+
Returns for less than 12 months can't be annualized.
366+
"""
367+
pwr = 12 / (1. + np.arange(tracking_diff.shape[0]))
368+
y = abs(tracking_diff)
369+
diff = np.sign(tracking_diff) * (y + 1.).pow(pwr, axis=0) - 1.
370+
return diff.iloc[_MONTHS_PER_YEAR - 1:] # returns for the first 11 months can't be annualized
371+
372+
@staticmethod
373+
def tracking_error(ror: pd.DataFrame) -> pd.DataFrame:
374+
"""
375+
Returns tracking error for a rate of return time series.
376+
Assets are compared with the index or another benchmark.
377+
Index should be in the first position (first column).
378+
"""
379+
if ror.shape[1] < 2:
380+
raise ValueError('At least 2 symbols should be provided to calculate Tracking Error.')
381+
cumsum = ror.subtract(ror.iloc[:, 0], axis=0).pow(2, axis=0).cumsum()
382+
cumsum.drop(cumsum.columns[0], axis=1, inplace=True) # drop the first column (stock index data)
383+
tracking_error = cumsum.divide((1. + np.arange(ror.shape[0])), axis=0).pow(0.5, axis=0)
384+
return tracking_error * np.sqrt(12)
385+
386+
@staticmethod
387+
def cov_cor(ror: pd.DataFrame, fn: str) -> pd.DataFrame:
388+
"""
389+
Returns the accumulated correlation or covariance time series.
390+
The period should be at least 12 months.
391+
"""
392+
if ror.shape[1] < 2:
393+
raise ValueError('At least 2 symbols should be provided.')
394+
if fn not in ['cov', 'corr']:
395+
raise ValueError('fn should be cor or cov')
396+
cov_matrix_ts = getattr(ror.expanding(), fn)()
397+
cov_matrix_ts = cov_matrix_ts.drop(index=ror.columns[1:], level=1).droplevel(1)
398+
cov_matrix_ts.drop(columns=ror.columns[0], inplace=True)
399+
return cov_matrix_ts.iloc[_MONTHS_PER_YEAR:]
400+
401+
@staticmethod
402+
def beta(ror: pd.DataFrame) -> pd.DataFrame:
403+
"""
404+
Returns beta coefficient the rate of return time series.
405+
Index (or benchmark) should be in the first position (first column).
406+
The period should be at least 12 months.
407+
"""
408+
cov = Index.cov_cor(ror, fn='cov')
409+
std = ror.expanding().std().drop(columns=ror.columns[0])
410+
std = std[_MONTHS_PER_YEAR:]
411+
return cov / std
412+

okama/plots.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,4 @@ def plot_forecast(self,
201201
else:
202202
self.ax.plot(x, y, linestyle='dashed', linewidth=1, label=f'Percentile {percentile}')
203203
self.ax.legend(loc='upper left')
204-
# place a text box in upper left in axes coords
205-
# max_value = y_end_values[percentiles[-1]]
206-
# min_value = y_end_values[percentiles[0]]
207-
# median_value = y_end_values[percentiles[1]]
208-
# textstr = '\n'.join((
209-
# f'Значение портфеля сегодня: {today_value}',
210-
# f'Максимальное значение через {years} лет: {max_value}',
211-
# f'Минимальное значение через {years} лет: {min_value}',
212-
# f'Наиболее вероятное значение через {years} лет: {median_value}'
213-
# ))
214-
# props = dict(boxstyle='round', facecolor='wheat', alpha=0.5)
215-
# self.ax.text(0.02, 0.85, textstr, transform=self.ax.transAxes, fontsize=14, verticalalignment='top', bbox=props)
216-
return self.ax
204+
return self.ax

tests/test_assets.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,33 @@ def test_growing_dividend_years(self):
115115
def test_paying_dividend_years(self):
116116
assert self.spy.dividend_paying_years.iloc[-2, 0] == 2
117117

118+
def test_tracking_difference_failing(self):
119+
with pytest.raises(Exception, match='At least 2 symbols should be provided to calculate Tracking Difference.'):
120+
self.spy.tracking_difference
121+
122+
def test_tracking_difference(self):
123+
assert self.asset_list.tracking_difference.iloc[-1, 0] == approx(0.4832375074686104, rel=1e-2)
124+
125+
def test_tracking_difference_annualized(self):
126+
assert self.asset_list.tracking_difference_annualized.iloc[-1, 0] == approx(0.438933, rel=1e-2)
127+
128+
def test_tracking_error(self):
129+
assert self.asset_list.tracking_error.iloc[-1, 0] == approx(0.203372, rel=1e-2)
130+
131+
def test_index_corr(self):
132+
assert self.asset_list.index_corr.iloc[-1, 0] == approx(-0.62177, rel=1e-2)
133+
134+
@mark.test
135+
def test_index_beta(self):
136+
assert self.asset_list.index_beta.iloc[-1, 0] == approx(-0.018598, rel=1e-2)
137+
138+
118139
@mark.portfolio
119140
def test_init_portfolio_failing():
120141
with pytest.raises(Exception, match=r'Number of tickers \(2\) should be equal to the weights number \(3\)'):
121142
Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], weights=[0.1, 0.2, 0.7]).symbols
122143
with pytest.raises(Exception, match='Weights sum is not equal to one.'):
123144
Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], weights=[0.1, 0.2]).symbols
124-
# with pytest.raises(Exception, match=r'RUB is not in allowed assets namespaces*'):
125-
# Portfolio(symbols=['RUB.RUB'], weights=[1])
126-
# # with pytest.raises(Exception):
127-
# # Portfolio(symbols=['XXXX.RE'], weights=[1])
128145

129146

130147
@mark.portfolio

0 commit comments

Comments
 (0)