Skip to content

Commit 43db91f

Browse files
committed
feat: add periods_per_year property to CashFlow
1 parent 243ed81 commit 43db91f

File tree

2 files changed

+92
-43
lines changed

2 files changed

+92
-43
lines changed

okama/portfolio.py

Lines changed: 91 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,7 +1612,7 @@ def monte_carlo_wealth(self, distr: str = "norm", years: int = 1, n: int = 100)
16121612
Examples
16131613
--------
16141614
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_period='month')
1615-
>>> pf.monte_carlo_wealth(distr='lognorm', years=5, n=1000)
1615+
>>> pf.monte_carlo_wealth_fv(distr='lognorm', years=5, n=1000)
16161616
0 1 ... 998 999
16171617
2021-07 3895.377293 3895.377293 ... 3895.377293 3895.377293
16181618
2021-08 3869.854680 4004.814981 ... 3874.455244 3935.913516
@@ -2966,21 +2966,23 @@ def monte_carlo_survival_period(self, threshold: float = 0) -> pd.Series:
29662966

29672967
def find_the_largest_withdrawals_size(
29682968
self,
2969-
withdrawal_steps: int,
2970-
confidence_level: float,
29712969
goal: str,
2970+
target_survival_period: int = 25,
2971+
percentile: float = 0.20,
29722972
threshold: float = 0,
2973-
target_survival_period: int = 25
2974-
) -> float:
2973+
tolerance_rel: float = 0.01,
2974+
iter_max: int =100
2975+
) -> Tuple[float, float]:
29752976
"""
29762977
Find the largest withdrawals size for Monte Carlo simulation according to Cashflow Strategy.
29772978
29782979
It's possible to find the largest withdrawl with 2 kind of goals:
29792980
2980-
— 'maintain_balance' to keep the purchasing power of the invesments after inflation
2981+
— 'maintain_balance_pv' to keep the purchasing power of the invesments after inflation
29812982
for the whole period defined in Monte Carlo parameteres.
29822983
— 'survival_period' to keep positive balance for a period defined by 'target_survival_period'.
2983-
The method works with IndexationStrategy and PercentageStrategy only.
2984+
The method works with IndexationStrategy
2985+
and PercentageStrategy only.
29842986
29852987
The withdrawal size defined in cash flow strategy must be negative.
29862988
@@ -2991,18 +2993,15 @@ def find_the_largest_withdrawals_size(
29912993
29922994
Parameters
29932995
----------
2994-
withdrawal_steps : int
2995-
The number of intermediate steps during the iteration of values fom maximum
2996-
to minimum of the withdrawal size.
2997-
The withdrawal size varies from 100% of the initial investment to zero.
2998-
2999-
confidence_level : float
2996+
percentile : float
30002997
Confidence level must be form 0 to 1.
30012998
Confidence level defines the percentile of Monte Carlo time series. 0.01 or 0.05 are the examples of "bad"
30022999
scenarios. 0.50 is mediane (50% percentile). 0.95 or 0.99 are optimiststic scenarios.
30033000
3004-
goal : {'maintain_balance', 'survival_period'}
3005-
'maintain_balance' - the goal is to keep the purchasing power of the invesments after inflation
3001+
goal : {'maintain_balance_fv', 'maintain_balance_pv', 'survival_period'}
3002+
'maintain_balance_fv' - the goal is to maintain the balance not lower than the nominal amount of the initial investment after inflation
3003+
for the whole period defined in Monte Carlo parameteres.
3004+
'maintain_balance_pv' - the goal is to keep the purchasing power of the invesments after inflation
30063005
for the whole period defined in Monte Carlo parameteres.
30073006
'survival_period' - the goal is to keep positive balance
30083007
for a period defined by 'target_survival_period'.
@@ -3014,6 +3013,10 @@ def find_the_largest_withdrawals_size(
30143013
target_survival_period: int, default 25
30153014
The smallest acceptable survival period. It wokrs with the 'survival_period' goal only.
30163015
3016+
iter_max : integer, default 100
3017+
3018+
tolerance_rel : float, default 0.10
3019+
30173020
Examples
30183021
--------
30193022
>>> pf = ok.Portfolio(
@@ -3037,50 +3040,89 @@ def find_the_largest_withdrawals_size(
30373040
...)
30383041
>>> pf.dcf.find_the_largest_withdrawals_size(
30393042
... withdrawal_steps=30,
3040-
... confidence_level=0.50,
3043+
... percentile=0.50,
30413044
... goal="survival_period",
30423045
... threshold=0.05,
30433046
... target_survival_period=25
30443047
...)
30453048
np.float64(-0.10344827586206895)
30463049
"""
30473050
# TODO: introduce withdrawals_range (500, 0)
3048-
max_withdrawal = 0
30493051
if target_survival_period > self.mc.period:
30503052
raise ValueError(f"target_survival_period must be less or equal than Monte Carlo simulation period ({self.mc.period}).")
3051-
if confidence_level > 1 or confidence_level < 0:
3052-
raise ValueError("confidence level must be between 0 and 1")
3053+
if percentile > 100 or percentile < 0:
3054+
raise ValueError("percentile must be between 0 and 100")
30533055
if threshold > 1 or threshold < 0:
30543056
raise ValueError("threshold must be between 0 and 1")
3057+
backup_obj = self.cashflow_parameters
3058+
start_investment = self.cashflow_parameters.initial_investment
3059+
expected_min_withdrawal = 0
30553060
if self.cashflow_parameters.NAME == "fixed_amount":
3056-
size_range = np.linspace(-self.cashflow_parameters.initial_investment, 0, withdrawal_steps)
3061+
3062+
if goal == "maintain_balance_pv":
3063+
self.cashflow_parameters.amount = - 0.03 * start_investment / self.cashflow_parameters.periods_per_year
3064+
expected_max_withdrawal = - 0.10 * start_investment / self.cashflow_parameters.periods_per_year
3065+
elif goal == "maintain_balance_fv":
3066+
self.cashflow_parameters.amount = - 0.04 * start_investment / self.cashflow_parameters.periods_per_year
3067+
expected_max_withdrawal = - 0.15 * start_investment / self.cashflow_parameters.periods_per_year
3068+
elif goal == "survival_period":
3069+
self.cashflow_parameters.amount = - 0.10 * start_investment / self.cashflow_parameters.periods_per_year
3070+
expected_max_withdrawal = - 0.20 * start_investment / self.cashflow_parameters.periods_per_year
30573071
elif self.cashflow_parameters.NAME == "fixed_percentage":
3058-
size_range = np.linspace(-1, 0, withdrawal_steps)
3059-
else:
3060-
raise TypeError("find_the_largest_withdrawals_size works with 'fixed_amount' or 'fixed_percentag' only.")
3061-
backup_obj = self.cashflow_parameters
3062-
for size in size_range:
3063-
if self.cashflow_parameters.NAME == "fixed_amount":
3064-
# TODO: amount should depend on "frequency" (devide by 12 for month)
3065-
self.cashflow_parameters.amount = size
3066-
elif self.cashflow_parameters.NAME == "fixed_percentage":
3067-
self.cashflow_parameters.percentage = size
3068-
3069-
sp_at_quantile = self.monte_carlo_survival_period(threshold=threshold).quantile(confidence_level)
3070-
if goal == "maintain_balance":
3071-
wealth_at_quantile = self.monte_carlo_wealth.iloc[-1, :].quantile(confidence_level)
3072-
condition = (sp_at_quantile == self.mc.period) and (wealth_at_quantile >= self.initial_investment_fv)
3073-
if condition:
3074-
max_withdrawal = size
3075-
break
3072+
# TODO: amount should depend on "frequency" (devide by 12 for month)
3073+
self.cashflow_parameters.percentage = - 0.03 * start_investment / self.cashflow_parameters.periods_per_year
3074+
# NEW Code ()
3075+
iter = 0
3076+
solutions = pd.DataFrame(columns=["withdrawal", "error_rel", "error_rel_change"])
3077+
while True:
3078+
wealth_at_quantile = self.monte_carlo_wealth_pv.iloc[-1, :].quantile(percentile / 100)
3079+
sp_at_quantile = self.monte_carlo_survival_period(threshold=0).quantile(percentile / 100)
3080+
3081+
if goal in ["maintain_balance_fv", "maintain_balance_pv"]:
3082+
condition = (wealth_at_quantile >= start_investment) and (sp_at_quantile == self.mc.period)
3083+
print(wealth_at_quantile, self.cashflow_parameters.amount)
3084+
error_rel = abs(wealth_at_quantile - start_investment) / start_investment
30763085
elif goal == "survival_period":
30773086
condition = sp_at_quantile >= target_survival_period
3078-
if condition:
3079-
max_withdrawal = size
3087+
print(f'{sp_at_quantile=:.2f}, {self.cashflow_parameters.amount=:.2f}')
3088+
error_rel = abs(sp_at_quantile - target_survival_period) / target_survival_period
3089+
3090+
solutions.at[iter, "withdrawal"] = self.cashflow_parameters.amount
3091+
solutions.at[iter, "error_rel"] = error_rel
3092+
gradient = solutions.at[iter, "error_rel"] - solutions.at[iter - 1, "error_rel"] if iter != 0 else 0
3093+
solutions.at[iter, "error_rel_change"] = gradient
3094+
3095+
print(f'{error_rel=:.3f}, {gradient=:.3f}')
3096+
3097+
if condition:
3098+
if error_rel < tolerance_rel:
3099+
max_withdrawal = self.cashflow_parameters.amount
3100+
print(f'solution found: {max_withdrawal} after {iter} steps.')
3101+
break
3102+
expected_min_withdrawal = self.cashflow_parameters.amount
3103+
delta = abs(expected_max_withdrawal - self.cashflow_parameters.amount) # ind.amount must be negative
3104+
self.cashflow_parameters.amount -= delta / 2
3105+
print("increasing withdrawal")
3106+
else:
3107+
if error_rel < tolerance_rel:
3108+
max_withdrawal = self.cashflow_parameters.amount
3109+
print(f'solution found: {max_withdrawal} after {iter} steps.')
30803110
break
3111+
expected_max_withdrawal = self.cashflow_parameters.amount
3112+
delta = abs(self.cashflow_parameters.amount - expected_min_withdrawal)
3113+
self.cashflow_parameters.amount += delta / 2
3114+
print("decreasing withdrawal")
3115+
iter += 1
3116+
if iter > iter_max:
3117+
condition = solutions["error_rel"].idxmin()
3118+
best_result = solutions.loc[condition]["withdrawal"]
3119+
max_withdrawal = solutions.loc[condition]["error_rel"]
3120+
print(
3121+
f"'Didn't found solution after {iter} steps. The closest withdrawal was {best_result} or {self.cashflow_parameters.amount / start_investment * 12} with an error: {max_withdrawal}")
3122+
break
30813123
self.cashflow_parameters = backup_obj
30823124
self.cashflow_parameters._clear_cf_cache()
3083-
return max_withdrawal
3125+
return max_withdrawal, error_rel
30843126

30853127

30863128
class MonteCarlo:
@@ -3236,7 +3278,14 @@ def frequency(self, frequency):
32363278
raise ValueError(f"frequency must be in {settings.frequency_mapping.keys()}")
32373279

32383280
@property
3239-
def initial_investment(self):
3281+
def periods_per_year(self) -> int:
3282+
"""
3283+
Show the number of periods per year. Period is defined by the frequency.
3284+
"""
3285+
return settings.frequency_periods_per_year[self.frequency]
3286+
3287+
@property
3288+
def initial_investment(self) -> float:
32403289
"""
32413290
Portfolio initial investment FV size (at last_date).
32423291

tests/test_portfolio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ def test_get_rolling_cagr_failing_no_inflation(portfolio_no_inflation):
308308

309309

310310
def test_monte_carlo_wealth(portfolio_rebalanced_month):
311-
df = portfolio_rebalanced_month.monte_carlo_wealth(distr="norm", years=1, n=1000)
311+
df = portfolio_rebalanced_month.monte_carlo_wealth_fv(distr="norm", years=1, n=1000)
312312
assert df.shape == (12, 1000)
313313
assert df.iloc[-1, :].mean() == approx(2915.55, rel=1e-1)
314314

0 commit comments

Comments
 (0)