In this project we will move beyond mean-variance portfolio construction and work through the following approaches to portfolio risk and allocation:
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.
Each approach gives us a different way to choose weights. Mean-variance uses expected returns and covariance; CVaR focuses on losses in the worst tail; risk parity allocates according to contributions to portfolio volatility; and robust optimization accounts for uncertainty in the estimated inputs. We’ll compare the portfolios on the same cross-asset universe and examine their returns, tail losses, turnover, and exposures.
The final library application repeats the model families on sector ETFs, giving us a second opportunity set for the same portfolio methods.
A useful way to read the whole project is to keep four objects separate:
Object
Symbol
What changes across models
Expected return estimate
\(\hat{\mu}\)
ignored, used directly, shrunk, or penalized
Covariance estimate
\(\hat{\Sigma}\)
used for variance, risk contributions, clustering, and robust norms
Scenario matrix
\(R\)
used directly by CVaR and indirectly by covariance/mean estimates
Constraint set
\(\mathcal{C}\)
keeps all models long-only, fully invested, and capped
In each model, the objective translates a particular view of return and risk into a choice of portfolio weights. The belief can be “the mean estimate is useful”, “the worst 5% scenarios matter more than variance”, “each sleeve should contribute equal risk”, or “the estimates are unreliable and need an uncertainty penalty”.
Two models can choose similar assets for different reasons. Mean-CVaR may hold an asset because it helps meet a historical tail-loss budget, while Wasserstein DRMV may hold it because its estimated return remains attractive after the distributional penalty. Looking at both the objective and the resulting weights helps us understand how each allocation was formed.
We also need to keep risk measurement separate from portfolio construction. A model can optimize CVaR and then still be evaluated by Sharpe, Calmar, drawdown, turnover, and expected shortfall. The objective tells us what the model was trying to do. The final report tells us whether that objective produced a useful portfolio under realistic out-of-sample testing.
The scenario rows and the constraints are held on a common footing so we can interpret the objectives alongside the allocations they produce.
1) The Portfolio Problem After Mean-Variance
We used mean-variance models earlier, and now we’ll build on that foundation by examining how different objectives respond to input estimates. Expected returns, covariances, and historical scenarios all come from data, so their estimates influence the weights we obtain.
A portfolio optimizer receives three kinds of information:
where \(\hat{\mu}\) is the estimated expected return vector, \(\hat{\Sigma}\) is the estimated covariance matrix, and \(\mathcal{C}\) is the constraint set, such as long-only weights, full investment, and max-weight caps. If those inputs were known perfectly, the optimization problem would be clean. In real markets, they aren’t known. We estimate them from a finite rolling sample, and that makes every model partly a statistical model before it becomes a portfolio model.
We’ll reuse the earlier data and baseline machinery, then study the following portfolio-construction question:
If variance and raw expected returns aren’t enough, how else can we define a portfolio that is safer, more balanced, or more robust?
We work with the same cross-asset ETF universe used in the previous portfolio projects. The data can be reproduced from the core_cross_asset_etfs folder inside the repository’s data layer. That folder contains the source notes and script workflow for rebuilding the file used by this project. We also repeat the workflow on sector ETFs later, using the sector_etfs data folder.
The baselines are also reused. Equal Weight, MinVar, MV, and MaxSharpe come from the same model family developed in Project 2, and Learned-Confidence BL comes from Project 6. We’ll use those portfolios as references when we compare the new allocations and their realized risk.
We start with the cross-asset ETF panel and the tools needed to study it. We’ll use cvxpy for the convex programs, SciPy clustering for hierarchical allocation, and the quantfinlab portfolio and risk-reporting utilities for the parts already developed in the earlier projects.
The repeated implementation convention is the same as earlier:
\[
r_{t,i} = \frac{P_{t,i}}{P_{t-1,i}} - 1
\]
where \(P_{t,i}\) is the adjusted close price of asset \(i\) at day \(t\), and \(r_{t,i}\) is the simple daily return. We stack the daily returns into a matrix
\[
R_t \in \mathbb{R}^{T \times N}
\]
where \(T\) is the rolling estimation window and \(N\) is the number of assets. Each row is one historical day and each column is one ETF.
For this project, the matrix matters more than in a simple mean-variance backtest because different models read it differently:
CVaR models read scenario losses from the rows of \(R_t\).
Parity models read covariance structure from \(\hat{\Sigma}_t\).
Robust models read both estimated means and estimation uncertainty.
Wasserstein DRO treats the empirical sample as only one possible distribution inside a neighborhood of plausible distributions.
The return panel is built before the portfolio models and is shared across their rolling estimation windows.
These two estimates summarize the return history through its mean and covariance. For CVaR, we’ll keep the individual scenario-level losses as inputs to the optimization:
\[
\ell_s(w)=-r_s^\top w,\qquad s=1,\ldots,T
\]
That means one very bad historical day can directly enter the optimization if it belongs to the worst tail. This is why CVaR is more connected to realized stress scenarios than variance. Variance smooths bad days into a second moment. CVaR identifies the worst days and averages their losses.
For example, if two portfolios have the same volatility but one has many small losses and the other has rare crash losses, variance can treat them as similar. CVaR will usually prefer the first one because its worst 5% outcomes are less severe. The resulting allocation depends on the shape and co-movement of losses in the historical window, as well as on the portfolio weights.
Show code
start_date ="2013-01-01"assets = ["SPY", "QQQ", "IWM", "EFA", "EEM", "TLT", "IEF", "SHY", "AGG", "LQD", "HYG", "GLD", "VNQ", "DBC"]signal_only_assets = ["UUP"] if"UUP"in close_all.columns else []close_after_start = close_all.loc[pd.Timestamp(start_date):].copy()missing_assets = [ticker for ticker in assets if ticker 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)
Our loaded close table contains 6,906 rows and 19 columns from 1999-01-04 to 2026-06-17. We don’t use the full historical range because the investable ETF universe needs aligned coverage. After the 2013 start filter and the all-valid ETF alignment, the investable return panel contains 3,384 daily observations from 2013-01-03 to 2026-06-17.
The aligned panel gives us enough history for multi-year covariance and scenario windows. It spans calm markets, the COVID crash, the 2022 inflation/rate shock, and the 2023–2026 growth rebound. These different periods let us examine allocations and risk across several market environments while using the same set of investable assets.
The signal-only asset UUP is included for the Black-Litterman baseline, but the investable universe has 14 ETFs. The missing-value check is also clean: after alignment, there are zero missing values in the investable return panel. That matters because CVaR optimization uses historical scenarios directly. A missing value inside a scenario matrix would change the tail set and could distort which days count as bad outcomes.
The aligned observations give each model a consistent set of daily scenarios across the selected ETF universe. We can then form historical windows without comparing different asset calendars.
2) Universe, Rebalancing, and Constraints
The tradable universe stays the same as the earlier cross-asset portfolio work, but the way constraints interact with the new models deserves attention.
We use 14 ETFs across four sleeves:
Sleeve
Assets
Main risk carried
Equities
SPY, QQQ, IWM, EFA, EEM
equity beta, growth/cyclicality, global risk appetite
gold, real estate, commodities, inflation/real-asset exposure
The earlier portfolio notebooks introduced the data and economic role of these ETFs. Here we’ll focus on how the same sleeves enter objectives based on variance, tail loss, risk contribution, and uncertainty about returns.
A variance-focused model may prefer SHY because it has very low volatility. A CVaR model may prefer assets that avoid large negative tail scenarios. A risk parity model may give large weight to low-volatility bonds because they need more capital to contribute equal volatility risk. A robust model may penalize assets with high expected-return uncertainty, even when their point forecast looks attractive.
The constraint set is:
\[
\mathbf{1}^\top w = 1
\]
\[
0 \le w_i \le \bar{w}_i
\]
where \(w_i\) is the weight of asset \(i\), \(\mathbf{1}^\top w=1\) means the portfolio is fully invested, and \(\bar{w}_i\) is the cap applied to that asset. The new local models use the common 40% cap, while the Black–Litterman reference uses the asset-specific caps in the table below. Bounds limit concentration when a sample makes a few assets look much more attractive or much safer than the rest.
rebalance_dates = make_rebalance_dates(returns.index, freq=rebal_freq, min_history_days=max(cov_lookback, mu_lookback, 756))fixed_universe_by_date = {pd.Timestamp(dt): {"tickers": list(assets), "avg_dollar_volume": pd.Series(1.0, index=assets)} for dt in rebalance_dates}universe_table = pd.DataFrame({"asset": assets, "sleeve": [sleeve_map.get(asset, "other") for asset in assets],"asset_cap": asset_caps.reindex(assets).values})benchmark_table = pd.DataFrame({"asset": assets, "sleeve": [sleeve_map.get(asset, "other") for asset in assets],"benchmark_weight": benchmark_w.reindex(assets).values,"asset_cap": asset_caps.reindex(assets).values})rebalance_summary = pd.DataFrame([{"count": len(rebalance_dates), "first_rebalance": rebalance_dates[0],"last_rebalance": rebalance_dates[-1], "frequency": rebal_freq,"cov_lookback": cov_lookback, "mu_lookback": mu_lookback}])display(universe_table)display(benchmark_table.round(4))display(rebalance_summary)
asset
sleeve
asset_cap
0
SPY
equities
0.40
1
QQQ
equities
0.40
2
IWM
equities
0.35
3
EFA
equities
0.35
4
EEM
equities
0.35
5
TLT
duration
0.25
6
IEF
duration
0.25
7
SHY
duration
0.15
8
AGG
duration
0.30
9
LQD
credit
0.25
10
HYG
credit
0.15
11
GLD
diversifiers
0.30
12
VNQ
diversifiers
0.30
13
DBC
diversifiers
0.25
asset
sleeve
benchmark_weight
asset_cap
0
SPY
equities
0.2264
0.40
1
QQQ
equities
0.0943
0.40
2
IWM
equities
0.0566
0.35
3
EFA
equities
0.1132
0.35
4
EEM
equities
0.0566
0.35
5
TLT
duration
0.0943
0.25
6
IEF
duration
0.0755
0.25
7
SHY
duration
0.0377
0.15
8
AGG
duration
0.0566
0.30
9
LQD
credit
0.0377
0.25
10
HYG
credit
0.0283
0.15
11
GLD
diversifiers
0.0472
0.30
12
VNQ
diversifiers
0.0377
0.30
13
DBC
diversifiers
0.0377
0.25
count
first_rebalance
last_rebalance
frequency
cov_lookback
mu_lookback
0
126
2016-01-29
2026-06-17
ME
756
252
The universe table lists the investable set and the asset-specific caps used by the Black–Litterman reference. SPY and QQQ can reach 40%, several equity and diversifier ETFs have 30–35% caps, and SHY has a 15% cap in that reference. The local CVaR, parity, hierarchical, and robust models use the common 40% upper bound instead, so their SHY positions can reach 40%.
The strategic benchmark provides the following reference weights for comparison. The largest neutral allocations are SPY at about 22.64%, EFA at 11.32%, QQQ and TLT at 9.43%, and IEF at 7.55%. We can use those weights as context when we compare the allocations produced by the different models. When we see a model hold 40% SHY, 40% DBC, or 40% EEM, we can compare that to the much more balanced benchmark and ask whether the model’s risk definition is forcing a concentrated decision.
The rebalance setup gives 126 monthly rebalance dates from 2016-01-29 to 2026-06-17. We start in 2016 because the covariance lookback is 756 trading days, about three years. The three-year window provides observations for covariance and tail estimates while rolling forward as the sample changes. The 252-day mean window can respond on a different timescale from the covariance window.
2.1 Portfolio optimization: objective, constraints, and the solution
Before comparing individual portfolio models, we can write their common decision in a general form. At a rebalance date we choose a vector of weights \(w\in\mathbb{R}^N\) using the information available in the estimation window. An objective function assigns a value to each candidate allocation, and the constraints decide which candidates we are allowed to hold:
Minimizing \(f(w)\) and maximizing \(-f(w)\) describe the same choice; the sign simply depends on whether we write the objective as a cost or a utility. Here \(\mathcal C\) is the feasible set. Full investment requires all weights to add to one, long-only weights rule out short positions, and the upper bounds limit concentration. For the local models below we use the common 40% asset cap; the Black–Litterman reference also has its separate asset-specific caps. A feasible fully invested portfolio requires the caps to add up to at least one.
A familiar example is mean-variance utility. If \(\hat\mu\) and \(\hat\Sigma\) are our return and covariance estimates, and \(\lambda>0\) controls the cost of risk, the maximization can be written as the minimization:
\[
f(w)=-\hat\mu^{\top}w+\frac{\lambda}{2}w^{\top}\hat\Sigma w.
\]
The first term rewards the portfolio for estimated return; the second charges for portfolio variance. The covariance term includes both individual variances and the covariances between positions, so the change in risk from adding one asset depends on the rest of the portfolio. A larger \(\lambda\) increases the weight placed on variance relative to expected return, while \(\lambda=0\) would leave a linear return objective subject to the same constraints.
We can understand an interior solution by differentiating. For a symmetric covariance matrix,
\[
\nabla_w f(w)=-\hat\mu+\lambda\hat\Sigma w.
\]
The component \((\hat\Sigma w)_i\) measures how asset \(i\) covaries with the portfolio. Without a budget constraint we would set the gradient to zero; with full investment, changing one weight requires us to move capital from another position. We therefore introduce a multiplier \(\nu\) for the equality constraint and compare the marginal objective across assets after accounting for that shared budget.
The inequality constraints have multipliers too. If \(\ell_i\geq0\) corresponds to \(w_i\geq0\) and \(u_i\geq0\) corresponds to \(w_i\leq\bar w_i\), the Lagrangian for this minimization is:
At a differentiable optimum satisfying the usual regularity conditions, the Karush–Kuhn–Tucker conditions combine stationarity, feasibility, and the constraint multipliers:
The last two equalities are complementary slackness. If an asset has a positive weight below its cap, both of its inequality multipliers are zero: its marginal cost is balanced through the common budget multiplier. If its weight is zero or at its cap, the relevant bound can influence the solution. A constraint at its boundary is called active; its multiplier describes the local effect of relaxing that constraint when the value function is differentiable.
For example, if an attractive asset reaches its 40% cap, the optimizer has to place the remaining capital elsewhere even if the unconstrained choice would hold more of that asset. A zero weight can be optimal as well: the estimated benefit of a small allocation may not compensate for its marginal risk and the opportunity to invest the same capital in other assets. The final holdings therefore come from the estimates, the objective, and the feasible set together.
When \(\hat\Sigma\) is positive semidefinite, the quadratic risk term is convex. With linear budget and bound constraints, minimizing the mean-variance cost is a convex problem, so a local optimum is also global. Some other objectives below can be written as linear or conic programs; nonlinear risk-budgeting objectives may require a different numerical method. In every case we check the returned weights and evaluate them over the next holding period, using a new estimation window at the next rebalance.
We now rebuild the earlier baseline grid. It searches through covariance and expected-return combinations, and we select one representative for each of these roles:
Equal Weight as the simplest fully diversified reference.
MinVar as the variance-only defensive benchmark.
MV as the standard mean-variance benchmark.
MaxSharpe as the return-seeking frontier benchmark.
We compare the same four roles while letting the grid select their estimates:
Role
What it contributes to the comparison
Equal weight
A simple reference with the same capital weight in every asset
Minimum variance
A reference chosen from covariance-based risk minimization
Mean-variance
A reference that combines estimated return and covariance
MaxSharpe
A frontier reference chosen using the return-to-risk ratio
Keeping these references in the comparison lets us assess the new objectives alongside familiar portfolio choices. We’ll compare return, drawdown, tail loss, turnover, and concentration rather than relying on only one performance measure.
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)
MV with Ledoit-Wolf covariance and Bayes-Stein expected returns
MaxSharpe
MaxSharpe with sample covariance and Bayes-Stein Momentum expected returns
The selected baseline portfolios have different return, risk, and turnover profiles. MaxSharpe has the highest baseline CAGR at 11.68% and Sharpe at 0.6553, but it also has high turnover of 21.77% per month and low effective diversification with average effective \(N\) around 3.20. That means it gets its performance from concentrated bets that move around.
MV with Ledoit-Wolf and Bayes-Stein is much more conservative: CAGR 8.14%, volatility 8.47%, Sharpe 0.4949, and a strong max drawdown of -13.78%. MinVar has even lower volatility but lower return. Equal Weight is stable and diversified, but its max drawdown is the worst among the baseline set at -20.38%.
The selected references give us four different profiles to compare:
MaxSharpe gives strong performance, but it concentrates and trades.
MinVar controls volatility, but gives up too much return.
MV is balanced, but still depends on a fragile expected-return estimate.
Equal Weight is stable by construction, but doesn’t react to changing risk.
We’ll use these results as a reference when we study the new tail-risk, risk-parity, hierarchical, and robust allocations.
3) Learned-Confidence Black-Litterman as a Strong Reference
We rebuild the Learned-Confidence BL strategy from Project 6 and include it as a reference. We don’t re-teach the full Black-Litterman posterior here, but it helps to remember what BL contributes to the comparison.
We start BL from an equilibrium prior, generate active relative views, learn confidence from realized payoff history, and convert the posterior return vector into a constrained portfolio. The resulting active portfolio gives us a reference built from a structured estimate of returns.
BL also addresses instability in expected-return estimates by combining a prior with views and learned confidence. Comparing it with classical MV and the new robust objectives lets us see how different ways of using return information affect the allocation and its subsequent performance.
At each rebalance, candidate views are filtered, their confidence is estimated from available realized payoffs, and the resulting posterior is passed to the constrained allocation step. The backtest then applies the selected portfolio over the next holding period, so we can compare this reference with the other approaches on the same dates.
strategic_weights = pd.DataFrame([benchmark_w.reindex(assets).rename(pd.Timestamp(dt)) for dt in rebalance_dates[:-1]])strategic_result = run_many_weights_backtests({"Strategic Benchmark": strategic_weights}, returns=returns[assets], cost_bps=cost_bps, rf_daily=risk_free_daily, w_min=w_min, w_max=w_max, long_only=True, normalize=True, weight_timing="next_close")["Strategic Benchmark"]bl_metric_table = selection.build_strategy_summary({"Learned-Confidence BL": learned_bl_result}, rf_daily=risk_free_daily, annualization=annualization)display(bl_metric_table.round(4))sanity_results = {"Strategic Benchmark": strategic_result,"Equal Weight": walkforward_grid.backtests[grid_equal_weight_name],"Learned-Confidence BL": learned_bl_result}for sanity_name in ["MinVar (OAS)", "MV (LedoitWolf, BayesStein)"]:if sanity_name in walkforward_grid.backtests: sanity_results[sanity_name] = walkforward_grid.backtests[sanity_name]sanity_actual = selection.build_strategy_summary(sanity_results, rf_daily=risk_free_daily, annualization=annualization)sanity_actual = sanity_actual.rename(columns={"CAGR": "cagr", "Vol": "annualized_volatility", "Sharpe": "sharpe","Sortino": "sortino", "Max Drawdown": "max_drawdown","Calmar": "calmar", "Turnover": "avg_turnover","Effective N": "effective_n"})expected_project6 = pd.DataFrame({"cagr": {"Strategic Benchmark": 0.0914, "Equal Weight": 0.0855,"MinVar (OAS)": 0.0686, "MV (LedoitWolf, BayesStein)": 0.0735,"Learned-Confidence BL": 0.1015},"annualized_volatility": {"Learned-Confidence BL": 0.1031},"sharpe": {"Strategic Benchmark": 0.5291, "Equal Weight": 0.4916,"MinVar (OAS)": 0.3806, "MV (LedoitWolf, BayesStein)": 0.4860,"Learned-Confidence BL": 0.6103},"sortino": {"Learned-Confidence BL": 0.7671},"max_drawdown": {"Strategic Benchmark": -0.2242, "Equal Weight": -0.2041,"MinVar (OAS)": -0.1975, "MV (LedoitWolf, BayesStein)": -0.1181,"Learned-Confidence BL": -0.2207},"calmar": {"Learned-Confidence BL": 0.4600},"avg_turnover": {"Learned-Confidence BL": 0.1761},"effective_n": {"Learned-Confidence BL": 6.7530}})sanity_compare = sanity_actual[[c for c in expected_project6.columns if c in sanity_actual.columns]].join(expected_project6, rsuffix="_expected")for col in expected_project6.columns:if col in sanity_actual.columns: sanity_compare[col +"_diff"] = sanity_compare[col] - sanity_compare[col +"_expected"]display(sanity_compare.round(4))bl_far =Falseif"Learned-Confidence BL"in sanity_compare.index: row = sanity_compare.loc["Learned-Confidence BL"] bl_far =bool(abs(row.get("cagr_diff", 0.0)) >0.03orabs(row.get("sharpe_diff",0.0)) >0.20orabs(row.get("max_drawdown_diff", 0.0)) >0.08)if bl_far:print("learned-confidence BL is materially away from the project 6 sanity anchors with the current data/settings") display(pd.Series(vars(bl_settings)).to_frame("setting"))
Optimizer
Mu model
Covariance model
CAGR
Vol
Sharpe
Max Drawdown
Calmar
Sortino
Turnover
Total Turnover
Cost Drag
Effective N
Fallbacks
Strategy
Learned-Confidence BL
Learned-Confidence BL
-
-
0.1253
0.115
0.7458
-0.182
0.6888
0.9287
0.3147
39.332
0.0226
5.2021
0
cagr
annualized_volatility
sharpe
sortino
max_drawdown
calmar
avg_turnover
effective_n
cagr_expected
annualized_volatility_expected
sharpe_expected
sortino_expected
max_drawdown_expected
calmar_expected
avg_turnover_expected
effective_n_expected
cagr_diff
annualized_volatility_diff
sharpe_diff
sortino_diff
max_drawdown_diff
calmar_diff
avg_turnover_diff
effective_n_diff
Strategy
Strategic Benchmark
0.0989
0.1105
0.5559
0.6828
-0.2301
0.4298
0.0147
9.4420
0.0914
NaN
0.5291
NaN
-0.2242
NaN
NaN
NaN
0.0075
NaN
0.0268
NaN
-0.0059
NaN
NaN
NaN
Equal Weight
0.0848
0.0935
0.4842
0.5934
-0.2038
0.4159
0.0114
14.0000
0.0855
NaN
0.4916
NaN
-0.2041
NaN
NaN
NaN
-0.0007
NaN
-0.0074
NaN
0.0003
NaN
NaN
NaN
Learned-Confidence BL
0.1253
0.1150
0.7458
0.9287
-0.1820
0.6888
0.3147
5.2021
0.1015
0.1031
0.6103
0.7671
-0.2207
0.46
0.1761
6.753
0.0238
0.0119
0.1355
0.1616
0.0387
0.2288
0.1386
-1.5509
MinVar (OAS)
0.0665
0.0754
0.3543
0.4044
-0.1973
0.3370
0.0031
9.1680
0.0686
NaN
0.3806
NaN
-0.1975
NaN
NaN
NaN
-0.0021
NaN
-0.0263
NaN
0.0002
NaN
NaN
NaN
MV (LedoitWolf, BayesStein)
0.0814
0.0847
0.4949
0.6049
-0.1378
0.5904
0.0369
5.0557
0.0735
NaN
0.4860
NaN
-0.1181
NaN
NaN
NaN
0.0079
NaN
0.0089
NaN
-0.0197
NaN
NaN
NaN
The BL rebuild generates 544 candidate view rows, 538 selected view rows, 538 confidence rows, and 125 posterior rows. These counts show the reference is active through almost the full monthly period.
It also improves the drawdown relative to the strategic benchmark: -18.20% versus -23.01%. Its turnover is high at 31.47% average monthly turnover, which is the cost of running active monthly views. The effective number of positions is about 5.20, so it is diversified enough to avoid a one-asset bet, but still meaningfully concentrated relative to Equal Weight.
The other models can be compared with this active reference across several dimensions. For example:
CVaR models can justify themselves if they reduce expected shortfall or drawdown.
Risk parity can justify itself if it gives a smoother defensive allocation.
Robust models can justify themselves if they preserve return while reducing dependence on fragile forecasts.
Wasserstein DRO can justify itself if it behaves like a less brittle version of aggressive mean-variance.
These comparisons keep both the portfolio’s objective and its observed return and risk in view.
Show code
results = {}results["Equal Weight"] = walkforward_grid.backtests[grid_equal_weight_name]results[best_minvar_name] = walkforward_grid.backtests[best_minvar_name]results[best_mv_name] = walkforward_grid.backtests[best_mv_name]results[best_maxsharpe_name] = walkforward_grid.backtests[best_maxsharpe_name]results["Learned-Confidence BL"] = learned_bl_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 estimating the new models, we’ll set up the common constraints and numerical utilities. These let us solve the objectives on the same investable universe and check that the resulting weights are fully invested and respect their bounds.
The constraints are enforced repeatedly:
\[
\sum_{i=1}^{N} w_i = 1
\]
\[
w_i \ge 0
\]
\[
w_i \le \bar{w}_i
\]
where \(\bar{w}_i\) is the cap for asset \(i\). We also clean weights after optimization so that tiny numerical negatives are clipped, weights are renormalized, and cap violations are corrected.
The PSD square-root routine also matters. Several models need a matrix square root of a covariance-like matrix:
\[
A^{1/2}(A^{1/2})^\top \approx A
\]
For example, portfolio volatility can be written as:
That norm form is useful in conic optimization, robust optimization, and Wasserstein DRO. Since empirical covariance matrices can have tiny numerical eigenvalue problems, the implementation symmetrizes the matrix and floors small eigenvalues before taking the square root. This doesn’t change the model’s economic idea. It prevents the numerical solver from failing because of floating-point noise.
The common cap of 0.40 applies to each local model in the main cross-asset comparison. The separately displayed asset-specific cap vector enters the Black–Litterman reference; keeping the two uses distinct helps us read an SHY weight near 40% in a local model alongside a 15% BL cap.
For the square root above we use the symmetric eigendecomposition \(A=Q\Lambda Q^\top\), giving \(A^{1/2}=Q\Lambda^{1/2}Q^\top\) after the small eigenvalue floor. Then \(\|A^{1/2}w\|_2^2=w^\top A w\) for the adjusted positive-semidefinite matrix.
Show code
def psd_sqrt(mat, eps=1e-10): arr = np.asarray(mat, dtype=float) arr =0.5* (arr + arr.T) vals, vecs = np.linalg.eigh(arr) vals = np.maximum(vals, eps) out = (vecs * np.sqrt(vals)) @ vecs.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
At each rebalance date, the walk-forward engine keeps the inputs needed by the portfolio models:
tickers, the current asset universe.
R_cov, the rolling return matrix used for covariance and scenario-risk estimation.
R_mu, the rolling return matrix used for expected-return estimation.
mu_ann_map, expected-return estimates under different models.
cov_ann_map, covariance estimates under different estimators.
avg_dollar_volume, retained from the general selection system.
All of the local models use the same walk-forward information set. At each rebalance we estimate inputs from the historical window available on that date, produce weights, and apply those weights over the following period. The covariance and mean windows can have different lengths, but their dates are aligned to the same decision.
That makes the later comparison much more meaningful. Differences in performance come from the objective and constraints, not from giving one model a better data window.
Each cached state is associated with its rebalance date, and the return and covariance estimators use the observations available within their corresponding lookback windows.
5) CVaR as a Tail-Risk Objective
Variance is easy to optimize, but it treats upside and downside symmetrically. A large positive return increases variance, and a large negative return also increases variance. For portfolio construction, investors usually don’t dislike both equally. The painful part is the left tail, especially months or days where many assets fall together.
To model that directly, we define portfolio return in scenario \(s\) as:
\[
r_{p,s} = r_s^\top w
\]
where \(r_s \in \mathbb{R}^N\) is the vector of asset returns in historical scenario \(s\), and \(w\) is the portfolio weight vector. Portfolio loss is the negative return:
\[
\ell_s(w) = -r_s^\top w
\]
Large positive \(\ell_s(w)\) means a bad portfolio outcome. At confidence level \(\alpha\), Value-at-Risk is the loss threshold:
For a continuous loss distribution, this is the average loss conditional on being in the worst 5% of outcomes. VaR gives the cutoff, while CVaR also uses the severity of the observations beyond it. That makes the tail size available as an objective or a constraint in portfolio construction.
The conditional-mean expression is exact for a continuous loss distribution. With a finite sample or probability mass at the VaR threshold, the worst \((1-\alpha)\) fraction can include only part of the probability at the cutoff. The optimization representation below handles that boundary directly, so CVaR remains well defined when the empirical losses have ties.
5.1 Rockafellar-Uryasev CVaR Linear Program
We can write empirical CVaR as a convex optimization problem by introducing one scalar \(\eta\) and one slack variable \(u_s\) for each historical scenario.
The variable \(\eta\) acts like the estimated VaR level. The slack \(u_s\) measures how far scenario \(s\) exceeds that VaR threshold. The empirical CVaR objective is:
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. All scenario losses are linear in the weights, so the objective and these constraints form a linear program over the historical return matrix.
For fixed weights, the two slack inequalities imply that the smallest feasible slack is \(u_s=(\ell_s(w)-\eta)_+\), where \((x)_+=\max(x,0)\). Substituting that value gives an equivalent one-dimensional problem for the threshold:
Away from a loss observation, increasing \(\eta\) by a small amount raises the first term but lowers each active excess loss. Its slope is \(1-n_>(\eta)/[(1-\alpha)T]\), where \(n_>(\eta)\) counts losses above the threshold. At the minimum the cutoff therefore lies at an empirical quantile, with the appropriate fraction of a boundary observation included when \((1-\alpha)T\) is not an integer.
The decision variables are the \(N\) weights, one threshold, and \(T\) slacks. Scenario loss is linear in \(w\), and all the displayed objective and constraints are linear in these variables, so the historical minimum-CVaR problem is a linear program. The later diagnostic computes an empirical quantile and the mean of observations above it; near a sample boundary that reported average can differ slightly from the fractional-tail value of the linear-program objective.
The slack-variable construction is also useful because it gives a very intuitive picture of what the optimizer is doing. Suppose we have five simplified loss scenarios:
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. As the weights change, the optimizer can reduce losses on some dates while changing which dates belong to the portfolio’s worst tail.
This is one reason CVaR is powerful but sample-sensitive. If the rolling window doesn’t contain the type of crisis that is coming next, the optimized tail may be the wrong tail.
The estimated tail reflects the observations that are available in the chosen rolling window.
Show code
cvar_alpha =0.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
At each rebalance, we solve the minimum-CVaR problem for a long-only allocation subject to the asset caps. The objective minimizes the average loss in the worst 5% of rolling historical scenarios, without using an expected-return estimate to choose among assets.
That objective creates a very defensive latest allocation:
SHY receives the max 40%.
HYG receives about 30.17%.
AGG receives about 21.99%.
DBC receives about 7.84%.
Most equity and long-duration assets receive zero.
At first, HYG may look surprising because high-yield credit is risky. But the model isn’t reading labels. It reads the empirical tail scenarios inside the rolling training window. If HYG’s recent worst-tail behavior is less damaging than equity or long-duration bonds under that particular window, the optimizer can still include it. This is one reason CVaR models need careful interpretation: they are only as good as the tail scenarios in the estimation window.
The latest training tail measures are a 95% VaR loss around 0.31% and CVaR loss around 0.44%. These numbers describe the daily scenarios in the estimation window used to choose the displayed allocation.
The pure Min-CVaR portfolio produces a particularly defensive backtest. It has the smallest annualized volatility among all models at 3.60%, the lowest historical ES at 0.54%, and the lowest worst month at -3.31%. That means the objective does what it was designed to do: it produces a portfolio with very small realized tail losses.
The Sharpe is negative because the project assumes a 4% annual risk-free rate, while the portfolio earns 2.40% annualized. The result illustrates the tradeoff of minimizing historical tail loss without a return term: the realized risk is low, and the realized return is below the assumed cash hurdle.
We can now add an expected-return objective while keeping a limit on tail loss. The next model makes that return-versus-tail-risk tradeoff explicit through a CVaR budget.
The 4% annual rate is the same reference used for the comparison across models, while the reported tail statistics describe the realized net returns.
The Min-CVaR allocation follows from the objective we specified. It chooses the feasible weights with the smallest historical tail-loss measure:
\[
\min_w CVaR_{0.95}(w)
\]
But the objective doesn’t contain a reward term. If an asset helps reduce the worst 5% losses but has poor expected return, the optimizer can still love it. If another asset has strong expected return but occasionally appears in the tail set, the optimizer can reject it completely.
A risk-minimizing allocation can therefore be useful as a defensive sleeve, while its standalone wealth path depends on the return earned by the selected assets. In this sample the selected holdings keep tail losses small but produce a low return relative to the 4% annual cash assumption.
The result also shows why comparing only max drawdown or ES can be misleading. Min-CVaR has excellent tail metrics, but if it earns less than the assumed risk-free rate, its Sharpe becomes negative. The assumed cash rate is the opportunity-cost reference used in the reported Sharpe ratio.
The tail statistic refers to the realized net-return distribution, while the optimization used historical scenarios from each rolling training window. Both are useful for reading the result because the portfolio is chosen again at each rebalance.
6) Mean-CVaR: Return Maximization Under a Tail Budget
The pure Min-CVaR objective minimizes the left tail but ignores return. Mean-CVaR changes the problem. Instead of minimizing CVaR directly, we maximize expected return subject to a CVaR limit.
The expected return estimate is:
\[
\hat{\mu}^\top w
\]
where \(\hat{\mu}\) is the annualized expected-return vector selected from the walk-forward grid. The CVaR constraint is:
\[
CVaR_{0.95}(w) \le B
\]
where \(B\) is a tail-risk budget. In this project, the budget is based on the equal-weight portfolio’s in-sample CVaR:
\[
B = c \cdot CVaR_{0.95}(w_{\text{EW}})
\]
where \(c\) is a budget scale. A value below 1.0 means the portfolio must have lower in-sample CVaR than equal weight. A value above 1.0 allows more tail risk than equal weight.
The optimization becomes:
\[
\max_{w,\eta,u}\; \hat{\mu}^\top w
\]
subject to:
\[
\eta + \frac{1}{(1-\alpha)T}\sum_{s=1}^{T}u_s \le B
\]
The return objective chooses among portfolios whose worst-scenario average loss remains inside the allowed budget. We can adjust the budget to compare different levels of permitted tail exposure.
The expected-return vector is annualized, while the scenario losses and their CVaR budget use the daily returns in the rolling scenario matrix. They appear on opposite sides of the constrained problem, so each side keeps its own units: \(B\) must be on the same daily-loss scale as the CVaR expression, and the optimized objective remains an annual return estimate.
The code first tries the 0.90 equal-weight budget and relaxes it to 1.00 or 1.10 only if needed for feasibility. Relaxing a budget enlarges the feasible set; it cannot reduce the best attainable objective when the remaining constraints and inputs are unchanged.
Mean-CVaR can be read as a risk-budget spending problem. The model receives a budget \(B\) and tries to buy as much expected return as possible without spending more than that amount of tail risk.
The Lagrangian intuition is useful. If we attach a multiplier \(\gamma \ge 0\) to the CVaR constraint, the problem behaves like:
\[
\max_w\; \hat{\mu}^\top w - \gamma \, CVaR_\alpha(w)
\]
The constrained form and penalized form are closely related. In the constrained form, we choose the tail budget directly. In the penalized form, we choose how expensive tail risk is. We use the constrained form because the budget is easier to interpret: it is a fraction of the equal-weight portfolio’s in-sample CVaR.
So the model must produce a portfolio whose in-sample tail loss is at least 10% lower than equal weight. The parameter \(\gamma\) in the penalized version instead gives the cost assigned to each unit of tail risk.
When the optimized CVaR sits almost exactly at the budget, it means the tail constraint is binding. The model would like to take more expected-return exposure, but the CVaR limit prevents it.
For the constrained maximization, the Lagrangian term is \(-\gamma(CVaR_\alpha(w)-B)\). When the constraint is slack, complementary slackness gives \(\gamma=0\). When it binds, a positive multiplier measures the local increase in the best estimated return from relaxing the budget by one unit, under the usual sensitivity conditions. A binding constraint can also have a zero multiplier in a degenerate case.
A larger \(\gamma\) makes an additional unit of tail risk more expensive in the penalized objective, so the optimizer has a stronger incentive to choose a lower-CVaR allocation.
Show code
mean_cvar_rows = []def training_cvar_loss(r, wv): loss_values =-r @ wv var =float(np.quantile(loss_values, cvar_alpha)) tail = loss_values[loss_values >= var]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.
We can compare the resulting Min-CVaR and Mean-CVaR portfolios using the same backtest:
\[
\text{Min-CVaR Sharpe}=-0.4073
\]
\[
\text{Mean-CVaR Sharpe}=0.5609
\]
Mean-CVaR earns 9.21% CAGR with 9.55% volatility, historical ES of 1.45%, and max drawdown of -14.61%. Adding the return objective changes the allocation and raises both return and realized tail exposure relative to Min-CVaR. The budget makes that tradeoff explicit at the time we choose the weights.
The two portfolios are evaluated using the same return and risk-reporting conventions, including the risk-free rate and the trading-cost assumption used across the comparison.
Show code
def latest_mean_cvar_budget(scale): dt = model_rebalance_dates[-1] state = walkforward_grid.cache[dt] tickers =list(state["tickers"]) caps = cap_array(tickers).to_numpy(dtype=float) r = state["R_cov"].reindex(columns=tickers).astype(float).dropna(how="any").to_numpy(dtype=float) mu_ann = state["mu_ann_map"][best_maxsharpe_cov][best_maxsharpe_mu].reindex(tickers).fillna(0.0).astype(float) ew_cvar = training_cvar_loss(r, np.repeat(1.0/len(tickers), len(tickers))) t, n = r.shape w = cp.Variable(n) eta = cp.Variable() u = cp.Variable(t) losses =-r @ w cvar = eta + cp.sum(u) / ((1.0- cvar_alpha) * t) budget =float(scale) * ew_cvar problem = cp.Problem(cp.Maximize(mu_ann.values @ w), [cp.sum(w) ==1.0, w >=0.0, w <= caps, u >=0.0, u >= losses - eta, cvar <= budget]) status = solve_cvx_problem(problem)if w.value 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.
The estimated return rises as the tail budget is loosened, although the increase is gradual across this range. The model is saying that within the latest rolling window, buying more expected return requires accepting more left-tail exposure, and the portfolio quickly hits concentration constraints.
For example, if the allowed CVaR budget is very tight, the model has to hold more defensive assets even when their expected return is low. When the budget loosens, it can add assets like GLD, DBC, or equity/credit exposures if their expected-return estimate justifies the extra tail risk.
The table uses one fixed set of estimates while changing the allowed tail budget, so the differences in weights and expected return can be read against the same current scenario matrix.
The budget path is also a small local efficient frontier. Each row answers:
\[
\text{Given this tail budget, what is the highest estimated return we can hold?}
\]
This is different from the classical mean-variance frontier, where the risk constraint is usually volatility:
\[
w^\top\Sigma w \le \sigma_\star^2
\]
Here the risk constraint is tail loss:
\[
CVaR_{0.95}(w) \le B_\star
\]
The difference matters because the two constraints can prefer different assets. A portfolio can have moderate volatility but ugly crash behavior if its losses are skewed or if it loads on assets that gap down together. A CVaR constraint directly limits the average loss inside the historical bad tail.
The latest budget table shows max weight stays at 0.40 across all budget scales. That tells us the cap constraint is binding together with the tail constraint. The optimizer wants to allocate more to the assets that best satisfy return-per-tail-risk, but the max-weight rule prevents unlimited concentration. The final weights reflect both the objective and the constraint geometry: the tail budget limits the scenario losses while the 40% cap limits how much of any one asset can be held.
The cap applies separately to each asset, while the tail budget applies to the portfolio loss across the historical scenario days.
7) Risk Parity and Risk Contributions
Risk parity allocates according to contributions to portfolio risk. Equal capital weights do not generally produce equal risk contributions, because each asset has its own volatility and covariance with the other holdings.
For a portfolio with weights \(w\) and covariance matrix \(\Sigma\), portfolio variance is:
\[
\sigma_p^2 = w^\top \Sigma w
\]
Portfolio volatility is:
\[
\sigma_p = \sqrt{w^\top \Sigma w}
\]
The marginal contribution of asset \(i\) to portfolio volatility is the partial derivative:
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.
We can derive the marginal contribution by differentiating the square root. Since \(\nabla_w(w^\top\Sigma w)=2\Sigma w\) for symmetric \(\Sigma\), the chain rule gives the expression above whenever \(\sigma_p>0\). Multiplying by weights and summing recovers the whole portfolio volatility:
This identity is why the percentage contributions add to one. Contributions can be negative for a position that hedges the rest of the portfolio, so the target and bounds should be interpreted at the level of the whole covariance structure.
The risk-contribution formula also shows why correlation matters. Expanding the numerator for asset \(i\):
\[
(\Sigma w)_i = \sum_{j=1}^{N}\Sigma_{ij}w_j
\]
So asset \(i\)’s contribution is not based only on its own volatility. It depends on how it covaries with everything else in the portfolio. If an asset has high standalone volatility but negative or low correlation with the rest of the portfolio, its risk contribution can be lower than its standalone volatility suggests. If a low-volatility asset is highly correlated with the largest portfolio positions, its contribution can be larger than expected.
We equalize contributions to the portfolio’s total volatility using the covariance terms, so every asset’s contribution depends on the other weights as well as on its own variance.
A two-asset example makes this clear. With weights \(w_1,w_2\), volatilities \(\sigma_1,\sigma_2\), and correlation \(\rho\), portfolio variance is:
When \(\rho\) is high, diversification is weak and the higher-volatility asset usually needs much lower capital weight. When \(\rho\) is low or negative, the same asset can receive more weight because it doesn’t add as much total portfolio risk.
If the two assets are uncorrelated, the covariance cross-term disappears and the portfolio variance contains only their individual weighted variance terms.
7.1 Capital Weights and Risk Contributions
Low-volatility assets often receive larger capital weights in risk parity because they need more dollars to contribute the same amount of portfolio risk. The actual balance also depends on correlations and on the portfolio bounds.
Suppose Asset A has 20% volatility and Asset B has 5% volatility. If they are weakly correlated, a 10% weight in A can contribute similar volatility risk to a 40% weight in B. So a risk parity portfolio may look “bond heavy” or “SHY heavy”, but that doesn’t mean it is trying to maximize bond exposure. It is trying to equalize the volatility contribution.
The model solves this kind of target indirectly by minimizing the gap between actual risk contributions and target risk contributions. In simplified form:
subject to the usual portfolio constraints. The exact numerical implementation can be different, but the target is the same: distribute risk contribution rather than distribute capital equally.
In this project, we also inspect sleeve-level risk contribution. The sleeve contribution for group \(g\) is the sum of asset contributions inside that group:
\[
PRC_g = \sum_{i \in g} PRC_i
\]
This is useful because a portfolio can have equal asset-level contributions but still load too much on one economic sleeve if several correlated assets belong to the same sleeve.
The actual numerical objective in erc_vector uses absolute volatility contributions, \(\sum_i(RC_i-\sigma_p/N)^2\), with full investment and bounds. We first form an equal-risk portfolio within each sleeve, calculate the resulting sleeve return histories, and then equalize risk across those sleeve portfolios. Final weight caps can make exact equality unattainable, so the displayed sleeve contributions are a diagnostic of the completed allocation.
The sleeve contributions are close to the equal-risk target even though the capital weights differ. Low-volatility positions can need more capital to contribute a similar share of the portfolio’s total risk.
The performance is defensive. Risk Parity has 4.53% CAGR, 4.92% annualized volatility, 0.132 Sharpe, -13.74% max drawdown, and historical ES of 0.73%. Its main strength is risk control, not return. This model becomes a tail-control benchmark: if another model has much higher return but also much higher ES, we can see the cost of buying that return.
The contribution figures are computed from the latest portfolio weights and covariance estimate.
8) Hierarchical Risk Parity
Hierarchical Risk Parity (HRP) starts by grouping assets according to their correlations. We use that hierarchy to organize the allocation before assigning weights across the resulting clusters.
The model converts correlation into a distance:
\[
d_{ij} = \sqrt{\frac{1-\rho_{ij}}{2}}
\]
where \(\rho_{ij}\) is the correlation between assets \(i\) and \(j\). This distance has useful properties:
If two assets are perfectly correlated, \(\rho_{ij}=1\), then \(d_{ij}=0\).
If two assets are uncorrelated, \(\rho_{ij}=0\), then \(d_{ij}=\sqrt{1/2}\).
If two assets are perfectly negatively correlated, \(\rho_{ij}=-1\), then \(d_{ij}=1\).
The clustering algorithm uses these distances to build a tree. Assets that move together are placed near each other. Once the tree is built, HRP orders the covariance matrix according to the tree structure and recursively splits the ordered list into clusters. At each split, it allocates more weight to the lower-risk cluster.
For a cluster \(C\), the cluster variance is estimated using an inverse-variance portfolio inside the cluster:
If a parent cluster is split into left and right subclusters with variances \(\sigma_L^2\) and \(\sigma_R^2\), the lower-variance side receives more capital. The recursive process continues until weights are assigned to individual assets.
The correlation-based ordering gives us a way to follow related assets through the recursive allocation.
The latest HRP dendrogram and ordered correlation matrix organize the assets into economically recognizable groups. Duration and bond-like assets cluster together, equity and credit-like assets cluster together, and diversifiers form their own partial grouping. Those groups arise from the estimated correlations, which organize the assets without using the sleeve labels as clustering inputs.
The latest HRP weights are extremely defensive:
SHY receives the cap at 40%.
HYG receives 13.17%.
AGG receives 7.11%.
IEF and LQD receive around 5%.
Equity ETFs receive small weights.
The cap on SHY matters here. HRP sees SHY as a low-risk cluster anchor, and without a cap it would likely receive even more. This is one of the practical issues with HRP in a cross-asset ETF universe: if the universe contains near-cash instruments, hierarchical allocation can become too defensive unless the constraints prevent it.
The HRP backtest has 5.19% CAGR, 5.04% volatility, 0.2547 Sharpe, -14.46% max drawdown, and ES of 0.75%. It improves tail behavior compared with aggressive models, but it doesn’t earn enough return to compete with BL, MaxSharpe, Mean-CVaR, or robust MV.
The HRP allocation process can be understood as a sequence of local decisions. At each branch of the tree, we ask how risky the left cluster is relative to the right cluster. If the left cluster has variance \(\sigma_L^2\) and the right cluster has variance \(\sigma_R^2\), the allocation to the left side is proportional to the inverse of its cluster variance:
So if the left cluster is much safer than the right cluster, \(\sigma_L^2\) is small and \(\alpha_L\) is large. The safer cluster receives more capital. This is repeated down the tree until every asset has a final weight.
The recursive allocation uses cluster variances at each split rather than inverting the full covariance matrix. This gives us a sequence of smaller allocation decisions based on the hierarchy. Since the rule uses risk rather than expected returns directly, very low-volatility assets can receive substantial weight in this cross-asset universe.
Each split uses the relative variances of the two subclusters at that branch.
9) Nested Clustered Optimization
NCO, or Nested Clustered Optimization, uses clustering like HRP but doesn’t stop at recursive bisection. It treats each cluster as a sub-portfolio and then optimizes across those cluster portfolios.
The workflow is:
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}}
\]
The inner weights form a portfolio for each group, and the outer optimization allocates capital across those group portfolios. This gives the covariance and return estimates a cluster-level role as well as an asset-level role.
The final allocation maps the cluster-level weights back to the original ETF universe.
NCO is more optimization-heavy than HRP, so its result depends more on the quality of the mean estimate. Inside each cluster, the inner optimizer decides which assets represent that cluster best. Then the outer optimizer decides how much capital each cluster gets.
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. We can compare the resulting cluster allocation with HRP after fitting both methods and examining their realized return, risk, and turnover.
The matrix \(W_{\text{inner}}\) has \(N\) rows and \(G\) columns, so the outer covariance has dimension \(G\times G\) and the product \(W_{\text{inner}}a\) returns \(N\) asset weights. The identities above hold when the same asset covariance is used throughout. Here we estimate the outer covariance directly from the historical returns of the constructed cluster portfolios; the inner optimizer uses the selected asset covariance estimate, which need not be identical to that sample covariance.
The latest NCO weights allocate 60% to the bond cluster, 24.45% to the GLD/DBC cluster, and 15.55% to the risky cluster. Within the bond cluster, SHY and AGG each hit 24% in the latest output, while TLT and LQD get zero. Within the real-asset cluster, GLD and DBC split the allocation.
The cluster-level estimates help explain these allocations. The bond cluster has the lowest standalone volatility but a negative forecast return. The diversifier cluster has a higher forecast return and higher volatility. The risky cluster has medium volatility but also a negative forecast in the latest window. The optimizer therefore places most weight in the low-vol bond cluster and keeps a meaningful diversifier sleeve.
In the backtest, NCO-MV has the same CAGR as HRP, 5.19%, but higher volatility at 6.36% and lower Sharpe at 0.2128. It also has much higher turnover, 16.45% average monthly turnover versus 1.86% for HRP. That makes HRP the better hierarchical model in this application.
The selected hierarchical model is HRP because it has higher Sharpe, better drawdown behavior, and much lower turnover than NCO-MV. In this cross-asset sample, NCO’s additional optimization layer produces the same CAGR as HRP with higher volatility and turnover. Those results determine the selection under the comparison rule used here.
The two methods use the cluster structure in different ways:
Clustering helps us understand portfolio structure.
Optimizing inside and across clusters can add return-seeking behavior, but it can also reintroduce mean-estimation risk.
HRP mostly avoids expected-return forecasts. NCO uses them. When the expected-return signal is noisy or when the universe has a very defensive low-volatility cluster, the NCO optimizer can still become unstable. HRP is simpler and slower-moving, which is exactly why it wins the hierarchical selection here.
Both methods use the same broad asset universe and backtest conventions.
10) Robust Optimization: Estimation Error as a Portfolio Risk
Robust optimization starts with uncertainty about the estimated expected-return vector \(\hat{\mu}\). In mean-variance optimization, small changes in \(\hat{\mu}\) can cause large changes in weights, especially when the covariance matrix has correlated assets and the optimizer is trying to rank similar opportunities.
A standard mean-variance objective is:
\[
\max_w\; \hat{\mu}^\top w - \frac{\lambda}{2}w^\top\hat{\Sigma}w
\]
where \(\lambda\) controls risk aversion. A standard optimizer uses \(\hat{\mu}\) as its input. We’ll extend the objective by considering a range of plausible expected-return vectors and choosing weights against the least favorable one in that range.
where \(\mathcal{U}\) is an uncertainty set around the estimated mean vector. The inner minimization is the adversary. It asks: if the true expected return is somewhere inside this plausible set, which version hurts the chosen portfolio the most?
This turns estimation uncertainty into a direct cost. A portfolio with large exposure to uncertain return forecasts gets penalized more than a portfolio whose expected return is more reliable.
The uncertainty set describes the alternative mean vectors considered by the inner minimization.
Expected-return estimates can have a large effect on optimized weights. The estimated portfolio return enters the mean-variance objective linearly:
\[
\hat{\mu}^\top w
\]
If one asset’s expected return estimate is too high by only a few percent annualized, a mean-variance optimizer may allocate heavily to it because the linear reward looks attractive. The optimizer doesn’t know that the estimate is noisy. It just sees a higher number.
The sampling error of a mean estimate is also large. For asset \(i\), if daily returns have volatility \(\sigma_i\) and the mean is estimated from \(T\) independent observations, the standard error is approximately:
After annualization, the uncertainty can still be large relative to the expected return itself. If an asset has an annual expected return estimate of 5% but the standard error is also several percent, the sign and rank of the forecast are not very reliable.
We can include a pessimistic adjustment to \(\hat{\mu}\) directly in the objective. A larger uncertainty set allows a larger adverse change in estimated returns, so a forecast needs to remain attractive after that adjustment to support the same allocation.
The same input estimates are used with the common portfolio constraints at each rebalance.
The covariance estimate \(\Sigma\) still enters the risk term while \(\hat{\mu}\) determines the expected-return reward.
10.1 Box Uncertainty
The first robust model uses a box uncertainty set. Each expected return can move independently within a range:
\[
(\mu^{\text{worst}})^\top w = \sum_{i=1}^{N}(\hat{\mu}_i-\rho s_i)w_i
\]
The optimization is then:
\[
\max_w\; (\hat{\mu}-\rho s)^\top w - \frac{\lambda}{2}w^\top\Sigma w
\]
subject to the portfolio constraints.
For the long-only allocation, we haircut each asset’s forecast by its own uncertainty scale before solving the constrained mean-variance problem. A larger uncertainty scale produces a larger downward adjustment for the same radius.
For the inputs used here, the scale is constructed from the selected annual covariance estimate and the number \(T_\mu\) of observations used for the return estimate:
We use this as the forecast-uncertainty scale in the haircut. It is a practical scaling choice for the selected return forecasts, including momentum estimates; the exact sampling standard error of an annualized daily sample mean would require consistent annualization and assumptions about serial dependence.
For a long-only portfolio, the box robust derivation is especially simple. The worst-case return is:
Since every \(w_i\ge 0\), the adversary lowers every \(\mu_i\) to its lower bound. So:
\[
\min_{\mu\in\mathcal{U}_{\text{box}}}\mu^\top w = \sum_{i=1}^{N}(\hat{\mu}_i-\rho s_i)w_i
\]
If shorting were allowed, the adversary would behave differently. For a negative weight, lowering \(\mu_i\) would help the portfolio, so the adversary would raise \(\mu_i\) instead. The general penalty would involve \(|w_i|\):
\[
\hat{\mu}^\top w - \rho\sum_{i=1}^{N}s_i|w_i|
\]
Because this project is long-only, the simpler haircut form is enough. We can therefore interpret the box adjustment as forecast haircutting: first construct the lower-return vector, then use it in the constrained mean-variance objective.
The box radius controls how skeptical we are. If \(\rho=0\), the model becomes standard MV. If \(\rho\) is large, only assets with very strong forecasts survive the haircut.
The radius controls the size of the allowed movement in each forecast.
For SPY, the estimated return is slightly negative and becomes more negative after the haircut. For QQQ, a small positive raw forecast becomes slightly negative. EEM still has a strong positive robust forecast: 3.06% raw, 2.77% after the haircut. DBC remains the strongest, 5.41% raw and 5.16% robust. GLD also stays positive after the haircut.
The resulting latest portfolio is concentrated:
EEM at 40%.
DBC at 40%.
GLD at 15.25%.
IWM at 2.82%.
SHY at 1.93%.
Most assets receive zero.
A few assets retain strong forecasts after the uncertainty haircut, and the resulting allocation concentrates in them up to the common cap. The adjustment changes the expected-return vector, while the 40% bound limits the weight of each individual holding.
The displayed table evaluates this adjustment at the latest rebalance date. The portfolio then balances those adjusted forecasts against covariance risk under the same 40% cap.
Box Robust MV performs much better than the defensive parity models. It earns 9.92% CAGR with 11.18% volatility, 0.5520 Sharpe, -15.57% max drawdown, and ES of 1.72%. That is close to Mean-CVaR and Ellipsoid Robust MV, but with slightly weaker Sharpe.
The model’s cost is turnover. Average turnover is 34.62% per month, and total turnover reaches 43.27 over the sample. The reason is that the robust expected-return vector still changes with the momentum forecast and standard errors. When the robust forecast ranking changes, the optimizer moves capital between the few names with positive robust expected returns.
Box Robust MV remains return-seeking after applying conservative mean estimates. Its robustness addresses mean-estimation uncertainty; the objective does not separately minimize turnover, so changes in the robust forecast ranking can still produce frequent trades.
The backtest includes the same trading-cost assumption used in the other portfolio comparisons.
10.2 Ellipsoidal Uncertainty
The second robust model uses an ellipsoid uncertainty set. Instead of allowing each expected return to move independently inside a box, it allows the whole vector to move inside a covariance-shaped region:
\(\Omega\) is the uncertainty covariance of the mean estimate.
\(\Omega^{1/2}\) is its matrix square root.
\(z\) is a standardized shock vector.
\(\rho\) controls the size of the uncertainty region.
The worst-case expected return for a portfolio has a closed form:
\[
\min_{\mu \in \mathcal{U}_{\text{ellipsoid}}} \mu^\top w = \hat{\mu}^\top w - \rho\|\Omega^{1/2}w\|_2
\]
The term \(\hat{\mu}^\top w\) is the estimated portfolio return. The penalty term \(\rho\|\Omega^{1/2}w\|_2\) is the amount the adversary can subtract from that return. The penalty is larger when the portfolio loads on directions where the mean estimate is more uncertain.
If \(\Omega=\Sigma/T\), then uncertainty is larger for assets and combinations with higher return variance and smaller sample size. So the model is saying: we trust expected returns less when they come from volatile assets or unstable directions.
The size of the norm depends on both portfolio weights and the estimated covariance of the uncertainty.
The ellipsoid penalty comes from Cauchy-Schwarz. Write the uncertain mean as:
\[
\mu = \hat{\mu}+\Omega^{1/2}z
\]
with \(\|z\|_2\le \rho\). Portfolio return under this mean is:
\[
\mu^\top w = \hat{\mu}^\top w + z^\top \Omega^{1/2}w
\]
The adversary chooses \(z\) to make the second term as negative as possible. The most negative possible value is:
This is useful because it turns a difficult “min over all plausible means” problem into a convex norm penalty. The penalty is portfolio-level, not asset-by-asset. If the portfolio loads on an uncertain combination of assets, the norm gets large. If it spreads across directions where forecast error cancels or is less severe, the norm is smaller.
The covariance-shaped region lets the penalty reflect how estimation errors move together across assets.
The minimizing shock points opposite to the portfolio’s uncertainty exposure. If \(\Omega^{1/2}w\ne0\), one such choice is:
Its norm is \(\rho\), so it lies on the boundary of the uncertainty ball. Substituting it into the return expression produces the negative norm penalty; when the exposure is zero, the penalty is zero and the direction of \(z\) has no effect.
The ellipsoid robust objective becomes:
\[
\max_w\; \hat{\mu}^\top w - \rho\|\Omega^{1/2}w\|_2 - \frac{\lambda}{2}w^\top\Sigma w
\]
Ellipsoid uncertainty penalizes the portfolio’s exposure to uncertain directions. Correlated estimation errors affect the norm jointly, so positions whose forecasts depend on a common source of uncertainty can produce a larger combined penalty.
For example, if SPY and QQQ both look attractive because the same growth regime has performed well, a box model may haircut them separately. An ellipsoid model can also recognize that their forecast errors are related. Loading on both doesn’t give two independent forecasts. It creates one larger exposure to a common uncertain direction.
For example, if two equally weighted forecasts have equal error variance and correlation \(\rho\), their combined error variance is proportional to \((1+\rho)/2\). A shared forecast error therefore offers less diversification as their correlation rises.
We can vary the ellipsoid radius to see how the estimated return, risk, and concentration respond to the uncertainty penalty. With radius 0.00, there is no robust penalty, and empirical return is about 3.72% in the latest state. As the radius increases to 1.50, the robust return falls to about 2.22%. Volatility also declines modestly from about 12.61% to 11.00%, and effective \(N\) stays around 2.9.
The latest allocation comparison between box and ellipsoid is very close. Both models hold:
EEM at 40%.
DBC at 40%.
GLD around 15-16%.
small IWM exposure.
little or no allocation to the rest.
The ellipsoid model drops SHY to zero and gives a slightly larger weight to IWM and GLD. The difference is not dramatic because the latest forecast environment is very directional: EEM and DBC dominate even after robust penalties. When the top forecasts are that strong, both box and ellipsoid uncertainty still point to a similar constrained solution.
In the backtest, Ellipsoid Robust MV slightly beats Box Robust MV: CAGR 10.01% versus 9.92%, Sharpe 0.5533 versus 0.5520, and turnover 34.01% versus 34.62%. The difference is small, but enough for the selection rule to choose Ellipsoid Robust MV.
The box and ellipsoid models have similar backtest characteristics in this implementation:
CAGR around 10%.
Annualized volatility around 11.2-11.3%.
Sharpe around 0.55.
Max drawdown around -15.6%.
High turnover above 34% average monthly.
Effective \(N\) around 3.1.
This tells us that most of the robust model’s behavior comes from the shared inputs: Ledoit-Wolf covariance, momentum expected returns, risk-aversion \(\lambda=2.0\), and the asset caps. The choice between box and ellipsoid affects the penalty geometry, but the latest and historical allocations still respond to the same broad signal.
Robust MV sits between MaxSharpe and Mean-CVaR on several of these return and risk measures. It is more return-seeking than tail-risk-only methods, but more cautious than a raw aggressive frontier model. Its drawdown is better than BL and MaxSharpe, but its ES is not as low as Risk Parity or HRP.
The box adjustment adds individual forecast haircuts, while the ellipsoid adjustment measures the combined exposure to correlated forecast errors.
The box and ellipsoid models describe uncertainty in the expected-return vector. Wasserstein distributionally robust optimization gives us a way to represent uncertainty about the return distribution around the empirical sample.
We start with the empirical distribution of historical returns:
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.
The code scales the ambiguity term using the estimated annual covariance and the return-estimation window:
The trace divided by \(N\) is the average individual annual variance, so its square root supplies a typical volatility scale. Dividing by \(\sqrt{T_\mu}\) scales it by the length of the mean-estimation window, and the chosen radius controls the size of both penalties. These quantities are recalculated from the available state at each rebalance.
Every term in the Wasserstein objective has a clear role:
\[
\hat{\mu}^\top w
\]
is the empirical expected return estimate.
\[
\sqrt{\delta}\|w\|_2
\]
is the distributional uncertainty tax on return. It penalizes concentrated portfolios because \(\|w\|_2\) is larger when weights are concentrated. For equal weights over \(N\) assets, \(\|w\|_2=1/\sqrt{N}\). For a single-asset portfolio, \(\|w\|_2=1\).
\[
\|\Sigma^{1/2}w\|_2
\]
is empirical portfolio volatility.
\[
\|\Sigma^{1/2}w\|_2+\sqrt{\delta}\|w\|_2
\]
is robust volatility. The model behaves as if the true volatility may be larger than the sample volatility by an amount tied to distributional uncertainty and concentration.
The resulting terms affect the allocation in several related ways:
It doesn’t fully trust the mean estimate.
It doesn’t fully trust the empirical risk estimate.
It penalizes portfolios that depend too heavily on a few positions.
That is why this model is often more stable than an aggressive return optimizer, but still more return-seeking than pure risk parity.
The Wasserstein penalty also has an intuitive concentration effect. The norm \(\|w\|_2\) behaves like an inverse diversification measure:
For a portfolio with 40%, 40%, and 20% in three assets:
\[
\|w\|_2 = \sqrt{0.4^2+0.4^2+0.2^2}=0.60
\]
So the same Wasserstein radius creates a much larger tax for the concentrated portfolio. The larger norm gives the concentrated portfolio a larger penalty at the same radius. The effect is visible in the robust return and risk terms even when the portfolio has the same total invested weight.
The penalty doesn’t force equal weight at the radius used here, but it changes the economics of concentration. A concentrated portfolio must have enough estimated return advantage to pay the ambiguity tax and still look attractive after robust risk inflation.
Because \(N_{\mathrm{eff}}=1/\sum_iw_i^2\) for fully invested long-only weights, the same norm can be written as \(\|w\|_2=1/\sqrt{N_{\mathrm{eff}}}\). The radius therefore connects directly to the concentration statistic we report later, although the optimizer still balances this term against expected return and covariance risk.
We can vary the Wasserstein radius to examine the sensitivity of the same allocation problem. At radius 0, the model behaves close to empirical robust MV: empirical return about 3.72%, volatility around 12.64%, effective \(N\) around 2.85, max weight 40%.
As the radius rises:
The return penalty increases.
Robust return falls.
Robust volatility becomes larger than empirical volatility.
Effective \(N\) eventually rises.
The max weight finally starts falling at very large radii.
At radius 10, the model has effective \(N\) around 9.14 and max weight about 23.06%. That is a very different portfolio from the radius 0 or 1 solution. The model is being forced to diversify because distributional uncertainty is now expensive enough that concentrated weights are unattractive.
We use radius 1.0 for the walk-forward strategy. The latest allocation remains concentrated in DBC, EEM, GLD, and IWM, so it still pursues expected return while charging for distributional uncertainty and concentration.
The latest Wasserstein DRMV weights are:
DBC at 40%.
EEM at 40%.
GLD at 15.23%.
IWM at 4.77%.
zero in most other assets.
This resembles the box/ellipsoid robust allocation because the current return signals are strongly concentrated in the same assets. The difference is in the objective accounting. Wasserstein DRMV tracks its uncertainty tax, robust volatility, robust return, risk penalty, and objective through time.
The latest three rows show a fairly stable \(\sqrt{\delta}\) around 0.009. Empirical return changes month by month, from 3.39% to 6.63% to 3.71%, while the DRO penalty stays around 0.53-0.54%. This means the uncertainty tax is meaningful, but it doesn’t dominate the expected-return forecast at radius 1.0.
The backtest result is strong relative to the other new models: 10.54% CAGR, 11.86% volatility, Sharpe 0.5743, and -17.61% max drawdown. It beats Box and Ellipsoid on return and Sharpe, but it also has a larger ES and higher drawdown than Mean-CVaR. That is the expected personality of the model: robust, but still return-seeking.
11.1 Wasserstein Target-Return Diagnostic
The target-return diagnostic asks a slightly different question. Instead of maximizing robust utility, we set a required robust return and minimize robust risk.
where \(r^\star\) is the target robust return. The left side of the return constraint is the expected return after the Wasserstein tax. The objective is robust risk, empirical volatility plus the same distributional concentration penalty.
This diagnostic produces a robust frontier. For each target return, we see how much empirical volatility and robust risk are needed. It is similar in spirit to an efficient frontier, but the risk axis includes distributional uncertainty.
The norm objective and robust-return constraint have a conic form. If auxiliary variables \(t\) and \(s\) bound empirical volatility and the weight norm, an equivalent representation is:
For a fixed target, both norm inequalities and the return condition define a convex feasible set. Raising the target narrows that set, so the minimum robust-risk objective cannot fall when the same inputs and constraints are used. The resulting frontier compares the cost of different return requirements under the chosen ambiguity scale.
Both the empirical and Wasserstein-adjusted portfolios are feasible across the displayed target grid. As target return increases from about -1.85% to 1.79%, empirical volatility rises from about 3.45% to 7.32%. The Wasserstein robust risk rises from about 3.95% to 8.92%.
The robust frontier lies above the empirical frontier because it adds the Wasserstein tax. That gap is useful. It shows how much risk the model attributes to distributional ambiguity rather than observed covariance alone.
The tax is lowest near the middle of the target range and rises near the extremes. That makes sense because extreme low- or high-return targets can require more concentrated or less natural allocations. The effective number of positions also moves with the target, rising toward the middle and falling when the target becomes more return-seeking.
We use the frontier as a diagnostic for the chosen DRO radius. It lets us compare the adjustment to risk and feasibility across target returns and see how much the ambiguity term changes the allocation. In this implementation, radius 1.0 looks moderate.
In the plotted frontier, the horizontal coordinate is the empirical volatility of each fitted portfolio. The table also reports the larger robust-risk quantity after adding the uncertainty norm; the separate tax plot shows that added component across target returns.
12) Full Model Table and Feasibility Checks
We can now combine the local models with the baselines and compare their return, risk, turnover, and allocation statistics in one table.
The full table includes:
Equal Weight.
MinVar.
MaxSharpe.
Learned-Confidence BL.
Min-CVaR.
Mean-CVaR.
Risk Parity.
HRP.
NCO-MV.
Box Robust MV.
Ellipsoid Robust MV.
Wasserstein DRMV.
We also check full investment for each local model at every rebalance:
\[
\max_t |\mathbf{1}^\top w_t - 1| = 0
\]
A zero value means the weights sum to one within the displayed precision. We also record the largest excess over the common cap and count the dates that use the fallback allocation.
Together these checks show whether the portfolio weights obey the constraints and whether a solver failure required the previous allocation to be carried forward. We can then interpret the performance comparison alongside the numerical results.
The checks use the portfolio weights recorded on the model rebalance dates and report their maximum deviation across the full backtest.
The models optimize different objectives, so we’ll compare them across four families of measures:
Family
Metrics
What it reveals
Return quality
CAGR, Sharpe, Sortino, Calmar
whether risk is being rewarded
Tail safety
max drawdown, VaR, ES, worst month
whether losses are controlled
Implementation cost
turnover, cost drag
whether the strategy can survive trading frictions
Allocation structure
effective \(N\), max weight, sleeve exposure
whether the model is diversified or concentrated
This matters because a model can win one family and lose another. Risk Parity wins tail safety but loses return quality. BL wins return quality but has high turnover and higher ES. Mean-CVaR has a strong middle position: not the highest return, not the lowest tail risk, but a good blend. W-DRO is stronger on return than Mean-CVaR, but weaker on tail risk.
The comparison is strongest when we keep these families separate. A single rank is useful at the end, but the model personality comes from the full metric profile.
The metrics are calculated on the same backtest period for the selected portfolios.
Show code
def full_result_table(result_dict): table = compact_result_table(result_dict) var_rows = []for name, result in result_dict.items(): r = result.net_returns.dropna() var = var_es_table(r.to_frame(name), alpha=0.05, methods=("hist",)) var_rows.append({"Strategy": name, "var_95": float(var.loc[name, "hist_var5"]),"es_95": float(var.loc[name, "hist_es5"]),"worst_month": float(r.resample("ME").apply(lambda x: (1.0+ x).prod() -1.0).min()),"avg_max_weight": float(result.weights.max(axis=1).mean())}) var_rows = pd.DataFrame(var_rows).set_index("Strategy") out = table.drop(columns=[c for c in var_rows.columns if c in table.columns], errors="ignore").join(var_rows, how="left") out = out.rename(columns={"vol": "ann_vol", "effective_n": "avg_effective_n"}) out["ann_return"] = out["cagr"] cols = ["cagr", "ann_return", "ann_vol", "sharpe", "sortino", "calmar", "max_drawdown", "var_95", "es_95", "worst_month","avg_turnover", "total_turnover", "cost_drag", "avg_effective_n", "avg_max_weight"]return out[[c for c in cols if c in out.columns]]full_summary = full_result_table(results)display(full_summary.sort_values(["sharpe", "max_drawdown", "cagr"], ascending=[False, False, False]).round(4))local_model_names = ["Min-CVaR 95", "Mean-CVaR 95", "Risk Parity", "HRP", "NCO-MV", "Box Robust MV", "Ellipsoid Robust MV","Wasserstein DRMV"]weight_check_rows = []for name in local_model_names:if name 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
For every local model, the displayed full-investment error and common-cap excess round to zero, and the fallback count is zero. The solvers therefore returned feasible portfolios for the displayed rebalance dates.
The Sharpe ranking and the other reported measures give us the following model comparison:
Model type
Strongest representative
Main result
Active Bayesian views
Learned-Confidence BL
highest Sharpe and CAGR
Aggressive frontier
MaxSharpe
highest Calmar, strong CAGR, high concentration
Distributionally robust
Wasserstein DRMV
best of the new robust models by Sharpe
Tail-budgeted
Mean-CVaR 95
strong balance of return and tail control
Classical robust MV
Ellipsoid Robust MV
slightly better than box robust
Pure tail/risk parity
Min-CVaR, RP, HRP
strongest risk control, weaker return
Learned-Confidence BL remains the top model overall by Sharpe at 0.7458 and CAGR at 12.53%. MaxSharpe is second by Sharpe at 0.6553 and has the best Calmar at 0.7065. Wasserstein DRMV is the best new robust model by Sharpe at 0.5743. Mean-CVaR is close behind at 0.5609 with lower volatility and lower drawdown.
Risk Parity and HRP have the best tail metrics. Risk Parity has ES of 0.73% and max drawdown of -13.74%, the best among finalists. HRP has ES of 0.75% and very low volatility. Their weakness is return, not risk.
Show code
def rank_for_choice(names, table, columns, ascending): available = [name for name in names if name in table.index]return table.loc[available].sort_values(columns, ascending=ascending).index[0]best_cvar_name = rank_for_choice(["Min-CVaR 95", "Mean-CVaR 95"], full_summary, ["sharpe", "calmar", "es_95", "max_drawdown", "cagr"], [False, False, True, False, False])benchmark_models = ["Equal Weight", best_minvar_name, best_mv_name, best_maxsharpe_name,"Learned-Confidence BL",]selected_models_raw = benchmark_models + [best_cvar_name, "Risk Parity", best_hier_name, best_robust_name, "Wasserstein DRMV"]selected_models =list(dict.fromkeys([name for name in selected_models_raw if name in results]))short_label_map = {"Equal Weight": "EW", best_minvar_name: "MinVar", best_mv_name: "MV", best_maxsharpe_name: "MaxSharpe", "Learned-Confidence BL": "BL", "Min-CVaR 95": "Min-CVaR","Mean-CVaR 95": "Mean-CVaR", "Risk Parity": "RP", "HRP": "HRP","NCO-MV": "NCO-MV", "Box Robust MV": "Box-Robust", "Ellipsoid Robust MV": "Ellipsoid","Wasserstein DRMV": "W-DRO"}def short_labels(names):return [short_label_map.get(name, name) for name in names]def small_legend(ax, loc="best"): ax.legend(fontsize=7, ncol=2, frameon=True, framealpha=0.85, loc=loc)reason_rows = []for name in selected_models:if name =="Equal Weight": role ="baseline" selected_from ="project 2 grid"elif name == best_minvar_name: role ="minimum variance benchmark" selected_from ="project 2 grid"elif name == best_mv_name: role ="mean-variance benchmark" selected_from ="project 2 grid"elif name == best_maxsharpe_name: role ="sharpe/frontier benchmark" selected_from ="project 2 grid"elif name =="Learned-Confidence BL": role ="black-litterman benchmark" selected_from ="project 6 rebuild"elif name == best_cvar_name: role ="tail-risk allocation" selected_from ="Min-CVaR 95 vs Mean-CVaR 95"elif name =="Risk Parity": role ="risk budget allocation" selected_from ="single specified model"elif name == best_hier_name: role ="hierarchical allocation" selected_from ="HRP vs NCO-MV"elif name == best_robust_name: role ="robust mean-variance" selected_from ="Box Robust MV vs Ellipsoid Robust MV"else: role ="distributionally robust allocation" selected_from ="single specified model" reason_rows.append({"model": name, "role": role, "selected_from": selected_from})selected_reason = pd.DataFrame(reason_rows).set_index("model")display(pd.Series(selected_models, name="model").to_frame())display(selected_reason)print("selected CVaR model:", best_cvar_name)
model
0
Equal Weight
1
MinVar (OAS)
2
MV (LedoitWolf, BayesStein)
3
MaxSharpe (Sample, BayesSteinMomentum)
4
Learned-Confidence BL
5
Mean-CVaR 95
6
Risk Parity
7
HRP
8
Ellipsoid Robust MV
9
Wasserstein DRMV
role
selected_from
model
Equal Weight
baseline
project 2 grid
MinVar (OAS)
minimum variance benchmark
project 2 grid
MV (LedoitWolf, BayesStein)
mean-variance benchmark
project 2 grid
MaxSharpe (Sample, BayesSteinMomentum)
sharpe/frontier benchmark
project 2 grid
Learned-Confidence BL
black-litterman benchmark
project 6 rebuild
Mean-CVaR 95
tail-risk allocation
Min-CVaR 95 vs Mean-CVaR 95
Risk Parity
risk budget allocation
single specified model
HRP
hierarchical allocation
HRP vs NCO-MV
Ellipsoid Robust MV
robust mean-variance
Box Robust MV vs Ellipsoid Robust MV
Wasserstein DRMV
distributionally robust allocation
single specified model
selected CVaR model: Mean-CVaR 95
We select the following finalists for the detailed comparison:
Equal Weight.
MinVar with OAS.
MV with Ledoit-Wolf and Bayes-Stein.
MaxSharpe with sample covariance and Bayes-Stein Momentum.
Learned-Confidence BL.
Mean-CVaR 95.
Risk Parity.
HRP.
Ellipsoid Robust MV.
Wasserstein DRMV.
Mean-CVaR 95 is the selected CVaR model: Min-CVaR has lower tail loss, while Mean-CVaR has the higher Sharpe used by the selection rule. The selected hierarchical model is HRP because it beats NCO-MV on Sharpe, drawdown, and turnover. The selected classical robust model is Ellipsoid Robust MV because it slightly beats Box Robust MV.
This filtered set now covers the main philosophical choices:
simple diversification,
variance minimization,
expected-return optimization,
Bayesian active views,
tail-risk budgeting,
risk-budgeting,
hierarchy-based allocation,
mean-uncertainty robustness,
distributional robustness.
Each selected model is compared using the same reported metrics and cost assumptions.
MaxSharpe is close in return, 11.68% CAGR, and has the best Calmar at 0.7065, but it has the worst ES among finalists at 1.86% and a very low effective \(N\) of 3.20. This is a high-conviction, high-turnover model.
Wasserstein DRMV earns 10.54% CAGR with Sharpe 0.5743. That is a good result for a robust model. It doesn’t beat BL or MaxSharpe, but it does beat classical MV, Equal Weight, MinVar, Risk Parity, and HRP on Sharpe.
Mean-CVaR 95 is one of the best balanced new models: 9.21% CAGR, 9.55% volatility, Sharpe 0.5609, and max drawdown -14.61%. Its ES is 1.45%, meaning it gives up some return to improve tail behavior.
Risk Parity wins risk control: ES 0.73% and max drawdown -13.74%. Its Sharpe is only 0.1320 because the portfolio is too defensive relative to the 4% risk-free rate.
Show code
selected_nav = pd.DataFrame({name: results[name].net_values for name in selected_models}).dropna(how="all")fig, ax = plt.subplots(figsize=(9.0, 4.4))plot_strategy_nav(selected_nav[selected_models], ax=ax, title="project 10 finalists: net wealth", labels=short_labels(selected_models))ax.set_ylabel("growth of $1")small_legend(ax, loc="upper left")plt.tight_layout()plt.show()
Learned-Confidence BL and MaxSharpe finish near the top of the net-wealth comparison, with Wasserstein DRMV and Ellipsoid Robust MV also compounding well. Mean-CVaR grows more slowly but with less severe tail exposure. Risk Parity and HRP trail because their defensive allocations don’t earn enough return in this sample.
The faster-growing active portfolios also experience deeper drawdowns. BL, MaxSharpe, and W-DRO all experience drawdowns around the mid-to-high teens. Mean-CVaR keeps drawdown closer to -14.6%. Risk Parity and HRP control drawdowns best.
We can compare how the selected portfolios balance return and risk:
BL and MaxSharpe behave like return engines.
W-DRO and Ellipsoid Robust MV behave like return engines with robust penalties.
Mean-CVaR behaves like a tail-budgeted growth model.
Risk Parity and HRP behave like defensive allocation models.
Equal Weight is a neutral reference that gets outperformed by active/robust models but keeps broad diversification.
Both plots use the same selected portfolio histories after the backtest costs and rebalancing conventions are applied.
In the risk-return scatter, marker size represents the ES 95 loss. Models in the upper-right deliver more return but also carry larger tail losses. Models in the lower-left are safer but less rewarding.
The scatter makes three groups visible:
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.
The positions and marker sizes correspond to the same finalist performance table.
14) Average Weights and Sleeve Exposures
We can examine the average holdings to connect each objective with the exposures it tends to select.
Equal Weight holds 7.14% in every asset by construction. MinVar and MV both allocate heavily to SHY, with MV also holding GLD and QQQ. MaxSharpe holds QQQ, GLD, and SHY as its top average positions. BL is more equity-oriented, with top average holdings in SPY, QQQ, and EFA.
Mean-CVaR holds SHY, GLD, and DBC as its top average positions. This makes sense: it wants return, but under a tail constraint, so it uses assets that have historically helped tail behavior or inflation regimes. Risk Parity and HRP are both dominated by SHY and bond-like exposures. Ellipsoid Robust and Wasserstein DRMV both hold GLD, DBC, and QQQ as major average positions, consistent with robust return-seeking behavior.
The sleeve table summarizes this even better:
BL has the largest equity sleeve at 56.33%.
Risk Parity has 57.90% duration.
HRP has 60.45% duration.
Mean-CVaR has 31.45% diversifiers and 36.25% duration.
Ellipsoid Robust and W-DRO have the largest diversifier exposure, around 35-36%.
The resulting sleeve preferences connect the models’ risk definitions to different economic exposures in the cross-asset universe.
Show code
average_weights = pd.DataFrame({name: results[name].weights.reindex(columns=assets).mean() for name in selected_models}).reindex(assets)def average_sleeves(result): w = result.weights.reindex(columns=assets).fillna(0.0) row = {}for sleeve, members in sleeve_groups.items(): row[f"avg_{sleeve}"] =float(w[members].sum(axis=1).mean()) row["avg_effective_n"] =float((1.0/ (w.pow(2).sum(axis=1))).replace([np.inf, -np.inf], np.nan).mean()) row["avg_max_weight"] =float(w.max(axis=1).mean())return 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 weights show how the models distribute capital across the ETF universe. Risk Parity and HRP place major weight in SHY and bond assets. Robust models put large average weight in GLD, DBC, QQQ, and sometimes EEM. BL distributes more across equity names and avoids the extremely defensive structure of parity models.
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.
Rolling 12-month Sharpe ratios change across market regimes. Around the COVID crash and 2022 inflation shock, Sharpe ratios shift quickly. Models that looked good during equity rallies can deteriorate when inflation or rates dominate. Defensive models can lag badly during strong risk-on recoveries.
The latest 12-month return table shows BL leading at 27.02%, followed by Ellipsoid Robust MV at 22.74% and Wasserstein DRMV at 22.56%. Equal Weight is at 18.83%, MV at 18.11%, Mean-CVaR at 17.02%, and HRP at 10.92%.
This recent period favors the active and robust return-seeking models. Their selected exposures, especially GLD/DBC/QQQ/EEM-type positions, participate strongly as those assets perform well in the recent window. HRP lags because the duration-heavy defensive allocation doesn’t match the current return environment.
Rolling Sharpe is useful because it prevents a single full-sample metric from hiding regime changes. A model can have a good full-sample Sharpe but still be very weak in a specific macro regime.
Each rolling observation uses the preceding 252 daily returns, so consecutive points share most of their underlying observations.
The historical VaR and ES estimates provide a tail-risk comparison across the finalists:
Risk Parity has the lowest historical ES at 0.73%.
HRP is next at 0.75%.
MinVar has ES 1.11%.
MV has ES 1.32%.
Mean-CVaR has ES 1.45%.
BL, Ellipsoid, MaxSharpe, and W-DRO have ES around 1.74-1.86%.
The filtered historical simulation (FHS) ES is more severe for the aggressive and robust return-seeking models. BL has FHS ES 2.90%, MaxSharpe 3.14%, Ellipsoid 3.31%, and W-DRO 3.30%. This tells us that when recent volatility is used to scale historical returns, the tail risk of the aggressive models looks larger.
Their return advantage comes with downside exposure that is visible when historical moves are scaled by recent volatility. Mean-CVaR’s FHS ES is much lower at 1.80%, and Risk Parity/HRP remain below 1%. The tail-aware models are doing their job in risk units.
The estimates are calculated from the recorded portfolio return series.
We compare the models across 2018 Q4, the COVID crash, and the 2022 inflation/rate shock. Each window highlights different portfolio exposures:
2022 inflation/rate shock stresses both stocks and duration bonds at the same time.
Risk Parity and HRP usually look better in pure equity drawdowns because they carry more duration and lower equity risk. However, 2022 is more complicated because duration itself was under pressure. Mean-CVaR is often more balanced because its tail budget can shift toward assets that helped in recent tail windows, but it still depends on the rolling scenarios.
BL and MaxSharpe have more upside capture but can lose more in sharp shocks. W-DRO and Ellipsoid Robust MV are meant to reduce input fragility, not eliminate market exposure. So they can still experience large stress losses if the assets they select are hit together.
The rolling beta plots show the same theme. Defensive models have lower market beta. Active return-seeking models have higher and more variable beta. A model’s Sharpe ratio alone doesn’t show this exposure path.
The rolling beta compares covariance with the aligned SPY return series against SPY variance in the same window, so a changing beta describes changing market exposure over time.
We can identify the leading model for each reported criterion:
Highest CAGR: Learned-Confidence BL.
Highest Sharpe: Learned-Confidence BL.
Highest Sortino: Learned-Confidence BL.
Highest Calmar: MaxSharpe.
Lowest max drawdown: Risk Parity.
Lowest ES 95: Risk Parity.
Lowest turnover: MinVar.
Highest effective \(N\): Equal Weight.
Best balanced rank: Learned-Confidence BL.
The balanced rank score combines Sharpe, Calmar, ES, drawdown, turnover, and diversification. BL wins because its return quality is strong enough to overcome its weaker tail and turnover ranks. MV comes second in balanced rank because it has excellent drawdown and reasonable turnover even though it doesn’t lead return metrics. Mean-CVaR and MaxSharpe tie closely after that, but for different reasons: Mean-CVaR is better on risk control, MaxSharpe is better on return and Calmar.
This ranking is useful because there is no universal “best” portfolio. A pension-style allocator might prefer Mean-CVaR or MV because drawdown and ES matter more. A return-seeking ETF strategy might prefer BL or W-DRO. A defensive allocation sleeve might prefer Risk Parity or HRP. The table makes those tradeoffs explicit rather than hiding them behind one metric.
The balanced score uses ranks rather than the raw units of those measures. For model \(m\), the implemented weighted score is:
Rank 1 is best for each criterion, so a lower combined score is preferred. Sharpe, Calmar, drawdown (less negative is better), and effective \(N\) are ranked from high to low; ES and turnover are ranked from low to high. The weights determine how strongly each criterion contributes to this particular comparison.
Show code
selected_reason_lookup = selected_reason["role"].to_dict()selected_from_lookup = selected_reason["selected_from"].to_dict()final_comparison = selected_scoreboard[["cagr", "sharpe", "calmar", "max_drawdown", "es_95", "avg_turnover", "avg_effective_n"]].copy()final_comparison.insert(0, "role", [selected_reason_lookup[name] for name in final_comparison.index])final_comparison.insert(0, "model", final_comparison.index)final_comparison["selected_reason"] = [selected_from_lookup[name] for name in final_comparison["model"]]final_comparison = final_comparison.reset_index(drop=True)display(final_comparison.round(4))
model
role
cagr
sharpe
calmar
max_drawdown
es_95
avg_turnover
avg_effective_n
selected_reason
0
Equal Weight
baseline
0.0848
0.4842
0.4159
-0.2038
0.0139
0.0114
14.0000
project 2 grid
1
MinVar (OAS)
minimum variance benchmark
0.0665
0.3543
0.3370
-0.1973
0.0111
0.0031
9.1680
project 2 grid
2
MV (LedoitWolf, BayesStein)
mean-variance benchmark
0.0814
0.4949
0.5904
-0.1378
0.0132
0.0369
5.0557
project 2 grid
3
MaxSharpe (Sample, BayesSteinMomentum)
sharpe/frontier benchmark
0.1168
0.6553
0.7065
-0.1653
0.0186
0.2177
3.2046
project 2 grid
4
Learned-Confidence BL
black-litterman benchmark
0.1253
0.7458
0.6888
-0.1820
0.0176
0.3147
5.2021
project 6 rebuild
5
Mean-CVaR 95
tail-risk allocation
0.0921
0.5609
0.6305
-0.1461
0.0145
0.3077
3.3662
Min-CVaR 95 vs Mean-CVaR 95
6
Risk Parity
risk budget allocation
0.0453
0.1320
0.3295
-0.1374
0.0073
0.0131
5.9635
single specified model
7
HRP
hierarchical allocation
0.0519
0.2547
0.3586
-0.1446
0.0075
0.0186
5.0967
HRP vs NCO-MV
8
Ellipsoid Robust MV
robust mean-variance
0.1001
0.5533
0.6421
-0.1559
0.0174
0.3401
3.1440
Box Robust MV vs Ellipsoid Robust MV
9
Wasserstein DRMV
distributionally robust allocation
0.1054
0.5743
0.5986
-0.1761
0.0184
0.3119
3.7144
single specified model
The final comparison brings the selected portfolios’ return, risk, turnover, and concentration measures together.
BL is the strongest all-around performer in this sample: 12.53% CAGR, 0.7458 Sharpe, 0.6888 Calmar. Its cost is turnover and meaningful tail risk. MaxSharpe is the best pure frontier-style baseline, with 11.68% CAGR and 0.7065 Calmar, but it has the highest ES among finalists.
Mean-CVaR earns a smaller 9.21% CAGR but gives a much better max drawdown than BL and a lower ES. That makes it the strongest tail-aware growth model. Risk Parity and HRP are defensive risk-control models. They reduce ES and drawdown, but their returns are too low to win in a risk-free-rate-aware Sharpe comparison.
Ellipsoid Robust MV and Wasserstein DRMV sit between MaxSharpe and Mean-CVaR. They preserve return better than the defensive models while adding uncertainty penalties to the optimizer. W-DRO is the stronger robust model by Sharpe and CAGR, while Ellipsoid has slightly better drawdown and Calmar.
This is the main result of Project 10: changing the risk definition changes the economic personality of the portfolio. Variance, tail loss, risk contribution, clustering, mean uncertainty, and distributional ambiguity all produce different portfolios, and those differences are visible in performance, drawdown, turnover, concentration, and sleeve exposure.
Min-CVaR, Risk Parity, and HRP produce the most defensive allocations in this sample. Their low tail losses and volatility come with lower realized returns than the more return-seeking models.
The models that use expected returns more aggressively, BL, MaxSharpe, robust MV, and W-DRO, perform better on CAGR and Sharpe. But their tails are larger and their turnover is higher. The successful models are the ones that balance a return engine with a risk-control mechanism:
BL uses Bayesian priors, learned confidence, constraints, and active views.
MaxSharpe uses the frontier but accepts concentration.
Mean-CVaR uses expected returns but caps the tail.
Robust MV uses expected returns but haircuts uncertainty.
W-DRO uses expected returns but taxes distributional ambiguity and concentration.
Together, these allocations connect classical mean-variance choices with tail budgets, risk contributions, and explicit uncertainty sets. Each objective rewards a different combination of return, diversification, and protection against adverse scenarios.
The observed tradeoffs depend on both the allocation choices and the sample period. The table keeps the realized return and risk measures next to each model’s portfolio-construction objective.
17) Sector ETF Repeat with the Library
We now repeat the model families on a sector ETF universe using the library. The sector opportunity set has a different structure from the cross-asset ETF universe used above.
The cross-asset universe contains equities, bonds, credit, gold, real estate, and commodities. Sector ETFs are all equity sectors. They share a common market beta, and diversification comes mostly from sector rotation rather than asset-class diversification.
The data is reproduced from the sector_etfs folder, with cross-asset signal data coming from core_cross_asset_etfs. We don’t link to direct generated files because the repository’s data layer is organized by source folders, each with its own reproducibility notes and scripts.
The sector panel is aligned across the available ETF histories before we form monthly rebalance dates. XLC has a shorter history than several other sector funds, so the shared sample starts later than the cross-asset panel.
The secondary application uses the same broad workflow and performance measures.
The sector repeat uses the same model families, but the interpretation changes.
In the cross-asset universe, Risk Parity can diversify across stocks, bonds, credit, and real assets. In the sector universe, Risk Parity can only diversify across sectors that all still have equity beta. So risk control is less powerful. A defensive sector like XLP or XLV may fall less than XLK in a crash, but it is still an equity ETF.
The BL sector views are also sector-specific:
sector momentum,
growth leadership,
defensive rotation,
cyclical breadth,
credit beta,
inflation beneficiaries,
duration-sensitive sectors,
small-cap risk-on,
quality defensive,
sector reversal.
The sector BL benchmark is equal weight across sectors, and the views express relative sector opportunities. We can therefore examine the same view and confidence machinery in a sector-rotation setting with a shared equity-market exposure.
Each view family contributes candidate relative sector signals to the BL reference.
Sector ETFs also make the robust-model question harder. In the cross-asset universe, a robust model can move toward gold, commodities, short duration, or emerging markets when those exposures survive the uncertainty penalty. In sectors, all assets are still claims on US equity cash flows. Even Energy and Utilities are different sectors, not different asset classes.
This reduces the value of some risk models. Risk parity can lower sector concentration, but it can’t create a Treasury hedge. CVaR can favor defensive sectors, but those sectors can still fall with the market. Wasserstein DRO can penalize concentration, but if all sectors become correlated in a crash, the distributional ambiguity doesn’t magically create an uncorrelated asset.
At the same time, sector rotation is a better environment for MaxSharpe when the winning sectors have persistent leadership. Technology, Communication Services, Consumer Discretionary, and Energy can dominate certain periods, and a return-seeking optimizer can capture that leadership if the signal is stable enough.
The secondary application lets us compare the model families when diversification comes from intra-equity rotation rather than allocation across different asset classes.
The available sectors give the optimizers a common equity-market opportunity set.
The aligned sector sample has 2,000 return rows from 2018-07-03, with monthly rebalances from 2020-07-31 to 2026-06-17. The BL run generates 310 candidate views and 280 selected views.
The sector benchmark returns are strong over this sample. Equal Weight earns 14.71% CAGR with 14.75% volatility and 0.7352 Sharpe. MaxSharpe is the strongest model among the first five benchmarks: 18.09% CAGR, 16.26% volatility, 0.8550 Sharpe, and 1.0872 Calmar.
BL improves over equal weight, but doesn’t beat MaxSharpe:
Its turnover is very high at 46.47% average monthly. The sector views move allocations frequently, so the benefit of the rotations needs to be evaluated alongside their trading costs and shared equity-market exposure.
MV has a strong Calmar of 0.9112 and a max drawdown of -15.99%, better than Equal Weight’s -19.23%. MinVar lowers volatility but gives up too much return, similar to the main cross-asset application. This benchmark-only table is important because the robust and tail-risk models need to beat a serious sector MaxSharpe baseline, not just Equal Weight.
After adding the new Project 10 models, the sector finalist table changes materially. Wasserstein DRMV, now run with the sector-specific EWMA covariance and BayesStein expected-return inputs, is essentially tied with MaxSharpe by Sharpe and is better on Calmar and drawdown.
With the sector-specific EWMA + BayesStein inputs, W-DRO is competitive with the best sector MaxSharpe model and holds a slightly more diversified average portfolio: effective \(N\) is 3.67 for W-DRO versus 3.24 for MaxSharpe. The cost is turnover, 22.05% versus MaxSharpe’s 17.29%, but that is still far below BL’s 46.47%.
The sector W-DRO run uses radius 2.0 and a Wasserstein risk-aversion parameter of 1.0 alongside the sector-specific covariance and return estimates. Those settings are shown in the final library implementation.
W-DRO is one of the strongest return-adjusted sector models in this sample, while the lowest historical ES belongs to MinVar. The W-DRO objective balances estimated return with distributionally adjusted risk; Mean-CVaR and MinVar use different objectives.
Historical ES values:
MinVar: 1.86%.
Mean-CVaR: 1.95%.
NCO-MV: 2.03%.
Risk Parity: 2.06%.
Equal Weight: 2.14%.
BL: 2.19%.
MV: 2.24%.
W-DRO: 2.36%.
MaxSharpe: 2.37%.
Ellipsoid Robust MV: 2.37%.
The filtered historical simulation ES ranking is even clearer. Mean-CVaR and Risk Parity are around 1.28%-1.29%, MinVar and Equal Weight are around 1.41%-1.43%, while W-DRO and Ellipsoid are around 2.64% and 2.63%. So the robust return-seeking models earn their higher Sharpe by accepting larger scaled tail exposure than the defensive models.
The range is narrower than in the cross-asset case because all sector ETFs still share equity beta. Sector rotation can reduce sector-specific drawdown, but it can’t fully escape market shocks. The plots show this: sector models differ in wealth paths and drawdowns, but they stay much more correlated than cross-asset models.
The ES estimates compare the finalist return histories on a consistent basis.
The sector stress and rolling-beta plots need to be read with this common-beta structure in mind. In a cross-asset universe, a model can reduce equity beta by moving into duration, cash-like bonds, gold, or commodities. In a sector universe, the model can only choose which kind of equity beta it wants. That means sector tail control usually comes from avoiding the worst sector leadership, not from escaping the equity market.
Sector selection has more room to help during 2022 than during the initial COVID crash. The 2022 environment had clear cross-sectional sector dispersion: Energy benefited from the inflation and commodity shock, defensive sectors held up better than long-duration growth, and rate-sensitive sectors struggled. A sector allocation model can exploit that dispersion. During COVID’s initial crash, sector dispersion existed, but the first-order move was broad market deleveraging. In that type of shock, all sector ETFs drop together and the model has less room to defend.
This is why sector Mean-CVaR has lower ES than the active return-seeking models but doesn’t become dramatically safer. Its historical ES is 1.95%, better than BL, W-DRO, MaxSharpe, and Ellipsoid, but still far above the defensive tail levels seen in the cross-asset Risk Parity and HRP portfolios. The opportunity set defines the achievable tail reduction.
The sector rolling-beta plots also help diagnose model behavior. A sector strategy can lower beta by leaning toward Staples, Health Care, and Utilities, or increase beta by leaning toward Technology, Consumer Discretionary, Financials, Industrials, and Energy depending on the regime. W-DRO’s better full-period drawdown comes from this rotation plus the Wasserstein diversification penalty, not from eliminating equity exposure. Beta rarely collapses toward zero because every tradable asset remains inside the equity market.
In the sector diagnostic panel, we can compare how the model tools behave in an equity-only universe:
The Mean-CVaR budget path still shows the return-tail tradeoff.
Risk Parity spreads risk more evenly across sectors than equal capital weighting.
The sector cluster tree separates growth, defensive, cyclicals, and rate-sensitive groups.
NCO-MV turns those clusters into a nested allocation.
The Wasserstein radius path shows robust diversification pressure as ambiguity increases.
Average weight heatmaps reveal which sector exposures each model repeatedly favors.
This secondary run is a good stress test of portability. The library functions work, the same model families run, and the comparison metrics are consistent. But the estimation recipe has to change with the universe. Cross-asset allocation can use structurally different risk premia. Sector allocation mostly chooses different expressions of equity beta, so the model benefits from a faster covariance estimate and a shrunk expected-return estimate.
The Wasserstein radius path is especially useful after the retune. Radius 2.0 produces almost the same Sharpe as the more aggressive low-radius sector W-DRO setting, but with better drawdown, lower turnover, and a higher effective \(N\). That is a good robust result: the ambiguity set is not just weakening the optimizer; it is buying a more stable version of a high-Sharpe sector allocation.
The sector plots also show a useful implementation lesson. The same library functions produce a consistent report, but the interpretation has to change with the asset universe.
The average-weight heatmap is especially useful here. In cross-asset allocation, seeing a large weight in SHY, DBC, GLD, or EEM tells us the model is making an asset-class decision. In sectors, seeing a large weight in XLK, XLE, XLV, or XLP tells us the model is making a style and macro sensitivity decision. XLK/XLC/XLY usually express growth and long-duration equity exposure. XLE/XLB express inflation and commodity sensitivity. XLV/XLP express quality/defensive cash-flow exposure. XLF/XLI express cyclicality and credit/economic acceleration.
In the sector version, allocations choose among different types of equity risk. MaxSharpe and W-DRO both lead in the sector run, despite placing different penalties on concentrated positions. MaxSharpe captures persistent sector leadership directly. W-DRO captures much of the same leadership, but taxes concentration and distributional ambiguity, which gives it slightly lower CAGR but better Calmar, drawdown, and diversification.
The two applications use the same robust objectives with inputs suited to each universe: LedoitWolf + Momentum for the cross-asset robust estimates and EWMA + BayesStein for the sector run. The comparison lets us see how the objective behaves when both the opportunity set and the return/covariance estimates change.
The sector EWMA covariance puts more weight on recent observations, while the Bayes–Stein return estimate shrinks sample excess means before scaling the expected-return signal. Those inputs enter the same robust allocation problem on the sector opportunity set.