Skip to content

Commit 1a01117

Browse files
committed
feat: add Portfolio.dcf.monte_carlo_cash_flow()
1 parent 50f2ae7 commit 1a01117

File tree

9 files changed

+837
-816
lines changed

9 files changed

+837
-816
lines changed

examples/04 investment portfolios with DCF.ipynb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@
274274
"output_type": "display_data"
275275
}
276276
],
277-
"source": "pf.dcf.wealth_index_fv.plot();"
277+
"source": "pf.dcf.wealth_index(discounting=\"fv\", include_negative_values=False).plot();"
278278
},
279279
{
280280
"cell_type": "markdown",
@@ -392,7 +392,7 @@
392392
"output_type": "display_data"
393393
}
394394
],
395-
"source": "pf.dcf.wealth_index_fv.plot()"
395+
"source": "pf.dcf.wealth_index(discounting=\"fv\", include_negative_values=False).plot()"
396396
},
397397
{
398398
"cell_type": "markdown",
@@ -504,7 +504,7 @@
504504
"output_type": "display_data"
505505
}
506506
],
507-
"source": "pf.dcf.wealth_index_fv.plot();"
507+
"source": "pf.dcf.wealth_index(discounting=\"fv\", include_negative_values=False).plot();"
508508
},
509509
{
510510
"cell_type": "markdown",
@@ -1406,7 +1406,7 @@
14061406
"output_type": "display_data"
14071407
}
14081408
],
1409-
"source": "pf.dcf.wealth_index_fv.plot();"
1409+
"source": "pf.dcf.wealth_index(discounting=\"fv\", include_negative_values=False).plot();"
14101410
},
14111411
{
14121412
"cell_type": "markdown",

main.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,16 @@
6060
number=100
6161
)
6262

63-
wi = pf.dcf.wealth_index_fv
64-
# cf = pf.dcf.cash_flow_ts_pv.resample("Y").sum()
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)
67+
68+
# cf[0].plot(kind="bar", legend=False)
69+
# plt.yscale('linear') # linear or log
70+
# plt.show()
6571

6672

67-
wi.plot(legend=False)
68-
# cf.plot(kind="bar", legend=False)
69-
plt.yscale('linear') # linear or log
70-
plt.show()
7173

7274
# df = pf.dcf.monte_carlo_wealth_fv
7375
# print(df)

main_notebook.ipynb

Lines changed: 719 additions & 710 deletions
Large diffs are not rendered by default.

okama/portfolios/cashflow_strategies.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def time_series_dic(self) -> dict:
106106
>>> # Assign the strategy to Portfolio
107107
>>> pf.dcf.cashflow_parameters = ts
108108
>>> # Plot wealth index with cash flow
109-
>>> pf.dcf.wealth_index_fv.plot()
109+
>>> pf.dcf.wealth_index(discounting="fv", include_negative_values=False).plot()
110110
>>> plt.show()
111111
"""
112112
return self._time_series_dic
@@ -130,9 +130,10 @@ def _make_series_from_dic(self):
130130
self.time_series.name = "cashflow_ts"
131131

132132
def _clear_cf_cache(self):
133-
self.parent.dcf._monte_carlo_wealth_fv = pd.DataFrame()
134-
self.parent.dcf._wealth_index_fv = pd.DataFrame()
135-
self.parent.dcf._cash_flow_fv = pd.DataFrame()
133+
self.parent.dcf._monte_carlo_wealth_fv = pd.DataFrame(dtype=float)
134+
self.parent.dcf._wealth_index_fv = pd.DataFrame(dtype=float)
135+
self.parent.dcf._cash_flow_fv = pd.DataFrame(dtype=float)
136+
self.parent.dcf._monte_carlo_cash_flow_fv = pd.DataFrame(dtype=float)
136137

137138

138139
class IndexationStrategy(CashFlow):
@@ -158,7 +159,7 @@ class IndexationStrategy(CashFlow):
158159
>>> pf.dcf.cashflow_parameters = ind
159160
>>> pf.dcf.use_discounted_values = False # do not discount initial investment value
160161
>>> # Plot wealth index with cash flow
161-
>>> pf.dcf.wealth_index_fv.plot()
162+
>>> pf.dcf.wealth_index(discounting="fv", include_negative_values=False).plot()
162163
>>> plt.show()
163164
"""
164165

@@ -251,7 +252,7 @@ class PercentageStrategy(CashFlow):
251252
>>> pf.dcf.cashflow_parameters = pc
252253
>>> pf.dcf.use_discounted_values = False # do not discount initial investment value
253254
>>> # Plot wealth index with cash flow
254-
>>> pf.dcf.wealth_index_fv.plot()
255+
>>> pf.dcf.wealth_index(discounting="fv", include_negative_values=False).plot()
255256
>>> plt.show()
256257
"""
257258

@@ -321,7 +322,7 @@ class TimeSeriesStrategy(CashFlow):
321322
>>> # Assign the strategy to Portfolio
322323
>>> pf.dcf.cashflow_parameters = ts
323324
>>> # Plot wealth index with cash flow
324-
>>> pf.dcf.wealth_index_fv.plot()
325+
>>> pf.dcf.wealth_index(discounting="fv", include_negative_values=False).plot()
325326
>>> plt.show()
326327
"""
327328

okama/portfolios/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ def wealth_index(self) -> pd.DataFrame:
389389
--------
390390
>>> import matplotlib.pyplot as plt
391391
>>> x = ok.Portfolio(['SPY.US', 'BND.US'])
392-
>>> x.wealth_index_fv.plot()
392+
>>> x.wealth_index(discounting="fv", include_negative_values=False).plot()
393393
>>> plt.show()
394394
"""
395395
df = self._add_inflation()

okama/portfolios/dcf.py

Lines changed: 88 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(
4242
self.discount_rate = discount_rate
4343
self._wealth_index_fv = pd.DataFrame(dtype=float)
4444
self._monte_carlo_wealth_fv = pd.DataFrame(dtype=float)
45+
self._monte_carlo_cash_flow_fv = pd.DataFrame(dtype=float)
4546
self._cash_flow_fv = pd.Series(dtype=float, name="cash_flow_fv")
4647
self.mc = mc.MonteCarlo(self)
4748
self._cashflow_parameters: Optional[cf.CashFlow] = None
@@ -122,7 +123,7 @@ def set_mc_parameters(self, distribution: str, period: int, number: int):
122123
>>> # Assign the strategy to Portfolio
123124
>>> pf.dcf.cashflow_parameters = ind
124125
>>> # Plot wealth index with cash flow
125-
>>> pf.dcf.wealth_index_fv.plot()
126+
>>> pf.dcf.wealth_index(discounting="fv", include_negative_values=False).plot()
126127
>>> plt.show()
127128
"""
128129
self.mc.distribution = distribution
@@ -156,7 +157,7 @@ def wealth_index(self, discounting: Literal["fv", "pv"], include_negative_values
156157
>>> ind.amount = -0.5 * 12 # initial withdrawals amount
157158
>>> ind.indexation = "inflation" # the indexation is equal to inflation
158159
>>> pf.dcf.cashflow_parameters = ind # assign the strategy to Portfolio
159-
>>> pf.dcf.wealth_index_fv.plot()
160+
>>> pf.dcf.wealth_index(discounting="fv", include_negative_values=False).plot()
160161
>>> plt.show()
161162
"""
162163
if self.cashflow_parameters is None:
@@ -178,9 +179,9 @@ def wealth_index(self, discounting: Literal["fv", "pv"], include_negative_values
178179
wealth_index_fv[self.parent.name] = wealth_index_fv_s.fillna(0)
179180
else:
180181
wealth_index_fv = self._wealth_index_fv.copy()
181-
if discounting == "fv":
182+
if discounting.lower() == "fv":
182183
return wealth_index_fv
183-
elif discounting == "pv":
184+
elif discounting.lower() == "pv":
184185
n_rows = wealth_index_fv.shape[0]
185186
discount_factors = (1.0 + self.discount_rate / settings._MONTHS_PER_YEAR) ** np.arange(n_rows)
186187
wealth_index_pv = wealth_index_fv.div(discount_factors, axis=0)
@@ -216,72 +217,35 @@ def cash_flow_ts(self, discounting: Literal["fv", "pv"], remove_if_wealth_index_
216217
>>> ind.amount = -0.5 * 12 # initial withdrawals amount
217218
>>> ind.indexation = "inflation" # the indexation is equal to inflation
218219
>>> pf.dcf.cashflow_parameters = ind # assign the strategy to Portfolio
219-
>>> pf.dcf.wealth_index_fv.plot()
220+
>>> pf.dcf.wealth_index(discounting="fv", include_negative_values=False).plot()
220221
>>> plt.show()
221222
"""
222223
if self.cashflow_parameters is None:
223224
raise AttributeError("'cashflow_parameters' is not defined.")
224225
if self._cash_flow_fv.empty:
225226
df = self.parent.ror
226-
df = dcf_calculations.get_cash_flow_fv(
227+
self._cash_flow_fv = dcf_calculations.get_cash_flow_fv(
227228
ror=df,
228229
portfolio_symbol=self.parent.symbol,
229230
cashflow_parameters=self.cashflow_parameters,
230231
task="backtest",
231232
)
232-
self._cash_flow_fv = df
233233
if remove_if_wealth_index_negative:
234234
cash_flow_fv = self._cash_flow_fv.copy()
235235
wealth_index = self.wealth_index(discounting="fv", include_negative_values=False)
236236
condition = wealth_index[self.parent.name] == 0
237237
cash_flow_fv[condition] = 0
238238
else:
239239
cash_flow_fv = self._cash_flow_fv.copy()
240-
if discounting == "fv":
240+
if discounting.lower() == "fv":
241241
return cash_flow_fv
242-
elif discounting == "pv":
242+
elif discounting.lower() == "pv":
243243
n_rows = cash_flow_fv.shape[0]
244244
discount_factors = (1.0 + self.discount_rate / settings._MONTHS_PER_YEAR) ** np.arange(n_rows)
245-
cash_flow_fv = cash_flow_fv.div(discount_factors, axis=0)
246-
return cash_flow_fv
247-
248-
@property
249-
def cash_flow_ts_pv(self) -> pd.Series:
250-
"""
251-
Wealth index Future Values (FV) time series for the portfolio with cash flow (contributions and
252-
withdrawals).
253-
254-
Wealth index (Cumulative Wealth Index) is a time series that presents the value of portfolio over
255-
historical time period considering cash flows.
256-
257-
Accumulated inflation time series is added if `inflation=True` in the Portfolio.
258-
259-
If there is no cash flow, Wealth index is obtained from the accumulated return multiplicated
260-
by the initial investments. That is: initial_amount_pv * (Acc_Return + 1)
261-
262-
Returns
263-
-------
264-
Time series of wealth index values for portfolio and accumulated inflation.
265-
266-
Examples
267-
--------
268-
>>> import matplotlib.pyplot as plt
269-
>>> pf = ok.Portfolio(['VOO.US', 'GLD.US'], weights=[0.8, 0.2])
270-
>>> ind = ok.IndexationStrategy(pf) # Set Cash Flow Strategy parameters
271-
>>> ind.initial_investment = 100 # initial investments value
272-
>>> ind.frequency = "year" # withdrawals frequency
273-
>>> ind.amount = -0.5 * 12 # initial withdrawals amount
274-
>>> ind.indexation = "inflation" # the indexation is equal to inflation
275-
>>> pf.dcf.cashflow_parameters = ind # assign the strategy to Portfolio
276-
>>> pf.dcf.wealth_index_fv.plot()
277-
>>> plt.show()
278-
"""
279-
cf_fv = self.cash_flow_ts_fv.copy()
280-
# Vectorized discounting
281-
n_rows = cf_fv.shape[0]
282-
discount_factors = (1.0 + self.discount_rate / settings._MONTHS_PER_YEAR) ** np.arange(n_rows)
283-
cf_pv = cf_fv.div(discount_factors, axis=0)
284-
return cf_pv
245+
cash_flow_pv = cash_flow_fv.div(discount_factors, axis=0)
246+
return cash_flow_pv
247+
else:
248+
raise ValueError("'discounting' must be either 'fv' or 'pv'")
285249

286250
@property
287251
def wealth_index_fv_with_assets(self) -> pd.DataFrame:
@@ -406,7 +370,7 @@ def survival_date_hist(self, threshold: float = 0) -> pd.Timestamp:
406370
>>> pf.dcf.survival_date_hist(threshold=0)
407371
Timestamp('2015-01-31 00:00:00')
408372
"""
409-
ws = self.wealth_index_fv.loc[:, self.parent.symbol]
373+
ws = self.wealth_index(discounting="fv", include_negative_values=False).loc[:, self.parent.symbol]
410374
# TODO: change threshold to nominal value (idea)
411375
return helpers.Frame.get_survival_date(ws, self.discount_rate, threshold)
412376

@@ -473,8 +437,11 @@ def initial_investment_fv(self) -> Optional[float]:
473437
return None
474438

475439

476-
@property
477-
def monte_carlo_wealth_fv(self) -> pd.DataFrame:
440+
def monte_carlo_wealth(
441+
self,
442+
discounting: Literal["fv", "pv"],
443+
include_negative_values: bool = True
444+
) -> pd.DataFrame:
478445
"""
479446
Portfolio not discounted random wealth indexes with cash flows (withdrawals/contributions) by Monte Carlo simulation.
480447
@@ -511,7 +478,7 @@ def monte_carlo_wealth_fv(self) -> pd.DataFrame:
511478
return_ts = self.parent.monte_carlo_returns_ts(
512479
distr=self.mc.distribution, years=self.mc.period, n=self.mc.number
513480
)
514-
df = return_ts.apply(
481+
self._monte_carlo_wealth_fv = return_ts.apply(
515482
dcf_calculations.get_wealth_indexes_fv_with_cashflow,
516483
axis=0,
517484
args=(
@@ -521,49 +488,90 @@ def monte_carlo_wealth_fv(self) -> pd.DataFrame:
521488
"monte_carlo", # calculate wealth index for Monte Carlo
522489
),
523490
)
524-
df = df.apply(dcf_calculations.remove_negative_values, axis=0)
525-
all_cells_are_nan = df.isna().all(axis=1)
526-
self._monte_carlo_wealth_fv = df[~all_cells_are_nan]
527-
return self._monte_carlo_wealth_fv
491+
if not include_negative_values:
492+
wealth_index_fv = self._monte_carlo_wealth_fv.copy()
493+
wealth_index_fv = wealth_index_fv.apply(dcf_calculations.remove_negative_values, axis=0)
494+
# all_cells_are_nan = wealth_index_fv.isna().all(axis=1)
495+
# monte_carlo_wealth_fv = wealth_index_fv[~all_cells_are_nan]
496+
monte_carlo_wealth_fv = wealth_index_fv.fillna(0)
497+
else:
498+
monte_carlo_wealth_fv = self._monte_carlo_wealth_fv.copy()
499+
if discounting.lower() == "fv":
500+
return monte_carlo_wealth_fv
501+
elif discounting.lower() == "pv":
502+
n_rows = monte_carlo_wealth_fv.shape[0]
503+
discount_factors = (1.0 + self.discount_rate / settings._MONTHS_PER_YEAR) ** np.arange(n_rows)
504+
monte_carlo_wealth_pv = monte_carlo_wealth_fv.div(discount_factors, axis=0)
505+
return monte_carlo_wealth_pv
506+
else:
507+
raise ValueError("'discounting' must be either 'fv' or 'pv'")
528508

529-
@property
530-
def monte_carlo_wealth_pv(self) -> pd.DataFrame:
509+
510+
def monte_carlo_cash_flow(
511+
self,
512+
discounting: Literal["fv", "pv"],
513+
remove_if_wealth_index_negative: bool = True
514+
) -> pd.DataFrame:
531515
"""
532-
Portfolio discounted random wealth indexes with cash flows (withdrawals/contributions) by Monte Carlo simulation.
516+
Portfolio not discounted random wealth indexes with cash flows (withdrawals/contributions) by Monte Carlo simulation.
533517
534-
Random Monte Carlo simulation monthly time series are discounted using `discount_rate` parameter.
518+
Monte Carlo simulation generates n random monthly time series (not discounted).
535519
Each wealth index is calculated with rate of return time series of a given distribution.
536520
537-
`discount_rate` parameter can be set in Portfolio.dcf.discount_rate.
538-
539-
Monte Carlo parameters are defined by Portfolio.dcf.set_mc_parameters() method.
521+
First date of forecasted returns is portfolio last_date.
522+
First value for the forecasted wealth indexes is the last historical portfolio index value. It is useful
523+
for a chart with historical wealth index and forecasted values.
540524
541525
Returns
542526
-------
543527
DataFrame
544-
Table with random discounted wealth indexes monthly time series.
528+
Table with n random wealth indexes monthly time series.
545529
546530
Examples
547531
--------
548532
>>> import matplotlib.pyplot as plt
549533
>>> pf = ok.Portfolio(['SPY.US', 'AGG.US', 'GLD.US'], weights=[.60, .35, .05], rebalancing_strategy='month')
550-
>>> pc = ok.PercentageStrategy(pf) # Define withdrawals strategy with fixed percentage
551-
>>> pc.frequency = "year" # set withdrawals frequency
552-
>>> pc.percentage = -0.08 # investor would take 8% every year
553-
>>> pf.dcf.cashflow_parameters = pc # Assign the strategy to Portfolio
554-
>>> pf.dcf.discount_rate = 0.05 # set dicount rate value to 5%
555534
>>> pf.dcf.set_mc_parameters(distribution="t", period=10, number=100) # Set Monte Carlo parameters
556-
>>> df = pf.dcf.monte_carlo_wealth_pv # calculate discounted random wealth indexes
557-
>>> df.plot() # create a chart
558-
>>> plt.legend("") # no legend is required
535+
>>> # set cash flow parameters
536+
>>> ind = ok.IndexationStrategy(pf) # create cash flow strategy linked to the portfolio
537+
>>> ind.initial_investment = 10_000 # add initial investment to cash flow strategy
538+
>>> ind.amount = -500 # set withdrawal size
539+
>>> ind.frequency = "year" # set withdrawal frequency
540+
>>> pf.dcf.cashflow_parameters = ind # assign cash flow strategy to portfolio
541+
>>> pf.dcf.monte_carlo_wealth_fv.plot()
542+
>>> plt.legend("") # don't show legend for each line
559543
>>> plt.show()
560544
"""
561-
wealth_df = self.monte_carlo_wealth_fv.copy()
562-
# Vectorized discounting
563-
n_rows = wealth_df.shape[0]
564-
discount_factors = (1.0 + self.discount_rate / settings._MONTHS_PER_YEAR) ** np.arange(n_rows)
565-
wealth_df_pv = wealth_df.div(discount_factors, axis=0)
566-
return wealth_df_pv
545+
if self.cashflow_parameters is None:
546+
raise AttributeError("'cashflow_parameters' is not defined.")
547+
if self._monte_carlo_cash_flow_fv.empty:
548+
return_ts = self.parent.monte_carlo_returns_ts(
549+
distr=self.mc.distribution, years=self.mc.period, n=self.mc.number
550+
)
551+
self._monte_carlo_cash_flow_fv = return_ts.apply(
552+
dcf_calculations.get_cash_flow_fv,
553+
axis=0,
554+
args=(
555+
self.parent.symbol, # portfolio_symbol
556+
self.cashflow_parameters,
557+
"monte_carlo", # task
558+
),
559+
)
560+
if remove_if_wealth_index_negative:
561+
mc_cash_flow_fv = self._monte_carlo_cash_flow_fv.copy()
562+
mc_wealth_index = self.monte_carlo_wealth(discounting="fv", include_negative_values=False)
563+
condition = mc_wealth_index == 0
564+
mc_cash_flow_fv[condition] = 0
565+
else:
566+
mc_cash_flow_fv = self._monte_carlo_cash_flow_fv.copy()
567+
if discounting.lower() == "fv":
568+
return mc_cash_flow_fv
569+
elif discounting.lower() == "pv":
570+
n_rows = mc_cash_flow_fv.shape[0]
571+
discount_factors = (1.0 + self.discount_rate / settings._MONTHS_PER_YEAR) ** np.arange(n_rows)
572+
return mc_cash_flow_fv.div(discount_factors, axis=0)
573+
else:
574+
raise ValueError("'discounting' must be either 'fv' or 'pv'")
567575

568576
def plot_forecast_monte_carlo(
569577
self,
@@ -612,7 +620,7 @@ def plot_forecast_monte_carlo(
612620
if self.cashflow_parameters is None:
613621
raise AttributeError("'cashflow_parameters' is not defined.")
614622
backup_obj = self.cashflow_parameters
615-
s1 = self.wealth_index_fv[self.parent.symbol]
623+
s1 = self.wealth_index(discounting="fv", include_negative_values=False)[self.parent.symbol]
616624
s1.plot(legend=None, figsize=figsize)
617625
last_backtest_value = s1.iloc[-1]
618626
if last_backtest_value > 0:

0 commit comments

Comments
 (0)