Skip to content

Commit

Permalink
chore: rename Portfolio forecast methods
Browse files Browse the repository at this point in the history
  • Loading branch information
chilango74 committed Sep 6, 2021
1 parent 850e7ff commit eb8baeb
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 145 deletions.
212 changes: 115 additions & 97 deletions examples/07 forecasting.ipynb

Large diffs are not rendered by default.

80 changes: 43 additions & 37 deletions okama/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -1130,15 +1130,17 @@ def _test_forecast_period(self, years):
f"It should not exceed 1/2 of portfolio history period length {self.period_length / 2} years"
)

def percentile_inverse(
def percentile_inverse_cagr(
self,
distr: str = "norm",
years: int = 1,
score: float = 0,
n: Optional[int] = None,
) -> float:
"""
Compute the percentile rank of a score (CAGR value) in a given time frame.
Compute the percentile rank of a score (CAGR value).
Percentile rank can be calculated for given distribution type or for hsitorical distribution of CAGR.
If percentile_inverse of, for example, 0% (CAGR value) is equal to 8% for 1 year time frame
it means that 8% of the CAGR values in the distribution are negative in 1 year periods. Or in other words
Expand Down Expand Up @@ -1169,7 +1171,7 @@ def percentile_inverse(
Examples
--------
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_period='year')
>>> pf.percentile_inverse(distr='lognorm', score=0, years=1, n=5000)
>>> pf.percentile_inverse_cagr(distr='lognorm', score=0, years=1, n=5000)
18.08
The probability of getting negative result (score=0) in 1 year period for lognormal distribution.
"""
Expand All @@ -1185,7 +1187,7 @@ def percentile_inverse(
raise ValueError('distr should be one of "norm", "lognorm", "hist".')
return scipy.stats.percentileofscore(cagr_distr, score, kind="rank")

def percentile_from_history(
def percentile_history_cagr(
self, years: int, percentiles: List[int] = [10, 50, 90]
) -> pd.DataFrame:
"""
Expand All @@ -1211,7 +1213,7 @@ def percentile_from_history(
Examples
--------
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_period='none')
>>> pf.percentile_from_history(years=5, percentiles=[1, 50, 99])
>>> pf.percentile_history_cagr(years=5, percentiles=[1, 50, 99])
1 50 99
years
1 -0.231327 0.098693 0.295343
Expand All @@ -1233,30 +1235,29 @@ def percentile_from_history(
df.index.rename("years", inplace=True)
return df

def forecast_wealth_history(
def percentile_wealth_history(
self, years: int = 1, percentiles: List[int] = [10, 50, 90]
) -> pd.DataFrame:
"""
Forecast portfolio wealth index percentiles.
Calculate portfolio wealth index percentiles.
Forecast is based on rolling CAGR historical distribution.
Percentiles are derived from rolling CAGR historical distribution.
CAGR - Compound Annual Growth Rate.
Wealth index (Cumulative Wealth Index) is a time series that presents the value of portfolio over a given
time period.
Each forecasted percentile is derived from 'percentile_from_history' method.
Actual portfolio wealth is adjusted to the last known historical value (from 'wealth_index'). It is useful
for a chart with historical wealth index and forecasted values.
Parameters
----------
years: int, default 1
Forecast period for portfolio wealth index percentiles.
Time frame for portfolio wealth index percentiles.
It should not exceed 1/2 of the portfolio history period length 'period_length'.
Percentiles are calculated for periods from 1 to 'years'.
percentiles: list of int, default [10, 50, 90]
List of percentiles to be forecasted.
List of percentiles to be calculated.
Returns
-------
Expand All @@ -1266,7 +1267,7 @@ def forecast_wealth_history(
Examples
--------
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_period='month')
>>> pf.forecast_wealth_history(years=5)
>>> pf.percentile_wealth_history(years=5)
10 50 90
years
1 3815.660408 4202.758919 4457.210561
Expand All @@ -1276,7 +1277,7 @@ def forecast_wealth_history(
5 4613.287195 5706.343210 6694.576137
"""
first_value = self.wealth_index[self.symbol].values[-1]
percentile_returns = self.percentile_from_history(
percentile_returns = self.percentile_history_cagr(
years=years, percentiles=percentiles
)
return first_value * (percentile_returns + 1.0).pow(
Expand All @@ -1292,11 +1293,11 @@ def _forecast_preparation(self, years: int):
ts_index = pd.period_range(start_period, end_period, freq="M")
return period_months, ts_index

def forecast_monte_carlo_returns(
def monte_carlo_returns_ts(
self, distr: str = "norm", years: int = 1, n: int = 100
) -> pd.DataFrame:
"""
Forecast portfolio monthly rate of return with Monte Carlo simulation.
Generate portfolio monthly rate of return time series with Monte Carlo simulation.
Monte Carlo simulation generates n random monthly time series with a given distribution.
Forecast period should not exceed 1/2 of portfolio history period length.
Expand All @@ -1323,7 +1324,7 @@ def forecast_monte_carlo_returns(
Examples
--------
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_period='month')
>>> pf.forecast_monte_carlo_returns(years=8, distr='norm', n=5000)
>>> pf.monte_carlo_returns_ts(years=8, distr='norm', n=5000)
0 1 2 ... 4997 4998 4999
2021-07 -0.008383 -0.013167 -0.031659 ... 0.046717 0.065675 0.017933
2021-08 0.038773 -0.023627 0.039208 ... -0.016075 0.034439 0.001856
Expand Down Expand Up @@ -1353,11 +1354,11 @@ def forecast_monte_carlo_returns(
raise ValueError('"distr" must be "norm" (default) or "lognorm".')
return pd.DataFrame(data=random_returns, index=ts_index)

def forecast_monte_carlo_wealth_indexes(
def _monte_carlo_wealth(
self, distr: str = "norm", years: int = 1, n: int = 100
) -> pd.DataFrame:
"""
Forecast portfolio wealth index with Monte Carlo simulation.
Generate portfolio wealth index with Monte Carlo simulation.
Monte Carlo simulation generates n random monthly time series.
Each wealth index is calculated with rate of return time series of a given distribution.
Expand Down Expand Up @@ -1387,7 +1388,7 @@ def forecast_monte_carlo_wealth_indexes(
Examples
--------
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_period='month')
>>> pf.forecast_monte_carlo_wealth_indexes(distr='lognorm', years=5, n=1000)
>>> pf._monte_carlo_wealth(distr='lognorm', years=5, n=1000)
0 1 ... 998 999
2021-07 3895.377293 3895.377293 ... 3895.377293 3895.377293
2021-08 3869.854680 4004.814981 ... 3874.455244 3935.913516
Expand All @@ -1398,7 +1399,7 @@ def forecast_monte_carlo_wealth_indexes(
"""
if distr not in ["norm", "lognorm"]:
raise ValueError('distr should be "norm" (default) or "lognorm".')
return_ts = self.forecast_monte_carlo_returns(distr=distr, years=years, n=n)
return_ts = self.monte_carlo_returns_ts(distr=distr, years=years, n=n)
first_value = self.wealth_index[self.symbol].values[-1]
return Frame.get_wealth_indexes(return_ts, first_value)

Expand All @@ -1412,34 +1413,35 @@ def _get_monte_carlo_cagr_distribution(
"""
if distr not in ["norm", "lognorm"]:
raise ValueError('distr should be "norm" (default) or "lognorm".')
return_ts = self.forecast_monte_carlo_returns(distr=distr, years=years, n=n)
return_ts = self.monte_carlo_returns_ts(distr=distr, years=years, n=n)
return Frame.get_cagr(return_ts)

def forecast_monte_carlo_cagr(
def percentile_distribution_cagr(
self,
distr: str = "norm",
years: int = 1,
percentiles: List[int] = [10, 50, 90],
n: int = 10000,
) -> Dict[int, float]:
"""
Calculate percentiles for forecasted CAGR distribution.
Calculate percentiles for a given CAGR distribution.
CAGR - Compound Annual Growth Rate.
CAGR is calculated for each of n future random returns time series of a given distribution.
Forecast period should not exceed 1/2 of portfolio history period length.
CAGR is calculated for each of n random returns time series of a given distribution. Random time series are
generated with Monte Carlo simulation.
CAGR time frame should not exceed 1/2 of portfolio history period length.
Parameters
----------
distr : {'norm', 'lognorm'}, default 'norm'
Distribution type for the rate of return of portfolio.
years: int, default 1
Forecast period for portfolio CAGR.
Time frame for portfolio CAGR.
It should not exceed 1/2 of the portfolio history period length 'period_length'.
percentiles: list of int, default [10, 50, 90]
List of percentiles to be forecasted.
List of percentiles to be calculated.
n : int, default 10000
Number of random time series to generate with Monte Carlo simulation.
Expand All @@ -1452,10 +1454,10 @@ def forecast_monte_carlo_cagr(
Examples
--------
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_period='year')
>>> pf.forecast_monte_carlo_cagr()
>>> pf.percentile_distribution_cagr()
{10: -0.0329600265453808, 50: 0.08247141141668779, 90: 0.21338327078214836}
Forecast CAGR according to normal distribution within 1 year period.
>>> pf.forecast_monte_carlo_cagr(years=5)
>>> pf.percentile_distribution_cagr(years=5)
{10: 0.030625112922274055, 50: 0.08346815557550402, 90: 0.13902575176654647}
Forecast CAGR according to normal distribution within 5 year period.
"""
Expand All @@ -1470,7 +1472,7 @@ def forecast_monte_carlo_cagr(
results.update({percentile: value})
return results

def forecast_wealth(
def percentile_wealth(
self,
distr: str = "norm",
years: int = 1,
Expand All @@ -1479,21 +1481,25 @@ def forecast_wealth(
n: int = 1000,
) -> Dict[int, float]:
"""
Calculate percentiles of forecasted random accumulated wealth distribution.
Random distribution could be normal lognormal or from history.
Calculate percentiles for portfolio wealth indexes distribution.
Portfolio wealth indexes are derived from CAGR time series with given distribution type.
CAGR - Compound Annual Growth Rate.
today_value - the value of portfolio today (before forecast period). If today_value is None
the last value of the historical wealth indexes is taken.
TODO: finish docstrings
"""
if distr == "hist":
results = (
self.forecast_wealth_history(years=years, percentiles=percentiles)
self.percentile_wealth_history(years=years, percentiles=percentiles)
.iloc[-1]
.to_dict()
)
elif distr in ["norm", "lognorm"]:
results = {}
wealth_indexes = self.forecast_monte_carlo_wealth_indexes(
wealth_indexes = self._monte_carlo_wealth(
distr=distr, years=years, n=n
)
for percentile in percentiles:
Expand Down Expand Up @@ -1529,7 +1535,7 @@ def plot_forecast(
x1 = self.last_date
x2 = x1.replace(year=x1.year + years)
y_start_value = wealth[self.symbol].iloc[-1]
y_end_values = self.forecast_wealth(
y_end_values = self.percentile_wealth(
distr=distr, years=years, percentiles=percentiles, n=n
)
if today_value:
Expand Down Expand Up @@ -1572,7 +1578,7 @@ def plot_forecast_monte_carlo(
Normal and lognormal distributions could be used for Monte Carlo simulation.
"""
s1 = self.wealth_index
s2 = self.forecast_monte_carlo_wealth_indexes(distr=distr, years=years, n=n)
s2 = self._monte_carlo_wealth(distr=distr, years=years, n=n)
s1[self.symbol].plot(legend=None, figsize=figsize)
for n in s2:
s2[n].plot(legend=None)
Expand Down
28 changes: 17 additions & 11 deletions tests/test_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,14 @@ def test_describe_no_inflation(portfolio_no_inflation):


def test_percentile_from_history(portfolio_rebalanced_month, portfolio_no_inflation, portfolio_short_history):
assert portfolio_rebalanced_month.percentile_from_history(years=1).iloc[0, 1] == approx(0.12456, rel=1e-2)
assert portfolio_no_inflation.percentile_from_history(years=1).iloc[0, 1] == approx(0.12456, rel=1e-2)
assert portfolio_rebalanced_month.percentile_history_cagr(years=1).iloc[0, 1] == approx(0.12456, rel=1e-2)
assert portfolio_no_inflation.percentile_history_cagr(years=1).iloc[0, 1] == approx(0.12456, rel=1e-2)
with pytest.raises(
ValueError,
match="Time series does not have enough history to forecast. "
"Period length is 0.90 years. At least 2 years are required.",
):
portfolio_short_history.percentile_from_history(years=1)
portfolio_short_history.percentile_history_cagr(years=1)


def test_table(portfolio_rebalanced_month):
Expand Down Expand Up @@ -263,22 +263,28 @@ def test_get_rolling_cagr_failing_no_inflation(portfolio_no_inflation):
portfolio_no_inflation.get_rolling_cagr(real=True)


def test_forecast_monte_carlo_norm_wealth_indexes(portfolio_rebalanced_month):
assert portfolio_rebalanced_month.forecast_monte_carlo_wealth_indexes(
years=1, n=1000
def test_monte_carlo_wealth(portfolio_rebalanced_month):
assert portfolio_rebalanced_month._monte_carlo_wealth(
distr='norm',
years=1,
n=1000
).iloc[-1, :].mean() == approx(2121, rel=1e-1)


def test_forecast_monte_carlo_percentile_wealth_indexes(portfolio_rebalanced_month):
dic = portfolio_rebalanced_month.forecast_wealth(years=1, n=100, percentiles=[50])
assert dic[50] == approx(2121, rel=1e-1)
@mark.parametrize(
"distribution, expected",
[('hist', 2096), ('norm', 2103), ('lognorm', 2093)],
)
def test_percentile_wealth(portfolio_rebalanced_month, distribution, expected):
dic = portfolio_rebalanced_month.percentile_wealth(distr=distribution, years=1, n=100, percentiles=[50])
assert dic[50] == approx(expected, rel=1e-1)


def test_forecast_monte_carlo_cagr(portfolio_rebalanced_month):
dic = portfolio_rebalanced_month.forecast_monte_carlo_cagr(years=2, distr='lognorm', n=100, percentiles=[50])
dic = portfolio_rebalanced_month.percentile_distribution_cagr(years=2, distr='lognorm', n=100, percentiles=[50])
assert dic[50] == approx(0.12, abs=5e-2)
with pytest.raises(ValueError):
portfolio_rebalanced_month.forecast_monte_carlo_cagr(years=10, distr='lognorm', n=100, percentiles=[50])
portfolio_rebalanced_month.percentile_distribution_cagr(years=10, distr='lognorm', n=100, percentiles=[50])


def test_skewness(portfolio_rebalanced_month):
Expand Down

0 comments on commit eb8baeb

Please sign in to comment.