In this project we move from mean-variance portfolio construction into three families of portfolio models that care about risk in different ways:
Tail-risk models, where the objective is based on VaR/CVaR rather than variance.
Parity and hierarchical models, where the allocation is driven by risk contribution and correlation structure.
Robust models, where we admit that expected returns and distributions are uncertain and build portfolios that survive estimation error.
Wasserstein distributionally robust optimization, where the model protects against nearby alternative return distributions rather than trusting one sample.
Full model comparison, where return, volatility, drawdown, expected shortfall, turnover, concentration, and stress behavior are compared together.
A secondary sector ETF application, where we repeat the same model families on a more equity-like universe using the library workflow.
The important idea is that portfolio optimization isn’t one problem. It’s a set of related problems with different definitions of risk. Mean-variance says risk is covariance. CVaR says risk is the average loss in the worst tail. Risk parity says risk is the amount each asset contributes to total portfolio volatility. Robust optimization says risk also comes from being wrong about the inputs. This project puts those definitions next to each other and lets the backtest show what each one buys and what it costs.
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
Most portfolio formulas look like they are optimizing weights, but they are really optimizing a translation of beliefs into 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”.
That distinction helps later when two models hold similar assets for different reasons. For example, Mean-CVaR and Wasserstein DRMV can both like GLD or DBC in a certain window. Mean-CVaR may like them because they help satisfy the historical tail-loss budget. Wasserstein DRMV may like them because their robust return still survives the distributional penalty. The weights can look similar, but the explanation behind those weights is different.
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.
1) The Portfolio Problem After Mean-Variance
We already used mean-variance models earlier, so we don’t need to rebuild that whole theory again. The useful starting point here is the weakness that kept showing up: most portfolio models are extremely sensitive to the input estimates.
A portfolio optimizer receives three kinds of information:
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.
The current project keeps the repeated data and baseline machinery short and spends the detail on the new 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. Here they serve as reference portfolios, not as the main teaching topic.
We start by loading the project environment, imports, plotting settings, and the cross-asset ETF panel. There isn’t much theory in this cell. The important thing is that the later models depend on cvxpy for convex programs, SciPy clustering for hierarchical allocation, and the quantfinlab portfolio/risk-reporting utilities for consistent backtesting.
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.
After that compression, the optimizer no longer sees individual crisis days. It only sees average return and covariance. CVaR works differently. It keeps the scenario-level losses:
\[
\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. This is exactly the kind of distinction that matters when we compare Mean-CVaR against MV and MaxSharpe later.
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 notin close_after_start.columns]if missing_assets:raiseValueError(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):raiseValueError("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)
The 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.
This is a clean setup for a portfolio model comparison. The panel has enough history to estimate multi-year covariance matrices, it spans calm markets, the COVID crash, the 2022 inflation/rate shock, and the 2023-2026 growth rebound. Those environments are important because the new models aren’t meant to look good only in average conditions. CVaR and robust models are especially useful when the average market environment is no longer representative of the next month.
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.
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
gold, real estate, commodities, inflation/real-asset exposure
We don’t re-explain every ETF here because the data and economic role of this universe were already discussed in the earlier portfolio notebooks. For Project 10, the key point is how these sleeves behave under different risk definitions.
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 asset-specific cap. These caps are essential. Without caps, many of the optimizers would collapse into one or two assets because the sample makes some assets look much better or much safer than the others.
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 confirms the investable set and caps. SPY and QQQ can reach 40%, several equity and diversifier ETFs have 30-35% caps, and safer bond-like instruments have lower or moderate caps depending on their role. SHY is capped at 15%, which is important because a pure variance minimizer would otherwise lean too heavily into it.
The benchmark table gives the strategic reference weights. 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%. This benchmark isn’t the object being optimized in the new models, but it gives useful context for later comparisons. 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. This is a good compromise: the window is long enough to estimate a stable covariance matrix and tail distribution, but short enough to adapt after large market structure changes.
We then rebuild the baseline grid. This is deliberately short because these models were already taught earlier. The grid searches through covariance and expected-return combinations, then selects:
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.
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 point isn’t to teach these again. The point is to keep them in the comparison because the new models need a serious benchmark. If a CVaR or robust model can’t beat a simple MaxSharpe or BL baseline on a useful metric, then the more complex objective needs to justify itself through lower drawdowns, lower tail loss, lower turnover, or better stability.
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)
The baseline table already gives a useful warning. 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%.
So the baseline problem is already visible:
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.
The new models will mostly try to control this exact tradeoff.
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.
The BL model starts from an equilibrium prior, generates active relative views, learns view confidence from realized payoff history, and converts the posterior return vector into a constrained portfolio. In this project, it gives us a strong “active but structured” benchmark.
The reason BL is important here is that the new models are not only competing against classical MV. They’re competing against a model that already tries to solve one of MV’s biggest weaknesses: the instability of raw expected returns. If a robust optimizer beats simple MV but loses to BL, that still tells us something. It means robustness helps relative to naive estimation, but perhaps not relative to a structured Bayesian view process.
The BL rebuild generates 544 candidate view rows, 538 selected view rows, 538 confidence rows, and 125 posterior rows. That confirms the BL baseline is active through almost the full monthly period.
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.
This sets the bar high. A new model doesn’t need to beat BL on every metric, but it has to show a clear role. 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.
That is the comparison logic we use for the rest of the project.
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_resultdef 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) ifnot result.weights.empty else pd.DataFrame(columns=assets) rows.append({"model": name, "avg_max_weight": float(attribution.max_weight(weights).mean()) ifnot weights.empty else np.nan}) extra = pd.DataFrame(rows).set_index("model") if rows else pd.DataFrame()ifnot 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 the new models start, we define a few shared optimization utilities. This part isn’t the center of the project, but it matters because the objective functions below are only useful if the optimizer returns valid portfolios.
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:
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.
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.Treturn0.5* (out + out.T)def cap_array(index, max_weight=None): idx = pd.Index(index)if max_weight isNone:return pd.Series(float(w_max), index=idx, dtype=float)ifisinstance(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)iffloat(cap.sum()) <1.0-1e-10: cap = pd.Series(max(float(w_max), 1.0/len(idx)), index=idx, dtype=float)return capdef 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)iffloat(w.sum()) <=1e-12: w = pd.Series(1.0/len(idx), index=idx, dtype=float)else: w = w /float(w.sum())for _ inrange(50): over = w > caps +1e-12ifnotbool(over.any()):break extra =float((w[over] - caps[over]).sum()) w[over] = caps[over] room = (caps[~over] - w[~over]).clip(lower=0.0)iffloat(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()) iffloat(w.sum()) >1e-12else 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 notin installed:continuetry: problem.solve(solver=solver, verbose=False)if problem.status in ["optimal", "optimal_inaccurate"]:return problem.statusexceptException:passreturnstr(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 isNone: 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_countreturn 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
The cache-key output shows the walk-forward engine stores the pieces every model needs at each rebalance date:
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.
This is important because all Project 10 models run on the same walk-forward information set. A CVaR model doesn’t get future returns. A robust model doesn’t get future realized volatility. A risk-parity model doesn’t get to know which period is coming next. Each rebalance sees only the historical window up to that date, produces weights, and then those weights are tested out-of-sample over the next period.
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.
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:
It asks: once we are already in the bad 5% of outcomes, what is the average loss? That is why CVaR is usually a better portfolio risk objective than VaR. VaR only finds the cliff edge. CVaR looks over the edge and measures how bad the fall is.
5.1 Rockafellar-Uryasev CVaR Linear Program
The useful optimization trick is that empirical CVaR can be written as a convex problem. We introduce one scalar \(\eta\) and one slack variable \(u_s\) per 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:
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:
It chooses \(\eta\), the tail cutoff.
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:
with the full-investment, long-only, cap, and CVaR slack constraints. This is one of the cleanest examples of why CVaR is practical: a tail-risk quantity that sounds complicated becomes a convex linear program over historical scenarios.
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:
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:
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. So the CVaR optimizer is not only reducing the size of losses in a fixed tail. It is also changing which historical days become the relevant tail days.
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 minimum-CVaR model solves this tail-risk minimization problem at each rebalance. It doesn’t ask which assets have the highest expected return. It only asks which long-only capped allocation minimizes the average loss in the worst 5% of the rolling historical scenarios.
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 numbers are tiny, with a 95% VaR loss around 0.31% and CVaR loss around 0.44%. That is the in-window optimized tail loss, not a promise about future drawdown.
Show code
cvar_alpha =0.95min_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 isNoneor status notin ["optimal", "optimal_inaccurate"]:returnNone 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()) iflen(tail) else var, "status": status})return outmin_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_resultmin_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
The backtest shows the limitation of pure Min-CVaR very clearly. 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.
The Sharpe is negative because the project assumes a 4% annual risk-free rate. A portfolio that earns only 2.40% annualized has failed the basic opportunity-cost test. In other words, pure tail minimization can become too defensive. It reduces loss severity, but it may reduce the expected return so much that the portfolio is no longer economically attractive.
This is the first major lesson from the project: tail control alone isn’t enough. A usable model needs to control tail risk while still earning a return premium. That motivates the Mean-CVaR model next.
The Min-CVaR result also teaches an important economic lesson about the difference between risk minimization and portfolio usefulness. In mathematical terms, the model solved exactly the problem we gave it:
\[
\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.
This is why pure risk minimization often creates portfolios that look safe in a risk table but weak in a wealth curve. The optimizer isn’t failing. The objective is incomplete for an investor who needs positive real returns. A risk-minimizing sleeve can be useful inside a larger allocation, but as a standalone strategy it can become too close to cash or short-duration bonds.
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. That is not a technical detail. It means the strategy has failed after accounting for opportunity cost.
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
\]
This is a much more useful portfolio construction problem. The model can take risk, but only if the worst-scenario average loss remains inside the allowed budget.
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.
This makes the result more transparent. A 0.90 budget scale means:
So the model must produce a portfolio whose in-sample tail loss is at least 10% lower than equal weight. That is much clearer than saying “we use a tail penalty of \(\gamma\)” because \(\gamma\) is harder to understand economically.
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.
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]returnfloat(tail.mean()) iflen(tail) else vardef 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.shapefor 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 isnotNoneand 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 outreturnNonemean_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_resultmean_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.
The performance comparison between Min-CVaR and Mean-CVaR is the important part:
\[
\text{Min-CVaR Sharpe}=-0.4073
\]
\[
\text{Mean-CVaR Sharpe}=0.5609
\]
Mean-CVaR sacrifices some tail purity, but it becomes a real investment strategy. It earns 9.21% CAGR with 9.55% volatility, historical ES of 1.45%, and max drawdown of -14.61%. This is one of the strongest examples in the project of why risk control needs to be tied to a return objective. A portfolio that only avoids losses can become cash-like. A portfolio that spends a controlled tail budget can still participate in risk premia.
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 isNoneor status notin ["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.
This is a useful diagnostic. If expected return didn’t rise when the CVaR budget loosened, then the expected-return estimate wouldn’t be giving the optimizer a meaningful direction. Here it does rise, but slowly. 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 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. This is exactly why constraints are part of the model, not just implementation details. In constrained portfolio optimization, the final weights come from the objective and the constraint geometry.
7) Risk Parity and Risk Contributions
Risk parity takes a different view of diversification. Equal weight gives each asset the same capital weight. Risk parity tries to give each asset, or each sleeve, the same contribution to portfolio risk.
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:
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:
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.
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.
This is why risk parity is a portfolio-level concept. We don’t equalize individual volatilities. We equalize each asset’s contribution to the portfolio’s total volatility. The covariance terms decide how much each asset adds to the combined risk.
A two-asset example makes this clear. With weights \(w_1,w_2\), volatilities \(\sigma_1,\sigma_2\), and correlation \(\rho\), portfolio variance is:
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.
7.1 Why Risk Parity Often Holds More Bonds
Risk parity is easy to misunderstand if we only look at weights. Low-volatility assets often receive large capital weights because they need more dollars to contribute the same risk.
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:
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.
This is exactly the target behavior. The capital weights are not equal, but the sleeve risk contributions are nearly equal. That means the optimizer is succeeding at the risk-budgeting problem.
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.
8) Hierarchical Risk Parity
HRP, or Hierarchical Risk Parity, starts from a different idea: assets shouldn’t be allocated as if all correlations are equally important. The first step is to understand which assets naturally cluster together.
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:
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 latest HRP dendrogram and ordered-correlation heatmap show the cross-asset structure clearly. Duration and bond-like assets cluster together, equity and credit-like assets cluster together, and diversifiers form their own partial grouping. This is economically sensible because the model isn’t told those labels directly. It sees them through correlation.
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:
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.
This recursive structure is why HRP can be stable. It doesn’t invert the full covariance matrix in the same way a mean-variance optimizer does. Instead, it uses the hierarchy to break the problem into smaller allocation decisions. The cost is that HRP doesn’t directly optimize expected return, and in a universe with very low-volatility assets it can become heavily defensive.
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:
Use the correlation distance matrix to cluster the assets.
Solve an inner optimization inside each cluster.
Treat each cluster portfolio as one synthetic asset.
Solve an outer optimization across the cluster portfolios.
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:
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}}
\]
This structure is useful because it prevents one large correlated group from dominating the optimizer asset-by-asset. The optimizer first decides how much risk/return to allocate to clusters, then distributes inside each cluster.
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.
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:
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. The bigger question is whether the clustering creates a better economic structure. In the results, HRP’s simpler structure wins.
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 map makes the same point visually. 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.
The selected hierarchical model is HRP because it has higher Sharpe, better drawdown behavior, and much lower turnover than NCO-MV. This doesn’t mean NCO is theoretically weak. It means that in this cross-asset universe and sample, the extra optimization layer didn’t create enough return to justify the additional turnover and risk.
The HRP versus NCO comparison is useful because it separates two ideas:
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.
10) Robust Optimization: Estimation Error as a Portfolio Risk
Robust optimization is the most important conceptual block in this project. The starting point is that \(\hat{\mu}\) is an estimate, not the truth. 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. The problem is that the optimizer behaves as if \(\hat{\mu}\) is exact. Robust optimization changes the objective to say: choose a portfolio that still looks acceptable under a bad but plausible version of the expected-return vector.
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.
Expected-return uncertainty is usually more damaging than covariance uncertainty. A rough reason is that portfolio variance is quadratic and constrained by \(\Sigma\), while expected return enters 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:
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.
Robust optimization makes this uncertainty explicit. Instead of pretending \(\hat{\mu}\) is the truth, it asks the optimizer to survive a pessimistic adjustment to \(\hat{\mu}\). The stronger the uncertainty set, the less the optimizer can exploit fragile forecast differences.
10.1 Box Uncertainty
The first robust model uses a box uncertainty set. Each expected return can move independently within a range:
\[
(\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.
The box model is intuitive and easy to explain: every asset’s forecast return is haircut by its own estimation uncertainty before the optimizer sees it. Assets with noisy high returns lose more of their appeal.
For a long-only portfolio, the box robust derivation is especially simple. The worst-case return is:
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. This is also why box robustness can be interpreted as forecast haircutting. We create a conservative expected-return vector first, then run a standard constrained mean-variance optimizer on that conservative vector.
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.
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.
This is an important detail. Robust doesn’t automatically mean diversified. If a few assets keep strong forecasts even after the uncertainty haircut, the model can still concentrate in them. The robust adjustment changes the expected-return vector; the cap constraint still determines how concentrated the solution can be.
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.
So Box Robust MV is not a low-turnover defensive model. It is a return-seeking model with conservative mean estimates. This distinction matters because the word “robust” can sound like it should automatically produce smooth weights. In this implementation, robustness is about mean-estimation uncertainty, not about minimizing trading.
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:
\(\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
\]
This formula is worth slowing down. The term \(\hat{\mu}^\top w\) is the normal 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 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:
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.
This is why ellipsoid robustness is often more elegant mathematically than box robustness. It respects the covariance structure of estimation error.
The ellipsoid robust objective becomes:
\[
\max_w\; \hat{\mu}^\top w - \rho\|\Omega^{1/2}w\|_2 - \frac{\lambda}{2}w^\top\Sigma w
\]
This has a different behavior than box robustness. Box uncertainty penalizes each asset independently. Ellipsoid uncertainty penalizes the portfolio’s exposure to uncertain directions. If two assets have correlated estimation errors, the ellipsoid penalty sees that. This is one reason ellipsoid robust optimization can be more realistic for portfolios: return-estimation errors are rarely independent across related assets.
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.
The ellipsoid radius path shows how the penalty changes as uncertainty aversion rises. 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.
The robust-model comparison confirms that the two uncertainty sets are close in this implementation. Both models have:
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.
The useful interpretation is that robust MV becomes a middle ground between MaxSharpe and Mean-CVaR. 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.
Box and ellipsoid robust optimization protect against uncertainty in the expected-return vector. Wasserstein distributionally robust optimization goes deeper. It protects against uncertainty in the entire return distribution.
We start with the empirical distribution of historical returns:
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.
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:
A return penalty that subtracts a term proportional to \(\|w\|_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.
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.
So Wasserstein DRO attacks two common optimizer problems at once:
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:
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. This is exactly what we want from a distributionally robust model. If we are unsure about the true return distribution, relying on a small number of bets is more dangerous than spreading across many exposures.
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.
The Wasserstein radius path gives a clean sensitivity analysis. 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.
The project uses radius 1.0. At that level, the latest portfolio is still concentrated in DBC, EEM, GLD, and IWM. The model hasn’t become a defensive equal-weight portfolio. It remains return-seeking, but with an explicit distributional penalty.
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 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.
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 target-return table shows both empirical and Wasserstein-adjusted portfolios are feasible across the 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.
This plot is not a strategy by itself. It is a diagnostic for the chosen DRO radius. It helps us see whether the radius is too small to matter, too large to make return targets impossible, or moderate enough to reshape the efficient frontier without destroying feasibility. In this implementation, radius 1.0 looks moderate.
12) Full Model Table and Feasibility Checks
After all local models are built, we combine them with the baselines into one table. This is where the project becomes a genuine model-comparison exercise.
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.
The feasibility check is also important. Every local model has:
\[
\max_t |\mathbf{1}^\top w_t - 1| = 0
\]
and no cap violations. Fallback count is zero for every local model. So when models perform differently, we don’t need to explain it through failed optimization or invalid weights. The solvers found valid portfolios under the common constraints.
This matters especially for CVaR and robust objectives. Convex formulations are powerful, but portfolio constraints, cap constraints, and numerical tolerances can still create failures. Here the implementation is stable enough that the comparison is clean.
A good comparison here needs more than one metric because the models optimize different things. The clean way to compare them is to group metrics into four families:
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.
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 notin 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
The full ranking by Sharpe tells the story:
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
The selection cell chooses a compact finalist set so the plots stay readable. The selected models are:
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.
The selected CVaR model is Mean-CVaR 95. This is exactly the right choice because Min-CVaR’s low tail loss comes with negative Sharpe. 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:
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()
The net-wealth plot shows the practical version of the table. Learned-Confidence BL and MaxSharpe finish near the top, 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 drawdown plot shows the other side. The active models that grow faster also accept 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.
This plot is where the model personalities become visible:
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.
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_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()
The risk-return-tail scatter is useful because marker size represents 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:
Return-oriented group: BL, MaxSharpe, W-DRO, Ellipsoid Robust. These have higher CAGR and higher volatility, with larger tail markers.
Balanced tail-budget group: Mean-CVaR and MV. They sit lower in return than BL but have more controlled drawdown and ES.
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.
14) Average Weights and Sleeve Exposures
Average holdings reveal the mechanism behind each model.
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%.
This tells us the models aren’t just changing weights randomly. Their objectives map into clear economic sleeve preferences.
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 rowsleeve_exposure = pd.DataFrame({name: average_sleeves(results[name]) for name in selected_models}).Ttop_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-weight heatmap reinforces the concentration story. 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.
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.
The rolling 12-month Sharpe plot shows that no model dominates every regime. 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 Wasserstein DRMV at 22.56% and Ellipsoid Robust MV at 22.74%. Equal Weight is at 18.83%, MV at 18.11%, Mean-CVaR at 17.02%, and HRP at 10.92%.
This recent period is clearly favorable to active and robust return-seeking models. The robust models are not simply defensive. In the recent window, they participate strongly because their selected exposures, especially GLD/DBC/QQQ/EEM-type positions, performed well. 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.
The risk report gives a more formal tail and stress comparison. The historical VaR/ES table confirms the earlier ranking:
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.
That doesn’t mean those models are bad. It means their return advantage comes with genuine downside exposure. 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 stress-window plots compare models across 2018 Q4, the COVID crash, and the 2022 inflation/rate shock. These windows are useful because they stress different portfolio weaknesses:
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 criterion table gives winners by specific objective:
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.
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 table is the cleanest version of the project result.
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.
There is also a useful design lesson in the ranking. The models that explicitly optimize a risk measure are not automatically the best full-portfolio strategies. Min-CVaR, Risk Parity, and HRP all optimize or organize risk very carefully, but they underperform because they don’t take enough rewarded risk in this sample.
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.
This is why Project 10 is a natural bridge between classical portfolio theory and more modern robust allocation. It doesn’t replace mean-variance with one superior model. It shows how each objective creates a different definition of “reasonable” portfolio behavior.
17) Sector ETF Repeat with the Library
The final cell repeats the same model families on a sector ETF universe using library-level functions. This secondary application matters because the sector universe has a different structure from cross-asset ETFs.
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 run starts in 2018-07-03 and has 2,000 return rows, with monthly rebalances from 2020-07-31 to 2026-06-17. The shorter history is caused by sector ETF availability, especially XLC.
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 run generates 310 candidate views and 280 selected views. This confirms that the view engine is active, but the benchmark is equal weight across sectors rather than a multi-asset strategic benchmark. That makes the secondary run closer to a sector-rotation experiment than a full asset-allocation experiment.
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.
So the secondary application is not just a replication. It checks whether the model family survives when the diversification mechanism changes from asset-class allocation to intra-equity rotation.
The sector benchmark table shows strong equity-market returns over the 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:
Its turnover is very high at 46.47% average monthly. That is a warning. Sector rotation views move allocations a lot, and when all assets share market beta, the benefit of those rotations must be large enough to pay for turnover.
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 important correction is that the robust models no longer lag. The weak sector result came from carrying the cross-asset LedoitWolf + Momentum recipe into a sector-only universe. With EWMA + BayesStein, the same W-DRO theory becomes competitive with the best sector MaxSharpe model while holding 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 risk table shows the other side of the result. W-DRO is now one of the best return-adjusted sector models, but it is not the lowest-tail-risk model. That is the right interpretation: this is distributionally robust mean-variance, not a CVaR minimizer.
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 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.
During 2022, this distinction helps the sector models more than during COVID. 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.
The sector diagnostic panel shows how the same model tools translate to 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.
So the sector version is less about “safe versus risky assets” and more about “which equity risk is being selected.” This is why MaxSharpe and W-DRO can both lead in the sector run. 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.
This secondary result makes the main project stronger. It shows that robust optimization is not a single fixed parameter choice. The theory stays the same, but the empirical inputs need to fit the market being modeled: LedoitWolf + Momentum works better as the cross-asset robust prior, while EWMA + BayesStein is the stronger sector recipe. That is the difference between a weak inspired version of W-DRO and a correctly implemented robust model that actually competes.