Skip to content

Commit 316dfe1

Browse files
committed
feat: add caching for calculations in EfficientFrontier
1 parent a0e4030 commit 316dfe1

File tree

1 file changed

+50
-66
lines changed

1 file changed

+50
-66
lines changed

okama/frontier/multi_period.py

Lines changed: 50 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def __init__(
119119
self.full_frontier = full_frontier
120120
self._ef_points = pd.DataFrame(dtype=float)
121121
self._mdp_points = pd.DataFrame(dtype=float)
122+
self._ror_cache: Dict[tuple, pd.Series] = {} # Cache for portfolio return time series by weights
122123

123124
def __repr__(self):
124125
dic = {
@@ -213,6 +214,34 @@ def n_points(self, n_points: int):
213214
def _clear_cache(self):
214215
self._ef_points = pd.DataFrame(dtype=float) # renew EF points DataFrame
215216
self._mdp_points = pd.DataFrame(dtype=float) # renew MDP points DataFrame
217+
self._ror_cache = {} # clear portfolio return time series cache
218+
219+
def _get_portfolio_ror_ts(self, weights: np.ndarray) -> pd.Series:
220+
"""
221+
Get portfolio return time series with caching based on weights.
222+
223+
This method caches the results of portfolio return calculations to avoid
224+
redundant computations during optimization when the same weights are evaluated
225+
multiple times (e.g., in objective function and constraints).
226+
227+
Parameters
228+
----------
229+
weights : np.ndarray
230+
Portfolio weights for each asset.
231+
232+
Returns
233+
-------
234+
pd.Series
235+
Monthly rate of return time series for the rebalanced portfolio.
236+
"""
237+
# Round weights to avoid floating point precision issues in cache keys
238+
weights_key = tuple(np.round(weights, decimals=10))
239+
240+
if weights_key not in self._ror_cache:
241+
rebalance = Rebalance(period=self.rebalancing_strategy.period)
242+
self._ror_cache[weights_key] = rebalance.return_ror_ts_ef(weights, self.assets_ror)
243+
244+
return self._ror_cache[weights_key]
216245

217246
@property
218247
def rebalancing_strategy(self) -> Rebalance:
@@ -341,10 +370,6 @@ def get_most_diversified_portfolio(
341370
n = self.assets_ror.shape[1]
342371
init_guess = np.repeat(1 / n, n)
343372

344-
args = dict(
345-
period=self.rebalancing_strategy.period,
346-
)
347-
348373
def objective_function(w):
349374
# Diversification Ratio
350375
assets_risk = ror.std()
@@ -353,7 +378,7 @@ def objective_function(w):
353378
weights = np.asarray(w)
354379
assets_sigma_weighted_sum = weights.T @ assets_annualized_risk
355380

356-
portfolio_ror = Rebalance(**args).return_ror_ts_ef(w, ror)
381+
portfolio_ror = self._get_portfolio_ror_ts(w)
357382
portfolio_mean_return_monthly = portfolio_ror.mean()
358383
portfolio_risk_monthly = portfolio_ror.std()
359384

@@ -428,17 +453,12 @@ def get_tangency_portfolio(self, rf_return: float = 0, rate_of_return: str = "ca
428453
>>> ef.get_tangency_portfolio(rate_of_return="mean_return", rf_return=0.03)
429454
{'Weights': array([2.95364739e-01, 1.08420217e-17, 7.04635261e-01]), 'Rate_of_return': 0.10654206521088283, 'Risk': 0.048279725208422115}
430455
"""
431-
ror = self.assets_ror
432456
n = self.assets_ror.shape[1]
433457
init_guess = np.repeat(1 / n, n)
434458

435-
args = dict(
436-
period=self.rebalancing_strategy.period,
437-
)
438-
439459
def of_arithmetic_mean(w):
440460
# Sharpe ratio with arithmetic mean
441-
portfolio_ror = Rebalance(**args).return_ror_ts_ef(w, ror)
461+
portfolio_ror = self._get_portfolio_ror_ts(w)
442462
mean_return_monthly = portfolio_ror.mean()
443463
risk_monthly = portfolio_ror.std()
444464
of_arithmetic_mean.rate_of_return = helpers.Float.annualize_return(mean_return_monthly)
@@ -447,7 +467,7 @@ def of_arithmetic_mean(w):
447467

448468
def of_geometric_mean(w):
449469
# Sharpe ratio with CAGR
450-
portfolio_ror = Rebalance(**args).return_ror_ts_ef(w, ror)
470+
portfolio_ror = self._get_portfolio_ror_ts(w)
451471
mean_return_monthly = portfolio_ror.mean()
452472
of_geometric_mean.rate_of_return = helpers.Frame.get_cagr(portfolio_ror)
453473
# Risk
@@ -501,16 +521,12 @@ def gmv_monthly_weights(self) -> np.ndarray:
501521
>>> frontier.gmv_monthly_weights
502522
array([0.0578446, 0.9421554])
503523
"""
504-
ror = self.assets_ror
505-
args = dict(
506-
period=self.rebalancing_strategy.period,
507-
)
508524
n = self.assets_ror.shape[1]
509525
init_guess = np.repeat(1 / n, n)
510526

511527
# Set the objective function
512528
def objective_function(w):
513-
risk = okama.common.helpers.rebalancing.Rebalance(**args).return_ror_ts_ef(w, ror).std()
529+
risk = self._get_portfolio_ror_ts(w).std()
514530
return risk
515531

516532
# construct the constraints
@@ -546,16 +562,12 @@ def gmv_annual_weights(self) -> np.ndarray:
546562
>>> frontier.gmv_monthly_weights
547563
array([0.05373824, 0.94626176])
548564
"""
549-
ror = self.assets_ror
550-
args = dict(
551-
period=self.rebalancing_strategy.period,
552-
)
553565
n = self.assets_ror.shape[1]
554566
init_guess = np.repeat(1 / n, n)
555567

556568
# Set the objective function
557569
def objective_function(w):
558-
ts = Rebalance(**args).return_ror_ts_ef(w, ror)
570+
ts = self._get_portfolio_ror_ts(w)
559571
mean_return = ts.mean()
560572
risk = ts.std()
561573
return helpers.Float.annualize_risk(risk=risk, mean_return=mean_return)
@@ -578,10 +590,7 @@ def _get_gmv_monthly(self) -> Tuple[float, float]:
578590
579591
Global Minimum Volatility portfolio is a portfolio with the lowest risk of all possible.
580592
"""
581-
args = dict(
582-
period=self.rebalancing_strategy.period,
583-
)
584-
ts = Rebalance(**args).return_ror_ts_ef(self.gmv_monthly_weights, self.assets_ror)
593+
ts = self._get_portfolio_ror_ts(self.gmv_monthly_weights)
585594
return ts.std(), ts.mean()
586595

587596
@property
@@ -606,10 +615,7 @@ def gmv_annual_values(self) -> Tuple[float, float]:
606615
>>> frontier.gmv_annual_values
607616
(0.03695845106087943, 0.04418318557516887)
608617
"""
609-
args = dict(
610-
period=self.rebalancing_strategy.period,
611-
)
612-
returns = Rebalance(**args).return_ror_ts_ef(self.gmv_annual_weights, self.assets_ror)
618+
returns = self._get_portfolio_ror_ts(self.gmv_annual_weights)
613619
return (
614620
helpers.Float.annualize_risk(returns.std(), returns.mean()),
615621
(returns + 1.0).prod() ** (settings._MONTHS_PER_YEAR / returns.shape[0]) - 1.0,
@@ -639,17 +645,13 @@ def global_max_return_portfolio(self) -> dict:
639645
>>> frontier.global_max_return_portfolio
640646
{'Weights': array([1., 0.]), 'CAGR': 0.10797159166196812, 'Risk': 0.1583011735798155, 'Risk_monthly': 0.0410282468594492}
641647
"""
642-
ror = self.assets_ror
643-
args = dict(
644-
period=self.rebalancing_strategy.period,
645-
)
646648
n = self.assets_ror.shape[1] # Number of assets
647649
init_guess = np.repeat(1 / n, n)
648650

649651
# Set the objective function
650652
def objective_function(w):
651653
# Accumulated return for rebalanced portfolio time series
652-
objective_function.returns = Rebalance(**args).return_ror_ts_ef(w, ror)
654+
objective_function.returns = self._get_portfolio_ror_ts(w)
653655
accumulated_return = (objective_function.returns + 1.0).prod() - 1.0
654656
return -accumulated_return
655657

@@ -678,11 +680,8 @@ def objective_function(w):
678680
}
679681
return point
680682

681-
def _get_cagr(self, weights):
682-
args = dict(
683-
period=self.rebalancing_strategy.period,
684-
)
685-
ts = Rebalance(**args).return_ror_ts_ef(weights, self.assets_ror)
683+
def _get_cagr(self, weights: np.ndarray) -> float:
684+
ts = self._get_portfolio_ror_ts(weights)
686685
acc_return = (ts + 1.0).prod() - 1.0
687686
return (1.0 + acc_return) ** (settings._MONTHS_PER_YEAR / ts.shape[0]) - 1.0
688687

@@ -730,17 +729,13 @@ def minimize_risk(self, target_value: float) -> Dict[str, float]:
730729

731730
max_ratio_data = self._max_ratio_asset_right_to_max_cagr
732731

733-
args = dict(
734-
period=self.rebalancing_strategy.period,
735-
)
736-
737732
if max_ratio_data is not None:
738733
init_guess = np.repeat(0, n) # clear weights
739734
init_guess[self._min_ratio_asset["list_position"]] = 1.0
740735

741736
def objective_function(w):
742737
# annual risk
743-
ts = Rebalance(**args).return_ror_ts_ef(w, self.assets_ror)
738+
ts = self._get_portfolio_ror_ts(w)
744739
risk_monthly = ts.std()
745740
objective_function.mean_return = ts.mean()
746741
return helpers.Float.annualize_risk(risk_monthly, objective_function.mean_return)
@@ -798,13 +793,9 @@ def _maximize_risk(self, target_return: float) -> Dict[str, float]:
798793
"""
799794
n = self.assets_ror.shape[1] # number of assets
800795

801-
args = dict(
802-
period=self.rebalancing_strategy.period,
803-
)
804-
805796
def objective_function(w):
806797
# annual risk
807-
ts = Rebalance(**args).return_ror_ts_ef(w, self.assets_ror)
798+
ts = self._get_portfolio_ror_ts(w)
808799
risk_monthly = ts.std()
809800
objective_function.mean_return = ts.mean()
810801
result = -helpers.Float.annualize_risk(risk_monthly, objective_function.mean_return)
@@ -1266,26 +1257,19 @@ def get_monte_carlo(self, n: int = 100) -> pd.DataFrame:
12661257
>>> ax.legend()
12671258
>>> plt.show()
12681259
"""
1269-
weights_df = helpers.Float.get_random_weights(n, self.assets_ror.shape[1], self.bounds)
1270-
1271-
args = dict(
1272-
period=self.rebalancing_strategy.period,
1273-
)
1274-
# Portfolio risk and cagr for each set of weights
1275-
portfolios_ror = weights_df.aggregate(
1276-
Rebalance(**args).return_ror_ts_ef,
1277-
ror=self.assets_ror,
1278-
)
1260+
weights_series = helpers.Float.get_random_weights(n, self.assets_ror.shape[1], self.bounds)
1261+
# Portfolio risk and cagr for each set of weights using cache
12791262
rows_list = [] # Collect all rows to create DataFrame once at the end
1280-
for _, data in portfolios_ror.iterrows():
1281-
risk_monthly = data.std()
1282-
mean_return = data.mean()
1263+
for weights in weights_series:
1264+
# Use cached portfolio return time series calculation
1265+
portfolio_ror = self._get_portfolio_ror_ts(weights)
1266+
risk_monthly = portfolio_ror.std()
1267+
mean_return = portfolio_ror.mean()
12831268
risk = helpers.Float.annualize_risk(risk_monthly, mean_return)
1284-
cagr = helpers.Frame.get_cagr(data)
1269+
cagr = helpers.Frame.get_cagr(portfolio_ror)
12851270
row = {"Risk": risk, "CAGR": cagr}
12861271
rows_list.append(row)
1287-
random_portfolios = pd.DataFrame.from_records(rows_list)
1288-
return random_portfolios
1272+
return pd.DataFrame.from_records(rows_list)
12891273

12901274
def plot_pair_ef(self, tickers="tickers", figsize: Optional[tuple] = None) -> Axes:
12911275
"""

0 commit comments

Comments
 (0)