Skip to main content

10. Portfolio Optimization: Tail Risk, Risk Parity and Robust Models

In this project we will move beyond mean-variance portfolio construction and work through the following approaches to portfolio risk and allocation:

Each approach gives us a different way to choose weights. Mean-variance uses expected returns and covariance; CVaR focuses on losses in the worst tail; risk parity allocates according to contributions to portfolio volatility; and robust optimization accounts for uncertainty in the estimated inputs. We’ll compare the portfolios on the same cross-asset universe and examine their returns, tail losses, turnover, and exposures.

The final library application repeats the model families on sector ETFs, giving us a second opportunity set for the same portfolio methods.

A useful way to read the whole project is to keep four objects separate:

Object Symbol What changes across models
Expected return estimate \(\hat{\mu}\) ignored, used directly, shrunk, or penalized
Covariance estimate \(\hat{\Sigma}\) used for variance, risk contributions, clustering, and robust norms
Scenario matrix \(R\) used directly by CVaR and indirectly by covariance/mean estimates
Constraint set \(\mathcal{C}\) keeps all models long-only, fully invested, and capped

In each model, the objective translates a particular view of return and risk into a choice of portfolio weights. The belief can be “the mean estimate is useful”, “the worst 5% scenarios matter more than variance”, “each sleeve should contribute equal risk”, or “the estimates are unreliable and need an uncertainty penalty”.

Two models can choose similar assets for different reasons. Mean-CVaR may hold an asset because it helps meet a historical tail-loss budget, while Wasserstein DRMV may hold it because its estimated return remains attractive after the distributional penalty. Looking at both the objective and the resulting weights helps us understand how each allocation was formed.

We also need to keep risk measurement separate from portfolio construction. A model can optimize CVaR and then still be evaluated by Sharpe, Calmar, drawdown, turnover, and expected shortfall. The objective tells us what the model was trying to do. The final report tells us whether that objective produced a useful portfolio under realistic out-of-sample testing.

The scenario rows and the constraints are held on a common footing so we can interpret the objectives alongside the allocations they produce.

1) The Portfolio Problem After Mean-Variance

We used mean-variance models earlier, and now we’ll build on that foundation by examining how different objectives respond to input estimates. Expected returns, covariances, and historical scenarios all come from data, so their estimates influence the weights we obtain.

A portfolio optimizer receives three kinds of information:

\[ \hat{\mu}, \qquad \hat{\Sigma}, \qquad \mathcal{C} \]

where \(\hat{\mu}\) is the estimated expected return vector, \(\hat{\Sigma}\) is the estimated covariance matrix, and \(\mathcal{C}\) is the constraint set, such as long-only weights, full investment, and max-weight caps. If those inputs were known perfectly, the optimization problem would be clean. In real markets, they aren’t known. We estimate them from a finite rolling sample, and that makes every model partly a statistical model before it becomes a portfolio model.

We’ll reuse the earlier data and baseline machinery, then study the following portfolio-construction question:

If variance and raw expected returns aren’t enough, how else can we define a portfolio that is safer, more balanced, or more robust?

We work with the same cross-asset ETF universe used in the previous portfolio projects. The data can be reproduced from the core_cross_asset_etfs folder inside the repository’s data layer. That folder contains the source notes and script workflow for rebuilding the file used by this project. We also repeat the workflow on sector ETFs later, using the sector_etfs data folder.

The baselines are also reused. Equal Weight, MinVar, MV, and MaxSharpe come from the same model family developed in Project 2, and Learned-Confidence BL comes from Project 6. We’ll use those portfolios as references when we compare the new allocations and their realized risk.

We start with the cross-asset ETF panel and the tools needed to study it. We’ll use cvxpy for the convex programs, SciPy clustering for hierarchical allocation, and the quantfinlab portfolio and risk-reporting utilities for the parts already developed in the earlier projects.

The repeated implementation convention is the same as earlier:

\[ r_{t,i} = \frac{P_{t,i}}{P_{t-1,i}} - 1 \]

where \(P_{t,i}\) is the adjusted close price of asset \(i\) at day \(t\), and \(r_{t,i}\) is the simple daily return. We stack the daily returns into a matrix

\[ R_t \in \mathbb{R}^{T \times N} \]

where \(T\) is the rolling estimation window and \(N\) is the number of assets. Each row is one historical day and each column is one ETF.

For this project, the matrix matters more than in a simple mean-variance backtest because different models read it differently:

  • CVaR models read scenario losses from the rows of \(R_t\).
  • Parity models read covariance structure from \(\hat{\Sigma}_t\).
  • Robust models read both estimated means and estimation uncertainty.
  • Wasserstein DRO treats the empirical sample as only one possible distribution inside a neighborhood of plausible distributions.

The return panel is built before the portfolio models and is shared across their rolling estimation windows.

Show code
import warnings, math
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import scipy.optimize as opt
from scipy.cluster import hierarchy
from scipy.spatial.distance import squareform
import cvxpy as cp
from cycler import cycler
from IPython.display import display
from quantfinlab.dataio import load_yfinance_panel
from quantfinlab.portfolio import covariance, expected_returns, selection, attribution
from quantfinlab.portfolio import prices_to_returns, make_rebalance_dates
from quantfinlab.portfolio.covariance import make_psd
from quantfinlab.portfolio.walkforward import run_walkforward_grid, append_frontiergrid_from_best_maxsharpe
from quantfinlab.portfolio import black_litterman as bl
from quantfinlab.portfolio import views as bl_views
from quantfinlab.backtest.portfolio import run_many_weights_backtests
from quantfinlab.risk import drawdown_episodes_table, drawdown_summary_table, stress_table, var_es_table, capm_table, rolling_volatility
from quantfinlab.plotting.portfolio import plot_strategy_nav, plot_strategy_drawdowns
from quantfinlab.reports import risk_report
warnings.filterwarnings("ignore")
pd.set_option("display.max_columns", 120)
pd.set_option("display.width", 180)
colors = ["#069AF3", "#FE420F", "#00008B", "#008080", "#CC79A7", "#DC143C", "#9614fa", "#0072B2", "#7BC8F6", "#04D8B2",
    "#800080", "#FF8072"]
plt.rcParams["axes.prop_cycle"] = cycler(color=colors)
plt.rcParams.update({"figure.figsize": (7, 3.5), "figure.dpi": 140, "savefig.dpi": 250,
                     "axes.grid": True, "grid.alpha": 0.20, "axes.spines.top": False,
                     "axes.spines.right": False, "axes.titlesize": 11, "axes.labelsize": 10,
                     "xtick.labelsize": 9, "ytick.labelsize": 9, "legend.fontsize": 8})
Show code
loaded = load_yfinance_panel("../data/core_cross_asset_etfs.csv", fields=("close",), source="yfinance_export", lowercase=False)
close_all = loaded["close"].sort_index()
print("Portfolio Optimization Models: Tail risk, Risk Parity and Robust Optimization")
print("loaded close table:", close_all.shape, close_all.index.min().date(), "to", close_all.index.max().date())
Portfolio Optimization Models: Tail risk, Risk Parity and Robust Optimization
loaded close table: (6906, 19) 1999-01-04 to 2026-06-17

The return matrix can be read in two ways. For mean-variance, we often compress it into two estimates:

\[ \hat{\mu} = \frac{1}{T}\sum_{s=1}^{T} r_s \]

\[ \hat{\Sigma} = \frac{1}{T-1}\sum_{s=1}^{T}(r_s-\hat{\mu})(r_s-\hat{\mu})^\top \]

These two estimates summarize the return history through its mean and covariance. For CVaR, we’ll keep the individual scenario-level losses as inputs to the optimization:

\[ \ell_s(w)=-r_s^\top w,\qquad s=1,\ldots,T \]

That means one very bad historical day can directly enter the optimization if it belongs to the worst tail. This is why CVaR is more connected to realized stress scenarios than variance. Variance smooths bad days into a second moment. CVaR identifies the worst days and averages their losses.

For example, if two portfolios have the same volatility but one has many small losses and the other has rare crash losses, variance can treat them as similar. CVaR will usually prefer the first one because its worst 5% outcomes are less severe. The resulting allocation depends on the shape and co-movement of losses in the historical window, as well as on the portfolio weights.

Show code
start_date = "2013-01-01"
assets = ["SPY", "QQQ", "IWM", "EFA", "EEM", "TLT", "IEF", "SHY", "AGG", "LQD", "HYG", "GLD", "VNQ", "DBC"]
signal_only_assets = ["UUP"] if "UUP" in close_all.columns else []
close_after_start = close_all.loc[pd.Timestamp(start_date):].copy()
missing_assets = [ticker for ticker in assets if ticker not in close_after_start.columns]
if missing_assets:
    raise ValueError(f"missing required investable ETF columns: {missing_assets}")
first_all_valid = close_after_start[assets].dropna(how="any").index.min()
if pd.isna(first_all_valid):
    raise ValueError("no date where all investable ETFs have adjusted close data")
close = close_after_start.loc[first_all_valid:, assets].ffill(limit=3).dropna(how="any")
returns = prices_to_returns(close, kind="simple").replace([np.inf, -np.inf], np.nan).dropna(how="all").fillna(0.0)
close = close.reindex(returns.index).ffill(limit=3)
signal_tickers = list(dict.fromkeys(assets + signal_only_assets))
signal_close = close_after_start.loc[first_all_valid:, signal_tickers].ffill(limit=3).reindex(returns.index).ffill(limit=3)
signal_returns = prices_to_returns(signal_close, kind="simple").replace([np.inf, -np.inf], np.nan).reindex(returns.index).fillna(0.0)
Show code
coverage_rows = []
for ticker in assets + signal_only_assets:
    px = close_after_start[ticker]
    valid = px.dropna()
    first = valid.index.min() if len(valid) else pd.NaT
    coverage_rows.append({"ticker": ticker, "first_date": first, "last_date": valid.index.max() if len(valid) else pd.NaT,
        "observations": int(len(valid)),
        "missing_after_first_valid": float(px.loc[first:].isna().sum()) if len(valid) else np.nan,
        "investable": ticker in assets})
coverage_table = pd.DataFrame(coverage_rows)
data_table = pd.DataFrame([{"number_of_assets": len(assets), "first_date": returns.index.min(),
                            "last_date": returns.index.max(), "daily_return_rows": len(returns),
                            "missing_values_after_alignment": int(returns[assets].isna().sum().sum())}])
display(data_table)
display(coverage_table)
print("signal assets:", list(signal_returns.columns))
number_of_assets first_date last_date daily_return_rows missing_values_after_alignment
0 14 2013-01-03 2026-06-17 3384 0
ticker first_date last_date observations missing_after_first_valid investable
0 SPY 2013-01-02 2026-06-17 3385 0.0 True
1 QQQ 2013-01-02 2026-06-17 3385 0.0 True
2 IWM 2013-01-02 2026-06-17 3385 0.0 True
3 EFA 2013-01-02 2026-06-17 3385 0.0 True
4 EEM 2013-01-02 2026-06-17 3385 0.0 True
5 TLT 2013-01-02 2026-06-17 3385 0.0 True
6 IEF 2013-01-02 2026-06-17 3385 0.0 True
7 SHY 2013-01-02 2026-06-17 3385 0.0 True
8 AGG 2013-01-02 2026-06-17 3385 0.0 True
9 LQD 2013-01-02 2026-06-17 3385 0.0 True
10 HYG 2013-01-02 2026-06-17 3385 0.0 True
11 GLD 2013-01-02 2026-06-17 3385 0.0 True
12 VNQ 2013-01-02 2026-06-17 3385 0.0 True
13 DBC 2013-01-02 2026-06-17 3385 0.0 True
14 UUP 2013-01-02 2026-06-17 3385 0.0 False
signal assets: ['SPY', 'QQQ', 'IWM', 'EFA', 'EEM', 'TLT', 'IEF', 'SHY', 'AGG', 'LQD', 'HYG', 'GLD', 'VNQ', 'DBC', 'UUP']

Our loaded close table contains 6,906 rows and 19 columns from 1999-01-04 to 2026-06-17. We don’t use the full historical range because the investable ETF universe needs aligned coverage. After the 2013 start filter and the all-valid ETF alignment, the investable return panel contains 3,384 daily observations from 2013-01-03 to 2026-06-17.

The aligned panel gives us enough history for multi-year covariance and scenario windows. It spans calm markets, the COVID crash, the 2022 inflation/rate shock, and the 2023–2026 growth rebound. These different periods let us examine allocations and risk across several market environments while using the same set of investable assets.

The signal-only asset UUP is included for the Black-Litterman baseline, but the investable universe has 14 ETFs. The missing-value check is also clean: after alignment, there are zero missing values in the investable return panel. That matters because CVaR optimization uses historical scenarios directly. A missing value inside a scenario matrix would change the tail set and could distort which days count as bad outcomes.

The aligned observations give each model a consistent set of daily scenarios across the selected ETF universe. We can then form historical windows without comparing different asset calendars.

2) Universe, Rebalancing, and Constraints

The tradable universe stays the same as the earlier cross-asset portfolio work, but the way constraints interact with the new models deserves attention.

We use 14 ETFs across four sleeves:

Sleeve Assets Main risk carried
Equities SPY, QQQ, IWM, EFA, EEM equity beta, growth/cyclicality, global risk appetite
Duration TLT, IEF, SHY, AGG interest-rate duration, curve shocks, flight-to-quality behavior
Credit LQD, HYG spread risk, default-risk repricing, equity-like credit beta
Diversifiers GLD, VNQ, DBC gold, real estate, commodities, inflation/real-asset exposure

The earlier portfolio notebooks introduced the data and economic role of these ETFs. Here we’ll focus on how the same sleeves enter objectives based on variance, tail loss, risk contribution, and uncertainty about returns.

A variance-focused model may prefer SHY because it has very low volatility. A CVaR model may prefer assets that avoid large negative tail scenarios. A risk parity model may give large weight to low-volatility bonds because they need more capital to contribute equal volatility risk. A robust model may penalize assets with high expected-return uncertainty, even when their point forecast looks attractive.

The constraint set is:

\[ \mathbf{1}^\top w = 1 \]

\[ 0 \le w_i \le \bar{w}_i \]

where \(w_i\) is the weight of asset \(i\), \(\mathbf{1}^\top w=1\) means the portfolio is fully invested, and \(\bar{w}_i\) is the cap applied to that asset. The new local models use the common 40% cap, while the Black–Litterman reference uses the asset-specific caps in the table below. Bounds limit concentration when a sample makes a few assets look much more attractive or much safer than the rest.

Show code
rebal_freq = "ME"
cov_lookback = 756
mu_lookback = 252
annualization = 252
ewma_lambda = 0.97
risk_free_rate_annual = 0.04
risk_free_daily = (1 + risk_free_rate_annual) ** (1 / annualization) - 1
cost_bps = 10.0
w_min = 0.0
w_max = 0.40
mv_lambda = 4.0
fixed_benchmark_weights = {"SPY": 0.24, "QQQ": 0.10, "IWM": 0.06,
                           "EFA": 0.12, "EEM": 0.06, "TLT": 0.10,
                           "IEF": 0.08, "SHY": 0.04, "AGG": 0.06,
                           "LQD": 0.04, "HYG": 0.03, "GLD": 0.05,
                           "VNQ": 0.04, "DBC": 0.04}
benchmark_w = pd.Series(fixed_benchmark_weights, dtype=float).reindex(assets).fillna(0.0)
benchmark_w = benchmark_w / float(benchmark_w.sum())
sleeve_groups = {"equities": ["SPY", "QQQ", "IWM", "EFA", "EEM"], "duration": ["TLT", "IEF", "SHY", "AGG"],
                 "credit": ["LQD", "HYG"], "diversifiers": ["GLD", "VNQ", "DBC"]}
sleeve_map = {asset: sleeve for sleeve, names in sleeve_groups.items() for asset in names}
asset_cap_map = {"SPY": 0.40, "QQQ": 0.40, "IWM": 0.35,
                 "EFA": 0.35, "EEM": 0.35, "TLT": 0.25,
                 "IEF": 0.25, "SHY": 0.15, "AGG": 0.30,
                 "LQD": 0.25, "HYG": 0.15, "GLD": 0.30,
                 "VNQ": 0.30, "DBC": 0.25}
asset_caps = pd.Series({asset: asset_cap_map[asset] for asset in assets}, index=assets, dtype=float)
Show code
rebalance_dates = make_rebalance_dates(returns.index, freq=rebal_freq, min_history_days=max(cov_lookback, mu_lookback, 756))
fixed_universe_by_date = {pd.Timestamp(dt): {"tickers": list(assets), "avg_dollar_volume": pd.Series(1.0,
    index=assets)} for dt in rebalance_dates}
universe_table = pd.DataFrame({"asset": assets, "sleeve": [sleeve_map.get(asset, "other") for asset in assets],
                               "asset_cap": asset_caps.reindex(assets).values})
benchmark_table = pd.DataFrame({"asset": assets, "sleeve": [sleeve_map.get(asset, "other") for asset in assets],
                                "benchmark_weight": benchmark_w.reindex(assets).values,
                                "asset_cap": asset_caps.reindex(assets).values})
rebalance_summary = pd.DataFrame([{"count": len(rebalance_dates), "first_rebalance": rebalance_dates[0],
                                   "last_rebalance": rebalance_dates[-1], "frequency": rebal_freq,
                                   "cov_lookback": cov_lookback, "mu_lookback": mu_lookback}])
display(universe_table)
display(benchmark_table.round(4))
display(rebalance_summary)
asset sleeve asset_cap
0 SPY equities 0.40
1 QQQ equities 0.40
2 IWM equities 0.35
3 EFA equities 0.35
4 EEM equities 0.35
5 TLT duration 0.25
6 IEF duration 0.25
7 SHY duration 0.15
8 AGG duration 0.30
9 LQD credit 0.25
10 HYG credit 0.15
11 GLD diversifiers 0.30
12 VNQ diversifiers 0.30
13 DBC diversifiers 0.25
asset sleeve benchmark_weight asset_cap
0 SPY equities 0.2264 0.40
1 QQQ equities 0.0943 0.40
2 IWM equities 0.0566 0.35
3 EFA equities 0.1132 0.35
4 EEM equities 0.0566 0.35
5 TLT duration 0.0943 0.25
6 IEF duration 0.0755 0.25
7 SHY duration 0.0377 0.15
8 AGG duration 0.0566 0.30
9 LQD credit 0.0377 0.25
10 HYG credit 0.0283 0.15
11 GLD diversifiers 0.0472 0.30
12 VNQ diversifiers 0.0377 0.30
13 DBC diversifiers 0.0377 0.25
count first_rebalance last_rebalance frequency cov_lookback mu_lookback
0 126 2016-01-29 2026-06-17 ME 756 252

The universe table lists the investable set and the asset-specific caps used by the Black–Litterman reference. SPY and QQQ can reach 40%, several equity and diversifier ETFs have 30–35% caps, and SHY has a 15% cap in that reference. The local CVaR, parity, hierarchical, and robust models use the common 40% upper bound instead, so their SHY positions can reach 40%.

The strategic benchmark provides the following reference weights for comparison. The largest neutral allocations are SPY at about 22.64%, EFA at 11.32%, QQQ and TLT at 9.43%, and IEF at 7.55%. We can use those weights as context when we compare the allocations produced by the different models. When we see a model hold 40% SHY, 40% DBC, or 40% EEM, we can compare that to the much more balanced benchmark and ask whether the model’s risk definition is forcing a concentrated decision.

The rebalance setup gives 126 monthly rebalance dates from 2016-01-29 to 2026-06-17. We start in 2016 because the covariance lookback is 756 trading days, about three years. The three-year window provides observations for covariance and tail estimates while rolling forward as the sample changes. The 252-day mean window can respond on a different timescale from the covariance window.

2.1 Portfolio optimization: objective, constraints, and the solution

Before comparing individual portfolio models, we can write their common decision in a general form. At a rebalance date we choose a vector of weights \(w\in\mathbb{R}^N\) using the information available in the estimation window. An objective function assigns a value to each candidate allocation, and the constraints decide which candidates we are allowed to hold:

\[ \min_{w\in\mathcal C} f(w), \qquad \mathcal C=\{w:\mathbf{1}^{\top}w=1,\;0\leq w_i\leq \bar w_i\}. \]

Minimizing \(f(w)\) and maximizing \(-f(w)\) describe the same choice; the sign simply depends on whether we write the objective as a cost or a utility. Here \(\mathcal C\) is the feasible set. Full investment requires all weights to add to one, long-only weights rule out short positions, and the upper bounds limit concentration. For the local models below we use the common 40% asset cap; the Black–Litterman reference also has its separate asset-specific caps. A feasible fully invested portfolio requires the caps to add up to at least one.

A familiar example is mean-variance utility. If \(\hat\mu\) and \(\hat\Sigma\) are our return and covariance estimates, and \(\lambda>0\) controls the cost of risk, the maximization can be written as the minimization:

\[ f(w)=-\hat\mu^{\top}w+\frac{\lambda}{2}w^{\top}\hat\Sigma w. \]

The first term rewards the portfolio for estimated return; the second charges for portfolio variance. The covariance term includes both individual variances and the covariances between positions, so the change in risk from adding one asset depends on the rest of the portfolio. A larger \(\lambda\) increases the weight placed on variance relative to expected return, while \(\lambda=0\) would leave a linear return objective subject to the same constraints.

We can understand an interior solution by differentiating. For a symmetric covariance matrix,

\[ \nabla_w f(w)=-\hat\mu+\lambda\hat\Sigma w. \]

The component \((\hat\Sigma w)_i\) measures how asset \(i\) covaries with the portfolio. Without a budget constraint we would set the gradient to zero; with full investment, changing one weight requires us to move capital from another position. We therefore introduce a multiplier \(\nu\) for the equality constraint and compare the marginal objective across assets after accounting for that shared budget.

The inequality constraints have multipliers too. If \(\ell_i\geq0\) corresponds to \(w_i\geq0\) and \(u_i\geq0\) corresponds to \(w_i\leq\bar w_i\), the Lagrangian for this minimization is:

\[ \mathcal L(w,\nu,\ell,u)=f(w)+\nu(\mathbf1^{\top}w-1)-\ell^{\top}w+u^{\top}(w-\bar w). \]

At a differentiable optimum satisfying the usual regularity conditions, the Karush–Kuhn–Tucker conditions combine stationarity, feasibility, and the constraint multipliers:

\[ \nabla f(w)+\nu\mathbf1-\ell+u=0,\qquad \ell_iw_i=0,\quad u_i(w_i-\bar w_i)=0. \]

The last two equalities are complementary slackness. If an asset has a positive weight below its cap, both of its inequality multipliers are zero: its marginal cost is balanced through the common budget multiplier. If its weight is zero or at its cap, the relevant bound can influence the solution. A constraint at its boundary is called active; its multiplier describes the local effect of relaxing that constraint when the value function is differentiable.

For example, if an attractive asset reaches its 40% cap, the optimizer has to place the remaining capital elsewhere even if the unconstrained choice would hold more of that asset. A zero weight can be optimal as well: the estimated benefit of a small allocation may not compensate for its marginal risk and the opportunity to invest the same capital in other assets. The final holdings therefore come from the estimates, the objective, and the feasible set together.

When \(\hat\Sigma\) is positive semidefinite, the quadratic risk term is convex. With linear budget and bound constraints, minimizing the mean-variance cost is a convex problem, so a local optimum is also global. Some other objectives below can be written as linear or conic programs; nonlinear risk-budgeting objectives may require a different numerical method. In every case we check the returned weights and evaluate them over the next holding period, using a new estimation window at the next rebalance.

We now rebuild the earlier baseline grid. It searches through covariance and expected-return combinations, and we select one representative for each of these roles:

  • Equal Weight as the simplest fully diversified reference.
  • MinVar as the variance-only defensive benchmark.
  • MV as the standard mean-variance benchmark.
  • MaxSharpe as the return-seeking frontier benchmark.

We compare the same four roles while letting the grid select their estimates:

Role What it contributes to the comparison
Equal weight A simple reference with the same capital weight in every asset
Minimum variance A reference chosen from covariance-based risk minimization
Mean-variance A reference that combines estimated return and covariance
MaxSharpe A frontier reference chosen using the return-to-risk ratio

Keeping these references in the comparison lets us assess the new objectives alongside familiar portfolio choices. We’ll compare return, drawdown, tail loss, turnover, and concentration rather than relying on only one performance measure.

Show code
cov_models = {"Sample": covariance.sample_covariance, "LedoitWolf": covariance.ledoit_wolf_covariance,
              "OAS": covariance.oas_covariance, "EWMA": covariance.ewma_covariance}
mu_models = {"Momentum": expected_returns.momentum_mu, "BayesStein": expected_returns.bayes_stein_mu,
             "BayesSteinMomentum": expected_returns.bayes_stein_momentum_mu}
walkforward_grid = run_walkforward_grid(returns=returns[assets], close=close[assets], rebalance_dates=rebalance_dates,
    universe_by_date=fixed_universe_by_date, cov_models=cov_models, mu_models=mu_models, cov_lookback=cov_lookback,
    mu_lookback=mu_lookback, min_cov_observations=cov_lookback - 1, min_mu_observations=mu_lookback - 1, max_weight=w_max,
    min_weight=w_min, long_only=True, trading_cost_bps=cost_bps, turnover_penalty_bps=cost_bps, fallback="equal",
    rf_daily=risk_free_daily, annualization=annualization, ewma_lambda=ewma_lambda,
    optimizer_params={"MV": {"mv_lambda": mv_lambda}, "RidgeMV": {"mv_lambda": mv_lambda}, "FrontierGrid": {"grid_n": 15}})
walkforward_grid = append_frontiergrid_from_best_maxsharpe(walkforward_grid, grid_n=15)
Show code
def ordered_strategy_table(table):
    cols = ["Sharpe", "Max Drawdown", "Turnover", "CAGR"]
    ascending = [False, False, True, False]
    present = [col for col in cols if col in table.columns]
    asc = [ascending[cols.index(col)] for col in present]
    return table.replace([np.inf, -np.inf], np.nan).sort_values(present, ascending=asc)
benchmark_summary = walkforward_grid.results.copy()
benchmark_summary_ranked = ordered_strategy_table(benchmark_summary)
Show code
def pick_best_strategy(summary, optimizer):
    candidates = summary[summary["Optimizer"].eq(optimizer)].replace([np.inf, -np.inf], np.nan).dropna(subset=["Sharpe"])
    if candidates.empty:
        return None
    return str(ordered_strategy_table(candidates).index[0])
grid_equal_weight_name = "EW" if "EW" in walkforward_grid.backtests else pick_best_strategy(benchmark_summary, "EW")
best_minvar_name = pick_best_strategy(benchmark_summary, "MinVar")
best_mv_name = pick_best_strategy(benchmark_summary, "MV")
best_maxsharpe_name = pick_best_strategy(benchmark_summary, "MaxSharpe")
if best_maxsharpe_name is None:
    best_maxsharpe_name = pick_best_strategy(benchmark_summary, "FrontierGrid")
if grid_equal_weight_name is None or best_minvar_name is None or best_mv_name is None or best_maxsharpe_name is None:
    raise ValueError("could not select all required benchmark strategies from the walk-forward grid")
_, _, best_minvar_cov = selection.parse_strategy_spec(best_minvar_name, walkforward_grid.backtests[best_minvar_name])
_, best_mv_mu, best_mv_cov = selection.parse_strategy_spec(best_mv_name, walkforward_grid.backtests[best_mv_name])
_, best_maxsharpe_mu, best_maxsharpe_cov = selection.parse_strategy_spec(best_maxsharpe_name,
    walkforward_grid.backtests[best_maxsharpe_name])
benchmark_results = {"Equal Weight": walkforward_grid.backtests[grid_equal_weight_name],
                     best_minvar_name: walkforward_grid.backtests[best_minvar_name],
                     best_mv_name: walkforward_grid.backtests[best_mv_name],
                     best_maxsharpe_name: walkforward_grid.backtests[best_maxsharpe_name]}
selected_benchmark_names = pd.Series({"equal_weight_name": grid_equal_weight_name,
                                      "best_minvar_name": best_minvar_name, "best_mv_name": best_mv_name,
                                      "best_maxsharpe_name": best_maxsharpe_name, "best_minvar_cov": best_minvar_cov,
                                      "best_mv_cov": best_mv_cov, "best_mv_mu": best_mv_mu,
                                      "best_maxsharpe_cov": best_maxsharpe_cov,
                                      "best_maxsharpe_mu": best_maxsharpe_mu})
selected_benchmark_table = benchmark_summary.loc[[grid_equal_weight_name, best_minvar_name, best_mv_name, best_maxsharpe_name]].copy()
print(selected_benchmark_names.to_string())
display(selected_benchmark_table.round(4))
equal_weight_name                                          EW
best_minvar_name                                 MinVar (OAS)
best_mv_name                      MV (LedoitWolf, BayesStein)
best_maxsharpe_name    MaxSharpe (Sample, BayesSteinMomentum)
best_minvar_cov                                           OAS
best_mv_cov                                        LedoitWolf
best_mv_mu                                         BayesStein
best_maxsharpe_cov                                     Sample
best_maxsharpe_mu                          BayesSteinMomentum
Optimizer Mu model Covariance model CAGR Vol Sharpe Max Drawdown Calmar Sortino Turnover Total Turnover Cost Drag Effective N Fallbacks
Strategy
EW EW - - 0.0848 0.0935 0.4842 -0.2038 0.4159 0.5934 0.0114 1.4385 0.0010 14.0000 0
MinVar (OAS) MinVar - OAS 0.0665 0.0754 0.3543 -0.1973 0.3370 0.4044 0.0031 0.3897 0.0003 9.1680 0
MV (LedoitWolf, BayesStein) MV BayesStein LedoitWolf 0.0814 0.0847 0.4949 -0.1378 0.5904 0.6049 0.0369 4.6481 0.0028 5.0557 0
MaxSharpe (Sample, BayesSteinMomentum) MaxSharpe BayesSteinMomentum Sample 0.1168 0.1189 0.6553 -0.1653 0.7065 0.8164 0.2177 27.4264 0.0158 3.2046 0

The selected versions are:

Role Selected model
Equal weight EW
Minimum variance MinVar with OAS covariance
Mean-variance MV with Ledoit-Wolf covariance and Bayes-Stein expected returns
MaxSharpe MaxSharpe with sample covariance and Bayes-Stein Momentum expected returns

The selected baseline portfolios have different return, risk, and turnover profiles. MaxSharpe has the highest baseline CAGR at 11.68% and Sharpe at 0.6553, but it also has high turnover of 21.77% per month and low effective diversification with average effective \(N\) around 3.20. That means it gets its performance from concentrated bets that move around.

MV with Ledoit-Wolf and Bayes-Stein is much more conservative: CAGR 8.14%, volatility 8.47%, Sharpe 0.4949, and a strong max drawdown of -13.78%. MinVar has even lower volatility but lower return. Equal Weight is stable and diversified, but its max drawdown is the worst among the baseline set at -20.38%.

The selected references give us four different profiles to compare:

  • MaxSharpe gives strong performance, but it concentrates and trades.
  • MinVar controls volatility, but gives up too much return.
  • MV is balanced, but still depends on a fragile expected-return estimate.
  • Equal Weight is stable by construction, but doesn’t react to changing risk.

We’ll use these results as a reference when we study the new tail-risk, risk-parity, hierarchical, and robust allocations.

3) Learned-Confidence Black-Litterman as a Strong Reference

We rebuild the Learned-Confidence BL strategy from Project 6 and include it as a reference. We don’t re-teach the full Black-Litterman posterior here, but it helps to remember what BL contributes to the comparison.

We start BL from an equilibrium prior, generate active relative views, learn confidence from realized payoff history, and convert the posterior return vector into a constrained portfolio. The resulting active portfolio gives us a reference built from a structured estimate of returns.

BL also addresses instability in expected-return estimates by combining a prior with views and learned confidence. Comparing it with classical MV and the new robust objectives lets us see how different ways of using return information affect the allocation and its subsequent performance.

At each rebalance, candidate views are filtered, their confidence is estimated from available realized payoffs, and the resulting posterior is passed to the constrained allocation step. The backtest then applies the selected portfolio over the next holding period, so we can compare this reference with the other approaches on the same dates.

Show code
family_q_caps = {"liquid_leadership": 0.08, "dual_momentum": 0.10, "inflation_rotation": 0.05,
                 "risk_adj_momentum": 0.10, "credit_switch": 0.05, "reflation_breadth": 0.10,
                 "growth_duration": 0.08, "correlation_stress": 0.08, "international_rotation": 0.10,
                 "duration_quality": 0.08}

roles = {"assets": assets, "risk_assets": ["SPY", "QQQ", "IWM", "EFA", "EEM", "HYG", "VNQ", "DBC"],
         "risky": ["SPY", "QQQ", "IWM", "EFA", "EEM", "HYG", "VNQ", "DBC"], "cyclical": ["IWM", "EEM", "VNQ", "HYG"],
         "defensive": ["TLT", "IEF", "SHY", "AGG", "LQD", "GLD"], "duration": ["TLT", "IEF", "SHY", "AGG"],
         "credit": ["LQD", "HYG"], "real_assets": ["GLD", "VNQ", "DBC"], "international": ["EFA", "EEM"],
         "growth": ["QQQ", "SPY"], "small_caps": ["IWM"],
         "dollar": ["UUP"] if "UUP" in signal_returns.columns else [], "sleeve_map": sleeve_map}
view_settings = bl_views.ViewSettings(family_q_caps=family_q_caps,
                                      family_display_names=bl_views.DEFAULT_PRIMARY_DISPLAY_NAMES, assets=assets,
                                      annualization=annualization, entry_z=0.50, q_strength_scale=1.25,
                                      view_horizon_days=21)

bl_settings = bl.BLSettings(rebalance_freq=rebal_freq, cov_lookback=cov_lookback, mu_lookback=mu_lookback,
                            annualization=annualization, risk_free_rate_annual=risk_free_rate_annual, tau=0.05,
                            ewma_lambda=ewma_lambda, delta_fallback=2.5, delta_min=0.50,
                            delta_max=8.00, transaction_cost_bps=cost_bps, max_weight=w_max,
                            min_weight=w_min, active_weight_limit=0.15, active_weight_relaxed=0.22,
                            max_selected_views=len(family_q_caps), redundancy_similarity=0.85, max_same_direction=3,
                            confidence_mode="learned", use_learned_q=True, full_view_covariance=True,
                            empirical_omega_rescale=False, posterior_mu_clip=0.30, te_gamma_fallback=3.0)

view_functions = [bl_views.liquid_leadership, bl_views.dual_momentum, bl_views.inflation_rotation,
    bl_views.risk_adjusted_momentum, bl_views.credit_switch, bl_views.reflation_breadth, bl_views.growth_duration,
    bl_views.correlation_stress, bl_views.international_rotation, bl_views.duration_quality]
bl_constraints = {"asset_caps": asset_caps.to_dict()}
Show code
learned_bl_run = bl.learned_confidence_bl(returns=returns[assets], signal_returns=signal_returns,
                                          rebalance_dates=rebalance_dates, benchmark_weights=benchmark_w, roles=roles,
                                          view_functions=view_functions, view_settings=view_settings,
                                          settings=bl_settings, constraints=bl_constraints)

learned_bl_result = run_many_weights_backtests({"Learned-Confidence BL": learned_bl_run.weights}, returns=returns[assets], 
                                               cost_bps=cost_bps, rf_daily=risk_free_daily, w_min=w_min, 
                                               w_max=w_max, long_only=True, normalize=True,
                                                   weight_timing="next_close")["Learned-Confidence BL"]
print("candidate view rows:", len(learned_bl_run.candidate_view_log))
print("selected view rows:", len(learned_bl_run.selected_view_log))
print("confidence rows:", len(learned_bl_run.confidence_log))
print("posterior rows:", len(learned_bl_run.posterior_log))
candidate view rows: 544
selected view rows: 538
confidence rows: 538
posterior rows: 125
Show code
strategic_weights = pd.DataFrame([benchmark_w.reindex(assets).rename(pd.Timestamp(dt)) for dt in rebalance_dates[:-1]])
strategic_result = run_many_weights_backtests({"Strategic Benchmark": strategic_weights}, returns=returns[assets],
                                              cost_bps=cost_bps, rf_daily=risk_free_daily, w_min=w_min,
                                              w_max=w_max, long_only=True, normalize=True,
                                              weight_timing="next_close")["Strategic Benchmark"]
bl_metric_table = selection.build_strategy_summary({"Learned-Confidence BL": learned_bl_result},
                                                   rf_daily=risk_free_daily, annualization=annualization)
display(bl_metric_table.round(4))
sanity_results = {"Strategic Benchmark": strategic_result,
                  "Equal Weight": walkforward_grid.backtests[grid_equal_weight_name],
                  "Learned-Confidence BL": learned_bl_result}
for sanity_name in ["MinVar (OAS)", "MV (LedoitWolf, BayesStein)"]:
    if sanity_name in walkforward_grid.backtests:
        sanity_results[sanity_name] = walkforward_grid.backtests[sanity_name]
sanity_actual = selection.build_strategy_summary(sanity_results, rf_daily=risk_free_daily, annualization=annualization)
sanity_actual = sanity_actual.rename(columns={"CAGR": "cagr", "Vol": "annualized_volatility", "Sharpe": "sharpe",
                                              "Sortino": "sortino", "Max Drawdown": "max_drawdown",
                                              "Calmar": "calmar", "Turnover": "avg_turnover",
                                              "Effective N": "effective_n"})
expected_project6 = pd.DataFrame({"cagr": {"Strategic Benchmark": 0.0914, "Equal Weight": 0.0855,
                                           "MinVar (OAS)": 0.0686, "MV (LedoitWolf, BayesStein)": 0.0735,
                                           "Learned-Confidence BL": 0.1015},
                                               "annualized_volatility": {"Learned-Confidence BL": 0.1031},
                                               "sharpe": {"Strategic Benchmark": 0.5291, "Equal Weight": 0.4916,
                                               "MinVar (OAS)": 0.3806, "MV (LedoitWolf, BayesStein)": 0.4860,
                                               "Learned-Confidence BL": 0.6103},
                                               "sortino": {"Learned-Confidence BL": 0.7671},
                                               "max_drawdown": {"Strategic Benchmark": -0.2242, "Equal Weight": -0.2041,
                                               "MinVar (OAS)": -0.1975, "MV (LedoitWolf, BayesStein)": -0.1181,
                                               "Learned-Confidence BL": -0.2207},
                                               "calmar": {"Learned-Confidence BL": 0.4600},
                                               "avg_turnover": {"Learned-Confidence BL": 0.1761},
                                               "effective_n": {"Learned-Confidence BL": 6.7530}})
sanity_compare = sanity_actual[[c for c in expected_project6.columns if c in sanity_actual.columns]].join(expected_project6,
    rsuffix="_expected")
for col in expected_project6.columns:
    if col in sanity_actual.columns:
        sanity_compare[col + "_diff"] = sanity_compare[col] - sanity_compare[col + "_expected"]
display(sanity_compare.round(4))
bl_far = False
if "Learned-Confidence BL" in sanity_compare.index:
    row = sanity_compare.loc["Learned-Confidence BL"]
    bl_far = bool(abs(row.get("cagr_diff", 0.0)) > 0.03 or abs(row.get("sharpe_diff",
        0.0)) > 0.20 or abs(row.get("max_drawdown_diff", 0.0)) > 0.08)
if bl_far:
    print("learned-confidence BL is materially away from the project 6 sanity anchors with the current data/settings")
    display(pd.Series(vars(bl_settings)).to_frame("setting"))
Optimizer Mu model Covariance model CAGR Vol Sharpe Max Drawdown Calmar Sortino Turnover Total Turnover Cost Drag Effective N Fallbacks
Strategy
Learned-Confidence BL Learned-Confidence BL - - 0.1253 0.115 0.7458 -0.182 0.6888 0.9287 0.3147 39.332 0.0226 5.2021 0
cagr annualized_volatility sharpe sortino max_drawdown calmar avg_turnover effective_n cagr_expected annualized_volatility_expected sharpe_expected sortino_expected max_drawdown_expected calmar_expected avg_turnover_expected effective_n_expected cagr_diff annualized_volatility_diff sharpe_diff sortino_diff max_drawdown_diff calmar_diff avg_turnover_diff effective_n_diff
Strategy
Strategic Benchmark 0.0989 0.1105 0.5559 0.6828 -0.2301 0.4298 0.0147 9.4420 0.0914 NaN 0.5291 NaN -0.2242 NaN NaN NaN 0.0075 NaN 0.0268 NaN -0.0059 NaN NaN NaN
Equal Weight 0.0848 0.0935 0.4842 0.5934 -0.2038 0.4159 0.0114 14.0000 0.0855 NaN 0.4916 NaN -0.2041 NaN NaN NaN -0.0007 NaN -0.0074 NaN 0.0003 NaN NaN NaN
Learned-Confidence BL 0.1253 0.1150 0.7458 0.9287 -0.1820 0.6888 0.3147 5.2021 0.1015 0.1031 0.6103 0.7671 -0.2207 0.46 0.1761 6.753 0.0238 0.0119 0.1355 0.1616 0.0387 0.2288 0.1386 -1.5509
MinVar (OAS) 0.0665 0.0754 0.3543 0.4044 -0.1973 0.3370 0.0031 9.1680 0.0686 NaN 0.3806 NaN -0.1975 NaN NaN NaN -0.0021 NaN -0.0263 NaN 0.0002 NaN NaN NaN
MV (LedoitWolf, BayesStein) 0.0814 0.0847 0.4949 0.6049 -0.1378 0.5904 0.0369 5.0557 0.0735 NaN 0.4860 NaN -0.1181 NaN NaN NaN 0.0079 NaN 0.0089 NaN -0.0197 NaN NaN NaN

The BL rebuild generates 544 candidate view rows, 538 selected view rows, 538 confidence rows, and 125 posterior rows. These counts show the reference is active through almost the full monthly period.

The BL reference performs very strongly:

\[ \text{CAGR} = 12.53\%, \qquad \sigma_{\text{ann}} = 11.50\%, \qquad \text{Sharpe} = 0.7458 \]

It also improves the drawdown relative to the strategic benchmark: -18.20% versus -23.01%. Its turnover is high at 31.47% average monthly turnover, which is the cost of running active monthly views. The effective number of positions is about 5.20, so it is diversified enough to avoid a one-asset bet, but still meaningfully concentrated relative to Equal Weight.

The other models can be compared with this active reference across several dimensions. For example:

  • CVaR models can justify themselves if they reduce expected shortfall or drawdown.
  • Risk parity can justify itself if it gives a smoother defensive allocation.
  • Robust models can justify themselves if they preserve return while reducing dependence on fragile forecasts.
  • Wasserstein DRO can justify itself if it behaves like a less brittle version of aggressive mean-variance.

These comparisons keep both the portfolio’s objective and its observed return and risk in view.

Show code
results = {}
results["Equal Weight"] = walkforward_grid.backtests[grid_equal_weight_name]
results[best_minvar_name] = walkforward_grid.backtests[best_minvar_name]
results[best_mv_name] = walkforward_grid.backtests[best_mv_name]
results[best_maxsharpe_name] = walkforward_grid.backtests[best_maxsharpe_name]
results["Learned-Confidence BL"] = learned_bl_result

def compact_result_table(result_map):
    out = selection.build_strategy_summary(result_map, rf_daily=risk_free_daily, annualization=annualization).copy()
    out = out.rename(columns={"CAGR": "cagr", "Vol": "ann_vol", "Sharpe": "sharpe",
                              "Sortino": "sortino", "Max Drawdown": "max_drawdown", "Calmar": "calmar",
                              "Turnover": "avg_turnover", "Total Turnover": "total_turnover",
                              "Cost Drag": "cost_drag", "Effective N": "avg_effective_n"})
    rows = []
    for name, result in result_map.items():
        weights = result.weights.reindex(columns=assets).fillna(0.0) if not result.weights.empty else pd.DataFrame(columns=assets)
        rows.append({"model": name, "avg_max_weight": float(attribution.max_weight(weights).mean()) if not weights.empty else np.nan})
    extra = pd.DataFrame(rows).set_index("model") if rows else pd.DataFrame()
    if not extra.empty:
        out = out.join(extra, how="left")
    cols = ["cagr", "ann_vol", "sharpe", "sortino", "max_drawdown", "calmar", "avg_turnover", "total_turnover", "cost_drag",
        "avg_effective_n", "avg_max_weight"]
    return out[[c for c in cols if c in out.columns]]
benchmark_table_compact = compact_result_table(results)
display(benchmark_table_compact.round(4))
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Equal Weight 0.0848 0.0935 0.4842 0.5934 -0.2038 0.4159 0.0114 1.4385 0.0010 14.0000 0.0714
MinVar (OAS) 0.0665 0.0754 0.3543 0.4044 -0.1973 0.3370 0.0031 0.3897 0.0003 9.1680 0.2394
MV (LedoitWolf, BayesStein) 0.0814 0.0847 0.4949 0.6049 -0.1378 0.5904 0.0369 4.6481 0.0028 5.0557 0.3212
MaxSharpe (Sample, BayesSteinMomentum) 0.1168 0.1189 0.6553 0.8164 -0.1653 0.7065 0.2177 27.4264 0.0158 3.2046 0.3897
Learned-Confidence BL 0.1253 0.1150 0.7458 0.9287 -0.1820 0.6888 0.3147 39.3320 0.0226 5.2021 0.2724

4) Shared Optimization Infrastructure

Before estimating the new models, we’ll set up the common constraints and numerical utilities. These let us solve the objectives on the same investable universe and check that the resulting weights are fully invested and respect their bounds.

The constraints are enforced repeatedly:

\[ \sum_{i=1}^{N} w_i = 1 \]

\[ w_i \ge 0 \]

\[ w_i \le \bar{w}_i \]

where \(\bar{w}_i\) is the cap for asset \(i\). We also clean weights after optimization so that tiny numerical negatives are clipped, weights are renormalized, and cap violations are corrected.

The PSD square-root routine also matters. Several models need a matrix square root of a covariance-like matrix:

\[ A^{1/2}(A^{1/2})^\top \approx A \]

For example, portfolio volatility can be written as:

\[ \sqrt{w^\top \Sigma w} = \|\Sigma^{1/2} w\|_2 \]

That norm form is useful in conic optimization, robust optimization, and Wasserstein DRO. Since empirical covariance matrices can have tiny numerical eigenvalue problems, the implementation symmetrizes the matrix and floors small eigenvalues before taking the square root. This doesn’t change the model’s economic idea. It prevents the numerical solver from failing because of floating-point noise.

The common cap of 0.40 applies to each local model in the main cross-asset comparison. The separately displayed asset-specific cap vector enters the Black–Litterman reference; keeping the two uses distinct helps us read an SHY weight near 40% in a local model alongside a 15% BL cap.

For the square root above we use the symmetric eigendecomposition \(A=Q\Lambda Q^\top\), giving \(A^{1/2}=Q\Lambda^{1/2}Q^\top\) after the small eigenvalue floor. Then \(\|A^{1/2}w\|_2^2=w^\top A w\) for the adjusted positive-semidefinite matrix.

Show code
def psd_sqrt(mat, eps=1e-10):
    arr = np.asarray(mat, dtype=float)
    arr = 0.5 * (arr + arr.T)
    vals, vecs = np.linalg.eigh(arr)
    vals = np.maximum(vals, eps)
    out = (vecs * np.sqrt(vals)) @ vecs.T
    return 0.5 * (out + out.T)

def cap_array(index, max_weight=None):
    idx = pd.Index(index)
    if max_weight is None:
        return pd.Series(float(w_max), index=idx, dtype=float)
    if isinstance(max_weight, (pd.Series, dict)):
        cap = pd.Series(max_weight, dtype=float).reindex(idx).fillna(w_max)
    else:
        arr = np.asarray(max_weight, dtype=float).reshape(-1)
        cap = pd.Series(arr, index=idx, dtype=float) if arr.size == len(idx) else pd.Series(float(max_weight), index=idx, dtype=float)
    cap = cap.replace([np.inf, -np.inf], np.nan).fillna(w_max).clip(lower=0.0)
    if float(cap.sum()) < 1.0 - 1e-10:
        cap = pd.Series(max(float(w_max), 1.0 / len(idx)), index=idx, dtype=float)
    return cap

def clean_weight_array(values, index, max_weight=None):
    idx = pd.Index(index)
    caps = cap_array(idx, max_weight)
    w = pd.Series(values, index=idx, dtype=float).replace([np.inf, -np.inf], np.nan).fillna(0.0).clip(lower=0.0)
    w = np.minimum(w, caps)
    if float(w.sum()) <= 1e-12:
        w = pd.Series(1.0 / len(idx), index=idx, dtype=float)
    else:
        w = w / float(w.sum())
    for _ in range(50):
        over = w > caps + 1e-12
        if not bool(over.any()):
            break
        extra = float((w[over] - caps[over]).sum())
        w[over] = caps[over]
        room = (caps[~over] - w[~over]).clip(lower=0.0)
        if float(room.sum()) <= 1e-12:
            break
        w.loc[room.index] = w.loc[room.index] + extra * room / float(room.sum())
    w = np.minimum(w.clip(lower=0.0), caps)
    return w / float(w.sum()) if float(w.sum()) > 1e-12 else pd.Series(1.0 / len(idx), index=idx, dtype=float)
Show code
def solve_cvx_problem(problem):
    installed = set(cp.installed_solvers())
    for solver in ["CLARABEL", "ECOS", "SCS"]:
        if solver not in installed:
            continue
        try:
            problem.solve(solver=solver, verbose=False)
            if problem.status in ["optimal", "optimal_inaccurate"]:
                return problem.status
        except Exception:
            pass
    return str(problem.status)

def backtest_weight_frame(name, weights):
    return run_many_weights_backtests({name: weights}, returns=returns[assets], cost_bps=cost_bps,
                                      rf_daily=risk_free_daily, w_min=w_min, w_max=w_max,
                                      long_only=True, normalize=True, weight_timing="next_close")[name]

def make_weight_frame_from_func(name, weight_func):
    usable_dates = [pd.Timestamp(dt) for dt in walkforward_grid.metadata["rebalance_dates"] if pd.Timestamp(dt) in walkforward_grid.cache][:-1]
    rows = []
    fallback_count = 0
    prev_w = pd.Series(1.0 / len(assets), index=assets, dtype=float)
    for dt in usable_dates:
        state = walkforward_grid.cache[pd.Timestamp(dt)]
        tickers = list(state.get("tickers", assets))
        raw = weight_func(pd.Timestamp(dt), state, prev_w.reindex(tickers).fillna(0.0))
        if raw is None:
            fallback_count += 1
            raw = prev_w.reindex(tickers).fillna(0.0)
        w = clean_weight_array(raw, tickers).reindex(assets).fillna(0.0)
        w = clean_weight_array(w, assets)
        rows.append(w.rename(pd.Timestamp(dt)))
        prev_w = w
    local_fallback_counts[name] = fallback_count
    return pd.DataFrame(rows).reindex(columns=assets).fillna(0.0)
local_fallback_counts = {}
model_rebalance_dates = [pd.Timestamp(dt) for dt in walkforward_grid.metadata["rebalance_dates"] if pd.Timestamp(dt) in walkforward_grid.cache][:-1]
first_cache_date = model_rebalance_dates[0]
display(pd.Series(list(walkforward_grid.cache[first_cache_date].keys()), name=str(first_cache_date.date())).to_frame("cache_keys"))
cache_keys
0 tickers
1 R_cov
2 R_mu
3 mu_raw_map
4 mu_ann_map
5 mu_info_map
6 cov_ann_map
7 avg_dollar_volume

At each rebalance date, the walk-forward engine keeps the inputs needed by the portfolio models:

  • tickers, the current asset universe.
  • R_cov, the rolling return matrix used for covariance and scenario-risk estimation.
  • R_mu, the rolling return matrix used for expected-return estimation.
  • mu_ann_map, expected-return estimates under different models.
  • cov_ann_map, covariance estimates under different estimators.
  • avg_dollar_volume, retained from the general selection system.

All of the local models use the same walk-forward information set. At each rebalance we estimate inputs from the historical window available on that date, produce weights, and apply those weights over the following period. The covariance and mean windows can have different lengths, but their dates are aligned to the same decision.

That makes the later comparison much more meaningful. Differences in performance come from the objective and constraints, not from giving one model a better data window.

Each cached state is associated with its rebalance date, and the return and covariance estimators use the observations available within their corresponding lookback windows.

5) CVaR as a Tail-Risk Objective

Variance is easy to optimize, but it treats upside and downside symmetrically. A large positive return increases variance, and a large negative return also increases variance. For portfolio construction, investors usually don’t dislike both equally. The painful part is the left tail, especially months or days where many assets fall together.

To model that directly, we define portfolio return in scenario \(s\) as:

\[ r_{p,s} = r_s^\top w \]

where \(r_s \in \mathbb{R}^N\) is the vector of asset returns in historical scenario \(s\), and \(w\) is the portfolio weight vector. Portfolio loss is the negative return:

\[ \ell_s(w) = -r_s^\top w \]

Large positive \(\ell_s(w)\) means a bad portfolio outcome. At confidence level \(\alpha\), Value-at-Risk is the loss threshold:

\[ VaR_\alpha(w) = \inf \left\{x : P(\ell(w) \le x) \ge \alpha \right\} \]

For \(\alpha=0.95\), this is the 95th percentile of losses. It tells us the cutoff loss where 95% of scenarios are better and 5% are worse.

CVaR, also called expected shortfall, goes one step further:

\[ CVaR_\alpha(w) = E[\ell(w) \mid \ell(w) \ge VaR_\alpha(w)] \]

For a continuous loss distribution, this is the average loss conditional on being in the worst 5% of outcomes. VaR gives the cutoff, while CVaR also uses the severity of the observations beyond it. That makes the tail size available as an objective or a constraint in portfolio construction.

The conditional-mean expression is exact for a continuous loss distribution. With a finite sample or probability mass at the VaR threshold, the worst \((1-\alpha)\) fraction can include only part of the probability at the cutoff. The optimization representation below handles that boundary directly, so CVaR remains well defined when the empirical losses have ties.

5.1 Rockafellar-Uryasev CVaR Linear Program

We can write empirical CVaR as a convex optimization problem by introducing one scalar \(\eta\) and one slack variable \(u_s\) for each historical scenario.

The variable \(\eta\) acts like the estimated VaR level. The slack \(u_s\) measures how far scenario \(s\) exceeds that VaR threshold. The empirical CVaR objective is:

\[ \eta + \frac{1}{(1-\alpha)T}\sum_{s=1}^{T}u_s \]

subject to:

\[ u_s \ge 0 \]

\[ u_s \ge \ell_s(w) - \eta \]

The second constraint says \(u_s\) is active only when the loss is above \(\eta\). If the scenario loss is smaller than \(\eta\), then \(\ell_s(w)-\eta\) is negative and \(u_s\) can stay at zero. If the loss is larger than \(\eta\), then \(u_s\) has to cover the excess loss above the threshold.

So the optimizer is doing two things at once:

  1. It chooses \(\eta\), the tail cutoff.
  2. It chooses \(w\), the portfolio that makes the average excess loss beyond \(\eta\) as small as possible.

For a pure minimum-CVaR portfolio, the optimization problem is:

\[ \min_{w,\eta,u}\; \eta + \frac{1}{(1-\alpha)T}\sum_{s=1}^{T}u_s \]

with the full-investment, long-only, cap, and CVaR slack constraints. All scenario losses are linear in the weights, so the objective and these constraints form a linear program over the historical return matrix.

For fixed weights, the two slack inequalities imply that the smallest feasible slack is \(u_s=(\ell_s(w)-\eta)_+\), where \((x)_+=\max(x,0)\). Substituting that value gives an equivalent one-dimensional problem for the threshold:

\[ \widehat{CVaR}_\alpha(w)=\min_\eta\left[\eta+\frac{1}{(1-\alpha)T}\sum_{s=1}^T(\ell_s(w)-\eta)_+\right]. \]

Away from a loss observation, increasing \(\eta\) by a small amount raises the first term but lowers each active excess loss. Its slope is \(1-n_>(\eta)/[(1-\alpha)T]\), where \(n_>(\eta)\) counts losses above the threshold. At the minimum the cutoff therefore lies at an empirical quantile, with the appropriate fraction of a boundary observation included when \((1-\alpha)T\) is not an integer.

The decision variables are the \(N\) weights, one threshold, and \(T\) slacks. Scenario loss is linear in \(w\), and all the displayed objective and constraints are linear in these variables, so the historical minimum-CVaR problem is a linear program. The later diagnostic computes an empirical quantile and the mean of observations above it; near a sample boundary that reported average can differ slightly from the fractional-tail value of the linear-program objective.

The slack-variable construction is also useful because it gives a very intuitive picture of what the optimizer is doing. Suppose we have five simplified loss scenarios:

\[ \ell(w)=\{0.1\%, 0.2\%, 0.4\%, 1.0\%, 3.0\%\} \]

If the tail threshold \(\eta\) is set near 1.0%, then only the 3.0% scenario creates a large positive slack. The small losses below \(\eta\) don’t matter much for the CVaR term. If the optimizer changes the weights and lowers the 3.0% scenario to 2.0%, the CVaR objective improves even if several normal scenarios barely change.

That is why CVaR optimization can produce portfolios that look different from variance-minimizing portfolios. Variance cares about all deviations, including the middle of the distribution. CVaR spends most of its attention on the tail set:

\[ \mathcal{T}_\alpha(w)=\{s:\ell_s(w)\ge VaR_\alpha(w)\} \]

The tail set itself depends on \(w\). When we change the portfolio, the worst scenarios can change. A portfolio with more commodities may have a different bad-scenario set than a portfolio with more duration. As the weights change, the optimizer can reduce losses on some dates while changing which dates belong to the portfolio’s worst tail.

This is one reason CVaR is powerful but sample-sensitive. If the rolling window doesn’t contain the type of crisis that is coming next, the optimized tail may be the wrong tail.

The estimated tail reflects the observations that are available in the chosen rolling window.

Show code
cvar_alpha = 0.95
min_cvar_rows = []
def min_cvar_weight(dt, state, prev_w):
    tickers = list(state["tickers"])
    caps = cap_array(tickers).to_numpy(dtype=float)
    r_train = state["R_cov"].reindex(columns=tickers).astype(float).dropna(how="any")
    r = r_train.to_numpy(dtype=float)
    t, n = r.shape
    w = cp.Variable(n)
    eta = cp.Variable()
    u = cp.Variable(t)
    losses = -r @ w
    cvar = eta + cp.sum(u) / ((1.0 - cvar_alpha) * t)
    problem = cp.Problem(cp.Minimize(cvar), [cp.sum(w) == 1.0, w >= 0.0, w <= caps, u >= 0.0, u >= losses - eta])
    status = solve_cvx_problem(problem)
    if w.value is None or status not in ["optimal", "optimal_inaccurate"]:
        return None
    out = clean_weight_array(w.value, tickers)
    loss_values = -r @ out.reindex(tickers).values
    var = float(np.quantile(loss_values, cvar_alpha))
    tail = loss_values[loss_values >= var]
    min_cvar_rows.append({"date": dt, "var_95_loss": var, "cvar_95_loss": float(tail.mean()) if len(tail) else var, "status": status})
    return out
min_cvar_weights = make_weight_frame_from_func("Min-CVaR 95", min_cvar_weight)
min_cvar_result = backtest_weight_frame("Min-CVaR 95", min_cvar_weights)
results["Min-CVaR 95"] = min_cvar_result
min_cvar_diag = pd.DataFrame(min_cvar_rows).set_index("date")
display(min_cvar_weights.iloc[-1].sort_values(ascending=False).to_frame("Min-CVaR 95").round(4))
display(compact_result_table({"Min-CVaR 95": min_cvar_result}).round(4))
display(min_cvar_diag.tail(1).round(6))
Min-CVaR 95
SHY 0.4000
HYG 0.3017
AGG 0.2199
DBC 0.0784
IEF 0.0000
GLD 0.0000
LQD 0.0000
QQQ 0.0000
SPY 0.0000
EFA 0.0000
EEM 0.0000
TLT 0.0000
VNQ 0.0000
IWM 0.0000
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Min-CVaR 95 0.024 0.036 -0.4073 -0.4977 -0.125 0.1918 0.0248 3.099 0.0027 2.9749 0.4
var_95_loss cvar_95_loss status
date
2026-05-29 0.003132 0.004446 optimal

At each rebalance, we solve the minimum-CVaR problem for a long-only allocation subject to the asset caps. The objective minimizes the average loss in the worst 5% of rolling historical scenarios, without using an expected-return estimate to choose among assets.

That objective creates a very defensive latest allocation:

  • SHY receives the max 40%.
  • HYG receives about 30.17%.
  • AGG receives about 21.99%.
  • DBC receives about 7.84%.
  • Most equity and long-duration assets receive zero.

At first, HYG may look surprising because high-yield credit is risky. But the model isn’t reading labels. It reads the empirical tail scenarios inside the rolling training window. If HYG’s recent worst-tail behavior is less damaging than equity or long-duration bonds under that particular window, the optimizer can still include it. This is one reason CVaR models need careful interpretation: they are only as good as the tail scenarios in the estimation window.

The latest training tail measures are a 95% VaR loss around 0.31% and CVaR loss around 0.44%. These numbers describe the daily scenarios in the estimation window used to choose the displayed allocation.

The pure Min-CVaR portfolio produces a particularly defensive backtest. It has the smallest annualized volatility among all models at 3.60%, the lowest historical ES at 0.54%, and the lowest worst month at -3.31%. That means the objective does what it was designed to do: it produces a portfolio with very small realized tail losses.

But the return side is too weak:

\[ \text{CAGR} = 2.40\%, \qquad \text{Sharpe} = -0.4073 \]

The Sharpe is negative because the project assumes a 4% annual risk-free rate, while the portfolio earns 2.40% annualized. The result illustrates the tradeoff of minimizing historical tail loss without a return term: the realized risk is low, and the realized return is below the assumed cash hurdle.

We can now add an expected-return objective while keeping a limit on tail loss. The next model makes that return-versus-tail-risk tradeoff explicit through a CVaR budget.

The 4% annual rate is the same reference used for the comparison across models, while the reported tail statistics describe the realized net returns.

The Min-CVaR allocation follows from the objective we specified. It chooses the feasible weights with the smallest historical tail-loss measure:

\[ \min_w CVaR_{0.95}(w) \]

But the objective doesn’t contain a reward term. If an asset helps reduce the worst 5% losses but has poor expected return, the optimizer can still love it. If another asset has strong expected return but occasionally appears in the tail set, the optimizer can reject it completely.

A risk-minimizing allocation can therefore be useful as a defensive sleeve, while its standalone wealth path depends on the return earned by the selected assets. In this sample the selected holdings keep tail losses small but produce a low return relative to the 4% annual cash assumption.

The result also shows why comparing only max drawdown or ES can be misleading. Min-CVaR has excellent tail metrics, but if it earns less than the assumed risk-free rate, its Sharpe becomes negative. The assumed cash rate is the opportunity-cost reference used in the reported Sharpe ratio.

The tail statistic refers to the realized net-return distribution, while the optimization used historical scenarios from each rolling training window. Both are useful for reading the result because the portfolio is chosen again at each rebalance.

6) Mean-CVaR: Return Maximization Under a Tail Budget

The pure Min-CVaR objective minimizes the left tail but ignores return. Mean-CVaR changes the problem. Instead of minimizing CVaR directly, we maximize expected return subject to a CVaR limit.

The expected return estimate is:

\[ \hat{\mu}^\top w \]

where \(\hat{\mu}\) is the annualized expected-return vector selected from the walk-forward grid. The CVaR constraint is:

\[ CVaR_{0.95}(w) \le B \]

where \(B\) is a tail-risk budget. In this project, the budget is based on the equal-weight portfolio’s in-sample CVaR:

\[ B = c \cdot CVaR_{0.95}(w_{\text{EW}}) \]

where \(c\) is a budget scale. A value below 1.0 means the portfolio must have lower in-sample CVaR than equal weight. A value above 1.0 allows more tail risk than equal weight.

The optimization becomes:

\[ \max_{w,\eta,u}\; \hat{\mu}^\top w \]

subject to:

\[ \eta + \frac{1}{(1-\alpha)T}\sum_{s=1}^{T}u_s \le B \]

\[ u_s \ge 0, \qquad u_s \ge -r_s^\top w - \eta \]

\[ \mathbf{1}^\top w=1,\qquad 0 \le w_i \le \bar{w}_i \]

The return objective chooses among portfolios whose worst-scenario average loss remains inside the allowed budget. We can adjust the budget to compare different levels of permitted tail exposure.

The expected-return vector is annualized, while the scenario losses and their CVaR budget use the daily returns in the rolling scenario matrix. They appear on opposite sides of the constrained problem, so each side keeps its own units: \(B\) must be on the same daily-loss scale as the CVaR expression, and the optimized objective remains an annual return estimate.

The code first tries the 0.90 equal-weight budget and relaxes it to 1.00 or 1.10 only if needed for feasibility. Relaxing a budget enlarges the feasible set; it cannot reduce the best attainable objective when the remaining constraints and inputs are unchanged.

Mean-CVaR can be read as a risk-budget spending problem. The model receives a budget \(B\) and tries to buy as much expected return as possible without spending more than that amount of tail risk.

The Lagrangian intuition is useful. If we attach a multiplier \(\gamma \ge 0\) to the CVaR constraint, the problem behaves like:

\[ \max_w\; \hat{\mu}^\top w - \gamma \, CVaR_\alpha(w) \]

The constrained form and penalized form are closely related. In the constrained form, we choose the tail budget directly. In the penalized form, we choose how expensive tail risk is. We use the constrained form because the budget is easier to interpret: it is a fraction of the equal-weight portfolio’s in-sample CVaR.

A budget scale of 0.90 means:

\[ CVaR_{0.95}(w) \le 0.90 \times CVaR_{0.95}(w_{\text{EW}}) \]

So the model must produce a portfolio whose in-sample tail loss is at least 10% lower than equal weight. The parameter \(\gamma\) in the penalized version instead gives the cost assigned to each unit of tail risk.

When the optimized CVaR sits almost exactly at the budget, it means the tail constraint is binding. The model would like to take more expected-return exposure, but the CVaR limit prevents it.

For the constrained maximization, the Lagrangian term is \(-\gamma(CVaR_\alpha(w)-B)\). When the constraint is slack, complementary slackness gives \(\gamma=0\). When it binds, a positive multiplier measures the local increase in the best estimated return from relaxing the budget by one unit, under the usual sensitivity conditions. A binding constraint can also have a zero multiplier in a degenerate case.

A larger \(\gamma\) makes an additional unit of tail risk more expensive in the penalized objective, so the optimizer has a stronger incentive to choose a lower-CVaR allocation.

Show code
mean_cvar_rows = []
def training_cvar_loss(r, wv):
    loss_values = -r @ wv
    var = float(np.quantile(loss_values, cvar_alpha))
    tail = loss_values[loss_values >= var]
    return float(tail.mean()) if len(tail) else var

def mean_cvar_weight(dt, state, prev_w):
    tickers = list(state["tickers"])
    caps = cap_array(tickers).to_numpy(dtype=float)
    r_train = state["R_cov"].reindex(columns=tickers).astype(float).dropna(how="any")
    r = r_train.to_numpy(dtype=float)
    mu_ann = state["mu_ann_map"][best_maxsharpe_cov][best_maxsharpe_mu].reindex(tickers).fillna(0.0).astype(float)
    ew = np.repeat(1.0 / len(tickers), len(tickers))
    ew_cvar = training_cvar_loss(r, ew)
    t, n = r.shape
    for scale in [0.90, 1.00, 1.10]:
        budget = scale * ew_cvar
        w = cp.Variable(n)
        eta = cp.Variable()
        u = cp.Variable(t)
        losses = -r @ w
        cvar = eta + cp.sum(u) / ((1.0 - cvar_alpha) * t)
        problem = cp.Problem(cp.Maximize(mu_ann.values @ w), [cp.sum(w) == 1.0, w >= 0.0, w <= caps, u >= 0.0,
            u >= losses - eta, cvar <= budget])
        status = solve_cvx_problem(problem)
        if w.value is not None and status in ["optimal", "optimal_inaccurate"]:
            out = clean_weight_array(w.value, tickers)
            cvar_value = training_cvar_loss(r, out.reindex(tickers).values)
            mean_cvar_rows.append({"date": dt, "ew_cvar_95_loss": ew_cvar, "budget_scale": scale,
                                   "cvar_budget": budget, "cvar_95_loss": cvar_value,
                                   "mu_ann": float(mu_ann.values @ out.reindex(tickers).values), "status": status})
            return out
    return None
mean_cvar_weights = make_weight_frame_from_func("Mean-CVaR 95", mean_cvar_weight)
mean_cvar_result = backtest_weight_frame("Mean-CVaR 95", mean_cvar_weights)
results["Mean-CVaR 95"] = mean_cvar_result
mean_cvar_diag = pd.DataFrame(mean_cvar_rows).set_index("date")
cvar_table = compact_result_table({"Min-CVaR 95": min_cvar_result, "Mean-CVaR 95": mean_cvar_result})
cvar_latest_weights = pd.concat({"Min-CVaR": min_cvar_weights.iloc[-1], "Mean-CVaR": mean_cvar_weights.iloc[-1]}, axis=1)
display(cvar_table.round(4))
display(mean_cvar_diag[["ew_cvar_95_loss", "budget_scale", "cvar_budget", "cvar_95_loss", "mu_ann"]].tail(5).round(6))
ax = cvar_latest_weights.plot(kind="bar", figsize=(8.5, 3.6), width=0.78)
ax.set_title("latest CVaR model weights")
ax.set_ylabel("weight")
ax.legend(fontsize=7, ncol=2, frameon=True, framealpha=0.85)
plt.tight_layout()
plt.show()
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Min-CVaR 95 0.0240 0.0360 -0.4073 -0.4977 -0.1250 0.1918 0.0248 3.0990 0.0027 2.9749 0.4000
Mean-CVaR 95 0.0921 0.0955 0.5609 0.7014 -0.1461 0.6305 0.3077 38.4629 0.0223 3.3662 0.3887
ew_cvar_95_loss budget_scale cvar_budget cvar_95_loss mu_ann
date
2026-01-30 0.011656 0.9 0.010490 0.010469 -0.001971
2026-02-27 0.011499 0.9 0.010349 0.010325 -0.002675
2026-03-31 0.011943 0.9 0.010749 0.010723 -0.002571
2026-04-30 0.011943 0.9 0.010749 0.010728 0.023956
2026-05-29 0.012068 0.9 0.010861 0.010839 0.001603

The Mean-CVaR model tests budget scales of 0.90, 1.00, and 1.10 and keeps the first feasible solution in the implementation flow. The displayed recent rows show the 0.90 budget being used in the last months. For example, on 2026-05-29, equal-weight CVaR is about 0.01207, the budget is 0.01086, and the optimized portfolio’s CVaR is 0.01084. The model is using almost the full tail budget, which is what we expect from a constrained optimizer. If expected return is being maximized, there is usually no reason to leave a valuable risk budget unused.

We can compare the resulting Min-CVaR and Mean-CVaR portfolios using the same backtest:

\[ \text{Min-CVaR Sharpe}=-0.4073 \]

\[ \text{Mean-CVaR Sharpe}=0.5609 \]

Mean-CVaR earns 9.21% CAGR with 9.55% volatility, historical ES of 1.45%, and max drawdown of -14.61%. Adding the return objective changes the allocation and raises both return and realized tail exposure relative to Min-CVaR. The budget makes that tradeoff explicit at the time we choose the weights.

The two portfolios are evaluated using the same return and risk-reporting conventions, including the risk-free rate and the trading-cost assumption used across the comparison.

Show code
def latest_mean_cvar_budget(scale):
    dt = model_rebalance_dates[-1]
    state = walkforward_grid.cache[dt]
    tickers = list(state["tickers"])
    caps = cap_array(tickers).to_numpy(dtype=float)
    r = state["R_cov"].reindex(columns=tickers).astype(float).dropna(how="any").to_numpy(dtype=float)
    mu_ann = state["mu_ann_map"][best_maxsharpe_cov][best_maxsharpe_mu].reindex(tickers).fillna(0.0).astype(float)
    ew_cvar = training_cvar_loss(r, np.repeat(1.0 / len(tickers), len(tickers)))
    t, n = r.shape
    w = cp.Variable(n)
    eta = cp.Variable()
    u = cp.Variable(t)
    losses = -r @ w
    cvar = eta + cp.sum(u) / ((1.0 - cvar_alpha) * t)
    budget = float(scale) * ew_cvar
    problem = cp.Problem(cp.Maximize(mu_ann.values @ w), [cp.sum(w) == 1.0, w >= 0.0, w <= caps, u >= 0.0, u >= losses - eta,
        cvar <= budget])
    status = solve_cvx_problem(problem)
    if w.value is None or status not in ["optimal", "optimal_inaccurate"]:
        return {"budget_scale": scale, "cvar_budget": budget, "status": status}
    out = clean_weight_array(w.value, tickers)
    return {"budget_scale": scale, "cvar_budget": budget,
            "expected_ann_return": float(mu_ann.values @ out.reindex(tickers).values),
            "cvar_95_loss": training_cvar_loss(r, out.reindex(tickers).values),
            "effective_n": float(1.0 / np.square(out.values).sum()), "max_weight": float(out.max()), "status": status}
latest_cvar_path = pd.DataFrame([latest_mean_cvar_budget(x) for x in [0.80, 0.90, 1.00, 1.10, 1.25]])
display(latest_cvar_path.round(6))
budget_scale cvar_budget expected_ann_return cvar_95_loss effective_n max_weight status
0 0.80 0.009655 0.000591 0.009634 3.086826 0.4 optimal
1 0.90 0.010861 0.001603 0.010839 2.780100 0.4 optimal
2 1.00 0.012068 0.002401 0.012045 3.008913 0.4 optimal
3 1.10 0.013275 0.003098 0.013250 3.068390 0.4 optimal
4 1.25 0.015085 0.004068 0.015056 2.870796 0.4 optimal

The CVaR budget-path table makes the tradeoff visible. At the latest rebalance, as the budget scale rises from 0.80 to 1.25, the allowed CVaR budget moves from about 0.00966 to 0.01509. The optimized expected annual return rises from about 0.00059 to 0.00407.

The relationship is monotonic in the expected direction:

  • More tail budget allows more return-seeking allocations.
  • The realized training CVaR stays just below the budget.
  • The max-weight constraint remains binding at 40% across the path.
  • Effective \(N\) stays low, around 2.8 to 3.1, meaning the optimal tail-return tradeoff is still concentrated.

The estimated return rises as the tail budget is loosened, although the increase is gradual across this range. The model is saying that within the latest rolling window, buying more expected return requires accepting more left-tail exposure, and the portfolio quickly hits concentration constraints.

For example, if the allowed CVaR budget is very tight, the model has to hold more defensive assets even when their expected return is low. When the budget loosens, it can add assets like GLD, DBC, or equity/credit exposures if their expected-return estimate justifies the extra tail risk.

The table uses one fixed set of estimates while changing the allowed tail budget, so the differences in weights and expected return can be read against the same current scenario matrix.

The budget path is also a small local efficient frontier. Each row answers:

\[ \text{Given this tail budget, what is the highest estimated return we can hold?} \]

This is different from the classical mean-variance frontier, where the risk constraint is usually volatility:

\[ w^\top\Sigma w \le \sigma_\star^2 \]

Here the risk constraint is tail loss:

\[ CVaR_{0.95}(w) \le B_\star \]

The difference matters because the two constraints can prefer different assets. A portfolio can have moderate volatility but ugly crash behavior if its losses are skewed or if it loads on assets that gap down together. A CVaR constraint directly limits the average loss inside the historical bad tail.

The latest budget table shows max weight stays at 0.40 across all budget scales. That tells us the cap constraint is binding together with the tail constraint. The optimizer wants to allocate more to the assets that best satisfy return-per-tail-risk, but the max-weight rule prevents unlimited concentration. The final weights reflect both the objective and the constraint geometry: the tail budget limits the scenario losses while the 40% cap limits how much of any one asset can be held.

The cap applies separately to each asset, while the tail budget applies to the portfolio loss across the historical scenario days.

7) Risk Parity and Risk Contributions

Risk parity allocates according to contributions to portfolio risk. Equal capital weights do not generally produce equal risk contributions, because each asset has its own volatility and covariance with the other holdings.

For a portfolio with weights \(w\) and covariance matrix \(\Sigma\), portfolio variance is:

\[ \sigma_p^2 = w^\top \Sigma w \]

Portfolio volatility is:

\[ \sigma_p = \sqrt{w^\top \Sigma w} \]

The marginal contribution of asset \(i\) to portfolio volatility is the partial derivative:

\[ \frac{\partial \sigma_p}{\partial w_i} = \frac{(\Sigma w)_i}{\sigma_p} \]

This derivative tells us how much portfolio volatility would change if we slightly increased asset \(i\)’s weight. The total risk contribution of asset \(i\) is weight times marginal contribution:

\[ RC_i = w_i \frac{(\Sigma w)_i}{\sigma_p} \]

If we divide by total portfolio volatility, we get the percentage contribution:

\[ PRC_i = \frac{w_i(\Sigma w)_i}{w^\top \Sigma w} \]

The percentage contributions sum to 1. Risk parity tries to make these contributions equal, or equal across predefined groups:

\[ PRC_i \approx b_i \]

where \(b_i\) is the target risk budget. For equal risk contribution across assets, \(b_i=1/N\). For sleeve parity, the target can be equal across sleeves, as this project checks in the diagnostics.

We can derive the marginal contribution by differentiating the square root. Since \(\nabla_w(w^\top\Sigma w)=2\Sigma w\) for symmetric \(\Sigma\), the chain rule gives the expression above whenever \(\sigma_p>0\). Multiplying by weights and summing recovers the whole portfolio volatility:

\[ \sum_i RC_i=\frac{w^\top\Sigma w}{\sigma_p}=\sigma_p. \]

This identity is why the percentage contributions add to one. Contributions can be negative for a position that hedges the rest of the portfolio, so the target and bounds should be interpreted at the level of the whole covariance structure.

The risk-contribution formula also shows why correlation matters. Expanding the numerator for asset \(i\):

\[ (\Sigma w)_i = \sum_{j=1}^{N}\Sigma_{ij}w_j \]

So asset \(i\)’s contribution is not based only on its own volatility. It depends on how it covaries with everything else in the portfolio. If an asset has high standalone volatility but negative or low correlation with the rest of the portfolio, its risk contribution can be lower than its standalone volatility suggests. If a low-volatility asset is highly correlated with the largest portfolio positions, its contribution can be larger than expected.

We equalize contributions to the portfolio’s total volatility using the covariance terms, so every asset’s contribution depends on the other weights as well as on its own variance.

A two-asset example makes this clear. With weights \(w_1,w_2\), volatilities \(\sigma_1,\sigma_2\), and correlation \(\rho\), portfolio variance is:

\[ \sigma_p^2 = w_1^2\sigma_1^2+w_2^2\sigma_2^2+2w_1w_2\rho\sigma_1\sigma_2 \]

When \(\rho\) is high, diversification is weak and the higher-volatility asset usually needs much lower capital weight. When \(\rho\) is low or negative, the same asset can receive more weight because it doesn’t add as much total portfolio risk.

If the two assets are uncorrelated, the covariance cross-term disappears and the portfolio variance contains only their individual weighted variance terms.

7.1 Capital Weights and Risk Contributions

Low-volatility assets often receive larger capital weights in risk parity because they need more dollars to contribute the same amount of portfolio risk. The actual balance also depends on correlations and on the portfolio bounds.

Suppose Asset A has 20% volatility and Asset B has 5% volatility. If they are weakly correlated, a 10% weight in A can contribute similar volatility risk to a 40% weight in B. So a risk parity portfolio may look “bond heavy” or “SHY heavy”, but that doesn’t mean it is trying to maximize bond exposure. It is trying to equalize the volatility contribution.

The model solves this kind of target indirectly by minimizing the gap between actual risk contributions and target risk contributions. In simplified form:

\[ \min_w \sum_{i=1}^{N}\left(PRC_i(w)-b_i\right)^2 \]

subject to the usual portfolio constraints. The exact numerical implementation can be different, but the target is the same: distribute risk contribution rather than distribute capital equally.

In this project, we also inspect sleeve-level risk contribution. The sleeve contribution for group \(g\) is the sum of asset contributions inside that group:

\[ PRC_g = \sum_{i \in g} PRC_i \]

This is useful because a portfolio can have equal asset-level contributions but still load too much on one economic sleeve if several correlated assets belong to the same sleeve.

The actual numerical objective in erc_vector uses absolute volatility contributions, \(\sum_i(RC_i-\sigma_p/N)^2\), with full investment and bounds. We first form an equal-risk portfolio within each sleeve, calculate the resulting sleeve return histories, and then equalize risk across those sleeve portfolios. Final weight caps can make exact equality unattainable, so the displayed sleeve contributions are a diagnostic of the completed allocation.

Show code
def erc_vector(cov_arr, upper=None):
    cov = make_psd(np.asarray(cov_arr, dtype=float), eps=1e-10)
    n = cov.shape[0]
    cap = np.ones(n, dtype=float) if upper is None else np.asarray(upper, dtype=float)
    if cap.size != n or float(cap.sum()) < 1.0:
        cap = np.ones(n, dtype=float)
    x0 = np.minimum(np.ones(n, dtype=float) / n, cap)
    x0 = x0 / float(x0.sum())
    bounds = [(0.0, float(c)) for c in cap]
    cons = {"type": "eq", "fun": lambda w: np.sum(w) - 1.0}
    def objective(w):
        sigma_w = cov @ w
        port_vol = math.sqrt(max(float(w @ sigma_w), 1e-18))
        rc = w * sigma_w / port_vol
        return float(np.sum((rc - port_vol / n) ** 2))
    res = opt.minimize(objective, x0, method="SLSQP", bounds=bounds, constraints=cons, options={"maxiter": 700, "ftol": 1e-12})
    if not res.success or not np.all(np.isfinite(res.x)):
        return x0
    return clean_weight_array(res.x, range(n), cap).values

def sleeve_risk_parity_weight(dt, state, prev_w):
    tickers = list(state["tickers"])
    r_train = state["R_cov"].reindex(columns=tickers).astype(float).dropna(how="any")
    cov_ann = pd.DataFrame(make_psd(np.asarray(state["cov_ann_map"][best_minvar_cov], dtype=float), eps=1e-10),
                           index=tickers, columns=tickers)
    sleeve_inner = {}
    sleeve_returns = {}
    sleeve_names = []
    for sleeve, names in sleeve_groups.items():
        names = [name for name in names if name in tickers]
        if not names:
            continue
        sub_cov = cov_ann.loc[names, names].values
        inner = pd.Series(erc_vector(sub_cov), index=names, dtype=float)
        sleeve_inner[sleeve] = inner
        sleeve_returns[sleeve] = r_train[names] @ inner.reindex(names).values
        sleeve_names.append(sleeve)
    sleeve_frame = pd.DataFrame(sleeve_returns).dropna(how="any")
    if sleeve_frame.shape[1] < 2:
        return None
    sleeve_cov = sleeve_frame.cov().values * annualization
    sleeve_w = pd.Series(erc_vector(sleeve_cov), index=sleeve_names, dtype=float)
    final = pd.Series(0.0, index=tickers, dtype=float)
    for sleeve in sleeve_names:
        final.loc[sleeve_inner[sleeve].index] = sleeve_w.loc[sleeve] * sleeve_inner[sleeve]
    return clean_weight_array(final, tickers)
rp_weights = make_weight_frame_from_func("Risk Parity", sleeve_risk_parity_weight)
rp_result = backtest_weight_frame("Risk Parity", rp_weights)
results["Risk Parity"] = rp_result
latest_rp_state = walkforward_grid.cache[model_rebalance_dates[-1]]
latest_rp_cov = pd.DataFrame(make_psd(latest_rp_state["cov_ann_map"][best_minvar_cov], eps=1e-10), index=assets, columns=assets)
latest_rp_w = rp_weights.iloc[-1].reindex(assets).fillna(0.0)
asset_rc = attribution.risk_contribution(latest_rp_w, latest_rp_cov.loc[assets, assets], tickers=assets)
sleeve_rc = pd.Series({sleeve: asset_rc.reindex(names).fillna(0.0).sum() for sleeve, names in sleeve_groups.items()})
sleeve_rc = sleeve_rc / float(sleeve_rc.sum())
display(latest_rp_w.sort_values(ascending=False).to_frame("Risk Parity").round(4))
display(sleeve_rc.to_frame("percent_risk_contribution").round(4))
fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), constrained_layout=True)
sleeve_rc.plot(kind="bar", ax=axes[0], color=colors[0])
axes[0].set_title("Risk Parity sleeve risk contribution")
axes[0].set_ylabel("share of risk")
latest_rp_w.sort_values(ascending=False).plot(kind="bar", ax=axes[1], color=colors[1])
axes[1].set_title("Risk Parity latest weights")
axes[1].set_ylabel("weight")
plt.show()
display(compact_result_table({"Risk Parity": rp_result}).round(4))
Risk Parity
SHY 0.2827
HYG 0.1453
LQD 0.1057
AGG 0.0881
IEF 0.0725
VNQ 0.0576
DBC 0.0555
GLD 0.0423
TLT 0.0371
EFA 0.0262
SPY 0.0249
EEM 0.0228
QQQ 0.0200
IWM 0.0193
percent_risk_contribution
equities 0.2483
duration 0.2532
credit 0.2499
diversifiers 0.2486

cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Risk Parity 0.0453 0.0492 0.132 0.1649 -0.1374 0.3295 0.0131 1.6326 0.0012 5.9635 0.3498

The latest risk-parity weights place substantial capital in lower-volatility fixed-income assets:

  • SHY gets about 34.98%.
  • AGG gets about 11.18%.
  • LQD gets about 9.87%.
  • IEF, VNQ, DBC, GLD, TLT, EFA, SPY, EEM, QQQ, and IWM receive smaller weights.

The four sleeves contribute roughly equal shares of portfolio volatility:

\[ \text{equities}=24.83\%, \quad \text{duration}=25.32\%, \quad \text{credit}=24.99\%, \quad \text{diversifiers}=24.86\% \]

The sleeve contributions are close to the equal-risk target even though the capital weights differ. Low-volatility positions can need more capital to contribute a similar share of the portfolio’s total risk.

The performance is defensive. Risk Parity has 4.53% CAGR, 4.92% annualized volatility, 0.132 Sharpe, -13.74% max drawdown, and historical ES of 0.73%. Its main strength is risk control, not return. This model becomes a tail-control benchmark: if another model has much higher return but also much higher ES, we can see the cost of buying that return.

The contribution figures are computed from the latest portfolio weights and covariance estimate.

8) Hierarchical Risk Parity

Hierarchical Risk Parity (HRP) starts by grouping assets according to their correlations. We use that hierarchy to organize the allocation before assigning weights across the resulting clusters.

The model converts correlation into a distance:

\[ d_{ij} = \sqrt{\frac{1-\rho_{ij}}{2}} \]

where \(\rho_{ij}\) is the correlation between assets \(i\) and \(j\). This distance has useful properties:

  • If two assets are perfectly correlated, \(\rho_{ij}=1\), then \(d_{ij}=0\).
  • If two assets are uncorrelated, \(\rho_{ij}=0\), then \(d_{ij}=\sqrt{1/2}\).
  • If two assets are perfectly negatively correlated, \(\rho_{ij}=-1\), then \(d_{ij}=1\).

The clustering algorithm uses these distances to build a tree. Assets that move together are placed near each other. Once the tree is built, HRP orders the covariance matrix according to the tree structure and recursively splits the ordered list into clusters. At each split, it allocates more weight to the lower-risk cluster.

For a cluster \(C\), the cluster variance is estimated using an inverse-variance portfolio inside the cluster:

\[ w_i^{(C)} = \frac{1/\sigma_i^2}{\sum_{j\in C}1/\sigma_j^2} \]

\[ \sigma_C^2 = (w^{(C)})^\top \Sigma_C w^{(C)} \]

If a parent cluster is split into left and right subclusters with variances \(\sigma_L^2\) and \(\sigma_R^2\), the lower-variance side receives more capital. The recursive process continues until weights are assigned to individual assets.

The correlation-based ordering gives us a way to follow related assets through the recursive allocation.

Show code
def corr_distance_link(cov_ann, method="average"):
    cov = pd.DataFrame(cov_ann).astype(float)
    diag = np.sqrt(np.maximum(np.diag(cov.values), 1e-12))
    corr = cov.values / np.outer(diag, diag)
    corr = np.clip(np.nan_to_num(corr, nan=0.0, posinf=0.0, neginf=0.0), -1.0, 1.0)
    np.fill_diagonal(corr, 1.0)
    distance = np.sqrt(np.clip((1.0 - corr) / 2.0, 0.0, 1.0))
    link = hierarchy.linkage(squareform(distance, checks=False), method=method)
    return corr, distance, link

def cluster_variance(cov, items):
    sub = cov[np.ix_(items, items)]
    diag = np.maximum(np.diag(sub), 1e-12)
    ivp = 1.0 / diag
    ivp = ivp / ivp.sum()
    return float(ivp @ sub @ ivp)

def hrp_allocation(cov_ann, tickers):
    cov = make_psd(np.asarray(cov_ann, dtype=float), eps=1e-10)
    corr, distance, link = corr_distance_link(cov, method="average")
    order = list(hierarchy.leaves_list(link))
    alloc = pd.Series(0.0, index=range(len(tickers)), dtype=float)
    def split(items, weight):
        if len(items) == 1:
            alloc.loc[items[0]] += weight
            return
        k = len(items) // 2
        left, right = items[:k], items[k:]
        left_var = cluster_variance(cov, left)
        right_var = cluster_variance(cov, right)
        left_weight = right_var / (left_var + right_var) if left_var + right_var > 1e-18 else 0.5
        split(left, weight * left_weight)
        split(right, weight * (1.0 - left_weight))
    split(order, 1.0)
    return clean_weight_array(pd.Series(alloc.values, index=tickers, dtype=float), tickers), corr, link, order

def hrp_weight(dt, state, prev_w):
    weights, corr, link, order = hrp_allocation(state["cov_ann_map"][best_minvar_cov], list(state["tickers"]))
    return weights
hrp_weights = make_weight_frame_from_func("HRP", hrp_weight)
hrp_result = backtest_weight_frame("HRP", hrp_weights)
results["HRP"] = hrp_result
latest_hrp_state = walkforward_grid.cache[model_rebalance_dates[-1]]
latest_hrp_weights, latest_hrp_corr, latest_hrp_link, latest_hrp_order = hrp_allocation(latest_hrp_state["cov_ann_map"][best_minvar_cov],
    assets)
display(hrp_weights.iloc[-1].sort_values(ascending=False).to_frame("HRP").round(4))
fig, ax = plt.subplots(figsize=(7.8, 3.6))
hierarchy.dendrogram(latest_hrp_link, labels=assets, leaf_rotation=90, ax=ax)
ax.set_title("latest HRP dendrogram, average linkage")
plt.tight_layout()
plt.show()
ordered_assets = [assets[i] for i in latest_hrp_order]
fig, ax = plt.subplots(figsize=(5.8, 5.0))
im = ax.imshow(pd.DataFrame(latest_hrp_corr, index=assets, columns=assets).loc[ordered_assets, ordered_assets],
    cmap="coolwarm", vmin=-1.0, vmax=1.0)
ax.set_title("latest HRP ordered correlation")
ax.set_xticks(range(len(ordered_assets)), ordered_assets, rotation=90)
ax.set_yticks(range(len(ordered_assets)), ordered_assets)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
plt.tight_layout()
plt.show()
hrp_ordered_weights = hrp_weights.iloc[-1].reindex(ordered_assets).fillna(0.0)
sleeve_color = {sleeve: colors[i] for i, sleeve in enumerate(sleeve_groups)}
bar_colors = [sleeve_color.get(sleeve_map.get(asset, "other"), colors[0]) for asset in ordered_assets]
fig, ax = plt.subplots(figsize=(8.2, 3.2))
ax.bar(hrp_ordered_weights.index, hrp_ordered_weights.values, color=bar_colors)
ax.set_title("latest HRP weights in hierarchical order")
ax.set_ylabel("weight")
ax.tick_params(axis="x", rotation=90)
handles = [plt.Line2D([0], [0], color=sleeve_color[sleeve], lw=6) for sleeve in sleeve_groups]
ax.legend(handles, list(sleeve_groups), fontsize=7, ncol=2, frameon=True, framealpha=0.85)
plt.tight_layout()
plt.show()
HRP
SHY 0.4000
HYG 0.1317
AGG 0.0711
IEF 0.0555
LQD 0.0518
DBC 0.0416
VNQ 0.0381
EFA 0.0356
GLD 0.0346
TLT 0.0301
SPY 0.0294
EEM 0.0280
IWM 0.0262
QQQ 0.0261

The latest HRP dendrogram and ordered correlation matrix organize the assets into economically recognizable groups. Duration and bond-like assets cluster together, equity and credit-like assets cluster together, and diversifiers form their own partial grouping. Those groups arise from the estimated correlations, which organize the assets without using the sleeve labels as clustering inputs.

The latest HRP weights are extremely defensive:

  • SHY receives the cap at 40%.
  • HYG receives 13.17%.
  • AGG receives 7.11%.
  • IEF and LQD receive around 5%.
  • Equity ETFs receive small weights.

The cap on SHY matters here. HRP sees SHY as a low-risk cluster anchor, and without a cap it would likely receive even more. This is one of the practical issues with HRP in a cross-asset ETF universe: if the universe contains near-cash instruments, hierarchical allocation can become too defensive unless the constraints prevent it.

The HRP backtest has 5.19% CAGR, 5.04% volatility, 0.2547 Sharpe, -14.46% max drawdown, and ES of 0.75%. It improves tail behavior compared with aggressive models, but it doesn’t earn enough return to compete with BL, MaxSharpe, Mean-CVaR, or robust MV.

The HRP allocation process can be understood as a sequence of local decisions. At each branch of the tree, we ask how risky the left cluster is relative to the right cluster. If the left cluster has variance \(\sigma_L^2\) and the right cluster has variance \(\sigma_R^2\), the allocation to the left side is proportional to the inverse of its cluster variance:

\[ \alpha_L = \frac{1/\sigma_L^2}{1/\sigma_L^2+1/\sigma_R^2} \]

which can also be written as:

\[ \alpha_L = \frac{\sigma_R^2}{\sigma_L^2+\sigma_R^2} \]

So if the left cluster is much safer than the right cluster, \(\sigma_L^2\) is small and \(\alpha_L\) is large. The safer cluster receives more capital. This is repeated down the tree until every asset has a final weight.

The recursive allocation uses cluster variances at each split rather than inverting the full covariance matrix. This gives us a sequence of smaller allocation decisions based on the hierarchy. Since the rule uses risk rather than expected returns directly, very low-volatility assets can receive substantial weight in this cross-asset universe.

Each split uses the relative variances of the two subclusters at that branch.

9) Nested Clustered Optimization

NCO, or Nested Clustered Optimization, uses clustering like HRP but doesn’t stop at recursive bisection. It treats each cluster as a sub-portfolio and then optimizes across those cluster portfolios.

The workflow is:

  1. Use the correlation distance matrix to cluster the assets.
  2. Solve an inner optimization inside each cluster.
  3. Treat each cluster portfolio as one synthetic asset.
  4. Solve an outer optimization across the cluster portfolios.
  5. Multiply outer cluster weights by inner asset weights to get final asset weights.

If cluster \(g\) contains assets in index set \(C_g\), the inner weights are:

\[ w_g^{\text{inner}} \in \mathbb{R}^{|C_g|} \]

The cluster return forecast is:

\[ \mu_g^{\text{cluster}} = (w_g^{\text{inner}})^\top \mu_{C_g} \]

The covariance between clusters \(g\) and \(h\) is:

\[ \Sigma_{g,h}^{\text{cluster}} = (w_g^{\text{inner}})^\top \Sigma_{C_g,C_h} w_h^{\text{inner}} \]

Then the outer optimizer chooses cluster weights \(a_g\). The final asset weight for asset \(i\) in cluster \(g\) is:

\[ w_i = a_g \cdot w_{g,i}^{\text{inner}} \]

The inner weights form a portfolio for each group, and the outer optimization allocates capital across those group portfolios. This gives the covariance and return estimates a cluster-level role as well as an asset-level role.

The final allocation maps the cluster-level weights back to the original ETF universe.

NCO is more optimization-heavy than HRP, so its result depends more on the quality of the mean estimate. Inside each cluster, the inner optimizer decides which assets represent that cluster best. Then the outer optimizer decides how much capital each cluster gets.

A compact matrix view is:

\[ W_{\text{inner}} = \begin{bmatrix} | & | & & | \\ w_1^{\text{inner}} & w_2^{\text{inner}} & \cdots & w_G^{\text{inner}} \\ | & | & & | \end{bmatrix} \]

where each column is a cluster portfolio placed in the full asset space, with zeros outside its cluster. If \(a\in\mathbb{R}^G\) is the vector of outer cluster weights, then the final asset portfolio is:

\[ w = W_{\text{inner}}a \]

The cluster-level expected return vector is:

\[ \mu^{(G)} = W_{\text{inner}}^\top \mu \]

and the cluster-level covariance matrix is:

\[ \Sigma^{(G)} = W_{\text{inner}}^\top \Sigma W_{\text{inner}} \]

Then the outer optimizer solves a smaller portfolio problem over \(G\) cluster portfolios instead of \(N\) individual assets. This can reduce instability when \(N\) is large or when assets are strongly correlated. In this project, \(N=14\), so the dimensionality benefit is modest. We can compare the resulting cluster allocation with HRP after fitting both methods and examining their realized return, risk, and turnover.

The matrix \(W_{\text{inner}}\) has \(N\) rows and \(G\) columns, so the outer covariance has dimension \(G\times G\) and the product \(W_{\text{inner}}a\) returns \(N\) asset weights. The identities above hold when the same asset covariance is used throughout. Here we estimate the outer covariance directly from the historical returns of the constructed cluster portfolios; the inner optimizer uses the selected asset covariance estimate, which need not be identical to that sample covariance.

Show code
def solve_mv_vector(mu_arr, cov_arr, cap=0.40, lam=3.0):
    mu = np.asarray(mu_arr, dtype=float).reshape(-1)
    cov = make_psd(np.asarray(cov_arr, dtype=float), eps=1e-10)
    n = len(mu)
    cap_value = max(float(cap), 1.0 / n)
    caps = np.repeat(cap_value, n)
    w = cp.Variable(n)
    problem = cp.Problem(cp.Maximize(mu @ w - 0.5 * float(lam) * cp.quad_form(w, cp.psd_wrap(cov))), [cp.sum(w) == 1.0,
        w >= 0.0, w <= caps])
    status = solve_cvx_problem(problem)
    if w.value is None or status not in ["optimal", "optimal_inaccurate"]:
        return clean_weight_array(np.ones(n, dtype=float), range(n), caps).values
    return clean_weight_array(w.value, range(n), caps).values

def nco_labels_from_cov(cov_ann, n_clusters=3):
    cov = make_psd(np.asarray(cov_ann, dtype=float), eps=1e-10)
    corr, distance, link = corr_distance_link(cov, method="average")
    labels = hierarchy.fcluster(link, t=int(n_clusters), criterion="maxclust")
    return labels, link

def nco_weight(dt, state, prev_w):
    tickers = list(state["tickers"])
    cov_ann = make_psd(np.asarray(state["cov_ann_map"][best_maxsharpe_cov], dtype=float), eps=1e-10)
    mu_ann = state["mu_ann_map"][best_maxsharpe_cov][best_maxsharpe_mu].reindex(tickers).fillna(0.0).astype(float)
    r_train = state["R_cov"].reindex(columns=tickers).astype(float).dropna(how="any")
    labels, link = nco_labels_from_cov(cov_ann, n_clusters=3)
    cluster_ids = sorted(pd.unique(labels))
    inner = {}
    cluster_returns = {}
    cluster_mu = []
    for cid in cluster_ids:
        locs = np.where(labels == cid)[0]
        names = [tickers[i] for i in locs]
        inner_cap = max(w_max, 1.0 / len(names))
        w_in = solve_mv_vector(mu_ann.reindex(names).values, cov_ann[np.ix_(locs, locs)], cap=inner_cap, lam=3.0)
        inner[cid] = pd.Series(w_in, index=names, dtype=float)
        cluster_returns[cid] = r_train[names] @ inner[cid].reindex(names).values
        cluster_mu.append(float(mu_ann.reindex(names).values @ inner[cid].reindex(names).values))
    cluster_frame = pd.DataFrame(cluster_returns).dropna(how="any")
    if cluster_frame.shape[1] < 2:
        return None
    cluster_cov = cluster_frame.cov().values * annualization
    outer = solve_mv_vector(np.asarray(cluster_mu, dtype=float), cluster_cov, cap=0.60, lam=3.0)
    final = pd.Series(0.0, index=tickers, dtype=float)
    for j, cid in enumerate(cluster_ids):
        final.loc[inner[cid].index] = outer[j] * inner[cid]
    return clean_weight_array(final, tickers)
nco_weights = make_weight_frame_from_func("NCO-MV", nco_weight)
nco_result = backtest_weight_frame("NCO-MV", nco_weights)
results["NCO-MV"] = nco_result
latest_nco_labels, latest_nco_link = nco_labels_from_cov(latest_hrp_state["cov_ann_map"][best_maxsharpe_cov], n_clusters=3)
cluster_membership = pd.DataFrame({"asset": assets, "cluster": latest_nco_labels}).sort_values(["cluster", "asset"])
hier_avg_weights = pd.concat({"HRP": hrp_weights.mean(), "NCO-MV": nco_weights.mean()}, axis=1).reindex(assets)
display(cluster_membership)
display(nco_weights.iloc[-1].sort_values(ascending=False).to_frame("NCO-MV").round(4))
display(compact_result_table({"HRP": hrp_result, "NCO-MV": nco_result}).round(4))
ax = hier_avg_weights.plot(kind="bar", figsize=(8.8, 3.6), width=0.78)
ax.set_title("HRP vs NCO-MV average weights")
ax.set_ylabel("average weight")
ax.legend(fontsize=7, ncol=2, frameon=True, framealpha=0.85)
plt.tight_layout()
plt.show()
latest_nco_state = walkforward_grid.cache[model_rebalance_dates[-1]]
latest_nco_cov = make_psd(np.asarray(latest_nco_state["cov_ann_map"][best_maxsharpe_cov], dtype=float), eps=1e-10)
latest_nco_mu = latest_nco_state["mu_ann_map"][best_maxsharpe_cov][best_maxsharpe_mu].reindex(assets).fillna(0.0).astype(float)
latest_nco_w = nco_weights.iloc[-1].reindex(assets).fillna(0.0)
nco_cluster_rows = []
for cid in sorted(pd.unique(latest_nco_labels)):
    names = [asset for asset, label in zip(assets, latest_nco_labels) if label == cid]
    weight = float(latest_nco_w.reindex(names).sum())
    if weight > 1e-12:
        within = latest_nco_w.reindex(names).fillna(0.0) / weight
    else:
        within = pd.Series(1.0 / len(names), index=names, dtype=float)
    locs = [assets.index(name) for name in names]
    sub_cov = latest_nco_cov[np.ix_(locs, locs)]
    vol = float(math.sqrt(max(within.values @ sub_cov @ within.values, 0.0)))
    ret = float(latest_nco_mu.reindex(names).values @ within.values)
    nco_cluster_rows.append({"cluster": int(cid), "assets": ", ".join(names), "cluster_weight": weight,
                             "forecast_return": ret, "standalone_vol": vol})
nco_cluster_stats = pd.DataFrame(nco_cluster_rows).set_index("cluster")
display(nco_cluster_stats.round(4))
fig, ax = plt.subplots(figsize=(7.2, 4.0))
sizes = 250 + 2200 * nco_cluster_stats["cluster_weight"].clip(lower=0.02)
ax.scatter(nco_cluster_stats["standalone_vol"], nco_cluster_stats["forecast_return"], s=sizes,
           color=colors[0], alpha=0.65, edgecolor="#1f2937",
           linewidth=0.7)
for cid, row in nco_cluster_stats.iterrows():
    ax.annotate(f"C{cid}: {row['cluster_weight']:.0%}", (row["standalone_vol"], row["forecast_return"]),
                xytext=(5, 5), textcoords="offset points", fontsize=8)
ax.set_title("latest NCO-MV cluster map")
ax.set_xlabel("standalone volatility")
ax.set_ylabel("forecast return")
plt.tight_layout()
plt.show()
asset cluster
13 DBC 1
11 GLD 1
8 AGG 2
6 IEF 2
9 LQD 2
7 SHY 2
5 TLT 2
4 EEM 3
3 EFA 3
10 HYG 3
2 IWM 3
1 QQQ 3
0 SPY 3
12 VNQ 3
NCO-MV
SHY 0.2400
AGG 0.2400
GLD 0.1223
DBC 0.1223
IEF 0.1200
HYG 0.0622
EEM 0.0301
VNQ 0.0256
SPY 0.0215
EFA 0.0161
LQD 0.0000
TLT 0.0000
QQQ 0.0000
IWM 0.0000
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
HRP 0.0519 0.0504 0.2547 0.3247 -0.1446 0.3586 0.0186 2.3204 0.0017 5.0967 0.4000
NCO-MV 0.0519 0.0636 0.2128 0.2538 -0.1509 0.3435 0.1645 20.5595 0.0155 5.7432 0.2467

assets cluster_weight forecast_return standalone_vol
cluster
1 GLD, DBC 0.2445 0.0050 0.1487
2 TLT, IEF, SHY, AGG, LQD 0.6000 -0.0077 0.0407
3 SPY, QQQ, IWM, EFA, EEM, HYG, VNQ 0.1555 -0.0032 0.1021

The latest NCO clustering gives us the following groups:

  • Cluster 1: GLD and DBC, the real-asset/diversifier group.
  • Cluster 2: TLT, IEF, SHY, AGG, LQD, the duration and investment-grade bond group.
  • Cluster 3: SPY, QQQ, IWM, EFA, EEM, HYG, VNQ, the equity/credit/risky group.

The latest NCO weights allocate 60% to the bond cluster, 24.45% to the GLD/DBC cluster, and 15.55% to the risky cluster. Within the bond cluster, SHY and AGG each hit 24% in the latest output, while TLT and LQD get zero. Within the real-asset cluster, GLD and DBC split the allocation.

The cluster-level estimates help explain these allocations. The bond cluster has the lowest standalone volatility but a negative forecast return. The diversifier cluster has a higher forecast return and higher volatility. The risky cluster has medium volatility but also a negative forecast in the latest window. The optimizer therefore places most weight in the low-vol bond cluster and keeps a meaningful diversifier sleeve.

In the backtest, NCO-MV has the same CAGR as HRP, 5.19%, but higher volatility at 6.36% and lower Sharpe at 0.2128. It also has much higher turnover, 16.45% average monthly turnover versus 1.86% for HRP. That makes HRP the better hierarchical model in this application.

Show code
hier_summary = compact_result_table({"HRP": hrp_result, "NCO-MV": nco_result})
best_hier_name = hier_summary.sort_values(["sharpe", "max_drawdown", "avg_turnover", "cagr"], ascending=[False, False, True,
    False]).index[0]
print("selected hierarchical model:", best_hier_name)
display(hier_summary.round(4))
selected hierarchical model: HRP
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
HRP 0.0519 0.0504 0.2547 0.3247 -0.1446 0.3586 0.0186 2.3204 0.0017 5.0967 0.4000
NCO-MV 0.0519 0.0636 0.2128 0.2538 -0.1509 0.3435 0.1645 20.5595 0.0155 5.7432 0.2467

The selected hierarchical model is HRP because it has higher Sharpe, better drawdown behavior, and much lower turnover than NCO-MV. In this cross-asset sample, NCO’s additional optimization layer produces the same CAGR as HRP with higher volatility and turnover. Those results determine the selection under the comparison rule used here.

The two methods use the cluster structure in different ways:

  • Clustering helps us understand portfolio structure.
  • Optimizing inside and across clusters can add return-seeking behavior, but it can also reintroduce mean-estimation risk.

HRP mostly avoids expected-return forecasts. NCO uses them. When the expected-return signal is noisy or when the universe has a very defensive low-volatility cluster, the NCO optimizer can still become unstable. HRP is simpler and slower-moving, which is exactly why it wins the hierarchical selection here.

Both methods use the same broad asset universe and backtest conventions.

10) Robust Optimization: Estimation Error as a Portfolio Risk

Robust optimization starts with uncertainty about the estimated expected-return vector \(\hat{\mu}\). In mean-variance optimization, small changes in \(\hat{\mu}\) can cause large changes in weights, especially when the covariance matrix has correlated assets and the optimizer is trying to rank similar opportunities.

A standard mean-variance objective is:

\[ \max_w\; \hat{\mu}^\top w - \frac{\lambda}{2}w^\top\hat{\Sigma}w \]

where \(\lambda\) controls risk aversion. A standard optimizer uses \(\hat{\mu}\) as its input. We’ll extend the objective by considering a range of plausible expected-return vectors and choosing weights against the least favorable one in that range.

The general robust form is:

\[ \max_w\; \min_{\mu \in \mathcal{U}} \left(\mu^\top w - \frac{\lambda}{2}w^\top\Sigma w\right) \]

where \(\mathcal{U}\) is an uncertainty set around the estimated mean vector. The inner minimization is the adversary. It asks: if the true expected return is somewhere inside this plausible set, which version hurts the chosen portfolio the most?

This turns estimation uncertainty into a direct cost. A portfolio with large exposure to uncertain return forecasts gets penalized more than a portfolio whose expected return is more reliable.

The uncertainty set describes the alternative mean vectors considered by the inner minimization.

Expected-return estimates can have a large effect on optimized weights. The estimated portfolio return enters the mean-variance objective linearly:

\[ \hat{\mu}^\top w \]

If one asset’s expected return estimate is too high by only a few percent annualized, a mean-variance optimizer may allocate heavily to it because the linear reward looks attractive. The optimizer doesn’t know that the estimate is noisy. It just sees a higher number.

The sampling error of a mean estimate is also large. For asset \(i\), if daily returns have volatility \(\sigma_i\) and the mean is estimated from \(T\) independent observations, the standard error is approximately:

\[ SE(\hat{\mu}_i) \approx \frac{\sigma_i}{\sqrt{T}} \]

After annualization, the uncertainty can still be large relative to the expected return itself. If an asset has an annual expected return estimate of 5% but the standard error is also several percent, the sign and rank of the forecast are not very reliable.

We can include a pessimistic adjustment to \(\hat{\mu}\) directly in the objective. A larger uncertainty set allows a larger adverse change in estimated returns, so a forecast needs to remain attractive after that adjustment to support the same allocation.

The same input estimates are used with the common portfolio constraints at each rebalance.

The covariance estimate \(\Sigma\) still enters the risk term while \(\hat{\mu}\) determines the expected-return reward.

10.1 Box Uncertainty

The first robust model uses a box uncertainty set. Each expected return can move independently within a range:

\[ \mathcal{U}_{\text{box}} = \left\{\mu : |\mu_i-\hat{\mu}_i| \le \rho s_i,\; i=1,\ldots,N \right\} \]

Here:

  • \(\hat{\mu}_i\) is the estimated annualized expected return of asset \(i\).
  • \(s_i\) is the uncertainty scale used for the asset’s estimated return.
  • \(\rho\) is the radius of the uncertainty box.
  • Larger \(\rho\) means we trust the mean estimate less.

For a long-only portfolio, the worst-case mean is simple. The adversary lowers every asset’s expected return by its uncertainty allowance:

\[ \mu_i^{\text{worst}} = \hat{\mu}_i - \rho s_i \]

So the robust return becomes:

\[ (\mu^{\text{worst}})^\top w = \sum_{i=1}^{N}(\hat{\mu}_i-\rho s_i)w_i \]

The optimization is then:

\[ \max_w\; (\hat{\mu}-\rho s)^\top w - \frac{\lambda}{2}w^\top\Sigma w \]

subject to the portfolio constraints.

For the long-only allocation, we haircut each asset’s forecast by its own uncertainty scale before solving the constrained mean-variance problem. A larger uncertainty scale produces a larger downward adjustment for the same radius.

For the inputs used here, the scale is constructed from the selected annual covariance estimate and the number \(T_\mu\) of observations used for the return estimate:

\[ s_i=\frac{\sqrt{(\hat\Sigma_{\mathrm{ann}})_{ii}}}{\sqrt{T_\mu}}. \]

We use this as the forecast-uncertainty scale in the haircut. It is a practical scaling choice for the selected return forecasts, including momentum estimates; the exact sampling standard error of an annualized daily sample mean would require consistent annualization and assumptions about serial dependence.

For a long-only portfolio, the box robust derivation is especially simple. The worst-case return is:

\[ \min_{\mu_i\in[\hat{\mu}_i-\rho s_i,\hat{\mu}_i+\rho s_i]}\sum_{i=1}^{N}\mu_i w_i \]

Since every \(w_i\ge 0\), the adversary lowers every \(\mu_i\) to its lower bound. So:

\[ \min_{\mu\in\mathcal{U}_{\text{box}}}\mu^\top w = \sum_{i=1}^{N}(\hat{\mu}_i-\rho s_i)w_i \]

If shorting were allowed, the adversary would behave differently. For a negative weight, lowering \(\mu_i\) would help the portfolio, so the adversary would raise \(\mu_i\) instead. The general penalty would involve \(|w_i|\):

\[ \hat{\mu}^\top w - \rho\sum_{i=1}^{N}s_i|w_i| \]

Because this project is long-only, the simpler haircut form is enough. We can therefore interpret the box adjustment as forecast haircutting: first construct the lower-return vector, then use it in the constrained mean-variance objective.

The box radius controls how skeptical we are. If \(\rho=0\), the model becomes standard MV. If \(\rho\) is large, only assets with very strong forecasts survive the haircut.

The radius controls the size of the allowed movement in each forecast.

Show code
robust_cov_model = "LedoitWolf"
robust_mu_model = "Momentum"
robust_mv_lambda = 2.0
box_radius = 0.25

def selected_mv_inputs(state):
    tickers = list(state["tickers"])
    cov_ann = make_psd(np.asarray(state["cov_ann_map"][robust_cov_model], dtype=float), eps=1e-10)
    mu_ann = state["mu_ann_map"][robust_cov_model][robust_mu_model].reindex(tickers).fillna(0.0).astype(float)
    t_mu = int(len(state["R_mu"]))
    return tickers, mu_ann, cov_ann, t_mu
box_rows = []
def box_robust_weight(dt, state, prev_w):
    tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(state)
    caps = cap_array(tickers).to_numpy(dtype=float)
    n = len(tickers)
    se_ann = np.sqrt(np.maximum(np.diag(cov_ann), 0.0)) / math.sqrt(max(t_mu, 1))
    robust_mu = mu_ann.to_numpy(dtype=float) - box_radius * se_ann
    w = cp.Variable(n)
    problem = cp.Problem(cp.Maximize(robust_mu @ w - 0.5 * robust_mv_lambda * cp.quad_form(w, cp.psd_wrap(cov_ann))),
        [cp.sum(w) == 1.0, w >= 0.0, w <= caps])
    status = solve_cvx_problem(problem)
    if w.value is None or status not in ["optimal", "optimal_inaccurate"]:
        return None
    out = clean_weight_array(w.value, tickers)
    box_rows.append({"date": dt, "status": status, "robust_return": float(robust_mu @ out.reindex(tickers).values)})
    return out
box_robust_weights = make_weight_frame_from_func("Box Robust MV", box_robust_weight)
box_robust_result = backtest_weight_frame("Box Robust MV", box_robust_weights)
results["Box Robust MV"] = box_robust_result
latest_box_state = walkforward_grid.cache[model_rebalance_dates[-1]]
latest_tickers, latest_mu_ann, latest_cov_ann, latest_t_mu = selected_mv_inputs(latest_box_state)
latest_se_ann = np.sqrt(np.maximum(np.diag(latest_cov_ann), 0.0)) / math.sqrt(max(latest_t_mu, 1))
latest_box_table = pd.DataFrame({"asset": latest_tickers, "mu_ann": latest_mu_ann.values, "se_ann": latest_se_ann,
                                 "robust_mu": latest_mu_ann.values - box_radius * latest_se_ann,
                                 "weight": box_robust_weights.iloc[-1].reindex(latest_tickers).values})
display(latest_box_table.round(4))
display(compact_result_table({"Box Robust MV": box_robust_result}).round(4))
asset mu_ann se_ann robust_mu weight
0 SPY -0.0019 0.0095 -0.0043 0.0000
1 QQQ 0.0017 0.0124 -0.0014 0.0000
2 IWM 0.0129 0.0133 0.0096 0.0282
3 EFA 0.0059 0.0096 0.0035 0.0000
4 EEM 0.0306 0.0114 0.0277 0.4000
5 TLT -0.0254 0.0088 -0.0276 0.0000
6 IEF -0.0213 0.0044 -0.0224 0.0000
7 SHY -0.0165 0.0018 -0.0170 0.0193
8 AGG -0.0192 0.0037 -0.0201 0.0000
9 LQD -0.0224 0.0048 -0.0236 0.0000
10 HYG -0.0133 0.0036 -0.0142 0.0000
11 GLD 0.0173 0.0125 0.0142 0.1525
12 VNQ -0.0024 0.0106 -0.0050 0.0000
13 DBC 0.0541 0.0103 0.0516 0.4000
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Box Robust MV 0.0992 0.1118 0.552 0.686 -0.1557 0.637 0.3462 43.2746 0.0247 3.1317 0.3922

At the latest rebalance, we reduce the momentum-based expected returns asset by asset:

\[ \mu_i^{\text{robust}} = \mu_i - 0.25 \cdot SE_i \]

For SPY, the estimated return is slightly negative and becomes more negative after the haircut. For QQQ, a small positive raw forecast becomes slightly negative. EEM still has a strong positive robust forecast: 3.06% raw, 2.77% after the haircut. DBC remains the strongest, 5.41% raw and 5.16% robust. GLD also stays positive after the haircut.

The resulting latest portfolio is concentrated:

  • EEM at 40%.
  • DBC at 40%.
  • GLD at 15.25%.
  • IWM at 2.82%.
  • SHY at 1.93%.
  • Most assets receive zero.

A few assets retain strong forecasts after the uncertainty haircut, and the resulting allocation concentrates in them up to the common cap. The adjustment changes the expected-return vector, while the 40% bound limits the weight of each individual holding.

The displayed table evaluates this adjustment at the latest rebalance date. The portfolio then balances those adjusted forecasts against covariance risk under the same 40% cap.

Box Robust MV performs much better than the defensive parity models. It earns 9.92% CAGR with 11.18% volatility, 0.5520 Sharpe, -15.57% max drawdown, and ES of 1.72%. That is close to Mean-CVaR and Ellipsoid Robust MV, but with slightly weaker Sharpe.

The model’s cost is turnover. Average turnover is 34.62% per month, and total turnover reaches 43.27 over the sample. The reason is that the robust expected-return vector still changes with the momentum forecast and standard errors. When the robust forecast ranking changes, the optimizer moves capital between the few names with positive robust expected returns.

Box Robust MV remains return-seeking after applying conservative mean estimates. Its robustness addresses mean-estimation uncertainty; the objective does not separately minimize turnover, so changes in the robust forecast ranking can still produce frequent trades.

The backtest includes the same trading-cost assumption used in the other portfolio comparisons.

10.2 Ellipsoidal Uncertainty

The second robust model uses an ellipsoid uncertainty set. Instead of allowing each expected return to move independently inside a box, it allows the whole vector to move inside a covariance-shaped region:

\[ \mathcal{U}_{\text{ellipsoid}} = \left\{\mu = \hat{\mu} + \Omega^{1/2}z : \|z\|_2 \le \rho \right\} \]

Here:

  • \(\Omega\) is the uncertainty covariance of the mean estimate.
  • \(\Omega^{1/2}\) is its matrix square root.
  • \(z\) is a standardized shock vector.
  • \(\rho\) controls the size of the uncertainty region.

The worst-case expected return for a portfolio has a closed form:

\[ \min_{\mu \in \mathcal{U}_{\text{ellipsoid}}} \mu^\top w = \hat{\mu}^\top w - \rho\|\Omega^{1/2}w\|_2 \]

The term \(\hat{\mu}^\top w\) is the estimated portfolio return. The penalty term \(\rho\|\Omega^{1/2}w\|_2\) is the amount the adversary can subtract from that return. The penalty is larger when the portfolio loads on directions where the mean estimate is more uncertain.

If \(\Omega=\Sigma/T\), then uncertainty is larger for assets and combinations with higher return variance and smaller sample size. So the model is saying: we trust expected returns less when they come from volatile assets or unstable directions.

The size of the norm depends on both portfolio weights and the estimated covariance of the uncertainty.

The ellipsoid penalty comes from Cauchy-Schwarz. Write the uncertain mean as:

\[ \mu = \hat{\mu}+\Omega^{1/2}z \]

with \(\|z\|_2\le \rho\). Portfolio return under this mean is:

\[ \mu^\top w = \hat{\mu}^\top w + z^\top \Omega^{1/2}w \]

The adversary chooses \(z\) to make the second term as negative as possible. The most negative possible value is:

\[ \min_{\|z\|_2\le \rho} z^\top \Omega^{1/2}w = -\rho\|\Omega^{1/2}w\|_2 \]

So the worst-case return is:

\[ \hat{\mu}^\top w - \rho\|\Omega^{1/2}w\|_2 \]

This is useful because it turns a difficult “min over all plausible means” problem into a convex norm penalty. The penalty is portfolio-level, not asset-by-asset. If the portfolio loads on an uncertain combination of assets, the norm gets large. If it spreads across directions where forecast error cancels or is less severe, the norm is smaller.

The covariance-shaped region lets the penalty reflect how estimation errors move together across assets.

The minimizing shock points opposite to the portfolio’s uncertainty exposure. If \(\Omega^{1/2}w\ne0\), one such choice is:

\[ z^*=-\rho\,\frac{\Omega^{1/2}w}{\|\Omega^{1/2}w\|_2}. \]

Its norm is \(\rho\), so it lies on the boundary of the uncertainty ball. Substituting it into the return expression produces the negative norm penalty; when the exposure is zero, the penalty is zero and the direction of \(z\) has no effect.

The ellipsoid robust objective becomes:

\[ \max_w\; \hat{\mu}^\top w - \rho\|\Omega^{1/2}w\|_2 - \frac{\lambda}{2}w^\top\Sigma w \]

subject to:

\[ \mathbf{1}^\top w=1,\qquad 0\le w_i\le \bar{w}_i \]

Ellipsoid uncertainty penalizes the portfolio’s exposure to uncertain directions. Correlated estimation errors affect the norm jointly, so positions whose forecasts depend on a common source of uncertainty can produce a larger combined penalty.

For example, if SPY and QQQ both look attractive because the same growth regime has performed well, a box model may haircut them separately. An ellipsoid model can also recognize that their forecast errors are related. Loading on both doesn’t give two independent forecasts. It creates one larger exposure to a common uncertain direction.

For example, if two equally weighted forecasts have equal error variance and correlation \(\rho\), their combined error variance is proportional to \((1+\rho)/2\). A shared forecast error therefore offers less diversification as their correlation rises.

Show code
ellipsoid_radius = 0.10
ellipsoid_rows = []
def solve_ellipsoid_portfolio(state, radius):
    tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(state)
    caps = cap_array(tickers).to_numpy(dtype=float)
    n = len(tickers)
    omega_sqrt = psd_sqrt(cov_ann / max(t_mu, 1))
    w = cp.Variable(n)
    penalty = float(radius) * cp.norm(omega_sqrt @ w, 2)
    problem = cp.Problem(cp.Maximize(mu_ann.values @ w - penalty - 0.5 * robust_mv_lambda * cp.quad_form(w,
        cp.psd_wrap(cov_ann))), [cp.sum(w) == 1.0, w >= 0.0, w <= caps])
    status = solve_cvx_problem(problem)
    if w.value is None or status not in ["optimal", "optimal_inaccurate"]:
        return None, status
    return clean_weight_array(w.value, tickers), status

def ellipsoid_robust_weight(dt, state, prev_w):
    out, status = solve_ellipsoid_portfolio(state, ellipsoid_radius)
    if out is None:
        return None
    tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(state)
    omega_sqrt = psd_sqrt(cov_ann / max(t_mu, 1))
    wv = out.reindex(tickers).values
    ellipsoid_rows.append({"date": dt, "status": status, "empirical_return": float(mu_ann.values @ wv),
                           "uncertainty_penalty": float(ellipsoid_radius * np.linalg.norm(omega_sqrt @ wv))})
    return out
ellipsoid_robust_weights = make_weight_frame_from_func("Ellipsoid Robust MV", ellipsoid_robust_weight)
ellipsoid_robust_result = backtest_weight_frame("Ellipsoid Robust MV", ellipsoid_robust_weights)
results["Ellipsoid Robust MV"] = ellipsoid_robust_result
radius_rows = []
for radius in [0.0, 0.05, 0.10, 0.25, 0.50, 0.75, 1.00, 1.25, 1.50]:
    w_radius, status = solve_ellipsoid_portfolio(latest_box_state, radius)
    if w_radius is None:
        radius_rows.append({"radius": radius, "status": status})
        continue
    tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(latest_box_state)
    omega_sqrt = psd_sqrt(cov_ann / max(t_mu, 1))
    wv = w_radius.reindex(tickers).values
    empirical_return = float(mu_ann.values @ wv)
    uncertainty_penalty = float(radius * np.linalg.norm(omega_sqrt @ wv))
    radius_rows.append({"radius": radius, "empirical_return": empirical_return,
                        "uncertainty_penalty": uncertainty_penalty,
                        "robust_return": empirical_return - uncertainty_penalty,
                        "volatility": float(math.sqrt(max(wv @ cov_ann @ wv, 0.0))),
                        "effective_n": float(1.0 / np.square(wv).sum()), "max_weight": float(w_radius.max()),
                        "status": status})
ellipsoid_radius_path = pd.DataFrame(radius_rows)
display(ellipsoid_radius_path.round(4))
fig, axes = plt.subplots(1, 3, figsize=(10.5, 3.2), constrained_layout=True)
for ax, col, title in zip(axes, ["robust_return", "volatility", "effective_n"], ["robust return", "volatility", "effective n"]):
    ax.plot(ellipsoid_radius_path["radius"], ellipsoid_radius_path[col], marker="o", color=colors[0])
    ax.set_title(title)
    ax.set_xlabel("radius")
plt.show()
box_ellipsoid_latest = pd.DataFrame({"asset": latest_tickers, "mu_ann": latest_mu_ann.values,
                                     "box_robust_mu": latest_mu_ann.values - box_radius * latest_se_ann,
                                     "box_weight": box_robust_weights.iloc[-1].reindex(latest_tickers).values,
                                     "ellipsoid_weight": ellipsoid_robust_weights.iloc[-1].reindex(latest_tickers).values})
display(box_ellipsoid_latest.round(4))
display(compact_result_table({"Box Robust MV": box_robust_result, "Ellipsoid Robust MV": ellipsoid_robust_result}).round(4))
radius empirical_return uncertainty_penalty robust_return volatility effective_n max_weight status
0 0.00 0.0372 0.0000 0.0372 0.1261 2.8771 0.4 optimal
1 0.05 0.0372 0.0004 0.0368 0.1261 2.8779 0.4 optimal
2 0.10 0.0372 0.0008 0.0364 0.1261 2.8786 0.4 optimal
3 0.25 0.0371 0.0020 0.0351 0.1260 2.8824 0.4 optimal
4 0.50 0.0359 0.0038 0.0321 0.1216 2.9334 0.4 optimal
5 0.75 0.0348 0.0056 0.0292 0.1176 2.9497 0.4 optimal
6 1.00 0.0338 0.0072 0.0266 0.1142 2.9410 0.4 optimal
7 1.25 0.0332 0.0088 0.0243 0.1120 2.9324 0.4 optimal
8 1.50 0.0326 0.0104 0.0222 0.1100 2.9133 0.4 optimal

asset mu_ann box_robust_mu box_weight ellipsoid_weight
0 SPY -0.0019 -0.0043 0.0000 0.0000
1 QQQ 0.0017 -0.0014 0.0000 0.0000
2 IWM 0.0129 0.0096 0.0282 0.0392
3 EFA 0.0059 0.0035 0.0000 0.0000
4 EEM 0.0306 0.0277 0.4000 0.4000
5 TLT -0.0254 -0.0276 0.0000 0.0000
6 IEF -0.0213 -0.0224 0.0000 0.0000
7 SHY -0.0165 -0.0170 0.0193 0.0000
8 AGG -0.0192 -0.0201 0.0000 0.0000
9 LQD -0.0224 -0.0236 0.0000 0.0000
10 HYG -0.0133 -0.0142 0.0000 0.0000
11 GLD 0.0173 0.0142 0.1525 0.1608
12 VNQ -0.0024 -0.0050 0.0000 0.0000
13 DBC 0.0541 0.0516 0.4000 0.4000
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Box Robust MV 0.0992 0.1118 0.5520 0.6860 -0.1557 0.6370 0.3462 43.2746 0.0247 3.1317 0.3922
Ellipsoid Robust MV 0.1001 0.1133 0.5533 0.6901 -0.1559 0.6421 0.3401 42.5105 0.0243 3.1440 0.3922

We can vary the ellipsoid radius to see how the estimated return, risk, and concentration respond to the uncertainty penalty. With radius 0.00, there is no robust penalty, and empirical return is about 3.72% in the latest state. As the radius increases to 1.50, the robust return falls to about 2.22%. Volatility also declines modestly from about 12.61% to 11.00%, and effective \(N\) stays around 2.9.

The latest allocation comparison between box and ellipsoid is very close. Both models hold:

  • EEM at 40%.
  • DBC at 40%.
  • GLD around 15-16%.
  • small IWM exposure.
  • little or no allocation to the rest.

The ellipsoid model drops SHY to zero and gives a slightly larger weight to IWM and GLD. The difference is not dramatic because the latest forecast environment is very directional: EEM and DBC dominate even after robust penalties. When the top forecasts are that strong, both box and ellipsoid uncertainty still point to a similar constrained solution.

In the backtest, Ellipsoid Robust MV slightly beats Box Robust MV: CAGR 10.01% versus 9.92%, Sharpe 0.5533 versus 0.5520, and turnover 34.01% versus 34.62%. The difference is small, but enough for the selection rule to choose Ellipsoid Robust MV.

Show code
robust_summary = compact_result_table({"Box Robust MV": box_robust_result, "Ellipsoid Robust MV": ellipsoid_robust_result})
best_robust_name = robust_summary.sort_values(["sharpe", "max_drawdown", "avg_turnover", "cagr"], ascending=[False, False,
    True, False]).index[0]
print("selected robust model:", best_robust_name)
display(robust_summary.round(4))
selected robust model: Ellipsoid Robust MV
cagr ann_vol sharpe sortino max_drawdown calmar avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Box Robust MV 0.0992 0.1118 0.5520 0.6860 -0.1557 0.6370 0.3462 43.2746 0.0247 3.1317 0.3922
Ellipsoid Robust MV 0.1001 0.1133 0.5533 0.6901 -0.1559 0.6421 0.3401 42.5105 0.0243 3.1440 0.3922

The box and ellipsoid models have similar backtest characteristics in this implementation:

  • CAGR around 10%.
  • Annualized volatility around 11.2-11.3%.
  • Sharpe around 0.55.
  • Max drawdown around -15.6%.
  • High turnover above 34% average monthly.
  • Effective \(N\) around 3.1.

This tells us that most of the robust model’s behavior comes from the shared inputs: Ledoit-Wolf covariance, momentum expected returns, risk-aversion \(\lambda=2.0\), and the asset caps. The choice between box and ellipsoid affects the penalty geometry, but the latest and historical allocations still respond to the same broad signal.

Robust MV sits between MaxSharpe and Mean-CVaR on several of these return and risk measures. It is more return-seeking than tail-risk-only methods, but more cautious than a raw aggressive frontier model. Its drawdown is better than BL and MaxSharpe, but its ES is not as low as Risk Parity or HRP.

The box adjustment adds individual forecast haircuts, while the ellipsoid adjustment measures the combined exposure to correlated forecast errors.

11) Wasserstein Distributionally Robust Mean-Variance

The box and ellipsoid models describe uncertainty in the expected-return vector. Wasserstein distributionally robust optimization gives us a way to represent uncertainty about the return distribution around the empirical sample.

We start with the empirical distribution of historical returns:

\[ \hat{P}_T = \frac{1}{T}\sum_{s=1}^{T}\delta_{r_s} \]

where \(\delta_{r_s}\) is a point mass at historical return vector \(r_s\). Standard empirical optimization trusts this distribution exactly. Wasserstein DRO says the true distribution may be near the empirical distribution but not equal to it.

The ambiguity set is:

\[ \mathcal{P}_\varepsilon = \left\{P : W(P,\hat{P}_T) \le \varepsilon \right\} \]

where \(W(P,\hat{P}_T)\) is the Wasserstein distance between distributions, and \(\varepsilon\) is the radius. A larger radius means we admit more distributional uncertainty.

The model then optimizes worst-case performance over all distributions in that ball. In this implementation, the robust mean-variance form creates two penalties:

  1. A return penalty that subtracts a term proportional to \(\|w\|_2\).
  2. A volatility inflation that adds the same uncertainty term to empirical volatility.

The resulting objective is:

\[ \max_w\; \hat{\mu}^\top w - \sqrt{\delta}\|w\|_2 - \frac{\lambda}{2}\left(\|\Sigma^{1/2}w\|_2+\sqrt{\delta}\|w\|_2\right)^2 \]

where \(\sqrt{\delta}\) is the radius-scaled uncertainty penalty used by the implementation.

The code scales the ambiguity term using the estimated annual covariance and the return-estimation window:

\[ \sqrt{\delta}=\text{radius}\;\frac{\sqrt{\operatorname{tr}(\hat\Sigma_{\mathrm{ann}})/N}}{\sqrt{T_\mu}}. \]

The trace divided by \(N\) is the average individual annual variance, so its square root supplies a typical volatility scale. Dividing by \(\sqrt{T_\mu}\) scales it by the length of the mean-estimation window, and the chosen radius controls the size of both penalties. These quantities are recalculated from the available state at each rebalance.

Every term in the Wasserstein objective has a clear role:

\[ \hat{\mu}^\top w \]

is the empirical expected return estimate.

\[ \sqrt{\delta}\|w\|_2 \]

is the distributional uncertainty tax on return. It penalizes concentrated portfolios because \(\|w\|_2\) is larger when weights are concentrated. For equal weights over \(N\) assets, \(\|w\|_2=1/\sqrt{N}\). For a single-asset portfolio, \(\|w\|_2=1\).

\[ \|\Sigma^{1/2}w\|_2 \]

is empirical portfolio volatility.

\[ \|\Sigma^{1/2}w\|_2+\sqrt{\delta}\|w\|_2 \]

is robust volatility. The model behaves as if the true volatility may be larger than the sample volatility by an amount tied to distributional uncertainty and concentration.

The resulting terms affect the allocation in several related ways:

  • It doesn’t fully trust the mean estimate.
  • It doesn’t fully trust the empirical risk estimate.
  • It penalizes portfolios that depend too heavily on a few positions.

That is why this model is often more stable than an aggressive return optimizer, but still more return-seeking than pure risk parity.

The Wasserstein penalty also has an intuitive concentration effect. The norm \(\|w\|_2\) behaves like an inverse diversification measure:

\[ \|w\|_2 = \sqrt{\sum_{i=1}^{N}w_i^2} \]

For equal weight over 14 assets:

\[ \|w_{\text{EW}}\|_2 = \sqrt{14\left(\frac{1}{14}\right)^2} = \frac{1}{\sqrt{14}} \approx 0.267 \]

For a portfolio with 40%, 40%, and 20% in three assets:

\[ \|w\|_2 = \sqrt{0.4^2+0.4^2+0.2^2}=0.60 \]

So the same Wasserstein radius creates a much larger tax for the concentrated portfolio. The larger norm gives the concentrated portfolio a larger penalty at the same radius. The effect is visible in the robust return and risk terms even when the portfolio has the same total invested weight.

The penalty doesn’t force equal weight at the radius used here, but it changes the economics of concentration. A concentrated portfolio must have enough estimated return advantage to pay the ambiguity tax and still look attractive after robust risk inflation.

Because \(N_{\mathrm{eff}}=1/\sum_iw_i^2\) for fully invested long-only weights, the same norm can be written as \(\|w\|_2=1/\sqrt{N_{\mathrm{eff}}}\). The radius therefore connects directly to the concentration statistic we report later, although the optimizer still balances this term against expected return and covariance risk.

Show code
dro_radius = 1.0
wasserstein_mv_lambda = 1.5
wasserstein_rows = []
def solve_wasserstein_utility(state, radius):
    tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(state)
    caps = cap_array(tickers).to_numpy(dtype=float)
    n = len(tickers)
    sqrt_delta = float(radius) * math.sqrt(float(np.trace(cov_ann)) / n) / math.sqrt(max(t_mu, 1))
    w = cp.Variable(n)
    sigma_sqrt = psd_sqrt(cov_ann)
    dro_penalty = sqrt_delta * cp.norm(w, 2)
    empirical_volatility = cp.norm(sigma_sqrt @ w, 2)
    robust_volatility = empirical_volatility + dro_penalty
    objective = cp.Maximize(mu_ann.values @ w - dro_penalty - 0.5 * wasserstein_mv_lambda * cp.square(robust_volatility))
    problem = cp.Problem(objective, [cp.sum(w) == 1.0, w >= 0.0, w <= caps])
    status = solve_cvx_problem(problem)
    if w.value is None or status not in ["optimal", "optimal_inaccurate"]:
        return None, status, sqrt_delta
    return clean_weight_array(w.value, tickers), status, sqrt_delta

def wasserstein_weight(dt, state, prev_w):
    out, status, sqrt_delta = solve_wasserstein_utility(state, dro_radius)
    if out is None:
        return None
    tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(state)
    wv = out.reindex(tickers).values
    empirical_return = float(mu_ann.values @ wv)
    wasserstein_penalty = float(sqrt_delta * np.linalg.norm(wv))
    empirical_volatility = float(math.sqrt(max(wv @ cov_ann @ wv, 0.0)))
    robust_volatility = empirical_volatility + wasserstein_penalty
    risk_penalty = 0.5 * wasserstein_mv_lambda * robust_volatility ** 2
    wasserstein_rows.append({"date": dt, "sqrt_delta": sqrt_delta, "empirical_return": empirical_return,
                             "dro_penalty": wasserstein_penalty,
                             "robust_return": empirical_return - wasserstein_penalty,
                             "volatility": empirical_volatility, "robust_volatility": robust_volatility,
                             "risk_penalty": risk_penalty,
                             "objective": empirical_return - wasserstein_penalty - risk_penalty,
                             "effective_n": float(1.0 / np.square(wv).sum()), "max_weight": float(out.max()),
                             "status": status})
    return out
wasserstein_weights = make_weight_frame_from_func("Wasserstein DRMV", wasserstein_weight)
wasserstein_result = backtest_weight_frame("Wasserstein DRMV", wasserstein_weights)
results["Wasserstein DRMV"] = wasserstein_result
wasserstein_diag = pd.DataFrame(wasserstein_rows).set_index("date")
dro_rows = []
latest_tickers, latest_mu_w, latest_cov_w, latest_t_mu_w = selected_mv_inputs(latest_box_state)
for radius in [0.0, 0.05, 0.10, 0.25, 0.50, 1.00, 2.00, 5.00, 10.00]:
    w_radius, status, sqrt_delta = solve_wasserstein_utility(latest_box_state, radius)
    if w_radius is None:
        dro_rows.append({"radius": radius, "sqrt_delta": sqrt_delta, "status": status})
        continue
    wv = w_radius.reindex(latest_tickers).values
    empirical_return = float(latest_mu_w.values @ wv)
    dro_penalty = float(sqrt_delta * np.linalg.norm(wv))
    empirical_volatility = float(math.sqrt(max(wv @ latest_cov_w @ wv, 0.0)))
    robust_volatility = empirical_volatility + dro_penalty
    risk_penalty = 0.5 * wasserstein_mv_lambda * robust_volatility ** 2
    dro_rows.append({"radius": radius, "sqrt_delta": sqrt_delta, "empirical_return": empirical_return,
                     "dro_penalty": dro_penalty, "robust_return": empirical_return - dro_penalty,
                     "volatility": empirical_volatility, "robust_volatility": robust_volatility,
                     "risk_penalty": risk_penalty, "objective": empirical_return - dro_penalty - risk_penalty,
                     "effective_n": float(1.0 / np.square(wv).sum()), "max_weight": float(w_radius.max()),
                     "status": status})
wasserstein_radius_path = pd.DataFrame(dro_rows)
display(wasserstein_radius_path.round(4))
fig, axes = plt.subplots(1, 3, figsize=(10.5, 3.2), constrained_layout=True)
for ax, col, title in zip(axes, ["objective", "robust_volatility", "effective_n"],
                          ["robust objective", "robust volatility", "effective n"]):
    ax.plot(wasserstein_radius_path["radius"], wasserstein_radius_path[col], marker="o", color=colors[0])
    ax.set_title(title)
    ax.set_xlabel("radius")
plt.show()
display(wasserstein_weights.iloc[-1].sort_values(ascending=False).to_frame("Wasserstein DRMV").round(4))
display(wasserstein_diag.tail(3).round(4))
robust_latest_weights = pd.concat({"Box-Robust": box_robust_weights.iloc[-1],
                                   "Ellipsoid": ellipsoid_robust_weights.iloc[-1],
                                   "W-DRO": wasserstein_weights.iloc[-1]}, axis=1).reindex(assets).fillna(0.0)
fig, ax = plt.subplots(figsize=(5.6, 4.8))
im = ax.imshow(robust_latest_weights.values, aspect="auto", cmap="Blues")
ax.set_xticks(range(len(robust_latest_weights.columns)))
ax.set_xticklabels(robust_latest_weights.columns, rotation=35, ha="right")
ax.set_yticks(range(len(robust_latest_weights.index)))
ax.set_yticklabels(robust_latest_weights.index)
ax.set_title("latest robust-model allocation map")
fig.colorbar(im, ax=ax, fraction=0.045, pad=0.03)
plt.tight_layout()
plt.show()
radius sqrt_delta empirical_return dro_penalty robust_return volatility robust_volatility risk_penalty objective effective_n max_weight status
0 0.00 0.0000 0.0372 0.0000 0.0372 0.1264 0.1264 0.0120 0.0252 2.8536 0.4000 optimal
1 0.05 0.0005 0.0372 0.0003 0.0369 0.1263 0.1266 0.0120 0.0249 2.8568 0.4000 optimal
2 0.10 0.0009 0.0372 0.0005 0.0367 0.1263 0.1268 0.0121 0.0246 2.8598 0.4000 optimal
3 0.25 0.0023 0.0372 0.0013 0.0358 0.1262 0.1276 0.0122 0.0236 2.8679 0.4000 optimal
4 0.50 0.0046 0.0372 0.0027 0.0345 0.1261 0.1288 0.0124 0.0220 2.8789 0.4000 optimal
5 1.00 0.0091 0.0371 0.0054 0.0318 0.1260 0.1314 0.0129 0.0188 2.8946 0.4000 optimal
6 2.00 0.0182 0.0370 0.0106 0.0264 0.1258 0.1365 0.0140 0.0124 2.9333 0.4000 optimal
7 5.00 0.0456 0.0310 0.0223 0.0088 0.1129 0.1352 0.0137 -0.0050 4.1869 0.4000 optimal
8 10.00 0.0912 0.0148 0.0302 -0.0154 0.0924 0.1225 0.0113 -0.0266 9.1402 0.2306 optimal

Wasserstein DRMV
DBC 0.4000
EEM 0.4000
GLD 0.1523
IWM 0.0477
EFA 0.0000
VNQ 0.0000
SHY 0.0000
SPY 0.0000
HYG 0.0000
QQQ 0.0000
AGG 0.0000
IEF 0.0000
LQD 0.0000
TLT 0.0000
sqrt_delta empirical_return dro_penalty robust_return volatility robust_volatility risk_penalty objective effective_n max_weight status
date
2026-03-31 0.0090 0.0339 0.0053 0.0286 0.1349 0.1402 0.0147 0.0139 2.8711 0.4 optimal
2026-04-30 0.0091 0.0663 0.0053 0.0610 0.1271 0.1324 0.0131 0.0478 2.9312 0.4 optimal
2026-05-29 0.0091 0.0371 0.0054 0.0318 0.1260 0.1314 0.0129 0.0188 2.8946 0.4 optimal

We can vary the Wasserstein radius to examine the sensitivity of the same allocation problem. At radius 0, the model behaves close to empirical robust MV: empirical return about 3.72%, volatility around 12.64%, effective \(N\) around 2.85, max weight 40%.

As the radius rises:

  • The return penalty increases.
  • Robust return falls.
  • Robust volatility becomes larger than empirical volatility.
  • Effective \(N\) eventually rises.
  • The max weight finally starts falling at very large radii.

At radius 10, the model has effective \(N\) around 9.14 and max weight about 23.06%. That is a very different portfolio from the radius 0 or 1 solution. The model is being forced to diversify because distributional uncertainty is now expensive enough that concentrated weights are unattractive.

We use radius 1.0 for the walk-forward strategy. The latest allocation remains concentrated in DBC, EEM, GLD, and IWM, so it still pursues expected return while charging for distributional uncertainty and concentration.

The latest Wasserstein DRMV weights are:

  • DBC at 40%.
  • EEM at 40%.
  • GLD at 15.23%.
  • IWM at 4.77%.
  • zero in most other assets.

This resembles the box/ellipsoid robust allocation because the current return signals are strongly concentrated in the same assets. The difference is in the objective accounting. Wasserstein DRMV tracks its uncertainty tax, robust volatility, robust return, risk penalty, and objective through time.

The latest three rows show a fairly stable \(\sqrt{\delta}\) around 0.009. Empirical return changes month by month, from 3.39% to 6.63% to 3.71%, while the DRO penalty stays around 0.53-0.54%. This means the uncertainty tax is meaningful, but it doesn’t dominate the expected-return forecast at radius 1.0.

The backtest result is strong relative to the other new models: 10.54% CAGR, 11.86% volatility, Sharpe 0.5743, and -17.61% max drawdown. It beats Box and Ellipsoid on return and Sharpe, but it also has a larger ES and higher drawdown than Mean-CVaR. That is the expected personality of the model: robust, but still return-seeking.

11.1 Wasserstein Target-Return Diagnostic

The target-return diagnostic asks a slightly different question. Instead of maximizing robust utility, we set a required robust return and minimize robust risk.

The problem is:

\[ \min_w\; \|\Sigma^{1/2}w\|_2 + \sqrt{\delta}\|w\|_2 \]

subject to:

\[ \hat{\mu}^\top w - \sqrt{\delta}\|w\|_2 \ge r^\star \]

\[ \mathbf{1}^\top w=1,\qquad 0\le w_i\le \bar{w}_i \]

where \(r^\star\) is the target robust return. The left side of the return constraint is the expected return after the Wasserstein tax. The objective is robust risk, empirical volatility plus the same distributional concentration penalty.

This diagnostic produces a robust frontier. For each target return, we see how much empirical volatility and robust risk are needed. It is similar in spirit to an efficient frontier, but the risk axis includes distributional uncertainty.

The norm objective and robust-return constraint have a conic form. If auxiliary variables \(t\) and \(s\) bound empirical volatility and the weight norm, an equivalent representation is:

\[ \|\Sigma^{1/2}w\|_2\le t,\quad \|w\|_2\le s,\quad \hat\mu^\top w-\sqrt\delta\,s\ge r^\star,\quad \min(t+\sqrt\delta\,s). \]

For a fixed target, both norm inequalities and the return condition define a convex feasible set. Raising the target narrows that set, so the minimum robust-risk objective cannot fall when the same inputs and constraints are used. The resulting frontier compares the cost of different return requirements under the chosen ambiguity scale.

Show code
def solve_wasserstein_target_return(state, radius, target_return):
    tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(state)
    caps = cap_array(tickers).to_numpy(dtype=float)
    n = len(tickers)
    sigma_sqrt = psd_sqrt(cov_ann)
    sqrt_delta = float(radius) * math.sqrt(float(np.trace(cov_ann)) / n) / math.sqrt(max(t_mu, 1))
    w = cp.Variable(n)
    empirical_risk = cp.norm(sigma_sqrt @ w, 2)
    penalty = sqrt_delta * cp.norm(w, 2)
    problem = cp.Problem(cp.Minimize(empirical_risk + penalty), [cp.sum(w) == 1.0, w >= 0.0, w <= caps,
        mu_ann.values @ w - penalty >= float(target_return)])
    status = solve_cvx_problem(problem)
    if w.value is None or status not in ["optimal", "optimal_inaccurate"]:
        return None, status, sqrt_delta
    return clean_weight_array(w.value, tickers), status, sqrt_delta
latest_state = walkforward_grid.cache[model_rebalance_dates[-1]]
tickers, mu_ann, cov_ann, t_mu = selected_mv_inputs(latest_state)
caps = cap_array(tickers).to_numpy(dtype=float)
sigma_sqrt = psd_sqrt(cov_ann)
target_grid = np.linspace(float(np.nanpercentile(mu_ann.values, 25)), float(np.nanpercentile(mu_ann.values, 85)), 10)
frontier_rows = []
for target in target_grid:
    n = len(tickers)
    w = cp.Variable(n)
    empirical_risk = cp.norm(sigma_sqrt @ w, 2)
    empirical_problem = cp.Problem(cp.Minimize(empirical_risk), [cp.sum(w) == 1.0, w >= 0.0, w <= caps, mu_ann.values @ w >= float(target)])
    empirical_status = solve_cvx_problem(empirical_problem)
    empirical_w = clean_weight_array(w.value, tickers) if w.value is not None and empirical_status in ["optimal",
        "optimal_inaccurate"] else None
    w2, target_status, sqrt_delta_used = solve_wasserstein_target_return(latest_state, dro_radius, float(target))
    row = {"target": float(target), "empirical_status": empirical_status, "target_status": target_status}
    if empirical_w is not None:
        ev = empirical_w.reindex(tickers).values
        row.update({"empirical_mv_vol": float(math.sqrt(max(ev @ cov_ann @ ev, 0.0))),
            "empirical_effective_n": float(1.0 / np.square(ev).sum())})
    if w2 is not None:
        wv = w2.reindex(tickers).values
        vol = float(math.sqrt(max(wv @ cov_ann @ wv, 0.0)))
        tax = float(sqrt_delta_used * np.linalg.norm(wv))
        row.update({"wasserstein_empirical_vol": vol, "wasserstein_tax": tax, "wasserstein_robust_risk": vol + tax,
                    "wasserstein_effective_n": float(1.0 / np.square(wv).sum())})
    frontier_rows.append(row)
frontier_table = pd.DataFrame(frontier_rows)
display(frontier_table.round(4))
frontier_plot = frontier_table.dropna(subset=["empirical_mv_vol", "wasserstein_empirical_vol"], how="all")
fig, ax = plt.subplots(figsize=(7.2, 4.0))
ax.plot(frontier_plot["empirical_mv_vol"], frontier_plot["target"], marker="o", label="empirical MV")
ax.plot(frontier_plot["wasserstein_empirical_vol"], frontier_plot["target"], marker="o", label="W-DRO target-return diagnostic")
ax.set_title("latest empirical frontier comparison")
ax.set_xlabel("empirical volatility")
ax.set_ylabel("target return")
ax.legend(fontsize=7, ncol=2, frameon=True, framealpha=0.85)
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(7.2, 3.6))
ax.plot(frontier_table["target"], frontier_table["wasserstein_tax"], marker="o", color=colors[1])
ax.set_title("latest Wasserstein target-return robustness tax")
ax.set_xlabel("target return")
ax.set_ylabel("Wasserstein penalty")
plt.tight_layout()
plt.show()
target empirical_status target_status empirical_mv_vol empirical_effective_n wasserstein_empirical_vol wasserstein_tax wasserstein_robust_risk wasserstein_effective_n
0 -0.0185 optimal optimal 0.0345 3.2614 0.0346 0.0049 0.0395 3.4839
1 -0.0145 optimal optimal 0.0345 3.2614 0.0349 0.0048 0.0397 3.5962
2 -0.0104 optimal optimal 0.0346 3.3194 0.0374 0.0047 0.0420 3.8402
3 -0.0064 optimal optimal 0.0367 3.4875 0.0415 0.0046 0.0461 4.0133
4 -0.0023 optimal optimal 0.0407 3.7090 0.0468 0.0045 0.0514 4.0136
5 0.0017 optimal optimal 0.0460 3.7888 0.0531 0.0046 0.0577 3.8673
6 0.0058 optimal optimal 0.0521 3.7079 0.0603 0.0046 0.0648 4.0084
7 0.0098 optimal optimal 0.0587 3.4857 0.0678 0.0046 0.0724 3.9461
8 0.0139 optimal optimal 0.0658 3.2278 0.0757 0.0047 0.0804 3.7208
9 0.0179 optimal optimal 0.0732 2.9883 0.0844 0.0048 0.0892 3.5460

Both the empirical and Wasserstein-adjusted portfolios are feasible across the displayed target grid. As target return increases from about -1.85% to 1.79%, empirical volatility rises from about 3.45% to 7.32%. The Wasserstein robust risk rises from about 3.95% to 8.92%.

The robust frontier lies above the empirical frontier because it adds the Wasserstein tax. That gap is useful. It shows how much risk the model attributes to distributional ambiguity rather than observed covariance alone.

The tax is lowest near the middle of the target range and rises near the extremes. That makes sense because extreme low- or high-return targets can require more concentrated or less natural allocations. The effective number of positions also moves with the target, rising toward the middle and falling when the target becomes more return-seeking.

We use the frontier as a diagnostic for the chosen DRO radius. It lets us compare the adjustment to risk and feasibility across target returns and see how much the ambiguity term changes the allocation. In this implementation, radius 1.0 looks moderate.

In the plotted frontier, the horizontal coordinate is the empirical volatility of each fitted portfolio. The table also reports the larger robust-risk quantity after adding the uncertainty norm; the separate tax plot shows that added component across target returns.

12) Full Model Table and Feasibility Checks

We can now combine the local models with the baselines and compare their return, risk, turnover, and allocation statistics in one table.

The full table includes:

  • Equal Weight.
  • MinVar.
  • MaxSharpe.
  • Learned-Confidence BL.
  • Min-CVaR.
  • Mean-CVaR.
  • Risk Parity.
  • HRP.
  • NCO-MV.
  • Box Robust MV.
  • Ellipsoid Robust MV.
  • Wasserstein DRMV.

We also check full investment for each local model at every rebalance:

\[ \max_t |\mathbf{1}^\top w_t - 1| = 0 \]

A zero value means the weights sum to one within the displayed precision. We also record the largest excess over the common cap and count the dates that use the fallback allocation.

Together these checks show whether the portfolio weights obey the constraints and whether a solver failure required the previous allocation to be carried forward. We can then interpret the performance comparison alongside the numerical results.

The checks use the portfolio weights recorded on the model rebalance dates and report their maximum deviation across the full backtest.

The models optimize different objectives, so we’ll compare them across four families of measures:

Family Metrics What it reveals
Return quality CAGR, Sharpe, Sortino, Calmar whether risk is being rewarded
Tail safety max drawdown, VaR, ES, worst month whether losses are controlled
Implementation cost turnover, cost drag whether the strategy can survive trading frictions
Allocation structure effective \(N\), max weight, sleeve exposure whether the model is diversified or concentrated

This matters because a model can win one family and lose another. Risk Parity wins tail safety but loses return quality. BL wins return quality but has high turnover and higher ES. Mean-CVaR has a strong middle position: not the highest return, not the lowest tail risk, but a good blend. W-DRO is stronger on return than Mean-CVaR, but weaker on tail risk.

The comparison is strongest when we keep these families separate. A single rank is useful at the end, but the model personality comes from the full metric profile.

The metrics are calculated on the same backtest period for the selected portfolios.

Show code
def full_result_table(result_dict):
    table = compact_result_table(result_dict)
    var_rows = []
    for name, result in result_dict.items():
        r = result.net_returns.dropna()
        var = var_es_table(r.to_frame(name), alpha=0.05, methods=("hist",))
        var_rows.append({"Strategy": name, "var_95": float(var.loc[name, "hist_var5"]),
                         "es_95": float(var.loc[name, "hist_es5"]),
                         "worst_month": float(r.resample("ME").apply(lambda x: (1.0 + x).prod() - 1.0).min()),
                         "avg_max_weight": float(result.weights.max(axis=1).mean())})
    var_rows = pd.DataFrame(var_rows).set_index("Strategy")
    out = table.drop(columns=[c for c in var_rows.columns if c in table.columns], errors="ignore").join(var_rows, how="left")
    out = out.rename(columns={"vol": "ann_vol", "effective_n": "avg_effective_n"})
    out["ann_return"] = out["cagr"]
    cols = ["cagr", "ann_return", "ann_vol", "sharpe", "sortino", "calmar", "max_drawdown", "var_95", "es_95", "worst_month",
        "avg_turnover", "total_turnover", "cost_drag", "avg_effective_n", "avg_max_weight"]
    return out[[c for c in cols if c in out.columns]]

full_summary = full_result_table(results)
display(full_summary.sort_values(["sharpe", "max_drawdown", "cagr"], ascending=[False, False, False]).round(4))

local_model_names = ["Min-CVaR 95", "Mean-CVaR 95", "Risk Parity", "HRP", "NCO-MV", "Box Robust MV", "Ellipsoid Robust MV",
    "Wasserstein DRMV"]
weight_check_rows = []
for name in local_model_names:
    if name not in results:
        continue
    w = results[name].weights.reindex(columns=assets).fillna(0.0)
    weight_check_rows.append({"model": name, "max_sum_error": float((w.sum(axis=1) - 1.0).abs().max()),
                              "max_cap_excess": float((w - w_max).max(axis=1).clip(lower=0.0).max()),
                              "fallback_count": int(local_fallback_counts.get(name, 0))})
weight_checks = pd.DataFrame(weight_check_rows).set_index("model")
display(weight_checks.round(8))
cagr ann_return ann_vol sharpe sortino calmar max_drawdown var_95 es_95 worst_month avg_turnover total_turnover cost_drag avg_effective_n avg_max_weight
Strategy
Learned-Confidence BL 0.1253 0.1253 0.1150 0.7458 0.9287 0.6888 -0.1820 0.0109 0.0176 -0.0742 0.3147 39.3320 0.0226 5.2021 0.2724
MaxSharpe (Sample, BayesSteinMomentum) 0.1168 0.1168 0.1189 0.6553 0.8164 0.7065 -0.1653 0.0115 0.0186 -0.0976 0.2177 27.4264 0.0158 3.2046 0.3897
Wasserstein DRMV 0.1054 0.1054 0.1186 0.5743 0.7120 0.5986 -0.1761 0.0118 0.0184 -0.0859 0.3119 38.9873 0.0223 3.7144 0.3632
Mean-CVaR 95 0.0921 0.0921 0.0955 0.5609 0.7014 0.6305 -0.1461 0.0090 0.0145 -0.0552 0.3077 38.4629 0.0223 3.3662 0.3887
Ellipsoid Robust MV 0.1001 0.1001 0.1133 0.5533 0.6901 0.6421 -0.1559 0.0112 0.0174 -0.0821 0.3401 42.5105 0.0243 3.1440 0.3922
Box Robust MV 0.0992 0.0992 0.1118 0.5520 0.6860 0.6370 -0.1557 0.0110 0.0172 -0.0835 0.3462 43.2746 0.0247 3.1317 0.3922
MV (LedoitWolf, BayesStein) 0.0814 0.0814 0.0847 0.4949 0.6049 0.5904 -0.1378 0.0083 0.0132 -0.0659 0.0369 4.6481 0.0028 5.0557 0.3212
Equal Weight 0.0848 0.0848 0.0935 0.4842 0.5934 0.4159 -0.2038 0.0086 0.0139 -0.0806 0.0114 1.4385 0.0010 14.0000 0.0714
MinVar (OAS) 0.0665 0.0665 0.0754 0.3543 0.4044 0.3370 -0.1973 0.0066 0.0111 -0.0761 0.0031 0.3897 0.0003 9.1680 0.2394
HRP 0.0519 0.0519 0.0504 0.2547 0.3247 0.3586 -0.1446 0.0047 0.0075 -0.0462 0.0186 2.3204 0.0017 5.0967 0.4000
NCO-MV 0.0519 0.0519 0.0636 0.2128 0.2538 0.3435 -0.1509 0.0056 0.0093 -0.0629 0.1645 20.5595 0.0155 5.7432 0.2467
Risk Parity 0.0453 0.0453 0.0492 0.1320 0.1649 0.3295 -0.1374 0.0045 0.0073 -0.0409 0.0131 1.6326 0.0012 5.9635 0.3498
Min-CVaR 95 0.0240 0.0240 0.0360 -0.4073 -0.4977 0.1918 -0.1250 0.0034 0.0054 -0.0331 0.0248 3.0990 0.0027 2.9749 0.4000
max_sum_error max_cap_excess fallback_count
model
Min-CVaR 95 0.0 0.0 0
Mean-CVaR 95 0.0 0.0 0
Risk Parity 0.0 0.0 0
HRP 0.0 0.0 0
NCO-MV 0.0 0.0 0
Box Robust MV 0.0 0.0 0
Ellipsoid Robust MV 0.0 0.0 0
Wasserstein DRMV 0.0 0.0 0

For every local model, the displayed full-investment error and common-cap excess round to zero, and the fallback count is zero. The solvers therefore returned feasible portfolios for the displayed rebalance dates.

The Sharpe ranking and the other reported measures give us the following model comparison:

Model type Strongest representative Main result
Active Bayesian views Learned-Confidence BL highest Sharpe and CAGR
Aggressive frontier MaxSharpe highest Calmar, strong CAGR, high concentration
Distributionally robust Wasserstein DRMV best of the new robust models by Sharpe
Tail-budgeted Mean-CVaR 95 strong balance of return and tail control
Classical robust MV Ellipsoid Robust MV slightly better than box robust
Pure tail/risk parity Min-CVaR, RP, HRP strongest risk control, weaker return

Learned-Confidence BL remains the top model overall by Sharpe at 0.7458 and CAGR at 12.53%. MaxSharpe is second by Sharpe at 0.6553 and has the best Calmar at 0.7065. Wasserstein DRMV is the best new robust model by Sharpe at 0.5743. Mean-CVaR is close behind at 0.5609 with lower volatility and lower drawdown.

Risk Parity and HRP have the best tail metrics. Risk Parity has ES of 0.73% and max drawdown of -13.74%, the best among finalists. HRP has ES of 0.75% and very low volatility. Their weakness is return, not risk.

Show code
def rank_for_choice(names, table, columns, ascending):
    available = [name for name in names if name in table.index]
    return table.loc[available].sort_values(columns, ascending=ascending).index[0]

best_cvar_name = rank_for_choice(["Min-CVaR 95", "Mean-CVaR 95"], full_summary,
                                 ["sharpe", "calmar", "es_95", "max_drawdown", "cagr"],
                                 [False, False, True, False, False])

benchmark_models = [
    "Equal Weight",
    best_minvar_name,
    best_mv_name,
    best_maxsharpe_name,
    "Learned-Confidence BL",
]

selected_models_raw = benchmark_models + [best_cvar_name, "Risk Parity", best_hier_name, best_robust_name, "Wasserstein DRMV"]
selected_models = list(dict.fromkeys([name for name in selected_models_raw if name in results]))
short_label_map = {"Equal Weight": "EW", best_minvar_name: "MinVar", best_mv_name: "MV",
                   best_maxsharpe_name: "MaxSharpe", "Learned-Confidence BL": "BL", "Min-CVaR 95": "Min-CVaR",
                   "Mean-CVaR 95": "Mean-CVaR", "Risk Parity": "RP", "HRP": "HRP",
                   "NCO-MV": "NCO-MV", "Box Robust MV": "Box-Robust", "Ellipsoid Robust MV": "Ellipsoid",
                   "Wasserstein DRMV": "W-DRO"}
def short_labels(names):
    return [short_label_map.get(name, name) for name in names]
def small_legend(ax, loc="best"):
    ax.legend(fontsize=7, ncol=2, frameon=True, framealpha=0.85, loc=loc)

reason_rows = []
for name in selected_models:
    if name == "Equal Weight":
        role = "baseline"
        selected_from = "project 2 grid"
    elif name == best_minvar_name:
        role = "minimum variance benchmark"
        selected_from = "project 2 grid"
    elif name == best_mv_name:
        role = "mean-variance benchmark"
        selected_from = "project 2 grid"
    elif name == best_maxsharpe_name:
        role = "sharpe/frontier benchmark"
        selected_from = "project 2 grid"
    elif name == "Learned-Confidence BL":
        role = "black-litterman benchmark"
        selected_from = "project 6 rebuild"
    elif name == best_cvar_name:
        role = "tail-risk allocation"
        selected_from = "Min-CVaR 95 vs Mean-CVaR 95"
    elif name == "Risk Parity":
        role = "risk budget allocation"
        selected_from = "single specified model"
    elif name == best_hier_name:
        role = "hierarchical allocation"
        selected_from = "HRP vs NCO-MV"
    elif name == best_robust_name:
        role = "robust mean-variance"
        selected_from = "Box Robust MV vs Ellipsoid Robust MV"
    else:
        role = "distributionally robust allocation"
        selected_from = "single specified model"
    reason_rows.append({"model": name, "role": role, "selected_from": selected_from})

selected_reason = pd.DataFrame(reason_rows).set_index("model")
display(pd.Series(selected_models, name="model").to_frame())
display(selected_reason)
print("selected CVaR model:", best_cvar_name)
model
0 Equal Weight
1 MinVar (OAS)
2 MV (LedoitWolf, BayesStein)
3 MaxSharpe (Sample, BayesSteinMomentum)
4 Learned-Confidence BL
5 Mean-CVaR 95
6 Risk Parity
7 HRP
8 Ellipsoid Robust MV
9 Wasserstein DRMV
role selected_from
model
Equal Weight baseline project 2 grid
MinVar (OAS) minimum variance benchmark project 2 grid
MV (LedoitWolf, BayesStein) mean-variance benchmark project 2 grid
MaxSharpe (Sample, BayesSteinMomentum) sharpe/frontier benchmark project 2 grid
Learned-Confidence BL black-litterman benchmark project 6 rebuild
Mean-CVaR 95 tail-risk allocation Min-CVaR 95 vs Mean-CVaR 95
Risk Parity risk budget allocation single specified model
HRP hierarchical allocation HRP vs NCO-MV
Ellipsoid Robust MV robust mean-variance Box Robust MV vs Ellipsoid Robust MV
Wasserstein DRMV distributionally robust allocation single specified model
selected CVaR model: Mean-CVaR 95

We select the following finalists for the detailed comparison:

  • Equal Weight.
  • MinVar with OAS.
  • MV with Ledoit-Wolf and Bayes-Stein.
  • MaxSharpe with sample covariance and Bayes-Stein Momentum.
  • Learned-Confidence BL.
  • Mean-CVaR 95.
  • Risk Parity.
  • HRP.
  • Ellipsoid Robust MV.
  • Wasserstein DRMV.

Mean-CVaR 95 is the selected CVaR model: Min-CVaR has lower tail loss, while Mean-CVaR has the higher Sharpe used by the selection rule. The selected hierarchical model is HRP because it beats NCO-MV on Sharpe, drawdown, and turnover. The selected classical robust model is Ellipsoid Robust MV because it slightly beats Box Robust MV.

This filtered set now covers the main philosophical choices:

  • simple diversification,
  • variance minimization,
  • expected-return optimization,
  • Bayesian active views,
  • tail-risk budgeting,
  • risk-budgeting,
  • hierarchy-based allocation,
  • mean-uncertainty robustness,
  • distributional robustness.

Each selected model is compared using the same reported metrics and cost assumptions.

Show code

selected_scoreboard = full_summary.loc[selected_models, ["cagr", "ann_vol", "sharpe", "sortino", "calmar", "max_drawdown",
    "es_95", "avg_turnover", "avg_effective_n", "avg_max_weight"]]
display(selected_scoreboard.sort_values("sharpe", ascending=False).round(4))
cagr ann_vol sharpe sortino calmar max_drawdown es_95 avg_turnover avg_effective_n avg_max_weight
Strategy
Learned-Confidence BL 0.1253 0.1150 0.7458 0.9287 0.6888 -0.1820 0.0176 0.3147 5.2021 0.2724
MaxSharpe (Sample, BayesSteinMomentum) 0.1168 0.1189 0.6553 0.8164 0.7065 -0.1653 0.0186 0.2177 3.2046 0.3897
Wasserstein DRMV 0.1054 0.1186 0.5743 0.7120 0.5986 -0.1761 0.0184 0.3119 3.7144 0.3632
Mean-CVaR 95 0.0921 0.0955 0.5609 0.7014 0.6305 -0.1461 0.0145 0.3077 3.3662 0.3887
Ellipsoid Robust MV 0.1001 0.1133 0.5533 0.6901 0.6421 -0.1559 0.0174 0.3401 3.1440 0.3922
MV (LedoitWolf, BayesStein) 0.0814 0.0847 0.4949 0.6049 0.5904 -0.1378 0.0132 0.0369 5.0557 0.3212
Equal Weight 0.0848 0.0935 0.4842 0.5934 0.4159 -0.2038 0.0139 0.0114 14.0000 0.0714
MinVar (OAS) 0.0665 0.0754 0.3543 0.4044 0.3370 -0.1973 0.0111 0.0031 9.1680 0.2394
HRP 0.0519 0.0504 0.2547 0.3247 0.3586 -0.1446 0.0075 0.0186 5.0967 0.4000
Risk Parity 0.0453 0.0492 0.1320 0.1649 0.3295 -0.1374 0.0073 0.0131 5.9635 0.3498

13) Finalist Scoreboard

We can compare the finalist portfolios across return, risk, tail loss, turnover, and concentration.

Learned-Confidence BL leads the table with:

\[ \text{CAGR}=12.53\%,\quad \text{Sharpe}=0.7458,\quad \text{Sortino}=0.9287 \]

Its weakness is drawdown and tail size compared with defensive models:

\[ \text{Max Drawdown}=-18.20\%,\quad ES_{95}=1.76\% \]

MaxSharpe is close in return, 11.68% CAGR, and has the best Calmar at 0.7065, but it has the worst ES among finalists at 1.86% and a very low effective \(N\) of 3.20. This is a high-conviction, high-turnover model.

Wasserstein DRMV earns 10.54% CAGR with Sharpe 0.5743. That is a good result for a robust model. It doesn’t beat BL or MaxSharpe, but it does beat classical MV, Equal Weight, MinVar, Risk Parity, and HRP on Sharpe.

Mean-CVaR 95 is one of the best balanced new models: 9.21% CAGR, 9.55% volatility, Sharpe 0.5609, and max drawdown -14.61%. Its ES is 1.45%, meaning it gives up some return to improve tail behavior.

Risk Parity wins risk control: ES 0.73% and max drawdown -13.74%. Its Sharpe is only 0.1320 because the portfolio is too defensive relative to the 4% risk-free rate.

Show code
selected_nav = pd.DataFrame({name: results[name].net_values for name in selected_models}).dropna(how="all")
fig, ax = plt.subplots(figsize=(9.0, 4.4))
plot_strategy_nav(selected_nav[selected_models], ax=ax, title="project 10 finalists: net wealth", labels=short_labels(selected_models))
ax.set_ylabel("growth of $1")
small_legend(ax, loc="upper left")
plt.tight_layout()
plt.show()

Learned-Confidence BL and MaxSharpe finish near the top of the net-wealth comparison, with Wasserstein DRMV and Ellipsoid Robust MV also compounding well. Mean-CVaR grows more slowly but with less severe tail exposure. Risk Parity and HRP trail because their defensive allocations don’t earn enough return in this sample.

The faster-growing active portfolios also experience deeper drawdowns. BL, MaxSharpe, and W-DRO all experience drawdowns around the mid-to-high teens. Mean-CVaR keeps drawdown closer to -14.6%. Risk Parity and HRP control drawdowns best.

We can compare how the selected portfolios balance return and risk:

  • BL and MaxSharpe behave like return engines.
  • W-DRO and Ellipsoid Robust MV behave like return engines with robust penalties.
  • Mean-CVaR behaves like a tail-budgeted growth model.
  • Risk Parity and HRP behave like defensive allocation models.
  • Equal Weight is a neutral reference that gets outperformed by active/robust models but keeps broad diversification.

Both plots use the same selected portfolio histories after the backtest costs and rebalancing conventions are applied.

Show code
fig, ax = plt.subplots(figsize=(9.0, 4.4))
plot_strategy_drawdowns(selected_nav[selected_models], ax=ax, title="project 10 finalists: drawdowns", labels=short_labels(selected_models))
ax.set_ylabel("drawdown")
small_legend(ax, loc="lower left")
plt.tight_layout()
plt.show()

Show code
scatter_data = selected_scoreboard.copy()
fig, ax = plt.subplots(figsize=(7.2, 4.2))
size = 1800 * scatter_data["es_95"].abs() / scatter_data["es_95"].abs().max()
ax.scatter(scatter_data["ann_vol"], scatter_data["cagr"], s=size, alpha=0.62, color=colors[0], edgecolor="#1f2937", linewidth=0.6)
for name, row in scatter_data.iterrows():
    ax.annotate(short_label_map.get(name, name), (row["ann_vol"], row["cagr"]), xytext=(4, 4), textcoords="offset points", fontsize=7)
ax.set_title("project 10 finalists: risk-return-tail scatter, marker size = ES 95 loss")
ax.set_xlabel("annualized volatility")
ax.set_ylabel("CAGR")
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(7.2, 4.0))
ax.scatter(scatter_data["avg_turnover"], scatter_data["sharpe"], s=70, alpha=0.72, color=colors[1], edgecolor="#1f2937", linewidth=0.6)
for name, row in scatter_data.iterrows():
    ax.annotate(short_label_map.get(name, name), (row["avg_turnover"], row["sharpe"]), xytext=(4, 4),
                textcoords="offset points", fontsize=7)
ax.set_title("project 10 finalists: turnover versus Sharpe")
ax.set_xlabel("average turnover")
ax.set_ylabel("Sharpe")
plt.tight_layout()
plt.show()

In the risk-return scatter, marker size represents the ES 95 loss. Models in the upper-right deliver more return but also carry larger tail losses. Models in the lower-left are safer but less rewarding.

The scatter makes three groups visible:

  1. Return-oriented group: BL, MaxSharpe, W-DRO, Ellipsoid Robust. These have higher CAGR and higher volatility, with larger tail markers.
  2. Balanced tail-budget group: Mean-CVaR and MV. They sit lower in return than BL but have more controlled drawdown and ES.
  3. Defensive group: Risk Parity, HRP, MinVar. These control volatility and ES but give up too much return.

The turnover-versus-Sharpe plot adds implementation realism. BL has the highest Sharpe but also high turnover. W-DRO and Ellipsoid Robust MV also have high turnover. MinVar, Equal Weight, Risk Parity, and HRP have much lower turnover, but lower Sharpe.

Trading cost is already included, but turnover still matters. A model with high turnover needs liquid assets, low slippage, and stable execution. In ETFs this is more plausible than in small stocks, but it is still a real implementation cost.

The positions and marker sizes correspond to the same finalist performance table.

14) Average Weights and Sleeve Exposures

We can examine the average holdings to connect each objective with the exposures it tends to select.

Equal Weight holds 7.14% in every asset by construction. MinVar and MV both allocate heavily to SHY, with MV also holding GLD and QQQ. MaxSharpe holds QQQ, GLD, and SHY as its top average positions. BL is more equity-oriented, with top average holdings in SPY, QQQ, and EFA.

Mean-CVaR holds SHY, GLD, and DBC as its top average positions. This makes sense: it wants return, but under a tail constraint, so it uses assets that have historically helped tail behavior or inflation regimes. Risk Parity and HRP are both dominated by SHY and bond-like exposures. Ellipsoid Robust and Wasserstein DRMV both hold GLD, DBC, and QQQ as major average positions, consistent with robust return-seeking behavior.

The sleeve table summarizes this even better:

  • BL has the largest equity sleeve at 56.33%.
  • Risk Parity has 57.90% duration.
  • HRP has 60.45% duration.
  • Mean-CVaR has 31.45% diversifiers and 36.25% duration.
  • Ellipsoid Robust and W-DRO have the largest diversifier exposure, around 35-36%.

The resulting sleeve preferences connect the models’ risk definitions to different economic exposures in the cross-asset universe.

Show code
average_weights = pd.DataFrame({name: results[name].weights.reindex(columns=assets).mean() for name in selected_models}).reindex(assets)

def average_sleeves(result):
    w = result.weights.reindex(columns=assets).fillna(0.0)
    row = {}
    for sleeve, members in sleeve_groups.items():
        row[f"avg_{sleeve}"] = float(w[members].sum(axis=1).mean())
    row["avg_effective_n"] = float((1.0 / (w.pow(2).sum(axis=1))).replace([np.inf, -np.inf], np.nan).mean())
    row["avg_max_weight"] = float(w.max(axis=1).mean())
    return row

sleeve_exposure = pd.DataFrame({name: average_sleeves(results[name]) for name in selected_models}).T
top_average_holdings = []
for name in selected_models:
    for asset, value in average_weights[name].nlargest(3).items():
        top_average_holdings.append({"model": name, "asset": asset, "avg_weight": value})
top_average_holdings = pd.DataFrame(top_average_holdings)
display(top_average_holdings.round(4))
display(sleeve_exposure.round(4))
fig, ax = plt.subplots(figsize=(8.5, 4.8))
im = ax.imshow(average_weights.fillna(0.0).values, aspect="auto", cmap="Blues")
ax.set_xticks(range(len(average_weights.columns)))
ax.set_xticklabels(short_labels(average_weights.columns), rotation=45, ha="right")
ax.set_yticks(range(len(average_weights.index)))
ax.set_yticklabels(average_weights.index)
ax.set_title("project 10 finalists: average weights")
fig.colorbar(im, ax=ax, fraction=0.035, pad=0.02)
plt.tight_layout()
plt.show()
sleeve_cols = [f"avg_{s}" for s in sleeve_groups]
sleeve_plot = sleeve_exposure[sleeve_cols].copy()
sleeve_plot.index = short_labels(sleeve_plot.index)
ax = sleeve_plot.plot(kind="bar", stacked=True, figsize=(8.8, 4.0), width=0.78)
ax.set_title("project 10 finalists: average sleeve exposure")
ax.set_ylabel("average weight")
small_legend(ax, loc="upper right")
plt.tight_layout()
plt.show()
model asset avg_weight
0 Equal Weight SPY 0.0714
1 Equal Weight QQQ 0.0714
2 Equal Weight IWM 0.0714
3 MinVar (OAS) SHY 0.2284
4 MinVar (OAS) SPY 0.0840
5 MinVar (OAS) GLD 0.0826
6 MV (LedoitWolf, BayesStein) SHY 0.2388
7 MV (LedoitWolf, BayesStein) GLD 0.1740
8 MV (LedoitWolf, BayesStein) QQQ 0.1152
9 MaxSharpe (Sample, BayesSteinMomentum) QQQ 0.1702
10 MaxSharpe (Sample, BayesSteinMomentum) GLD 0.1445
11 MaxSharpe (Sample, BayesSteinMomentum) SHY 0.1162
12 Learned-Confidence BL SPY 0.1949
13 Learned-Confidence BL QQQ 0.1272
14 Learned-Confidence BL EFA 0.0884
15 Mean-CVaR 95 SHY 0.2042
16 Mean-CVaR 95 GLD 0.1820
17 Mean-CVaR 95 DBC 0.1054
18 Risk Parity SHY 0.3498
19 Risk Parity AGG 0.1118
20 Risk Parity LQD 0.0987
21 HRP SHY 0.4000
22 HRP AGG 0.1005
23 HRP IEF 0.0721
24 Ellipsoid Robust MV GLD 0.1908
25 Ellipsoid Robust MV DBC 0.1174
26 Ellipsoid Robust MV QQQ 0.1165
27 Wasserstein DRMV GLD 0.1903
28 Wasserstein DRMV QQQ 0.1291
29 Wasserstein DRMV DBC 0.1156
avg_equities avg_duration avg_credit avg_diversifiers avg_effective_n avg_max_weight
Equal Weight 0.3571 0.2857 0.1429 0.2143 14.0000 0.0714
MinVar (OAS) 0.2668 0.4135 0.1298 0.1900 9.1680 0.2394
MV (LedoitWolf, BayesStein) 0.2137 0.4676 0.0399 0.2788 5.0557 0.3212
MaxSharpe (Sample, BayesSteinMomentum) 0.4261 0.2697 0.0540 0.2502 3.2046 0.3897
Learned-Confidence BL 0.5633 0.2203 0.0231 0.1932 5.2021 0.2724
Mean-CVaR 95 0.2208 0.3625 0.1023 0.3145 3.3662 0.3887
Risk Parity 0.0999 0.5790 0.1928 0.1283 5.9635 0.3498
HRP 0.1462 0.6045 0.1337 0.1156 5.0967 0.4000
Ellipsoid Robust MV 0.3221 0.2604 0.0636 0.3540 3.1440 0.3922
Wasserstein DRMV 0.3617 0.2186 0.0592 0.3604 3.7144 0.3632

The average weights show how the models distribute capital across the ETF universe. Risk Parity and HRP place major weight in SHY and bond assets. Robust models put large average weight in GLD, DBC, QQQ, and sometimes EEM. BL distributes more across equity names and avoids the extremely defensive structure of parity models.

Effective \(N\) helps quantify concentration:

\[ N_{\text{eff}}=\frac{1}{\sum_{i=1}^{N}w_i^2} \]

Equal Weight has \(N_{\text{eff}}=14\) because all weights are equal. If a model holds only three or four large positions, effective \(N\) falls. MaxSharpe has effective \(N\) around 3.20, Mean-CVaR around 3.37, Ellipsoid around 3.14, and W-DRO around 3.71. BL is more diversified at 5.20.

This is one of the clearest practical differences between BL and robust MV. BL is active and high-turnover, but it keeps more positions alive because the view structure and constraints distribute active bets. Robust MV models are more concentrated because a few assets survive the expected-return penalty and then hit caps.

Show code
selected_returns = pd.DataFrame({name: results[name].net_returns for name in selected_models}).dropna(how="all")
rolling_return_252 = (1.0 + selected_returns).rolling(252).apply(np.prod, raw=True) - 1.0
rolling_vol_252 = selected_returns.rolling(252).std() * np.sqrt(annualization)
rolling_mean_252 = selected_returns.rolling(252).mean() * annualization
rolling_sharpe_252 = (rolling_mean_252 - risk_free_rate_annual) / rolling_vol_252

rolling_plot_names = selected_models
if len(rolling_plot_names) > 7:
    rolling_plot_names = list(dict.fromkeys(["Equal Weight", best_mv_name, "Learned-Confidence BL", best_cvar_name,
        best_hier_name, best_robust_name, "Wasserstein DRMV"]))

fig, ax = plt.subplots(figsize=(8.8, 4.2))
rolling_sharpe_252[rolling_plot_names].rename(columns=dict(zip(rolling_plot_names,
    short_labels(rolling_plot_names)))).plot(ax=ax, linewidth=1.2)
ax.axhline(0.0, color="#1f2937", linewidth=0.8, alpha=0.6)
ax.set_title("project 10 finalists: rolling 12-month Sharpe")
ax.set_ylabel("rolling Sharpe")
small_legend(ax, loc="upper left")
plt.tight_layout()
plt.show()

display(rolling_return_252[rolling_plot_names].tail(1).T.rename(columns={rolling_return_252.index[-1]: "latest_12m_return"}).round(4))

date latest_12m_return
Equal Weight 0.1883
MV (LedoitWolf, BayesStein) 0.1811
Learned-Confidence BL 0.2702
Mean-CVaR 95 0.1702
HRP 0.1092
Ellipsoid Robust MV 0.2274
Wasserstein DRMV 0.2256

Rolling 12-month Sharpe ratios change across market regimes. Around the COVID crash and 2022 inflation shock, Sharpe ratios shift quickly. Models that looked good during equity rallies can deteriorate when inflation or rates dominate. Defensive models can lag badly during strong risk-on recoveries.

The latest 12-month return table shows BL leading at 27.02%, followed by Ellipsoid Robust MV at 22.74% and Wasserstein DRMV at 22.56%. Equal Weight is at 18.83%, MV at 18.11%, Mean-CVaR at 17.02%, and HRP at 10.92%.

This recent period favors the active and robust return-seeking models. Their selected exposures, especially GLD/DBC/QQQ/EEM-type positions, participate strongly as those assets perform well in the recent window. HRP lags because the duration-heavy defensive allocation doesn’t match the current return environment.

Rolling Sharpe is useful because it prevents a single full-sample metric from hiding regime changes. A model can have a good full-sample Sharpe but still be very weak in a specific macro regime.

Each rolling observation uses the preceding 252 daily returns, so consecutive points share most of their underlying observations.

Show code
risk_returns = selected_returns.dropna(how="all")
stress_windows = {"2018_q4": ("2018-10-01", "2018-12-31"), "covid_crash_2020": ("2020-02-19", "2020-03-23"),
                  "inflation_rate_shock_2022": ("2022-01-03", "2022-10-14")}

spy_ref = returns["SPY"].reindex(risk_returns.index).dropna()
label_overrides = dict(zip(selected_models, short_labels(selected_models)))
risk_report_artifacts = risk_report(
    objects={name: risk_returns[name] for name in selected_models},
    market_ret=spy_ref,
    rf_daily=risk_free_daily,
    include={"performance_tables": False, "shape_tables": False, "drawdowns": False,
             "drawdown_episodes": False, "var_es": True, "var_backtest": True,
             "stress": True, "capm": True, "rolling_beta": True,
             "correlation": False, "attribution": False, "exec_bullets": False},
    var_settings={"alpha": 0.05, "methods": ["hist", "cf", "fhs"]},
    backtest_settings={"alpha": 0.05, "methods": ["hist", "cf", "fhs"], "lookback": 252, "plot_method": "best"},
    rolling_settings={"vol_windows": [252], "beta_windows": [252]},
    stress_settings={"windows": stress_windows, "worst_only": False},
    layout={"ncols": 5, "sharex": True, "sharey": False},
    output={"display_table_keys": ["var_es"], "round_tables": 4, "print_exec_bullets": False,
            "show_figures": False, "short_labels": True, "label_max_len": 12,
            "label_map": label_overrides},
)
risk_report_tables = risk_report_artifacts.tables
stress = risk_report_artifacts.series.get("stress_full", risk_report_tables.get("stress"))
var_es = risk_report_tables["var_es"]
var_backtest = risk_report_tables["var_backtest"]
for key in ["var_backtest", "stress", "rolling_beta"]:
    for fig in risk_report_artifacts.figures.get(key, []):
        display(fig)
        plt.close(fig)
hist_var5 hist_es5 cf_var5 cf_es5 fhs_var5 fhs_es5
object
BL 0.0109 0.0176 0.0119 0.0230 0.0199 0.0290
EW 0.0086 0.0139 0.0091 0.0243 0.0119 0.0182
Ellipsoid 0.0112 0.0174 0.0120 0.0220 0.0229 0.0331
HRP 0.0047 0.0075 0.0048 0.0110 0.0060 0.0091
MV 0.0083 0.0132 0.0089 0.0170 0.0144 0.0209
MaxSharpe 0.0115 0.0186 0.0122 0.0223 0.0213 0.0314
Mean-CVaR 0.0090 0.0145 0.0099 0.0198 0.0123 0.0180
MinVar 0.0066 0.0111 0.0072 0.0276 0.0086 0.0132
RP 0.0045 0.0073 0.0047 0.0121 0.0060 0.0090
W-DRO 0.0118 0.0184 0.0125 0.0231 0.0228 0.0330

15) Risk Report: VaR, ES, Stress, and Beta

The historical VaR and ES estimates provide a tail-risk comparison across the finalists:

  • Risk Parity has the lowest historical ES at 0.73%.
  • HRP is next at 0.75%.
  • MinVar has ES 1.11%.
  • MV has ES 1.32%.
  • Mean-CVaR has ES 1.45%.
  • BL, Ellipsoid, MaxSharpe, and W-DRO have ES around 1.74-1.86%.

The filtered historical simulation (FHS) ES is more severe for the aggressive and robust return-seeking models. BL has FHS ES 2.90%, MaxSharpe 3.14%, Ellipsoid 3.31%, and W-DRO 3.30%. This tells us that when recent volatility is used to scale historical returns, the tail risk of the aggressive models looks larger.

Their return advantage comes with downside exposure that is visible when historical moves are scaled by recent volatility. Mean-CVaR’s FHS ES is much lower at 1.80%, and Risk Parity/HRP remain below 1%. The tail-aware models are doing their job in risk units.

The estimates are calculated from the recorded portfolio return series.

We compare the models across 2018 Q4, the COVID crash, and the 2022 inflation/rate shock. Each window highlights different portfolio exposures:

  • 2018 Q4 stresses equity beta and growth exposure.
  • COVID crash stresses sudden liquidity-driven cross-asset selling.
  • 2022 inflation/rate shock stresses both stocks and duration bonds at the same time.

Risk Parity and HRP usually look better in pure equity drawdowns because they carry more duration and lower equity risk. However, 2022 is more complicated because duration itself was under pressure. Mean-CVaR is often more balanced because its tail budget can shift toward assets that helped in recent tail windows, but it still depends on the rolling scenarios.

BL and MaxSharpe have more upside capture but can lose more in sharp shocks. W-DRO and Ellipsoid Robust MV are meant to reduce input fragility, not eliminate market exposure. So they can still experience large stress losses if the assets they select are hit together.

The rolling beta plots show the same theme. Defensive models have lower market beta. Active return-seeking models have higher and more variable beta. A model’s Sharpe ratio alone doesn’t show this exposure path.

The rolling beta compares covariance with the aligned SPY return series against SPY variance in the same window, so a changing beta describes changing market exposure over time.

Show code

criterion_rows = []
criteria = [
    ("highest cagr", "cagr", False),
    ("highest sharpe", "sharpe", False),
    ("highest sortino", "sortino", False),
    ("highest calmar", "calmar", False),
    ("lowest max drawdown", "max_drawdown", False),
    ("lowest es 95", "es_95", True),
    ("lowest avg turnover", "avg_turnover", True),
    ("highest avg effective n", "avg_effective_n", False),
]
for criterion, column, ascending in criteria:
    values = selected_scoreboard[column].dropna()
    winner = values.sort_values(ascending=ascending).index[0]
    criterion_rows.append({"criterion": criterion, "winner": winner, "value": values.loc[winner]})

rank_data = selected_scoreboard.copy()
rank_table = pd.DataFrame(index=rank_data.index)
rank_table["sharpe_rank"] = rank_data["sharpe"].rank(ascending=False, method="min")
rank_table["calmar_rank"] = rank_data["calmar"].rank(ascending=False, method="min")
rank_table["es_95_rank"] = rank_data["es_95"].rank(ascending=True, method="min")
rank_table["max_drawdown_rank"] = rank_data["max_drawdown"].rank(ascending=False, method="min")
rank_table["avg_turnover_rank"] = rank_data["avg_turnover"].rank(ascending=True, method="min")
rank_table["avg_effective_n_rank"] = rank_data["avg_effective_n"].rank(ascending=False, method="min")
rank_table["balanced_rank_score"] = (
    0.25 * rank_table["sharpe_rank"]
    + 0.20 * rank_table["calmar_rank"]
    + 0.20 * rank_table["es_95_rank"]
    + 0.15 * rank_table["max_drawdown_rank"]
    + 0.10 * rank_table["avg_turnover_rank"]
    + 0.10 * rank_table["avg_effective_n_rank"]
)
rank_table = rank_table.sort_values("balanced_rank_score")
criterion_rows.append({"criterion": "best balanced rank", "winner": rank_table.index[0],
                       "value": rank_table.iloc[0]["balanced_rank_score"]})

display(pd.DataFrame(criterion_rows).set_index("criterion").round(4))
display(rank_table.round(3))
winner value
criterion
highest cagr Learned-Confidence BL 0.1253
highest sharpe Learned-Confidence BL 0.7458
highest sortino Learned-Confidence BL 0.9287
highest calmar MaxSharpe (Sample, BayesSteinMomentum) 0.7065
lowest max drawdown Risk Parity -0.1374
lowest es 95 Risk Parity 0.0073
lowest avg turnover MinVar (OAS) 0.0031
highest avg effective n Equal Weight 14.0000
best balanced rank Learned-Confidence BL 4.7500
sharpe_rank calmar_rank es_95_rank max_drawdown_rank avg_turnover_rank avg_effective_n_rank balanced_rank_score
Strategy
Learned-Confidence BL 1.0 2.0 8.0 8.0 9.0 4.0 4.75
MV (LedoitWolf, BayesStein) 6.0 6.0 4.0 2.0 5.0 6.0 4.90
Mean-CVaR 95 4.0 4.0 6.0 4.0 7.0 8.0 5.10
MaxSharpe (Sample, BayesSteinMomentum) 2.0 1.0 10.0 6.0 6.0 9.0 5.10
Risk Parity 10.0 10.0 1.0 1.0 3.0 3.0 5.45
HRP 9.0 8.0 2.0 3.0 4.0 5.0 5.60
Equal Weight 7.0 7.0 5.0 10.0 2.0 1.0 5.95
Ellipsoid Robust MV 5.0 3.0 7.0 5.0 10.0 10.0 6.00
MinVar (OAS) 8.0 9.0 3.0 9.0 1.0 2.0 6.05
Wasserstein DRMV 3.0 5.0 9.0 7.0 8.0 7.0 6.10

16) Balanced Ranking

We can identify the leading model for each reported criterion:

  • Highest CAGR: Learned-Confidence BL.
  • Highest Sharpe: Learned-Confidence BL.
  • Highest Sortino: Learned-Confidence BL.
  • Highest Calmar: MaxSharpe.
  • Lowest max drawdown: Risk Parity.
  • Lowest ES 95: Risk Parity.
  • Lowest turnover: MinVar.
  • Highest effective \(N\): Equal Weight.
  • Best balanced rank: Learned-Confidence BL.

The balanced rank score combines Sharpe, Calmar, ES, drawdown, turnover, and diversification. BL wins because its return quality is strong enough to overcome its weaker tail and turnover ranks. MV comes second in balanced rank because it has excellent drawdown and reasonable turnover even though it doesn’t lead return metrics. Mean-CVaR and MaxSharpe tie closely after that, but for different reasons: Mean-CVaR is better on risk control, MaxSharpe is better on return and Calmar.

This ranking is useful because there is no universal “best” portfolio. A pension-style allocator might prefer Mean-CVaR or MV because drawdown and ES matter more. A return-seeking ETF strategy might prefer BL or W-DRO. A defensive allocation sleeve might prefer Risk Parity or HRP. The table makes those tradeoffs explicit rather than hiding them behind one metric.

The balanced score uses ranks rather than the raw units of those measures. For model \(m\), the implemented weighted score is:

\[ S_m=0.25R_{\mathrm{Sharpe},m}+0.20R_{\mathrm{Calmar},m}+0.20R_{\mathrm{ES},m} +0.15R_{\mathrm{DD},m}+0.10R_{\mathrm{turnover},m}+0.10R_{N_{\mathrm{eff}},m}. \]

Rank 1 is best for each criterion, so a lower combined score is preferred. Sharpe, Calmar, drawdown (less negative is better), and effective \(N\) are ranked from high to low; ES and turnover are ranked from low to high. The weights determine how strongly each criterion contributes to this particular comparison.

Show code

selected_reason_lookup = selected_reason["role"].to_dict()
selected_from_lookup = selected_reason["selected_from"].to_dict()
final_comparison = selected_scoreboard[["cagr", "sharpe", "calmar", "max_drawdown", "es_95", "avg_turnover", "avg_effective_n"]].copy()
final_comparison.insert(0, "role", [selected_reason_lookup[name] for name in final_comparison.index])
final_comparison.insert(0, "model", final_comparison.index)
final_comparison["selected_reason"] = [selected_from_lookup[name] for name in final_comparison["model"]]
final_comparison = final_comparison.reset_index(drop=True)
display(final_comparison.round(4))
model role cagr sharpe calmar max_drawdown es_95 avg_turnover avg_effective_n selected_reason
0 Equal Weight baseline 0.0848 0.4842 0.4159 -0.2038 0.0139 0.0114 14.0000 project 2 grid
1 MinVar (OAS) minimum variance benchmark 0.0665 0.3543 0.3370 -0.1973 0.0111 0.0031 9.1680 project 2 grid
2 MV (LedoitWolf, BayesStein) mean-variance benchmark 0.0814 0.4949 0.5904 -0.1378 0.0132 0.0369 5.0557 project 2 grid
3 MaxSharpe (Sample, BayesSteinMomentum) sharpe/frontier benchmark 0.1168 0.6553 0.7065 -0.1653 0.0186 0.2177 3.2046 project 2 grid
4 Learned-Confidence BL black-litterman benchmark 0.1253 0.7458 0.6888 -0.1820 0.0176 0.3147 5.2021 project 6 rebuild
5 Mean-CVaR 95 tail-risk allocation 0.0921 0.5609 0.6305 -0.1461 0.0145 0.3077 3.3662 Min-CVaR 95 vs Mean-CVaR 95
6 Risk Parity risk budget allocation 0.0453 0.1320 0.3295 -0.1374 0.0073 0.0131 5.9635 single specified model
7 HRP hierarchical allocation 0.0519 0.2547 0.3586 -0.1446 0.0075 0.0186 5.0967 HRP vs NCO-MV
8 Ellipsoid Robust MV robust mean-variance 0.1001 0.5533 0.6421 -0.1559 0.0174 0.3401 3.1440 Box Robust MV vs Ellipsoid Robust MV
9 Wasserstein DRMV distributionally robust allocation 0.1054 0.5743 0.5986 -0.1761 0.0184 0.3119 3.7144 single specified model

The final comparison brings the selected portfolios’ return, risk, turnover, and concentration measures together.

BL is the strongest all-around performer in this sample: 12.53% CAGR, 0.7458 Sharpe, 0.6888 Calmar. Its cost is turnover and meaningful tail risk. MaxSharpe is the best pure frontier-style baseline, with 11.68% CAGR and 0.7065 Calmar, but it has the highest ES among finalists.

Mean-CVaR earns a smaller 9.21% CAGR but gives a much better max drawdown than BL and a lower ES. That makes it the strongest tail-aware growth model. Risk Parity and HRP are defensive risk-control models. They reduce ES and drawdown, but their returns are too low to win in a risk-free-rate-aware Sharpe comparison.

Ellipsoid Robust MV and Wasserstein DRMV sit between MaxSharpe and Mean-CVaR. They preserve return better than the defensive models while adding uncertainty penalties to the optimizer. W-DRO is the stronger robust model by Sharpe and CAGR, while Ellipsoid has slightly better drawdown and Calmar.

This is the main result of Project 10: changing the risk definition changes the economic personality of the portfolio. Variance, tail loss, risk contribution, clustering, mean uncertainty, and distributional ambiguity all produce different portfolios, and those differences are visible in performance, drawdown, turnover, concentration, and sleeve exposure.

Min-CVaR, Risk Parity, and HRP produce the most defensive allocations in this sample. Their low tail losses and volatility come with lower realized returns than the more return-seeking models.

The models that use expected returns more aggressively, BL, MaxSharpe, robust MV, and W-DRO, perform better on CAGR and Sharpe. But their tails are larger and their turnover is higher. The successful models are the ones that balance a return engine with a risk-control mechanism:

  • BL uses Bayesian priors, learned confidence, constraints, and active views.
  • MaxSharpe uses the frontier but accepts concentration.
  • Mean-CVaR uses expected returns but caps the tail.
  • Robust MV uses expected returns but haircuts uncertainty.
  • W-DRO uses expected returns but taxes distributional ambiguity and concentration.

Together, these allocations connect classical mean-variance choices with tail budgets, risk contributions, and explicit uncertainty sets. Each objective rewards a different combination of return, diversification, and protection against adverse scenarios.

The observed tradeoffs depend on both the allocation choices and the sample period. The table keeps the realized return and risk measures next to each model’s portfolio-construction objective.

17) Sector ETF Repeat with the Library

We now repeat the model families on a sector ETF universe using the library. The sector opportunity set has a different structure from the cross-asset ETF universe used above.

The cross-asset universe contains equities, bonds, credit, gold, real estate, and commodities. Sector ETFs are all equity sectors. They share a common market beta, and diversification comes mostly from sector rotation rather than asset-class diversification.

The data is reproduced from the sector_etfs folder, with cross-asset signal data coming from core_cross_asset_etfs. We don’t link to direct generated files because the repository’s data layer is organized by source folders, each with its own reproducibility notes and scripts.

The sector tradable universe is:

\[ \{XLK, XLC, XLY, XLF, XLI, XLE, XLB, XLV, XLP, XLU, XLRE\} \]

The sector panel is aligned across the available ETF histories before we form monthly rebalance dates. XLC has a shorter history than several other sector funds, so the shared sample starts later than the cross-asset panel.

The secondary application uses the same broad workflow and performance measures.

The sector repeat uses the same model families, but the interpretation changes.

In the cross-asset universe, Risk Parity can diversify across stocks, bonds, credit, and real assets. In the sector universe, Risk Parity can only diversify across sectors that all still have equity beta. So risk control is less powerful. A defensive sector like XLP or XLV may fall less than XLK in a crash, but it is still an equity ETF.

The BL sector views are also sector-specific:

  • sector momentum,
  • growth leadership,
  • defensive rotation,
  • cyclical breadth,
  • credit beta,
  • inflation beneficiaries,
  • duration-sensitive sectors,
  • small-cap risk-on,
  • quality defensive,
  • sector reversal.

The sector BL benchmark is equal weight across sectors, and the views express relative sector opportunities. We can therefore examine the same view and confidence machinery in a sector-rotation setting with a shared equity-market exposure.

Each view family contributes candidate relative sector signals to the BL reference.

Sector ETFs also make the robust-model question harder. In the cross-asset universe, a robust model can move toward gold, commodities, short duration, or emerging markets when those exposures survive the uncertainty penalty. In sectors, all assets are still claims on US equity cash flows. Even Energy and Utilities are different sectors, not different asset classes.

This reduces the value of some risk models. Risk parity can lower sector concentration, but it can’t create a Treasury hedge. CVaR can favor defensive sectors, but those sectors can still fall with the market. Wasserstein DRO can penalize concentration, but if all sectors become correlated in a crash, the distributional ambiguity doesn’t magically create an uncorrelated asset.

At the same time, sector rotation is a better environment for MaxSharpe when the winning sectors have persistent leadership. Technology, Communication Services, Consumer Discretionary, and Energy can dominate certain periods, and a return-seeking optimizer can capture that leadership if the signal is stable enough.

The secondary application lets us compare the model families when diversification comes from intra-equity rotation rather than allocation across different asset classes.

The available sectors give the optimizers a common equity-market opportunity set.

Show code
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from cycler import cycler
from IPython.display import display

from quantfinlab.dataio import load_yfinance_panel
from quantfinlab.portfolio import prices_to_returns, make_rebalance_dates, selection
from quantfinlab.portfolio.walkforward import run_walkforward_grid, append_frontiergrid_from_best_maxsharpe
from quantfinlab.portfolio import black_litterman as bl
from quantfinlab.portfolio import views as bl_views
from quantfinlab.portfolio.cvar import min_cvar_weight_frame, mean_cvar_weight_frame, cvar_budget_path
from quantfinlab.portfolio.risk_parity import risk_parity_weight_frame, risk_contribution_table
from quantfinlab.portfolio.hrp import hrp_weight_frame, nco_mv_weight_frame, cluster_membership_table
from quantfinlab.portfolio.robust import robust_weight_frames, robust_radius_path
from quantfinlab.backtest.portfolio import run_many_weights_backtests
from quantfinlab.reports import risk_report
from quantfinlab.plotting.portfolio import (plot_average_weight_heatmap, plot_cvar_budget_path,
                                             plot_finalist_drawdowns, plot_finalist_metric_bar, plot_finalist_nav,
                                             plot_hrp_dendrogram, plot_nco_cluster_weights, plot_risk_contribution_bar,
                                             plot_risk_return_scatter, plot_sleeve_exposure_bar,
                                             plot_wasserstein_radius_path)

warnings.filterwarnings("ignore")

colors = [
    "#069AF3", "#FE420F", "#00008B", "#008080",
    "#CC79A7", "#DC143C", "#9614fa", "#0072B2",
    "#7BC8F6", "#04D8B2", "#800080", "#FF8072",
]

plt.rcParams["axes.prop_cycle"] = cycler(color=colors)
plt.rcParams.update({"figure.figsize": (7, 3.5), "figure.dpi": 140, "savefig.dpi": 250,
                     "axes.grid": True, "grid.alpha": 0.20, "axes.spines.top": False,
                     "axes.spines.right": False, "axes.titlesize": 10, "axes.labelsize": 9,
                     "xtick.labelsize": 8, "ytick.labelsize": 8, "legend.fontsize": 7})

start = "2018-07-01"
annualization = 252
rebal_freq = "ME"
cov_lookback = 504
mu_lookback = 252
ewma_lambda = 0.97
risk_free_rate_annual = 0.04
risk_free_daily = (1.0 + risk_free_rate_annual) ** (1.0 / annualization) - 1.0
cost_bps = 10.0
w_min = 0.0
w_max = 0.35
mv_lambda = 3.0
alt_mv_lambda = 3.0

assets = ["XLK", "XLC", "XLY", "XLF", "XLI", "XLE", "XLB", "XLV", "XLP", "XLU", "XLRE"]
signal_assets = ["SPY", "QQQ", "IWM", "TLT", "IEF", "SHY", "LQD", "HYG", "GLD", "DBC", "UUP"]

sleeve_map = {"XLK": "growth", "XLC": "growth", "XLY": "cyclical",
              "XLF": "cyclical", "XLI": "cyclical", "XLE": "cyclical",
              "XLB": "cyclical", "XLV": "defensive", "XLP": "defensive",
              "XLU": "defensive", "XLRE": "rate_sensitive"}

label_map = {"Equal Weight": "EW", "MinVar": "MinVar", "MV": "MV",
             "MaxSharpe": "MaxSharpe", "Learned-Confidence BL": "BL", "Min-CVaR 95": "Min-CVaR",
             "Mean-CVaR 95": "Mean-CVaR", "Risk Parity": "RP", "HRP": "HRP",
             "NCO-MV": "NCO-MV", "Box Robust MV": "Box-Robust", "Ellipsoid Robust MV": "Ellipsoid",
             "Wasserstein DRMV": "W-DRO"}

close_all = load_yfinance_panel(["../data/sector_etfs.csv", "../data/core_cross_asset_etfs.csv"], fields=("close",),
                                lowercase=False, source="yfinance_export")["close"]
available_assets = [ticker for ticker in assets if ticker in close_all.columns]
available_signals = [ticker for ticker in signal_assets if ticker in close_all.columns]
if len(available_assets) != len(assets):
    raise ValueError(f"missing sector ETF data: {sorted(set(assets) - set(available_assets))}")

close = close_all.loc[pd.Timestamp(start):, assets].ffill(limit=3).dropna(how="any")
returns = prices_to_returns(close, kind="simple").replace([np.inf, -np.inf], np.nan).dropna(how="all").fillna(0.0)
close = close.reindex(returns.index).ffill(limit=3)

signal_tickers = list(dict.fromkeys(assets + available_signals))
signal_close = close_all.loc[returns.index.min():, signal_tickers].ffill(limit=3).reindex(returns.index).ffill(limit=3)
signal_returns = prices_to_returns(signal_close, kind="simple").replace([np.inf, -np.inf], np.nan).reindex(returns.index).fillna(0.0)

rebalance_dates = make_rebalance_dates(returns.index, freq=rebal_freq,
                                       min_history_days=max(cov_lookback, mu_lookback))

universe_by_date = {
    pd.Timestamp(dt): {"tickers": list(assets), "avg_dollar_volume": pd.Series(1.0, index=assets)}
    for dt in rebalance_dates
}

print("sector returns:", returns.shape, returns.index.min().date(), "to", returns.index.max().date())
print("rebalance dates:", len(rebalance_dates), rebalance_dates[0].date(), "to", rebalance_dates[-1].date())

grid = run_walkforward_grid(
    returns=returns,
    close=close,
    rebalance_dates=rebalance_dates,
    universe_by_date=universe_by_date,
    cov_lookback=cov_lookback,
    mu_lookback=mu_lookback,
    min_cov_observations=cov_lookback - 1,
    min_mu_observations=mu_lookback - 1,
    max_weight=w_max,
    min_weight=w_min,
    long_only=True,
    trading_cost_bps=cost_bps,
    turnover_penalty_bps=cost_bps,
    fallback="equal",
    rf_daily=risk_free_daily,
    annualization=annualization,
    ewma_lambda=ewma_lambda,
    optimizer_params={"MV": {"mv_lambda": mv_lambda}, "RidgeMV": {"mv_lambda": mv_lambda},
                      "FrontierGrid": {"grid_n": 15}},
)

grid = append_frontiergrid_from_best_maxsharpe(grid, metric="Sharpe", grid_n=25)
grid_summary = grid.results.replace([np.inf, -np.inf], np.nan)

equal_grid_name = "EW" if "EW" in grid.backtests else str(grid_summary.index[grid_summary["Optimizer"].eq("EW")][0])
minvar_name = str(grid_summary.loc[grid_summary["Optimizer"].eq("MinVar")].dropna(subset=["Sharpe"]).sort_values(["Sharpe",
    "Max Drawdown", "Turnover", "CAGR"], ascending=[False, False, True, False]).index[0])
mv_name = str(grid_summary.loc[grid_summary["Optimizer"].eq("MV")].dropna(subset=["Sharpe"]).sort_values(["Sharpe",
    "Max Drawdown", "Turnover", "CAGR"], ascending=[False, False, True, False]).index[0])
maxsharpe_candidates = grid_summary.loc[grid_summary["Optimizer"].eq("MaxSharpe")].dropna(subset=["Sharpe"])
if maxsharpe_candidates.empty:
    maxsharpe_candidates = grid_summary.loc[grid_summary["Optimizer"].eq("FrontierGrid")].dropna(subset=["Sharpe"])
maxsharpe_name = str(maxsharpe_candidates.sort_values(["Sharpe", "Max Drawdown", "Turnover", "CAGR"], ascending=[False,
    False, True, False]).index[0])

_, _, minvar_cov = selection.parse_strategy_spec(minvar_name, grid.backtests[minvar_name])
_, mv_mu, mv_cov = selection.parse_strategy_spec(mv_name, grid.backtests[mv_name])
_, maxsharpe_mu, maxsharpe_cov = selection.parse_strategy_spec(maxsharpe_name, grid.backtests[maxsharpe_name])

robust_cov = "EWMA" if "EWMA" in grid.metadata.get("cov_models", []) else maxsharpe_cov
robust_mu = "BayesStein" if "BayesStein" in grid.metadata.get("mu_models", []) else maxsharpe_mu
robust_box_radius = 0.10
robust_ellipsoid_radius = 0.05
robust_wasserstein_radius = 2.0
robust_mv_lambda = 1.5
robust_wasserstein_lambda = 1.0

print("selected grid models:", {"MinVar": minvar_name, "MV": mv_name, "MaxSharpe": maxsharpe_name})
print("selected robust models:", {"cov": robust_cov, "mu": robust_mu, "lambda": robust_mv_lambda,
                                  "wasserstein_lambda": robust_wasserstein_lambda, "box_radius": robust_box_radius,
                                  "ellipsoid_radius": robust_ellipsoid_radius,
                                  "wasserstein_radius": robust_wasserstein_radius})

benchmark_w = pd.Series(1.0 / len(assets), index=assets, dtype=float)
roles = {"assets": assets, "growth": ["XLK", "XLC", "XLY"], "defensive": ["XLP", "XLU", "XLV"],
         "cyclical": ["XLI", "XLF", "XLB", "XLE"], "inflation_beneficiaries": ["XLE", "XLB"],
         "rate_sensitive": ["XLU", "XLV", "XLRE"], "credit_sensitive": ["XLF", "XLY", "XLI"],
         "quality_defensive": ["XLV", "XLP"], "small_cap_sensitive": ["XLI", "XLF", "XLY"], "risky": assets,
         "sleeve_map": {ticker: "Sector" for ticker in assets}}
family_caps = {"sector_momentum": 0.08, "growth_leadership": 0.06, "defensive_rotation": 0.06,
               "cyclical_breadth": 0.80, "credit_beta": 0.07, "inflation_beneficiaries": 0.06,
               "duration_sensitive": 0.06, "small_cap_risk_on": 0.08, "quality_defensive": 0.05,
               "sector_reversal": 0.08}

view_settings = bl_views.ViewSettings(family_q_caps=family_caps,
                                      family_display_names=bl_views.DEFAULT_SECTOR_DISPLAY_NAMES, assets=assets,
                                      entry_z=0.50, q_strength_scale=1.25, annualization=annualization,
                                      view_horizon_days=21)

bl_settings = bl.BLSettings(cov_lookback=504, mu_lookback=252, risk_free_rate_annual=risk_free_rate_annual,
                            transaction_cost_bps=cost_bps, tau=0.05, ewma_lambda=ewma_lambda,
                            max_weight=0.35, active_weight_limit=0.15, active_weight_relaxed=0.22,
                            max_selected_views=7, redundancy_similarity=0.85, max_same_direction=5)

view_functions = (
    bl_views.sector_momentum,
    bl_views.growth_leadership,
    bl_views.defensive_rotation,
    bl_views.cyclical_breadth,
    bl_views.credit_beta,
    bl_views.inflation_beneficiaries,
    bl_views.duration_sensitive,
    bl_views.small_cap_risk_on,
    bl_views.quality_defensive,
    bl_views.sector_reversal,
)

asset_caps = pd.Series(bl_settings.max_weight, index=assets, dtype=float)
bl_run = bl.learned_confidence_bl(returns=returns, signal_returns=signal_returns, rebalance_dates=rebalance_dates,
                                  benchmark_weights=benchmark_w, roles=roles, view_functions=view_functions,
                                  view_settings=view_settings, settings=bl_settings,
                                  constraints={"asset_caps": asset_caps})

bl_result = run_many_weights_backtests({"Learned-Confidence BL": bl_run.weights}, returns=returns, cost_bps=cost_bps,
                                       rf_daily=risk_free_daily, w_min=bl_settings.min_weight,
                                       w_max=bl_settings.max_weight, long_only=True, normalize=True,
                                       weight_timing="next_close")["Learned-Confidence BL"]

print("BL view rows:", len(bl_run.candidate_view_log), "candidate,", len(bl_run.selected_view_log), "selected")

benchmark_results = {"Equal Weight": grid.backtests[equal_grid_name], "MinVar": grid.backtests[minvar_name],
                     "MV": grid.backtests[mv_name], "MaxSharpe": grid.backtests[maxsharpe_name],
                     "Learned-Confidence BL": bl_result}

benchmark_table = selection.build_strategy_summary(benchmark_results, rf_daily=risk_free_daily,
                                                   annualization=annualization)
display(benchmark_table[["CAGR", "Vol", "Sharpe", "Sortino", "Calmar", "Max Drawdown", "Turnover", "Effective N"]].round(4))

weights_min_cvar = min_cvar_weight_frame(cache=grid.cache, rebalance_dates=rebalance_dates, cov_model=minvar_cov,
                                         alpha=0.95, w_min=w_min, w_max=w_max)

weights_mean_cvar = mean_cvar_weight_frame(cache=grid.cache, rebalance_dates=rebalance_dates, cov_model=maxsharpe_cov,
                                           mu_model=maxsharpe_mu, reference="equal", alpha=0.95,
                                           budget_scale=0.90, w_min=w_min, w_max=w_max)

weights_rp = risk_parity_weight_frame(cache=grid.cache, rebalance_dates=rebalance_dates, cov_model=minvar_cov,
                                      w_min=w_min, w_max=w_max)

weights_hrp = hrp_weight_frame(cache=grid.cache, rebalance_dates=rebalance_dates, cov_model=minvar_cov,
                               linkage_method="average", w_min=w_min, w_max=w_max)

weights_nco = nco_mv_weight_frame(cache=grid.cache, rebalance_dates=rebalance_dates, cov_model=maxsharpe_cov,
                                  mu_model=maxsharpe_mu, n_clusters=3, inner_lambda=alt_mv_lambda,
                                  outer_lambda=alt_mv_lambda, cluster_cap=0.75, linkage_method="average",
                                  w_min=w_min, w_max=w_max)

robust_weights = robust_weight_frames(cache=grid.cache, rebalance_dates=rebalance_dates, cov_model=robust_cov,
                                      mu_model=robust_mu, box_radius=robust_box_radius,
                                      ellipsoid_radius=robust_ellipsoid_radius,
                                      wasserstein_radius=robust_wasserstein_radius, mv_lambda=robust_mv_lambda,
                                      wasserstein_mv_lambda=robust_wasserstein_lambda, w_min=w_min, w_max=w_max)
weights_box = robust_weights["Box Robust MV"]
weights_ellipsoid = robust_weights["Ellipsoid Robust MV"]
weights_wasserstein = robust_weights["Wasserstein DRMV"]

weights_by_name = {"Min-CVaR 95": weights_min_cvar, "Mean-CVaR 95": weights_mean_cvar, "Risk Parity": weights_rp,
                   "HRP": weights_hrp, "NCO-MV": weights_nco, "Box Robust MV": weights_box,
                   "Ellipsoid Robust MV": weights_ellipsoid, "Wasserstein DRMV": weights_wasserstein}

results = dict(benchmark_results)
results.update(
    run_many_weights_backtests(weights_by_name, returns=returns, cost_bps=cost_bps,
                               rf_daily=risk_free_daily, w_min=w_min, w_max=w_max,
                               long_only=True, normalize=True, weight_timing="next_close")
)

summary = selection.build_strategy_summary(results, rf_daily=risk_free_daily,
    annualization=annualization).sort_values(["Sharpe", "Calmar", "CAGR"], ascending=[False, False, False])

best_cvar = summary.loc[["Min-CVaR 95", "Mean-CVaR 95"]].sort_values(["Sharpe", "Calmar", "CAGR"], ascending=[False, False, False]).index[0]
best_hier = summary.loc[["HRP", "NCO-MV"]].sort_values(["Sharpe", "Calmar", "CAGR"], ascending=[False, False, False]).index[0]
best_robust = summary.loc[["Box Robust MV", "Ellipsoid Robust MV"]].sort_values(["Sharpe", "Calmar", "CAGR"],
    ascending=[False, False, False]).index[0]

finalists = list(
    dict.fromkeys(
        [
            "Equal Weight",
            "MinVar",
            "MV",
            "MaxSharpe",
            "Learned-Confidence BL",
            best_cvar,
            "Risk Parity",
            best_hier,
            best_robust,
            "Wasserstein DRMV",
        ]
    )
)

final_table = summary.loc[finalists, ["CAGR", "Vol", "Sharpe", "Sortino", "Calmar", "Max Drawdown", "Turnover", "Effective N"]]
display(final_table.round(4))

nav = pd.concat({name: results[name].net_values for name in finalists}, axis=1)
returns_final = pd.concat({name: results[name].net_returns for name in finalists}, axis=1).dropna(how="all")
weights_final = {name: results[name].weights for name in finalists}

latest_dt = pd.Timestamp(weights_wasserstein.index[-1])
latest_state = grid.cache[latest_dt]
latest_tickers = list(latest_state["tickers"])
latest_cov = pd.DataFrame(latest_state["cov_ann_map"][maxsharpe_cov], index=latest_tickers, columns=latest_tickers)
latest_mu = latest_state["mu_ann_map"][maxsharpe_cov][maxsharpe_mu].reindex(latest_tickers).fillna(0.0)
latest_robust_cov = pd.DataFrame(latest_state["cov_ann_map"][robust_cov], index=latest_tickers, columns=latest_tickers)
latest_robust_mu = latest_state["mu_ann_map"][robust_cov][robust_mu].reindex(latest_tickers).fillna(0.0)

cvar_path = cvar_budget_path(returns=latest_state["R_cov"].reindex(columns=latest_tickers), mu_ann=latest_mu,
                             budget_scales=[0.80, 0.90, 1.00, 1.10, 1.25], alpha=0.95, w_min=w_min,
                             w_max=w_max)

rp_table = risk_contribution_table(
    weights=weights_rp.iloc[-1].reindex(latest_tickers).fillna(0.0),
    cov_ann=latest_state["cov_ann_map"][minvar_cov],
)

cluster_table = cluster_membership_table(cov_ann=latest_state["cov_ann_map"][maxsharpe_cov], tickers=latest_tickers,
                                         n_clusters=3, linkage_method="average")

wasserstein_path = robust_radius_path(model="wasserstein", mu_ann=latest_robust_mu, cov_ann=latest_robust_cov,
                                      n_mu_obs=len(latest_state["R_mu"]),
                                      radii=[0.0, 0.05, 0.10, 0.25, 0.50, 1.00, 2.00, 5.00],
                                      mv_lambda=robust_wasserstein_lambda, w_min=w_min, w_max=w_max)

fig, axes = plt.subplots(3, 4, figsize=(20, 14))
axes = axes.ravel()

plot_cvar_budget_path(cvar_path, ax=axes[0], title="Mean-CVaR budget path")
plot_risk_contribution_bar(rp_table, ax=axes[1], title="Risk Parity risk contribution")
plot_hrp_dendrogram(latest_state["cov_ann_map"][maxsharpe_cov], labels=latest_tickers, linkage_method="average",
                    ax=axes[2], title="sector cluster tree")
plot_nco_cluster_weights(cluster_table, weights_nco.iloc[-1], ax=axes[3], title="NCO-MV cluster allocation")
plot_finalist_nav(nav, strategies=finalists, ax=axes[4], title="net wealth", short_labels=label_map)
plot_finalist_drawdowns(nav, strategies=finalists, ax=axes[5], title="drawdowns", short_labels=label_map)
plot_wasserstein_radius_path(wasserstein_path, ax=axes[6], title="Wasserstein radius path")
plot_average_weight_heatmap(weights_final, ax=axes[7], title="average finalist weights", short_labels=label_map)
plot_sleeve_exposure_bar(weights_final, sleeve_map, ax=axes[8], title="average sleeve exposure", short_labels=label_map)
plot_risk_return_scatter(final_table, ax=axes[9], title="risk-return",
                         risk_col="Vol", return_col="CAGR", color_col="Sharpe",
                         short_labels=label_map)
plot_finalist_metric_bar(final_table, metric="Sharpe", ax=axes[10], title="Sharpe", short_labels=label_map)
plot_finalist_metric_bar(final_table, metric="Max Drawdown", ax=axes[11], title="max drawdown", short_labels=label_map)

plt.tight_layout()
plt.show()

stress_windows = {"2018_q4": ("2018-10-01", "2018-12-31"), "covid_crash_2020": ("2020-02-19", "2020-03-23"),
                  "inflation_rate_shock_2022": ("2022-01-03", "2022-10-14")}

if returns_final.index.max() >= pd.Timestamp("2025-01-01"):
    stress_windows["recent_2025_2026"] = ("2025-01-01", str(returns_final.index.max().date()))

market_ret = (signal_returns["SPY"].reindex(returns_final.index) if "SPY" in signal_returns.columns
              else returns.mean(axis=1).reindex(returns_final.index))

report = risk_report(
    objects={name: returns_final[name].dropna() for name in finalists},
    market_ret=market_ret,
    rf_daily=risk_free_daily,
    include={"performance_tables": False, "shape_tables": False, "drawdowns": False,
             "drawdown_episodes": False, "var_es": True, "var_backtest": True,
             "stress": True, "capm": True, "rolling_beta": True,
             "correlation": False, "attribution": False, "exec_bullets": False},
    var_settings={"alpha": 0.05, "methods": ["hist", "cf", "fhs"]},
    backtest_settings={"alpha": 0.05, "methods": ["hist", "cf", "fhs"], "lookback": 252, "plot_method": "best"},
    rolling_settings={"vol_windows": [252], "beta_windows": [252]},
    stress_settings={"windows": stress_windows, "worst_only": False},
    layout={"ncols": 5, "sharex": True, "sharey": False},
    output={"display_table_keys": ["var_es"], "round_tables": 4, "print_exec_bullets": False,
            "show_figures": False, "short_labels": True, "label_max_len": 12,
            "label_map": label_map},
)

for key in ["var_backtest", "stress", "rolling_beta"]:
    for fig in report.figures.get(key, []):
        display(fig)
        plt.close(fig)
sector returns: (2000, 11) 2018-07-03 to 2026-06-17
rebalance dates: 72 2020-07-31 to 2026-06-17
selected grid models: {'MinVar': 'MinVar (LedoitWolf)', 'MV': 'MV (EWMA, BayesStein)', 'MaxSharpe': 'MaxSharpe (Sample, BayesStein)'}
selected robust models: {'cov': 'EWMA', 'mu': 'BayesStein', 'lambda': 1.5, 'wasserstein_lambda': 1.0, 'box_radius': 0.1, 'ellipsoid_radius': 0.05, 'wasserstein_radius': 2.0}
BL view rows: 310 candidate, 280 selected
CAGR Vol Sharpe Sortino Calmar Max Drawdown Turnover Effective N
Strategy
Equal Weight 0.1471 0.1475 0.7352 1.0058 0.7650 -0.1923 0.0136 11.0000
MinVar 0.0961 0.1283 0.4729 0.6577 0.5150 -0.1867 0.0069 5.2647
MV 0.1457 0.1522 0.7049 0.9502 0.9112 -0.1599 0.1008 3.6762
MaxSharpe 0.1809 0.1626 0.8550 1.1656 1.0872 -0.1664 0.1729 3.2425
Learned-Confidence BL 0.1519 0.1522 0.7497 1.0631 0.9169 -0.1657 0.4647 5.0931
CAGR Vol Sharpe Sortino Calmar Max Drawdown Turnover Effective N
Strategy
Equal Weight 0.1471 0.1475 0.7352 1.0058 0.7650 -0.1923 0.0136 11.0000
MinVar 0.0961 0.1283 0.4729 0.6577 0.5150 -0.1867 0.0069 5.2647
MV 0.1457 0.1522 0.7049 0.9502 0.9112 -0.1599 0.1008 3.6762
MaxSharpe 0.1809 0.1626 0.8550 1.1656 1.0872 -0.1664 0.1729 3.2425
Learned-Confidence BL 0.1519 0.1522 0.7497 1.0631 0.9169 -0.1657 0.4647 5.0931
Mean-CVaR 95 0.0979 0.1332 0.4601 0.6322 0.6194 -0.1581 0.2246 3.7279
Risk Parity 0.1316 0.1414 0.6681 0.9119 0.6791 -0.1938 0.0209 10.4816
NCO-MV 0.1309 0.1421 0.6568 0.9132 0.7840 -0.1670 0.1699 5.1084
Ellipsoid Robust MV 0.1764 0.1608 0.8355 1.1300 1.0334 -0.1707 0.2515 3.1838
Wasserstein DRMV 0.1803 0.1610 0.8550 1.1614 1.1295 -0.1596 0.2205 3.6690

hist_var5 hist_es5 cf_var5 cf_es5 fhs_var5 fhs_es5
object
BL 0.0149 0.0219 0.0150 0.0246 0.0127 0.0186
EW 0.0141 0.0214 0.0139 0.0268 0.0102 0.0143
Ellipsoid 0.0159 0.0237 0.0164 0.0270 0.0178 0.0263
MV 0.0151 0.0224 0.0153 0.0266 0.0153 0.0228
MaxSharpe 0.0162 0.0237 0.0164 0.0269 0.0168 0.0243
Mean-CVaR 0.0132 0.0195 0.0135 0.0229 0.0088 0.0128
MinVar 0.0124 0.0186 0.0128 0.0225 0.0098 0.0141
NCO-MV 0.0142 0.0203 0.0139 0.0240 0.0145 0.0204
RP 0.0136 0.0206 0.0135 0.0257 0.0089 0.0129
W-DRO 0.0164 0.0236 0.0163 0.0268 0.0178 0.0264

The aligned sector sample has 2,000 return rows from 2018-07-03, with monthly rebalances from 2020-07-31 to 2026-06-17. The BL run generates 310 candidate views and 280 selected views.

The sector benchmark returns are strong over this sample. Equal Weight earns 14.71% CAGR with 14.75% volatility and 0.7352 Sharpe. MaxSharpe is the strongest model among the first five benchmarks: 18.09% CAGR, 16.26% volatility, 0.8550 Sharpe, and 1.0872 Calmar.

BL improves over equal weight, but doesn’t beat MaxSharpe:

\[ \text{BL CAGR}=15.19\%,\qquad \text{BL Sharpe}=0.7497 \]

Its turnover is very high at 46.47% average monthly. The sector views move allocations frequently, so the benefit of the rotations needs to be evaluated alongside their trading costs and shared equity-market exposure.

MV has a strong Calmar of 0.9112 and a max drawdown of -15.99%, better than Equal Weight’s -19.23%. MinVar lowers volatility but gives up too much return, similar to the main cross-asset application. This benchmark-only table is important because the robust and tail-risk models need to beat a serious sector MaxSharpe baseline, not just Equal Weight.

After adding the new Project 10 models, the sector finalist table changes materially. Wasserstein DRMV, now run with the sector-specific EWMA covariance and BayesStein expected-return inputs, is essentially tied with MaxSharpe by Sharpe and is better on Calmar and drawdown.

The sector results are:

  • MaxSharpe: 18.09% CAGR, 0.8550 Sharpe, 1.0872 Calmar.
  • Wasserstein DRMV: 18.03% CAGR, 0.8550 Sharpe, 1.1295 Calmar.
  • Ellipsoid Robust MV: 17.64% CAGR, 0.8355 Sharpe, 1.0334 Calmar.
  • Learned-Confidence BL: 15.19% CAGR, 0.7497 Sharpe.
  • Equal Weight: 14.71% CAGR, 0.7352 Sharpe.
  • MV: 14.57% CAGR, 0.7049 Sharpe.
  • NCO-MV: 13.09% CAGR, 0.6568 Sharpe.
  • Risk Parity: 13.16% CAGR, 0.6681 Sharpe.
  • Mean-CVaR 95: 9.79% CAGR, 0.4601 Sharpe.

With the sector-specific EWMA + BayesStein inputs, W-DRO is competitive with the best sector MaxSharpe model and holds a slightly more diversified average portfolio: effective \(N\) is 3.67 for W-DRO versus 3.24 for MaxSharpe. The cost is turnover, 22.05% versus MaxSharpe’s 17.29%, but that is still far below BL’s 46.47%.

The sector W-DRO run uses radius 2.0 and a Wasserstein risk-aversion parameter of 1.0 alongside the sector-specific covariance and return estimates. Those settings are shown in the final library implementation.

W-DRO is one of the strongest return-adjusted sector models in this sample, while the lowest historical ES belongs to MinVar. The W-DRO objective balances estimated return with distributionally adjusted risk; Mean-CVaR and MinVar use different objectives.

Historical ES values:

  • MinVar: 1.86%.
  • Mean-CVaR: 1.95%.
  • NCO-MV: 2.03%.
  • Risk Parity: 2.06%.
  • Equal Weight: 2.14%.
  • BL: 2.19%.
  • MV: 2.24%.
  • W-DRO: 2.36%.
  • MaxSharpe: 2.37%.
  • Ellipsoid Robust MV: 2.37%.

The filtered historical simulation ES ranking is even clearer. Mean-CVaR and Risk Parity are around 1.28%-1.29%, MinVar and Equal Weight are around 1.41%-1.43%, while W-DRO and Ellipsoid are around 2.64% and 2.63%. So the robust return-seeking models earn their higher Sharpe by accepting larger scaled tail exposure than the defensive models.

The range is narrower than in the cross-asset case because all sector ETFs still share equity beta. Sector rotation can reduce sector-specific drawdown, but it can’t fully escape market shocks. The plots show this: sector models differ in wealth paths and drawdowns, but they stay much more correlated than cross-asset models.

The ES estimates compare the finalist return histories on a consistent basis.

The sector stress and rolling-beta plots need to be read with this common-beta structure in mind. In a cross-asset universe, a model can reduce equity beta by moving into duration, cash-like bonds, gold, or commodities. In a sector universe, the model can only choose which kind of equity beta it wants. That means sector tail control usually comes from avoiding the worst sector leadership, not from escaping the equity market.

Sector selection has more room to help during 2022 than during the initial COVID crash. The 2022 environment had clear cross-sectional sector dispersion: Energy benefited from the inflation and commodity shock, defensive sectors held up better than long-duration growth, and rate-sensitive sectors struggled. A sector allocation model can exploit that dispersion. During COVID’s initial crash, sector dispersion existed, but the first-order move was broad market deleveraging. In that type of shock, all sector ETFs drop together and the model has less room to defend.

This is why sector Mean-CVaR has lower ES than the active return-seeking models but doesn’t become dramatically safer. Its historical ES is 1.95%, better than BL, W-DRO, MaxSharpe, and Ellipsoid, but still far above the defensive tail levels seen in the cross-asset Risk Parity and HRP portfolios. The opportunity set defines the achievable tail reduction.

The sector rolling-beta plots also help diagnose model behavior. A sector strategy can lower beta by leaning toward Staples, Health Care, and Utilities, or increase beta by leaning toward Technology, Consumer Discretionary, Financials, Industrials, and Energy depending on the regime. W-DRO’s better full-period drawdown comes from this rotation plus the Wasserstein diversification penalty, not from eliminating equity exposure. Beta rarely collapses toward zero because every tradable asset remains inside the equity market.

In the sector diagnostic panel, we can compare how the model tools behave in an equity-only universe:

  • The Mean-CVaR budget path still shows the return-tail tradeoff.
  • Risk Parity spreads risk more evenly across sectors than equal capital weighting.
  • The sector cluster tree separates growth, defensive, cyclicals, and rate-sensitive groups.
  • NCO-MV turns those clusters into a nested allocation.
  • The Wasserstein radius path shows robust diversification pressure as ambiguity increases.
  • Average weight heatmaps reveal which sector exposures each model repeatedly favors.

This secondary run is a good stress test of portability. The library functions work, the same model families run, and the comparison metrics are consistent. But the estimation recipe has to change with the universe. Cross-asset allocation can use structurally different risk premia. Sector allocation mostly chooses different expressions of equity beta, so the model benefits from a faster covariance estimate and a shrunk expected-return estimate.

The Wasserstein radius path is especially useful after the retune. Radius 2.0 produces almost the same Sharpe as the more aggressive low-radius sector W-DRO setting, but with better drawdown, lower turnover, and a higher effective \(N\). That is a good robust result: the ambiguity set is not just weakening the optimizer; it is buying a more stable version of a high-Sharpe sector allocation.

The sector plots also show a useful implementation lesson. The same library functions produce a consistent report, but the interpretation has to change with the asset universe.

The average-weight heatmap is especially useful here. In cross-asset allocation, seeing a large weight in SHY, DBC, GLD, or EEM tells us the model is making an asset-class decision. In sectors, seeing a large weight in XLK, XLE, XLV, or XLP tells us the model is making a style and macro sensitivity decision. XLK/XLC/XLY usually express growth and long-duration equity exposure. XLE/XLB express inflation and commodity sensitivity. XLV/XLP express quality/defensive cash-flow exposure. XLF/XLI express cyclicality and credit/economic acceleration.

In the sector version, allocations choose among different types of equity risk. MaxSharpe and W-DRO both lead in the sector run, despite placing different penalties on concentrated positions. MaxSharpe captures persistent sector leadership directly. W-DRO captures much of the same leadership, but taxes concentration and distributional ambiguity, which gives it slightly lower CAGR but better Calmar, drawdown, and diversification.

The two applications use the same robust objectives with inputs suited to each universe: LedoitWolf + Momentum for the cross-asset robust estimates and EWMA + BayesStein for the sector run. The comparison lets us see how the objective behaves when both the opportunity set and the return/covariance estimates change.

The sector EWMA covariance puts more weight on recent observations, while the Bayes–Stein return estimate shrinks sample excess means before scaling the expected-return signal. Those inputs enter the same robust allocation problem on the sector opportunity set.