Skip to content

Commit 9e8a805

Browse files
committed
feat: Portfolio has .get_cagr method
- Portfolio .cagr property is replaced with .get_cagr ('YTD', period length or no parameters) - Portfolio .describe shows dividend yield - EfficientFrontier.__repr__ is fixed - only relative imports in the package - tests modules import okama as ok - several unit tests added
1 parent 5521566 commit 9e8a805

File tree

14 files changed

+128
-81
lines changed

14 files changed

+128
-81
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<img src="https://img.shields.io/badge/python-v3-brightgreen.svg"
55
alt="python"></a> &nbsp;
66
<a href="https://pypi.org/project/okama/">
7-
<img src="https://img.shields.io/badge/pypi-v0.94-brightgreen.svg"
7+
<img src="https://img.shields.io/badge/pypi-v0.95-brightgreen.svg"
88
alt="pypi"></a> &nbsp;
99
<a href="https://opensource.org/licenses/MIT">
1010
<img src="https://img.shields.io/badge/license-MIT-brightgreen.svg"

main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import okama as ok
22

3-
x = ok.AssetList(['SXR8.XETR', 'SXRG.XETR', 'XRS2.XETR'])
4-
print(x.get_cagr(5))
3+
x = ok.Portfolio(symbols=['SBER.MOEX', 'T.US'], ccy='RUB', last_date='2020-01', inflation=True)
4+
# print(x.get_cagr('YTD'))
5+
print(x.describe())
6+

okama/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,4 @@
3333
from okama.helpers import Float, Frame, Rebalance, Date
3434
import okama.settings
3535

36-
__version__ = '0.94'
36+
__version__ = '0.95'

okama/assets.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,16 @@ def __init__(self,
108108
self.inflation_ts: pd.Series = self._inflation_instance.values_ts
109109
self.inflation_first_date: pd.Timestamp = self._inflation_instance.first_date
110110
self.inflation_last_date: pd.Timestamp = self._inflation_instance.last_date
111-
self.first_date: pd.Timestamp = max(self.first_date, self.inflation_first_date)
111+
self.first_date = max(self.first_date, self.inflation_first_date)
112112
self.last_date: pd.Timestamp = min(self.last_date, self.inflation_last_date)
113113
# Add inflation to the date range dict
114114
self.assets_first_dates.update({self.inflation: self.inflation_first_date})
115115
self.assets_last_dates.update({self.inflation: self.inflation_last_date})
116116
if first_date:
117-
self.first_date: pd.Timestamp = max(self.first_date, pd.to_datetime(first_date))
117+
self.first_date = max(self.first_date, pd.to_datetime(first_date))
118118
self.ror = self.ror[self.first_date:]
119119
if last_date:
120-
# TODO: self.assets_last_dates should be less or equal to self.last_date
121-
self.last_date: pd.Timestamp = min(self.last_date, pd.to_datetime(last_date))
120+
self.last_date = min(self.last_date, pd.to_datetime(last_date))
122121
self.ror: pd.DataFrame = self.ror[self.first_date: self.last_date]
123122
self.period_length: float = round((self.last_date - self.first_date) / np.timedelta64(365, 'D'), ndigits=1)
124123
self.pl = PeriodLength(self.ror.shape[0] // _MONTHS_PER_YEAR, self.ror.shape[0] % _MONTHS_PER_YEAR)
@@ -148,6 +147,7 @@ def __make_asset_list(self, ls: list) -> None:
148147
last_dates: Dict[str, pd.Timestamp] = {}
149148
names: Dict[str, str] = {}
150149
currencies: Dict[str, str] = {}
150+
df = pd.DataFrame()
151151
for i, x in enumerate(ls):
152152
asset = Asset(x)
153153
if i == 0:
@@ -169,8 +169,8 @@ def __make_asset_list(self, ls: list) -> None:
169169
first_dates.update({self.currency.name: self.currency.first_date})
170170
last_dates.update({self.currency.name: self.currency.last_date})
171171

172-
first_dates_sorted = sorted(first_dates.items(), key=lambda x: x[1])
173-
last_dates_sorted = sorted(last_dates.items(), key=lambda x: x[1])
172+
first_dates_sorted = sorted(first_dates.items(), key=lambda y: y[1])
173+
last_dates_sorted = sorted(last_dates.items(), key=lambda y: y[1])
174174
self.first_date: pd.Timestamp = first_dates_sorted[-1][1]
175175
self.last_date: pd.Timestamp = last_dates_sorted[0][1]
176176
self.newest_asset: str = first_dates_sorted[-1][0]
@@ -254,7 +254,7 @@ def semideviation_annual(self) -> float:
254254
"""
255255
Returns semideviation annual values for each asset (full period).
256256
"""
257-
return Frame.get_semideviation(self.returns_ts) * 12 ** 0.5
257+
return Frame.get_semideviation(self.ror) * 12 ** 0.5
258258

259259
def get_var_historic(self, level: int = 5) -> pd.Series:
260260
"""
@@ -292,7 +292,7 @@ def get_cagr(self, period: Union[str, int, None] = None) -> pd.Series:
292292

293293
if not period:
294294
cagr = Frame.get_cagr(df)
295-
elif period == 'YTD':
295+
elif str(period).lower() == 'ytd':
296296
year = dt0.year
297297
cagr = (df[str(year):] + 1.).prod() - 1.
298298
elif isinstance(period, int):
@@ -665,7 +665,7 @@ def jarque_bera(self):
665665
"""
666666
return Frame.jarque_bera_dataframe(self.ror)
667667

668-
def kstest(self, distr: str = 'norm') -> dict:
668+
def kstest(self, distr: str = 'norm') -> pd.DataFrame:
669669
"""
670670
Perform Kolmogorov-Smirnov test for goodness of fit the asset returns to a given distribution.
671671

okama/frontier/multi_period.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from scipy.optimize import minimize
88

9-
from okama.helpers import Float, Frame, Rebalance
10-
from okama.assets import AssetList
11-
from okama.settings import _MONTHS_PER_YEAR
9+
from ..helpers import Float, Frame, Rebalance
10+
from ..assets import AssetList
11+
from ..settings import _MONTHS_PER_YEAR
1212

1313

1414
class EfficientFrontierReb(AssetList):

okama/frontier/single_period.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ def __repr__(self):
4343
'first date': self.first_date.strftime("%Y-%m"),
4444
'last_date': self.last_date.strftime("%Y-%m"),
4545
'period length': self._pl_txt,
46-
'rebalancing period': self.reb_period,
4746
'inflation': self.inflation if hasattr(self, 'inflation') else 'None',
4847
}
4948
return repr(pd.Series(dic))

okama/helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ def get_semideviation(ror: Union[pd.DataFrame, pd.Series]) -> Union[pd.Series, f
207207
Returns semideviation for each asset given returns time series.
208208
"""
209209
is_negative = ror < 0
210-
return ror[is_negative].std(ddof=0)
210+
sem = ror[is_negative].std(ddof=0)
211+
return sem
211212

212213
@staticmethod
213214
def get_var_historic(ror: Union[pd.DataFrame, pd.Series], level: int = 5) -> Union[pd.Series, float]:
@@ -269,7 +270,7 @@ def skewness_rolling(ror: Union[pd.DataFrame, pd.Series], window: int = 60) -> U
269270
return sk
270271

271272
@staticmethod
272-
def kurtosis(ror: pd.Series):
273+
def kurtosis(ror: Union[pd.Series, pd.DataFrame]):
273274
"""
274275
Calculate expanding Fisher (normalized) kurtosis time series.
275276
Kurtosis should be close to zero for normal distribution.
@@ -286,8 +287,7 @@ def kurtosis_rolling(ror: Union[pd.Series, pd.DataFrame], window: int = 60):
286287
"""
287288
check_rolling_window(window, ror)
288289
kt = ror.rolling(window=window).kurt()
289-
kt.dropna(inplace=True)
290-
return kt
290+
return kt.dropna()
291291

292292
@staticmethod
293293
def jarque_bera_series(ror: pd.Series) -> dict:

okama/macro.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
from abc import ABC, abstractmethod
2+
from typing import Union
23

34
import numpy as np
45
import pandas as pd
56

6-
from okama.api.data_queries import QueryData
7-
from okama.api.namespaces import get_macro_namespaces
8-
from okama.helpers import Float, Frame, Date
9-
from okama.settings import default_macro, PeriodLength, _MONTHS_PER_YEAR
7+
from .api.data_queries import QueryData
8+
from .api.namespaces import get_macro_namespaces
9+
from .helpers import Float, Frame, Date
10+
from .settings import default_macro, PeriodLength, _MONTHS_PER_YEAR
1011

1112

1213
class MacroABC(ABC):
13-
def __init__(self, symbol: str = default_macro, first_date: str = '1800-01', last_date: str = '2030-01'):
14+
def __init__(self,
15+
symbol: str = default_macro,
16+
first_date: Union[str, pd.Timestamp] = '1800-01',
17+
last_date: Union[str, pd.Timestamp] = '2030-01'
18+
):
1419
self.symbol: str = symbol
1520
self._check_namespace()
1621
self._get_symbol_data(symbol)

okama/plots.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,10 @@ def plot_transition_map(self, bounds=None, full_frontier=False, cagr=True) -> pl
100100
n_points=10
101101
).ef_points
102102
self._verify_axes()
103-
x_axe = 'CAGR (approx)' if cagr else 'Risk'
103+
x_axe = 'CAGR' if cagr else 'Risk'
104104
fig = plt.figure(figsize=(12, 6))
105105
for i in ef:
106-
if i not in ('Risk', 'Mean return', 'CAGR (approx)'): # select only columns with tickers
106+
if i not in ('Risk', 'Mean return', 'CAGR'): # select only columns with tickers
107107
self.ax.plot(ef[x_axe], ef.loc[:, i], label=i)
108108
self.ax.set_xlim(ef[x_axe].min(), ef[x_axe].max())
109109
if cagr:

okama/portfolio.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,34 @@ def mean_return_monthly(self) -> float:
117117
def mean_return_annual(self) -> float:
118118
return Float.annualize_return(self.mean_return_monthly)
119119

120-
@property
121-
def cagr(self) -> Union[pd.Series, float]:
120+
def get_cagr(self, period: Union[str, int, None] = None) -> pd.Series:
121+
"""
122+
Calculates Compound Annual Growth Rate (CAGR) for a given period:
123+
None: full time
124+
'YTD': Year To Date compound rate of return (formally not a CAGR)
125+
Integer: several years
126+
"""
122127
if hasattr(self, 'inflation'):
123128
df = pd.concat([self.returns_ts, self.inflation_ts], axis=1, join='inner', copy='false')
124129
else:
125130
df = self.returns_ts
126-
return Frame.get_cagr(df)
131+
dt0 = self.last_date
132+
133+
if not period:
134+
cagr = Frame.get_cagr(df)
135+
elif str(period).lower() == 'ytd':
136+
year = dt0.year
137+
cagr = (df[str(year):] + 1.).prod() - 1.
138+
elif isinstance(period, int):
139+
dt = Date.subtract_years(dt0, period)
140+
if dt >= self.first_date:
141+
cagr = Frame.get_cagr(df[dt:])
142+
else:
143+
row = {x: None for x in df.columns}
144+
cagr = pd.Series(row)
145+
else:
146+
raise ValueError(f'{period} is not a valid value for period')
147+
return cagr
127148

128149
@property
129150
def annual_return_ts(self) -> pd.DataFrame:
@@ -134,6 +155,8 @@ def dividend_yield(self) -> pd.DataFrame:
134155
"""
135156
Calculates dividend yield time series in all base currencies of portfolio assets.
136157
For every currency dividend yield is a weighted sum of the assets dividend yields.
158+
Portfolio asset allocation (weights) is a constant (monthly rebalanced portfolios).
159+
TODO: calculate for not rebalance portfolios (and arbitrary reb period).
137160
"""
138161
div_yield_assets = self._list.dividend_yield
139162
currencies_dict = self._list.currencies
@@ -144,6 +167,7 @@ def dividend_yield(self) -> pd.DataFrame:
144167
for currency in currencies_list:
145168
assets_with_the_same_currency = [x for x in currencies_dict if currencies_dict[x] == currency]
146169
df = div_yield_assets[assets_with_the_same_currency]
170+
# for monthly rebalanced portfolio
147171
weights = [self.assets_weights[k] for k in self.assets_weights if k in assets_with_the_same_currency]
148172
weighted_weights = np.asarray(weights) / np.asarray(weights).sum()
149173
div_yield_series = Frame.get_portfolio_return_ts(weighted_weights, df)
@@ -223,10 +247,10 @@ def describe(self, years: Tuple[int] = (1, 5, 10)) -> pd.DataFrame:
223247
else:
224248
row = {'portfolio': value}
225249
row.update({'period': 'YTD'})
226-
row.update({'rebalancing': 'Not rebalanced'})
250+
row.update({'rebalancing': '1 year'})
227251
row.update({'property': 'compound return'})
228252
description = description.append(row, ignore_index=True)
229-
# CAGR for a list of periods (rebalanced 1 month)
253+
# CAGR for a list of periods (rebalanced 1 year)
230254
for i in years:
231255
dt = Date.subtract_years(dt0, i)
232256
if dt >= self.first_date:
@@ -258,7 +282,7 @@ def describe(self, years: Tuple[int] = (1, 5, 10)) -> pd.DataFrame:
258282
row.update({'property': 'CAGR'})
259283
description = description.append(row, ignore_index=True)
260284
# CAGR rebalanced 1 month
261-
value = self.cagr
285+
value = self.get_cagr()
262286
if hasattr(self, 'inflation'):
263287
row = value.to_dict()
264288
full_inflation = value.loc[self.inflation] # full period inflation is required for following calc
@@ -278,6 +302,15 @@ def describe(self, years: Tuple[int] = (1, 5, 10)) -> pd.DataFrame:
278302
row.update({'rebalancing': 'Not rebalanced'})
279303
row.update({'property': 'CAGR'})
280304
description = description.append(row, ignore_index=True)
305+
# Dividend Yield
306+
dy = self.dividend_yield
307+
for i, ccy in enumerate(dy):
308+
value = self.dividend_yield.iloc[-1, i]
309+
row = {'portfolio': value}
310+
row.update({'period': 'LTM'})
311+
row.update({'rebalancing': '1 month'})
312+
row.update({'property': f'Dividend yield ({ccy})'})
313+
description = description.append(row, ignore_index=True)
281314
# risk (rebalanced 1 month)
282315
row = {'portfolio': self.risk_annual}
283316
row.update({'period': f'{self.period_length} years'})
@@ -319,8 +352,9 @@ def table(self) -> pd.DataFrame:
319352
def get_rolling_cagr(self, years: int = 1) -> pd.Series:
320353
"""
321354
Rolling portfolio CAGR (annualized rate of return) time series.
322-
TODO: check if self.period_length is below 1 year
323355
"""
356+
if self.pl.years < 1:
357+
raise ValueError('Portfolio history data period length should be at least 12 months.')
324358
rolling_return = (self.returns_ts + 1.).rolling(_MONTHS_PER_YEAR * years).apply(np.prod, raw=True) ** (1 / years) - 1.
325359
rolling_return.dropna(inplace=True)
326360
return rolling_return

tests/conftest.py

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,73 @@
11
import pytest
2-
from okama.assets import Asset, AssetList
3-
from okama.portfolio import Portfolio
4-
from okama.macro import Inflation, Rate
5-
from okama.frontier.single_period import EfficientFrontier
6-
from okama.frontier.multi_period import EfficientFrontierReb
7-
from okama.plots import Plots
2+
import okama as ok
3+
# from okama.assets import Asset, AssetList
4+
# from okama.portfolio import Portfolio
5+
# from okama.macro import Inflation, Rate
6+
# from okama.frontier.single_period import EfficientFrontier
7+
# from okama.frontier.multi_period import EfficientFrontierReb
8+
# from okama.plots import Plots
89

910

1011
@pytest.fixture(scope='class')
1112
def _init_asset(request):
12-
request.cls.spy = Asset(symbol='SPY.US')
13-
request.cls.otkr = Asset(symbol='0165-70287767.PIF')
13+
request.cls.spy = ok.Asset(symbol='SPY.US')
14+
request.cls.otkr = ok.Asset(symbol='0165-70287767.PIF')
1415

1516

1617
@pytest.fixture(scope='class')
1718
def _init_asset_list(request) -> None:
18-
request.cls.asset_list = AssetList(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
19-
first_date='2019-01', last_date='2020-01', inflation=True)
20-
request.cls.asset_list_lt = AssetList(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
21-
first_date='2003-03', last_date='2020-01', inflation=True)
22-
request.cls.currencies = AssetList(['RUBUSD.FX', 'EURUSD.FX', 'CNYUSD.FX'], ccy='USD',
23-
first_date='2019-01', last_date='2020-01', inflation=True)
24-
request.cls.spy = AssetList(first_date='2000-01', last_date='2002-01', inflation=True)
25-
request.cls.real_estate = AssetList(symbols=['RUS_SEC.RE', 'MOW_PR.RE'], ccy='RUB',
26-
first_date='2010-01', last_date='2015-01', inflation=True)
19+
request.cls.asset_list = ok.AssetList(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
20+
first_date='2019-01', last_date='2020-01', inflation=True)
21+
request.cls.asset_list_lt = ok.AssetList(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
22+
first_date='2003-03', last_date='2020-01', inflation=True)
23+
request.cls.currencies = ok.AssetList(['RUBUSD.FX', 'EURUSD.FX', 'CNYUSD.FX'], ccy='USD',
24+
first_date='2019-01', last_date='2020-01', inflation=True)
25+
request.cls.spy = ok.AssetList(first_date='2000-01', last_date='2002-01', inflation=True)
26+
request.cls.real_estate = ok.AssetList(symbols=['RUS_SEC.RE', 'MOW_PR.RE'], ccy='RUB',
27+
first_date='2010-01', last_date='2015-01', inflation=True)
2728

2829

2930
@pytest.fixture(scope='class')
3031
def _init_portfolio(request):
31-
request.cls.portfolio = Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
32-
first_date='2015-01', last_date='2020-01', inflation=True)
33-
request.cls.portfolio_short_history = Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
34-
first_date='2019-02', last_date='2020-01', inflation=True)
35-
request.cls.portfolio_no_inflation = Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
36-
first_date='2015-01', last_date='2020-01', inflation=False)
32+
request.cls.portfolio = ok.Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
33+
first_date='2015-01', last_date='2020-01', inflation=True)
34+
request.cls.portfolio_short_history = ok.Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
35+
first_date='2019-02', last_date='2020-01', inflation=True)
36+
request.cls.portfolio_no_inflation = ok.Portfolio(symbols=['RUB.FX', 'MCFTR.INDX'], ccy='RUB',
37+
first_date='2015-01', last_date='2020-01', inflation=False)
3738

3839

3940
@pytest.fixture(scope='class')
4041
def _init_inflation(request):
41-
request.cls.infl_rub = Inflation(symbol='RUB.INFL', last_date='2001-01')
42-
request.cls.infl_usd = Inflation(symbol='USD.INFL', last_date='1923-01')
43-
request.cls.infl_eur = Inflation(symbol='EUR.INFL', last_date='2006-02')
42+
request.cls.infl_rub = ok.Inflation(symbol='RUB.INFL', last_date='2001-01')
43+
request.cls.infl_usd = ok.Inflation(symbol='USD.INFL', last_date='1923-01')
44+
request.cls.infl_eur = ok.Inflation(symbol='EUR.INFL', last_date='2006-02')
4445

4546

4647
@pytest.fixture(scope='class')
4748
def _init_rates(request):
48-
request.cls.rates_rub = Rate(symbol='RUS_RUB.RATE', first_date='2015-01', last_date='2020-02')
49+
request.cls.rates_rub = ok.Rate(symbol='RUS_RUB.RATE', first_date='2015-01', last_date='2020-02')
4950

5051

5152
@pytest.fixture(scope='module')
5253
def init_plots():
53-
return Plots(symbols=['RUB.FX', 'EUR.FX', 'MCFTR.INDX'], ccy='RUB', first_date='2010-01', last_date='2020-01')
54+
return ok.Plots(symbols=['RUB.FX', 'EUR.FX', 'MCFTR.INDX'], ccy='RUB', first_date='2010-01', last_date='2020-01')
5455

5556

5657
@pytest.fixture(scope='module')
5758
def init_efficient_frontier():
5859
ls = ['SPY.US', 'SBMX.MOEX']
59-
return EfficientFrontier(symbols=ls, ccy='RUB', first_date='2018-11', last_date='2020-02', n_points=2)
60+
return ok.EfficientFrontier(symbols=ls, ccy='RUB', first_date='2018-11', last_date='2020-02', n_points=2)
6061

6162

6263
@pytest.fixture(scope='module')
6364
def init_efficient_frontier_bounds():
6465
ls = ['SPY.US', 'SBMX.MOEX']
6566
bounds = ((0, 0.5), (0, 1.))
66-
return EfficientFrontier(symbols=ls, ccy='RUB', first_date='2018-11', last_date='2020-02', n_points=2, bounds=bounds)
67+
return ok.EfficientFrontier(symbols=ls, ccy='RUB', first_date='2018-11', last_date='2020-02', n_points=2, bounds=bounds)
6768

6869

6970
@pytest.fixture(scope='module')
7071
def init_efficient_frontier_reb():
7172
ls = ['SPY.US', 'GLD.US']
72-
return EfficientFrontierReb(symbols=ls, ccy='RUB', first_date='2019-01', last_date='2020-02', n_points=3)
73+
return ok.EfficientFrontierReb(symbols=ls, ccy='RUB', first_date='2019-01', last_date='2020-02', n_points=3)

0 commit comments

Comments
 (0)