Skip to content

Commit 649abee

Browse files
committed
chore: add VanguardDynamicSpending to wealth and cashflow calculations
1 parent c4eb241 commit 649abee

File tree

4 files changed

+142
-54
lines changed

4 files changed

+142
-54
lines changed

main.py

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,26 @@
1616

1717
rs = ok.Rebalance(
1818
period="year",
19-
abs_deviation=0.10,
20-
rel_deviation=0.40
19+
# abs_deviation=0.10,
20+
# rel_deviation=0.40
2121
)
2222
weights = [0.12, 0.21, 0.42, 0.25]
2323
pf = ok.Portfolio(
2424
['RGBITR.INDX', 'RUCBTRNS.INDX', 'MCFTR.INDX', 'GC.COMM'],
2525
weights=weights,
26-
first_date='2014-06',
26+
# first_date='2014-06',
2727
ccy="RUB",
2828
inflation=True,
2929
rebalancing_strategy=rs,
3030
symbol="My_portfolio.PF",
3131
)
3232
pf.dcf.discount_rate = 0.09
33-
# Percentage CF strategy
34-
cf_strategy = ok.PercentageStrategy(pf) # create PercentageStrategy linked to the portfolio
35-
36-
cf_strategy.initial_investment = 83_000_000 # initial investments size
37-
cf_strategy.frequency = "year" # withdrawals frequency
38-
cf_strategy.percentage = -0.40
33+
# # Percentage CF strategy
34+
# cf_strategy = ok.PercentageStrategy(pf) # create PercentageStrategy linked to the portfolio
35+
#
36+
# cf_strategy.initial_investment = 83_000_000 # initial investments size
37+
# cf_strategy.frequency = "year" # withdrawals frequency
38+
# cf_strategy.percentage = -0.40
3939

4040
# # Indexation CF strategy
4141
# cf_strategy = ok.IndexationStrategy(pf)
@@ -45,27 +45,56 @@
4545
# cf_strategy.amount = 1_500_000 * 12
4646
# cf_strategy.indexation = 0.09
4747

48-
d = {
49-
"2015-06": -35_000_000,
50-
}
51-
52-
cf_strategy.time_series_dic = d
53-
cf_strategy.time_series_discounted_values = False
48+
# d = {
49+
# "2015-06": -35_000_000,
50+
# }
51+
#
52+
# cf_strategy.time_series_dic = d
53+
# cf_strategy.time_series_discounted_values = False
54+
55+
# Fixed Percentage strategy
56+
cf_strategy = ok.VanguardDynamicSpending(pf)
57+
cf_strategy.initial_investment = 10_000_000
58+
cf_strategy.frequency = "year"
59+
cf_strategy.percentage = -0.15
60+
cf_strategy.indexation = 0.09
61+
cf_strategy.maximum_annual_withdrawal = 10_000_000 / 5 # 20%
62+
cf_strategy.minimum_annual_withdrawal = 10_000_000 / 10 # 10%
63+
cf_strategy.ceiling = 0.10
64+
cf_strategy.floor = -0.10
5465

5566
pf.dcf.cashflow_parameters = cf_strategy # assign the cash flow strategy to portfolio
5667

68+
# w = cf_strategy.calculate_withdrawal_size(
69+
# last_withdrawal=0,
70+
# balance=cf_strategy.initial_investment,
71+
# number_of_periods=1
72+
# )
73+
74+
# print(w)
5775
pf.dcf.set_mc_parameters(
5876
distribution="norm",
5977
period=15,
6078
number=100
6179
)
6280

63-
# wi = pf.dcf.wealth_index(discounting="fv", include_negative_values=True)
64-
# cf = pf.dcf.cash_flow_ts(discounting="fv", remove_if_wealth_index_negative=False).resample("Y").sum()
65-
cf = pf.dcf.monte_carlo_cash_flow(discounting="fv", remove_if_wealth_index_negative=True)
66-
print(cf)
81+
# wi = pf.dcf.wealth_index(discounting="fv", include_negative_values=False)
82+
# cf = pf.dcf.cash_flow_ts(discounting="pv", remove_if_wealth_index_negative=True).resample("Y").sum()
83+
wi = pf.dcf.monte_carlo_wealth(discounting="fv", include_negative_values=False)
84+
# cf = pf.dcf.monte_carlo_cash_flow(discounting="fv", remove_if_wealth_index_negative=True)
85+
# print(cf)
86+
87+
wi.plot(
88+
# kind="bar",
89+
legend=False
90+
)
91+
plt.yscale('log') # linear or log
92+
plt.show()
6793

68-
# cf[0].plot(kind="bar", legend=False)
94+
# cf.plot(
95+
# kind="bar",
96+
# legend=False
97+
# )
6998
# plt.yscale('linear') # linear or log
7099
# plt.show()
71100

main_notebook.ipynb

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"cells": [
33
{
44
"cell_type": "code",
5-
"execution_count": 2,
5+
"execution_count": 1,
66
"metadata": {
77
"ExecuteTime": {
88
"end_time": "2025-07-29T11:26:06.735435Z",
@@ -36,7 +36,7 @@
3636
},
3737
{
3838
"cell_type": "code",
39-
"execution_count": 3,
39+
"execution_count": 2,
4040
"metadata": {
4141
"ExecuteTime": {
4242
"end_time": "2025-07-29T11:26:09.894658Z",
@@ -54,7 +54,7 @@
5454
"'1.5.0'"
5555
]
5656
},
57-
"execution_count": 3,
57+
"execution_count": 2,
5858
"metadata": {},
5959
"output_type": "execute_result"
6060
}
@@ -77,7 +77,7 @@
7777
},
7878
{
7979
"cell_type": "code",
80-
"execution_count": 4,
80+
"execution_count": 3,
8181
"metadata": {
8282
"ExecuteTime": {
8383
"end_time": "2025-07-29T11:26:11.913869Z",
@@ -93,7 +93,7 @@
9393
},
9494
{
9595
"cell_type": "code",
96-
"execution_count": 5,
96+
"execution_count": 4,
9797
"metadata": {
9898
"ExecuteTime": {
9999
"end_time": "2025-07-29T11:26:55.347301Z",
@@ -109,7 +109,7 @@
109109
"name": "stdout",
110110
"output_type": "stream",
111111
"text": [
112-
"symbol portfolio_6434.PF\n",
112+
"symbol portfolio_7799.PF\n",
113113
"assets [RGBITR.INDX, RUCBTRNS.INDX, MCFTR.INDX, GC.COMM]\n",
114114
"weights [0.1, 0.1, 0.55, 0.25]\n",
115115
"rebalancing_period year\n",
@@ -142,7 +142,7 @@
142142
},
143143
{
144144
"cell_type": "code",
145-
"execution_count": 222,
145+
"execution_count": 5,
146146
"metadata": {},
147147
"outputs": [],
148148
"source": [
@@ -1641,15 +1641,15 @@
16411641
{
16421642
"data": {
16431643
"text/plain": [
1644-
"Portfolio symbol portfolio_6434.PF\n",
1645-
"Cash flow initial investment 10000000\n",
1646-
"Cash flow frequency year\n",
1647-
"Cash flow strategy VDS\n",
1648-
"Cash flow percentage -0.07000\n",
1649-
"Minimum annual withdrawal 400,000.00000\n",
1650-
"Maximum annual withdrawal 1,000,000.00000\n",
1651-
"Ceiling 0.10000\n",
1652-
"Floor -0.10000\n",
1644+
"Portfolio symbol Portfolio.PF\n",
1645+
"Cash flow initial investment 10000000\n",
1646+
"Cash flow frequency year\n",
1647+
"Cash flow strategy VDS\n",
1648+
"Cash flow percentage -0.07000\n",
1649+
"Minimum annual withdrawal 400,000.00000\n",
1650+
"Maximum annual withdrawal 1,000,000.00000\n",
1651+
"Ceiling 0.10000\n",
1652+
"Floor -0.10000\n",
16531653
"dtype: object"
16541654
]
16551655
},
@@ -1664,12 +1664,25 @@
16641664
},
16651665
{
16661666
"cell_type": "code",
1667-
"execution_count": null,
1667+
"execution_count": 9,
16681668
"metadata": {},
1669-
"outputs": [],
1669+
"outputs": [
1670+
{
1671+
"data": {
1672+
"text/plain": [
1673+
"-0.0"
1674+
]
1675+
},
1676+
"execution_count": 9,
1677+
"metadata": {},
1678+
"output_type": "execute_result"
1679+
}
1680+
],
16701681
"source": [
16711682
"vds.calculate_withdrawal_size(\n",
1672-
" last_withdrawal=\n",
1683+
" last_withdrawal=0,\n",
1684+
" balance=1000,\n",
1685+
" number_of_periods=1\n",
16731686
")"
16741687
]
16751688
},

okama/portfolios/cashflow_strategies.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def initial_investment(self, initial_investment):
9090
@property
9191
def time_series_dic(self) -> dict:
9292
"""
93+
# TODO: rename to extra_cashflow_ts
9394
Cash flow time series in form of dictionary.
9495
9596
Negative number corresponds to withdrawals, positive number corresponds to contributions.
@@ -378,27 +379,43 @@ def __repr__(self):
378379
}
379380
return repr(pd.Series(dic))
380381

381-
def calculate_withdrawal_size(self, last_withdrawal: float, balance: float, number_of_months: int) -> float:
382-
withdrawal_size_by_percentage = balance * self.percentage
383-
ceiling = last_withdrawal * (1 + self.ceiling)
384-
floor = last_withdrawal * (1 + self.floor)
385-
min_indexed = self.minimum_annual_withdrawal * (1 + self.indexation) ** number_of_months
386-
max_indexed = self.maximum_annual_withdrawal * (1 + self.indexation) ** number_of_months
382+
def calculate_withdrawal_size(self, last_withdrawal: float, balance: float, number_of_periods: int) -> float:
383+
"""
384+
Calculate regular withdrawal size (Extra Withdrawals are not taken into account).
385+
"""
386+
# All values are postive
387+
withdrawal_size_by_percentage = balance * abs(self.percentage)
388+
ceiling = abs(last_withdrawal) * (1 + self.ceiling)
389+
floor = abs(last_withdrawal) * (1 + self.floor)
390+
min_indexed = abs(self.minimum_annual_withdrawal) * (1 + self.indexation) ** number_of_periods
391+
max_indexed = abs(self.maximum_annual_withdrawal) * (1 + self.indexation) ** number_of_periods
392+
# Chek what limitation is actual
393+
# Upper limit
387394
if ceiling > max_indexed:
388395
max_final = max_indexed
389-
else:
396+
elif min_indexed < ceiling <= max_indexed:
390397
max_final = ceiling
391-
if floor < min_indexed:
392-
min_final = min_indexed
393398
else:
399+
# ceiling = 0
400+
max_final = max_indexed
401+
# Lower limit
402+
if floor > min_indexed:
394403
min_final = floor
395-
404+
elif 0 < floor <= min_indexed:
405+
min_final = min_indexed
406+
else:
407+
# floor = 0
408+
min_final = min_indexed
409+
# Apply the limitation to the withdrawal
396410
if min_final <= withdrawal_size_by_percentage <= max_final:
397-
withdrawal = withdrawal_size_by_percentage
411+
withdrawal = - withdrawal_size_by_percentage
412+
# print(f"withdrawal by percentage. Max: {max_final: .0f}, Min: {min_final: .0f}")
398413
elif withdrawal_size_by_percentage > max_final:
399-
withdrawal = max_final
414+
withdrawal = - max_final
415+
# print(f"withdrawal by max_final. By percentage was {withdrawal_size_by_percentage: .0f}")
400416
elif withdrawal_size_by_percentage < min_final:
401-
withdrawal = min_final
417+
withdrawal = - min_final
418+
# print(f"withdrawal by min_final. By percentage was {withdrawal_size_by_percentage: .0f}")
402419
else:
403420
raise ValueError('Wrong withdrawal size. Check the calculation.')
404421
return withdrawal

okama/portfolios/dcf_calculations.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def get_wealth_indexes_fv_with_cashflow(
2626
dcf_object.cashflow_parameters = cashflow_parameters
2727
period_initial_amount = cashflow_parameters.initial_investment
2828
period_initial_amount_cached = period_initial_amount
29+
last_regular_cash_flow = 0
2930
amount = getattr(cashflow_parameters, "amount", None)
3031
if isinstance(ror, pd.DataFrame):
3132
portfolio_position = ror.columns.get_loc(portfolio_symbol)
@@ -66,6 +67,12 @@ def get_wealth_indexes_fv_with_cashflow(
6667
cashflow = cashflow_parameters.percentage / periods_per_year * period_initial_amount
6768
elif cashflow_parameters.NAME == "time_series":
6869
cashflow = 0
70+
elif cashflow_parameters.NAME == "VDS":
71+
cashflow = cashflow_parameters.calculate_withdrawal_size(
72+
last_withdrawal=last_regular_cash_flow if n > 0 else 0,
73+
balance=period_initial_amount,
74+
number_of_periods=n,
75+
)
6976
else:
7077
raise ValueError("Wrong cashflow strategy name value.")
7178
period_initial_amount = period_initial_amount * (r + 1) + cashflow + cash_flow_ts[date]
@@ -94,6 +101,12 @@ def get_wealth_indexes_fv_with_cashflow(
94101
cashflow_value = amount * (1 + cashflow_parameters.indexation / periods_per_year) ** n
95102
elif cashflow_parameters.NAME == "fixed_percentage":
96103
cashflow_value = cashflow_parameters.percentage / periods_per_year * period_initial_amount
104+
elif cashflow_parameters.NAME == "VDS":
105+
cashflow_value = cashflow_parameters.calculate_withdrawal_size(
106+
last_withdrawal=last_regular_cash_flow if n > 0 else 0,
107+
balance=period_initial_amount,
108+
number_of_periods=n,
109+
)
97110
else:
98111
raise ValueError("Wrong cashflow_method value.")
99112
period_final_balance = period_wealth_index.iloc[-1] + cashflow_value
@@ -126,6 +139,7 @@ def get_cash_flow_fv(
126139
dcf_object = cashflow_parameters.parent.dcf
127140
dcf_object.cashflow_parameters = cashflow_parameters
128141
period_initial_amount = cashflow_parameters.initial_investment
142+
last_regular_cash_flow = 0
129143
cs_fv = pd.Series(dtype=float, name="cash_flow_fv")
130144
amount = getattr(cashflow_parameters, "amount", None)
131145
if isinstance(ror, pd.DataFrame):
@@ -162,14 +176,23 @@ def get_cash_flow_fv(
162176
for n, row in enumerate(ror.itertuples()):
163177
date = row[0]
164178
r = row[portfolio_position + 1]
179+
# Calculate regular cash flow
165180
if cashflow_parameters.NAME == "fixed_amount":
166181
cashflow = amount * (1 + cashflow_parameters.indexation / settings._MONTHS_PER_YEAR) ** n
167182
elif cashflow_parameters.NAME == "fixed_percentage":
168183
cashflow = cashflow_parameters.percentage / periods_per_year * period_initial_amount
169184
elif cashflow_parameters.NAME == "time_series":
170185
cashflow = 0
186+
elif cashflow_parameters.NAME == "VDS":
187+
cashflow = cashflow_parameters.calculate_withdrawal_size(
188+
last_withdrawal=last_regular_cash_flow if n > 0 else 0,
189+
balance=period_initial_amount,
190+
number_of_periods=n,
191+
)
171192
else:
172193
raise ValueError("Wrong cashflow strategy name value.")
194+
# add Extra Withdrawals/Contributions
195+
last_regular_cash_flow = cashflow
173196
cs_value = cashflow + cash_flow_ts[date]
174197
period_initial_amount = period_initial_amount * (r + 1) + cs_value
175198
cs_fv[date] = cs_value
@@ -179,7 +202,7 @@ def get_cash_flow_fv(
179202
for n, x in enumerate(ror_cashflow_df.resample(rule=pandas_frequency, convention="start")):
180203
ror_ts = x[1].iloc[:, portfolio_position] # select ror part of the grouped data
181204
cashflow_ts_local = x[1].loc[:, "cashflow_ts"].copy()
182-
# CashFlow inside period
205+
# CashFlow inside period (Extra cash flow)
183206
if (cashflow_ts_local != 0).any():
184207
period_wealth_index = pd.Series(dtype=float, name=portfolio_symbol)
185208
for k, (date, r) in enumerate(ror_ts.items()):
@@ -190,11 +213,17 @@ def get_cash_flow_fv(
190213
period_wealth_index[date] = month_balance
191214
else:
192215
period_wealth_index = period_initial_amount * (1 + ror_ts).cumprod()
193-
# CashFlow END period
216+
# CashFlow END period (Regular Cash Flow)
194217
if cashflow_parameters.NAME == "fixed_amount":
195218
cashflow_value = amount * (1 + cashflow_parameters.indexation / periods_per_year) ** n
196219
elif cashflow_parameters.NAME == "fixed_percentage":
197220
cashflow_value = cashflow_parameters.percentage / periods_per_year * period_initial_amount
221+
elif cashflow_parameters.NAME == "VDS":
222+
cashflow_value = cashflow_parameters.calculate_withdrawal_size(
223+
last_withdrawal=last_regular_cash_flow if n > 0 else 0,
224+
balance=period_initial_amount,
225+
number_of_periods=n,
226+
)
198227
else:
199228
raise ValueError("Wrong cashflow_method value.")
200229
period_final_balance = period_wealth_index.iloc[-1] + cashflow_value

0 commit comments

Comments
 (0)