Skip to content

Commit 114db9f

Browse files
committed
New methods for distribution backtesting in AssetList and Portfolio:
- skewness - skewness_rolling - kurtosis - kurtsis_roling - jarque_bera
1 parent 6d4891a commit 114db9f

File tree

10 files changed

+284
-38
lines changed

10 files changed

+284
-38
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.88-brightgreen.svg"
7+
<img src="https://img.shields.io/badge/pypi-v0.89-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: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import okama as ok
22

3-
funds1 = ['MCFTR.INDX', '0177-71671092.PIF', '0890-94127385.PIF']
4-
curr = 'RUB'
5-
x1 = ok.AssetList(funds1, curr=curr)
6-
x1.tracking_difference_annualized
3+
y = ok.Asset('LKOH.MOEX')
4+
print(y.dividends)

notebooks/index funds perfomance.ipynb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@
205205
},
206206
{
207207
"cell_type": "code",
208-
"execution_count": 7,
208+
"execution_count": 3,
209209
"metadata": {},
210210
"outputs": [
211211
{
@@ -360,7 +360,7 @@
360360
"cell_type": "markdown",
361361
"metadata": {},
362362
"source": [
363-
"It can be clearly seen that real estat ETF (VNQ) has bigger correlation with stocks comparing with gold prices and bonds during this period."
363+
"It can be clearly seen that real estat ETF (VNQ) has higher correlation with stocks comparing with gold prices and bonds during this period."
364364
]
365365
},
366366
{

okama/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
"""
2+
*okama* is a Python package developed for asset allocation and investments portfolio optimization tasks
3+
according to Modern Portfolio Theory (MPT).
4+
=====================================================================
5+
The package is supplied with **free** «end of day» historical stock markets data and macroeconomic indicators through API.
6+
7+
Main features:
8+
9+
- Investment portfolio constrained Markowitz Mean-Variance Analysis (MVA) and optimization
10+
- Rebalanced portfolio optimization
11+
- Monte Carlo Simulations for financial assets and investment portfolios
12+
- Popular risk metrics: VAR, CVaR, semidiviation, variance and drawdowns
13+
- Forecasting models according to normal and lognormal distribution
14+
- Testing distribution on historical data
15+
- Dividend yield and other dividend indicators for stocks
16+
- Backtesting and comparing historical performance of broad range of assets and indexes in multiple currencies
17+
- Methods to track the perfomance of index funds (ETF) and compare them with benchmarks
18+
- Main macroeconomic indicators: inflation, central banks rates
19+
- Matplotlib visualization scripts for the Efficient Frontier, Transition map and assets risk / return performance
20+
21+
"""
22+
123
from okama.assets import Asset, AssetList, Portfolio
224
from okama.macro import Inflation, Rate
325
from okama.frontier import EfficientFrontier

okama/assets.py

Lines changed: 149 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -592,30 +592,84 @@ def tracking_error(self):
592592
@property
593593
def index_corr(self):
594594
"""
595-
Returns the accumulated correlation with the index (or benchmark) time series for the assets.
595+
Compute expanding correlation with the index (or benchmark) time series for the assets.
596596
Index should be in the first position (first column).
597597
The period should be at least 12 months.
598598
"""
599599
return Index.cov_cor(self.ror, fn='corr')
600600

601-
def index_rolling_corr(self, window=60):
601+
def index_rolling_corr(self, window: int = 60):
602602
"""
603-
Returns the rolling correlation with the index (or benchmark) time series for the assets.
603+
Compute rolling correlation with the index (or benchmark) time series for the assets.
604604
Index should be in the first position (first column).
605605
The period should be at least 12 months.
606-
window - the rolling window size (default is 5 years).
606+
window - the rolling window size in months (default is 5 years).
607607
"""
608608
return Index.rolling_cov_cor(self.ror, window=window, fn='corr')
609609

610610
@property
611611
def index_beta(self):
612612
"""
613-
Returns beta coefficient time series for the assets.
613+
Compute beta coefficient time series for the assets.
614614
Index (or benchmark) should be in the first position (first column).
615615
Rolling window size should be at least 12 months.
616616
"""
617617
return Index.beta(self.ror)
618618

619+
# distributions
620+
@property
621+
def skewness(self):
622+
"""
623+
Compute expanding skewness of the return time series for each asset.
624+
For normally distributed data, the skewness should be about zero.
625+
A skewness value greater than zero means that there is more weight in the right tail of the distribution.
626+
"""
627+
return Frame.skewness(self.ror)
628+
629+
def skewness_rolling(self, window: int = 60):
630+
"""
631+
Compute rolling skewness of the return time series for each asset.
632+
For normally distributed data, the skewness should be about zero.
633+
A skewness value greater than zero means that there is more weight in the right tail of the distribution.
634+
635+
window - the rolling window size in months (default is 5 years).
636+
The window size should be at least 12 months.
637+
"""
638+
return Frame.skewness_rolling(self.ror, window=window)
639+
640+
@property
641+
def kurtosis(self):
642+
"""
643+
Calculate expanding Fisher (normalized) kurtosis time series for each asset.
644+
Kurtosis is the fourth central moment divided by the square of the variance.
645+
Kurtosis should be close to zero for normal distribution.
646+
"""
647+
return Frame.kurtosis(self.ror)
648+
649+
def kurtosis_rolling(self, window: int = 60):
650+
"""
651+
Calculate rolling Fisher (normalized) kurtosis time series for each asset.
652+
Kurtosis is the fourth central moment divided by the square of the variance.
653+
Kurtosis should be close to zero for normal distribution.
654+
655+
window - the rolling window size in months (default is 5 years).
656+
The window size should be at least 12 months.
657+
"""
658+
return Frame.kurtosis_rolling(self.ror, window=window)
659+
660+
@property
661+
def jarque_bera(self):
662+
"""
663+
Jarque-Bera is a test for normality.
664+
It shows whether the returns have the skewness and kurtosis matching a normal distribution.
665+
666+
Returns:
667+
(The test statistic, The p-value for the hypothesis test)
668+
Low statistic numbers correspond to normal distribution.
669+
TODO: implement for daily values
670+
"""
671+
return Frame.jarque_bera(self.ror)
672+
619673

620674
class Portfolio:
621675
"""
@@ -683,6 +737,11 @@ def weights(self, weights: list):
683737

684738
@property
685739
def returns_ts(self) -> pd.Series:
740+
"""
741+
Rate of return time series for portfolio.
742+
Returns:
743+
pd.Series
744+
"""
686745
s = Frame.get_portfolio_return_ts(self.weights, self._ror)
687746
s.rename('portfolio', inplace=True)
688747
return s
@@ -947,11 +1006,7 @@ def forecast_from_history(self, percentiles: List[int] = [10, 50, 90]) -> pd.Dat
9471006
df.index.rename('years', inplace=True)
9481007
return df
9491008

950-
def forecast_monte_carlo_norm_returns(self, years: int = 5, n: int = 100) -> pd.DataFrame:
951-
"""
952-
Generates N random returns time series with normal distribution.
953-
Forecast period should not exceed 1/2 of portfolio history period length.
954-
"""
1009+
def _forecast_preparation(self, years):
9551010
max_period_years = round(self.period_length / 2)
9561011
if max_period_years < 1:
9571012
raise ValueError(f'Time series does not have enough history to forecast.'
@@ -964,32 +1019,55 @@ def forecast_monte_carlo_norm_returns(self, years: int = 5, n: int = 100) -> pd.
9641019
start_period = self.last_date.to_period('M')
9651020
end_period = self.last_date.to_period('M') + period_months - 1
9661021
ts_index = pd.period_range(start_period, end_period, freq='M')
1022+
return period_months, ts_index
1023+
1024+
def forecast_monte_carlo_returns(self, distr: str = 'norm', years: int = 5, n: int = 100) -> pd.DataFrame:
1025+
"""
1026+
Generates N random returns time series with normal or lognormal distributions.
1027+
Forecast period should not exceed 1/2 of portfolio history period length.
1028+
"""
1029+
period_months, ts_index = self._forecast_preparation(years)
9671030
# random returns
968-
random_returns = np.random.normal(self.mean_return_monthly, self.risk_monthly, (period_months, n))
1031+
if distr == 'norm':
1032+
random_returns = np.random.normal(self.mean_return_monthly, self.risk_monthly, (period_months, n))
1033+
elif distr == 'lognorm':
1034+
ln_ret = np.log(self.returns_ts + 1.)
1035+
mu = ln_ret.mean() # arithmetic mean of logarithmic returns
1036+
std = ln_ret.std() # standard deviation of logarithmic returns
1037+
random_returns = np.random.lognormal(mu, std, size=(period_months, n)) - 1.
1038+
else:
1039+
raise ValueError('distr should be "norm" (default) or "lognormal".')
9691040
return_ts = pd.DataFrame(data=random_returns, index=ts_index)
9701041
return return_ts
9711042

972-
def forecast_monte_carlo_norm_wealth_indexes(self, years: int = 5, n: int = 100) -> pd.DataFrame:
1043+
def forecast_monte_carlo_wealth_indexes(self, distr: str = 'norm', years: int = 5, n: int = 100) -> pd.DataFrame:
9731044
"""
974-
Generates N future wealth indexes with normally distributed monthly returns for a given period.
1045+
Generates N future random wealth indexes with monthly returns for a given period.
1046+
Random distribution could be normal or lognormal.
9751047
"""
976-
return_ts = self.forecast_monte_carlo_norm_returns(years=years, n=n)
1048+
if distr not in ['norm', 'lognorm']:
1049+
raise ValueError('distr should be "norm" (default) or "lognormal".')
1050+
return_ts = self.forecast_monte_carlo_returns(distr=distr, years=years, n=n)
9771051
first_value = self.wealth_index['portfolio'].values[-1]
9781052
forecast_wealth = Frame.get_wealth_indexes(return_ts, first_value)
9791053
return forecast_wealth
9801054

9811055
def forecast_monte_carlo_percentile_wealth_indexes(self,
1056+
distr: str = 'norm',
9821057
years: int = 5,
9831058
percentiles: List[int] = [10, 50, 90],
9841059
today_value: Optional[int] = None,
9851060
n: int = 1000,
9861061
) -> Dict[int, float]:
9871062
"""
988-
Calculates the final values of N forecasted wealth indexes with normal distribution assumption.
989-
Final values are taken for given percentiles.
990-
today_value - the value of portfolio today (before forecast period)
1063+
Calculates the final values of N forecasted random wealth indexes.
1064+
Random distribution could be normal or lognormal.
1065+
Final values are taken for a given percentiles list.
1066+
today_value - the value of portfolio today (before forecast period).
9911067
"""
992-
wealth_indexes = self.forecast_monte_carlo_norm_wealth_indexes(years=years, n=n)
1068+
if distr not in ['norm', 'lognorm']:
1069+
raise ValueError('distr should be "norm" (default) or "lognormal".')
1070+
wealth_indexes = self.forecast_monte_carlo_wealth_indexes(distr=distr, years=years, n=n)
9931071
results = dict()
9941072
for percentile in percentiles:
9951073
value = wealth_indexes.iloc[-1, :].quantile(percentile / 100)
@@ -998,3 +1076,56 @@ def forecast_monte_carlo_percentile_wealth_indexes(self,
9981076
modifier = today_value / self.wealth_index['portfolio'].values[-1]
9991077
results.update((x, y * modifier)for x, y in results.items())
10001078
return results
1079+
1080+
# distributions
1081+
@property
1082+
def skewness(self):
1083+
"""
1084+
Compute expanding skewness of the return time series.
1085+
For normally distributed data, the skewness should be about zero.
1086+
A skewness value greater than zero means that there is more weight in the right tail of the distribution.
1087+
"""
1088+
return Frame.skewness(self.returns_ts)
1089+
1090+
def skewness_rolling(self, window: int = 60):
1091+
"""
1092+
Compute rolling skewness of the return time series.
1093+
For normally distributed data, the skewness should be about zero.
1094+
A skewness value greater than zero means that there is more weight in the right tail of the distribution.
1095+
1096+
window - the rolling window size in months (default is 5 years).
1097+
The window size should be at least 12 months.
1098+
"""
1099+
return Frame.skewness_rolling(self.returns_ts, window=window)
1100+
1101+
@property
1102+
def kurtosis(self):
1103+
"""
1104+
Calculate expanding Fisher (normalized) kurtosis time series for portfolio returns.
1105+
Kurtosis is the fourth central moment divided by the square of the variance.
1106+
Kurtosis should be close to zero for normal distribution.
1107+
"""
1108+
return Frame.kurtosis(self.returns_ts)
1109+
1110+
def kurtosis_rolling(self, window: int = 60):
1111+
"""
1112+
Calculate rolling Fisher (normalized) kurtosis time series for portfolio returns.
1113+
Kurtosis is the fourth central moment divided by the square of the variance.
1114+
Kurtosis should be close to zero for normal distribution.
1115+
1116+
window - the rolling window size in months (default is 5 years).
1117+
The window size should be at least 12 months.
1118+
"""
1119+
return Frame.kurtosis_rolling(self.returns_ts, window=window)
1120+
1121+
@property
1122+
def jarque_bera(self):
1123+
"""
1124+
Jarque-Bera is a test for normality.
1125+
It shows whether the returns have the skewness and kurtosis matching a normal distribution.
1126+
1127+
Returns:
1128+
(The test statistic, The p-value for the hypothesis test)
1129+
Low statistic numbers correspond to normal distribution.
1130+
"""
1131+
return Frame.jarque_bera(self.returns_ts)

0 commit comments

Comments
 (0)