Skip to content

Repository files navigation

Portfolio Construction Arena

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.

Python NumPy SciPy pandas tests licence

The interface

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.


What this does

  • 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.

Headline result

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:

  1. 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?"
  2. 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:

  1. 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)

Install and run

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 Node

The interface

Five 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.

Design decisions

  • 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.py runs 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_fn receives 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.

Repo map

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

Data

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.


Further reading

  • 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.

Licence

MIT.

About

Markowitz vs Risk Parity vs HRP vs Black-Litterman, walk-forward over 19 years of ETF data with turnover and transaction costs charged explicitly

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages