Markowitz vs Risk Parity vs HRP vs Black–Litterman, run walk-forward over nineteen years of real ETF data with turnover and transaction costs charged explicitly.
Live demo: https://codeebytee.github.io/06-portfolio-construction-arena/ (enable Pages: Settings → Pages → main /docs)
Run the interface locally: clone the repo and double-click docs/index.html. No install, no server, no internet connection. requirements.txt is only needed to re-run the research.
- Implements six construction methods from the papers — mean-variance (constrained and not), Ledoit–Wolf and OAS shrinkage, equal-risk-contribution risk parity, Hierarchical Risk Parity, and Black–Litterman with a view builder — and runs all of them through one walk-forward harness so the comparison is apples to apples.
- Charges every method for its own turnover. The cost drag column is the one most backtests omit, and here it reorders the results table.
- Quantifies estimation error instead of asserting it. Hold the covariance and the mean fixed — they are the world — draw a finite sample, and measure how much of each method's answer is noise.
Walk-forward, 756-day estimation window, monthly rebalance, 10 bp one-way costs, twelve ETFs, 2010–2026:
| Strategy | Net CAGR | Net Sharpe | Vol | Max DD | Turnover/yr | Cost drag |
|---|---|---|---|---|---|---|
| Risk parity (ERC) | +5.80% | 0.79 | 7.5% | −20.5% | 0.39 | 4 bp |
| Max Sharpe (sample cov) | +7.61% | 0.78 | 10.0% | −22.5% | 2.72 | 29 bp |
| Max Sharpe (Ledoit–Wolf) | +7.55% | 0.78 | 10.0% | −22.2% | 2.75 | 30 bp |
| Min variance (Ledoit–Wolf) | +4.40% | 0.75 | 5.9% | −16.5% | 0.51 | 5 bp |
| Inverse volatility | +6.20% | 0.72 | 8.9% | −26.1% | 0.37 | 4 bp |
| HRP | +4.49% | 0.69 | 6.7% | −20.1% | 0.84 | 9 bp |
| Black–Litterman (equilibrium) | +6.96% | 0.63 | 11.9% | −36.4% | 0.37 | 4 bp |
| Equal weight (1/N) | +7.13% | 0.62 | 12.4% | −37.6% | 0.37 | 4 bp |
| Markowitz, unconstrained | −100.00% | — | 450% | −108% | 632.87 | 9,385 bp |
Read it honestly. The spread from the best net Sharpe (0.79) to equal weight (0.62) is 0.17 — smaller than the ±0.23 standard error on any single one of them over this sample. Most of this table is not a ranking; it is noise with an ordering imposed on it.
The two results that are large enough to mean something:
- Unconstrained Markowitz is wiped out. Not "underperforms" — the equity curve reaches zero. It loses 6.2% a year before costs, then turns over 633× the book annually and hands another 94 percentage points to the spread. This is the honest answer to "why do practitioners constrain everything?"
- 1/N is not beaten by a margin this sample can resolve. It estimates nothing, so it pays for nothing. Every method that estimates something pays for its inputs twice — once in weight error, once at the spread.
And the finding that contradicts the folklore:
- HRP is the least stable of the long-only methods here. Resampling 200 times from the real covariance produces 62 distinct dendrograms; only 25 draws recover the ordering the true matrix implies. HRP's clustering step is discrete, so a tiny change in ρ reshuffles the whole allocation. Against unconstrained Markowitz it looks superb — but that comparison flatters it, because most of unconstrained Markowitz's instability is the missing short-sale constraint, not the matrix inverse. On a small liquid universe, the constraint set does more regularisation work than the clever algorithm does. (DEEP_DIVE §6.3)
pip install -r requirements.txt
python scripts/make_results.py # every experiment; ~2.5 hours
python scripts/build_frontend.py # regenerates docs/data.js from results/Then double-click docs/index.html. To verify everything:
pytest # 164 tests, ~4 minutes
python scripts/check_page.py # static checks + JS/Python parity under NodeFive tabs, all of them driving real computation rather than displaying stored pictures:
- Arena — the equity-curve race. The transaction-cost slider is exact, not interpolated: the page ships daily gross returns and rebalance-day turnover separately, and net return is
gross − turnover × bp/10⁴. Weights never depend on the cost assumption, so sliding it re-derives the whole race with no approximation. - Frontier — pick an estimation window and a covariance estimator; the frontier is solved in your browser by the same FISTA algorithm the Python library uses. Set the window to 60 days and watch the unconstrained frontier detonate.
- Allocations & risk — capital weights against risk contributions, and the correlation matrix reordered live by the HRP tree.
- Black–Litterman views — build views with a confidence slider and watch weights shift. Includes a live demonstration that τ cancels out of the posterior mean exactly under the He–Litterman convention, which is not what most commentary implies.
- Estimation error — resample a known world in the browser and watch each method's weights move on information that is noise by construction.
- The browser runs the same algorithms, not a lookup table. The constrained solver is FISTA with adaptive restart, chosen partly because the capped-simplex projection is exact and the whole thing is forty lines that port to JavaScript unchanged.
scripts/check_page.pyruns the port under Node and pins every output against the Python library — HRP weights and leaf order, ERC, the frontier, the Black–Litterman posterior. A port is a second implementation, and a second implementation is a liability unless it is tested against the first. - Costs are a post-processing step, and that is exploited twice. Weights never depend on the cost assumption, so the 60-cell parameter grid collapses to 12 backtests, and the page's cost slider is continuous and exact instead of snapping between precomputed levels.
- Look-ahead is prevented structurally, and the test has a control.
weight_fnreceives an array of past returns and never sees a date, so it cannot index the future. The test builds a strategy that would post an absurd Sharpe if it could see day t and confirms it doesn't — alongside a control that shifts the window forward one day and confirms the Sharpe explodes past 8. Without the control, the first test proves nothing. - Results are reported with their error bars. Comparing nine methods on one sample makes the winner an order statistic. The deflated-Sharpe hurdle and Lo's standard error are printed next to the table rather than left implicit.
config.yaml every constant; src/ invents none
src/
models/covariance.py sample, Ledoit-Wolf, OAS, EWMA + diagnostics
models/optimizers.py capped-simplex projection, FISTA, frontiers
models/risk_parity.py ERC and risk budgeting by coordinate descent
models/hrp.py tree, quasi-diagonalisation, recursive bisection
models/black_litterman.py reverse optimisation, posterior, view parser
models/estimation_error.py the resampling and perturbation lab
backtest/strategies.py the contestant registry
backtest/walkforward.py the harness, with the bias controls documented
data/universe.py cache -> yfinance -> synthetic, in that order
scripts/
make_results.py runs everything, writes results/
build_frontend.py results/arena_results.json -> docs/data.js
check_page.py static checks + JS/Python parity
make_gif.py headless-Chrome recording of the interface
refresh_data.py the only script that touches the network
docs/index.html the entire interface, one file, file:// safe
notebooks/arena_story.ipynb the argument as a runnable narrative
tests/ 164 tests
results/ every figure, table and calibration
Twelve liquid ETFs — SPY, QQQ, IWM, EFA, EEM (equity), TLT, IEF (rates), LQD, HYG (credit), GLD, DBC, VNQ (real assets) — daily adjusted closes from Yahoo via yfinance, cached in data/prices.csv and committed. The sample starts 2007-05-01, bounded by HYG's inception, and therefore contains the global financial crisis.
No API key is needed. If the cache is missing and the network is unavailable, src/data/universe.py falls back to a seeded synthetic panel with a realistic block-correlation structure, labelled as synthetic everywhere it surfaces — so a fresh clone runs pytest and builds the page on a plane.
Survivorship is not solved: these are twelve ETFs selected today, all of which survived. Backfill bias is smaller for broad-index ETFs than for single names but it is not zero, and every return here should be read as a mild upper bound.
- PREREQUISITES.md — the on-ramp. No finance background assumed; explains the question, the vocabulary and the glossary.
- DEEP_DIVE.md — the equations, the solver choices, the validation residuals, and the results read as an analyst would read them.
MIT.
