Skip to content

Commit 20bed2c

Browse files
committed
feat: add MDP to EfficientFrontierReb
1 parent 6508dac commit 20bed2c

File tree

2 files changed

+265
-1
lines changed

2 files changed

+265
-1
lines changed

okama/frontier/multi_period.py

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def __init__(
117117
self.verbose = verbose
118118
self.full_frontier = full_frontier
119119
self._ef_points = pd.DataFrame(dtype=float)
120+
self._mdp_points = pd.DataFrame(dtype=float)
120121

121122
def __repr__(self):
122123
dic = {
@@ -169,6 +170,7 @@ def bounds(self) -> Tuple[Tuple[float, ...], ...]:
169170
def bounds(self, bounds):
170171

171172
self._ef_points = pd.DataFrame(dtype=float)
173+
self._mdp_points = pd.DataFrame(dtype=float)
172174

173175
if bounds:
174176
if len(bounds) != len(self.symbols):
@@ -209,6 +211,7 @@ def n_points(self, n_points: int):
209211

210212
def _clear_cache(self):
211213
self._ef_points = pd.DataFrame(dtype=float) # renew EF points DataFrame
214+
self._mdp_points = pd.DataFrame(dtype=float) # renew MDP points DataFrame
212215

213216
@property
214217
def rebalancing_strategy(self) -> Rebalance:
@@ -284,7 +287,108 @@ def verbose(self, verbose: bool):
284287
self._clear_cache()
285288
self._verbose = verbose
286289

287-
# TODO: add get_most_diversified_portfolio (as in EfficientFronier)
290+
def get_most_diversified_portfolio(
291+
self,
292+
target_return: Optional[float] = None,
293+
) -> Dict[str, float]:
294+
"""
295+
Calculate assets weights, annualized values for risk and return, Diversification ratio
296+
for the most diversified portfolio given the target CAGR within given bounds.
297+
298+
The most diversified portfolio has the largest Diversification Ratio.
299+
300+
The Diversification Ratio is the ratio of the weighted average of assets risks divided by the portfolio risk.
301+
In this case risk is the annualized standard deviation for the rate of return.
302+
303+
Returns
304+
-------
305+
dict
306+
Weights of assets and annualized values for risk, CAGR and Diversification ratio of the most diversified portfolio.
307+
308+
Parameters
309+
----------
310+
target_return : float, optional
311+
Target Compound Annual Growth Rate (CAGR) for the portfolio. The optimization process looks for a portfolio
312+
with the target_return and largest Diversification ratio. If not specified global most diversified portfolio
313+
is obtained.
314+
315+
Examples
316+
--------
317+
>>> ls4 = ['SPY.US', 'AGG.US', 'VNQ.US', 'GLD.US']
318+
>>> x = ok.EfficientFrontierReb(assets=ls4, ccy='USD', last_date='2021-12')
319+
>>> x.get_most_diversified_portfolio() # get a global most diversified portfolio
320+
{'SPY.US': 0.19612726258395477,
321+
'AGG.US': 0.649730553241489,
322+
'VNQ.US': 0.020096313783052246,
323+
'GLD.US': 0.13404587039150392,
324+
'CAGR': 0.062355715886719176,
325+
'Risk': 0.05510135025563423,
326+
'Diversification ratio': 1.5665720501693001}
327+
328+
It is possible to get the most diversified portfolio for a given target CAGR.
329+
330+
>>> x.get_most_diversified_portfolio(target_return=0.10)
331+
{'SPY.US': 0.3389762570274293,
332+
'AGG.US': 0.12915657041748244,
333+
'VNQ.US': 0.15083042115027034,
334+
'GLD.US': 0.3810367514048179,
335+
'CAGR': 0.09370688842211439,
336+
'Risk': 0.11725067815643951,
337+
'Diversification ratio': 1.4419864802150442}
338+
"""
339+
ror = self.assets_ror
340+
n = self.assets_ror.shape[1]
341+
init_guess = np.repeat(1 / n, n)
342+
343+
args = dict(
344+
period=self.rebalancing_strategy.period,
345+
)
346+
347+
def objective_function(w):
348+
# Diversification Ratio
349+
assets_risk = ror.std()
350+
assets_mean_return = self.assets_ror.mean()
351+
assets_annualized_risk = helpers.Float.annualize_risk(assets_risk, assets_mean_return)
352+
weights = np.asarray(w)
353+
assets_sigma_weighted_sum = weights.T @ assets_annualized_risk
354+
355+
portfolio_ror = Rebalance(**args).return_ror_ts_ef(w, ror)
356+
portfolio_mean_return_monthly = portfolio_ror.mean()
357+
portfolio_risk_monthly = portfolio_ror.std()
358+
359+
objective_function.annual_risk = helpers.Float.annualize_risk(
360+
portfolio_risk_monthly, portfolio_mean_return_monthly
361+
)
362+
return -assets_sigma_weighted_sum / objective_function.annual_risk
363+
364+
# construct the constraints
365+
weights_sum_to_1 = {"type": "eq", "fun": lambda weights: np.sum(weights) - 1}
366+
cagr_is_target = {
367+
"type": "eq",
368+
"fun": lambda weights: target_return - self._get_cagr(weights),
369+
}
370+
constraints = (weights_sum_to_1,) if target_return is None else (weights_sum_to_1, cagr_is_target)
371+
372+
# set optimizer
373+
weights = minimize(
374+
objective_function,
375+
init_guess,
376+
method="SLSQP",
377+
options={"disp": False},
378+
constraints=constraints,
379+
bounds=self.bounds,
380+
)
381+
if weights.success:
382+
# CAGR calculation
383+
cagr = self._get_cagr(weights.x)
384+
asset_labels = self.symbols if self.ticker_names else list(self.names.values())
385+
point = {x: y for x, y in zip(asset_labels, weights.x)}
386+
point["CAGR"] = cagr
387+
point["Risk"] = objective_function.annual_risk
388+
point["Diversification ratio"] = -weights.fun
389+
return point
390+
else:
391+
raise RecursionError("No solutions where found")
288392

289393
@property
290394
def gmv_monthly_weights(self) -> np.ndarray:
@@ -952,6 +1056,67 @@ def compute_right_part_of_ef(i, target_cagr):
9521056
logger.info(f"Total time taken is {(main_end_time - main_start_time) / 60:.2f} min.")
9531057
self._ef_points = df
9541058

1059+
@property
1060+
def mdp_points(self) -> pd.DataFrame:
1061+
"""
1062+
Generate Most diversified portfolios frontier for rebalanced portfolios.
1063+
1064+
Each point on the Most diversified portfolios frontier is a rebalanced portfolio with optimized
1065+
Diversification ratio for a given CAGR.
1066+
1067+
The points are obtained through the constrained optimization process (optimization with bounds).
1068+
Bounds are defined with 'bounds' property.
1069+
1070+
Returns
1071+
-------
1072+
DataFrame
1073+
Table of weights and risk/return values for the Most Diversified Portfolios Frontier.
1074+
The columns:
1075+
1076+
- assets weights
1077+
- CAGR (geometric mean)
1078+
- Risk (standard deviation)
1079+
- Diversification ratio
1080+
1081+
All the values are annualized.
1082+
1083+
Examples
1084+
--------
1085+
>>> ls4 = ['SP500TR.INDX', 'MCFTR.INDX', 'RGBITR.INDX', 'GC.COMM']
1086+
>>> y = ok.EfficientFrontierReb(assets=ls4, ccy='RUB', last_date='2021-12', n_points=20)
1087+
>>> y.mdp_points # print mdp weights, risk, CAGR and Diversification ratio
1088+
Risk CAGR Diversification ratio ... MCFTR.INDX RGBITR.INDX SP500TR.INDX
1089+
0 0.066040 0.092220 1.234567 ... 2.081668e-16 1.000000e+00 0.000000e+00
1090+
1 0.064299 0.093451 1.245678 ... 0.000000e+00 9.844942e-01 5.828671e-16
1091+
...
1092+
1093+
To plot the Most diversification portfolios line use the DataFrame with the points data.
1094+
Additionally 'Plot.plot_assets()' can be used to show the assets in the chart.
1095+
1096+
>>> import matplotlib.pyplot as plt
1097+
>>> fig = plt.figure()
1098+
>>> # Plot the assets points
1099+
>>> y.plot_assets(kind='cagr') # kind should be set to "cagr" as we take "CAGR" column from the mdp_points.
1100+
>>> ax = plt.gca()
1101+
>>> # Plot the Most diversified portfolios line
1102+
>>> df = y.mdp_points
1103+
>>> ax.plot(df['Risk'], df['CAGR']) # we chose to plot CAGR which is geometric mean of return series
1104+
>>> # Set the axis labels and the title
1105+
>>> ax.set_title('Most diversified portfolios line')
1106+
>>> ax.set_xlabel('Risk (Standard Deviation)')
1107+
>>> ax.set_ylabel('Return (CAGR)')
1108+
>>> plt.show()
1109+
"""
1110+
if self._mdp_points.empty:
1111+
target_cagrs = self._target_cagr_range_left
1112+
df = pd.DataFrame(dtype="float")
1113+
for x in target_cagrs:
1114+
row = self.get_most_diversified_portfolio(target_return=x)
1115+
df = pd.concat([df, pd.DataFrame(row, index=[0])], ignore_index=True)
1116+
df = helpers.Frame.change_columns_order(df, ["Risk", "CAGR"])
1117+
self._mdp_points = df
1118+
return self._mdp_points
1119+
9551120
def get_monte_carlo(self, n: int = 100) -> pd.DataFrame:
9561121
"""
9571122
Generate N random rebalanced portfolios with Monte Carlo simulation.

tests/test_frontier_reb.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,102 @@ def test_minimize_risk_with_extreme_target(ef_reb_ab):
342342
assert "Weights" in result
343343
assert result["CAGR"] == pytest.approx(gmv_cagr, rel=1e-2, abs=1e-3)
344344

345+
346+
def test_get_most_diversified_portfolio_has_expected_fields(ef_reb_ab):
347+
"""Test get_most_diversified_portfolio returns dict with required fields."""
348+
dic = ef_reb_ab.get_most_diversified_portfolio()
349+
# Check that all required fields are present
350+
assert set(dic.keys()) >= {"Risk", "CAGR", "Diversification ratio"}
351+
# Check that asset weights are present
352+
assert any(symbol in dic for symbol in ef_reb_ab.symbols)
353+
# Check types
354+
assert isinstance(dic["Risk"], (float, np.floating))
355+
assert isinstance(dic["CAGR"], (float, np.floating))
356+
assert isinstance(dic["Diversification ratio"], (float, np.floating))
357+
358+
359+
def test_get_most_diversified_portfolio_with_target_return(ef_reb_ab):
360+
"""Test get_most_diversified_portfolio with specified target_return."""
361+
# Get a target CAGR in the middle of the range
362+
r = ef_reb_ab._target_cagr_range_left
363+
target = float((r[0] + r[-1]) / 2)
364+
dic = ef_reb_ab.get_most_diversified_portfolio(target_return=target)
365+
# Check that CAGR is close to target
366+
assert dic["CAGR"] == pytest.approx(target, rel=1e-2, abs=1e-3)
367+
# Check that weights sum to 1
368+
weights = [dic[s] for s in ef_reb_ab.symbols]
369+
assert_allclose(np.sum(weights), 1.0, atol=1e-8)
370+
371+
372+
def test_get_most_diversified_portfolio_weights_sum_to_one(ef_reb_ab):
373+
"""Test that weights in MDP sum to 1."""
374+
dic = ef_reb_ab.get_most_diversified_portfolio()
375+
weights = [dic[s] for s in ef_reb_ab.symbols]
376+
assert_allclose(np.sum(weights), 1.0, atol=1e-8)
377+
378+
379+
def test_get_most_diversified_portfolio_with_bounds(synthetic_env):
380+
"""Test get_most_diversified_portfolio respects bounds."""
381+
ef = ok.EfficientFrontierReb(
382+
["A.US", "B.US"], ccy="USD", inflation=False, n_points=10,
383+
rebalancing_strategy=ok.Rebalance(period="year"),
384+
bounds=((0.3, 0.7), (0.3, 0.7))
385+
)
386+
dic = ef.get_most_diversified_portfolio()
387+
# Check bounds are respected
388+
for i, symbol in enumerate(ef.symbols):
389+
lo, hi = ef.bounds[i]
390+
assert lo <= dic[symbol] <= hi
391+
392+
393+
def test_mdp_points_basic_properties(ef_reb_three):
394+
"""Test mdp_points returns DataFrame with correct structure."""
395+
mdp = ef_reb_three.mdp_points
396+
# Expected number of points
397+
assert len(mdp) == ef_reb_three.n_points
398+
# Columns include required metrics
399+
assert {"Risk", "CAGR", "Diversification ratio"}.issubset(set(mdp.columns))
400+
# Weights columns are the asset symbols
401+
weight_cols = [c for c in mdp.columns if c in ef_reb_three.symbols]
402+
assert set(weight_cols) == set(ef_reb_three.symbols)
403+
# Each row weights sum to 1 (within numerical tolerance)
404+
s = mdp[weight_cols].sum(axis=1)
405+
assert np.allclose(s.values, 1.0, atol=1e-8)
406+
407+
408+
def test_mdp_points_caching(ef_reb_ab):
409+
"""Test that mdp_points results are cached properly."""
410+
# First call computes
411+
pts1 = ef_reb_ab.mdp_points
412+
# Second call returns cached result
413+
pts2 = ef_reb_ab.mdp_points
414+
assert pts1 is pts2 # Same object reference
415+
pd.testing.assert_frame_equal(pts1, pts2)
416+
417+
418+
def test_mdp_points_cleared_on_bounds_change(ef_reb_ab):
419+
"""Test that mdp_points cache is cleared when bounds change."""
420+
# Pre-fill cache
421+
_ = ef_reb_ab.mdp_points
422+
assert not ef_reb_ab._mdp_points.empty
423+
# Change bounds and ensure cache is cleared
424+
ef_reb_ab.bounds = ((0.2, 0.8), (0.2, 0.8))
425+
assert ef_reb_ab._mdp_points.empty
426+
427+
428+
def test_mdp_points_cleared_on_n_points_change(ef_reb_ab):
429+
"""Test that mdp_points cache is cleared when n_points changes."""
430+
# Pre-fill cache
431+
_ = ef_reb_ab.mdp_points
432+
assert not ef_reb_ab._mdp_points.empty
433+
# Change n_points and ensure cache is cleared
434+
ef_reb_ab.n_points = 15
435+
assert ef_reb_ab._mdp_points.empty
436+
437+
438+
def test_mdp_points_cagr_monotonicity(ef_reb_ab):
439+
"""Test that CAGR values in mdp_points are non-decreasing."""
440+
mdp = ef_reb_ab.mdp_points
441+
# CAGR should be non-decreasing
442+
assert np.all(np.diff(mdp["CAGR"]) >= -1e-12)
443+

0 commit comments

Comments
 (0)