Skip to content

Repository files navigation

quant-edge

Backtest of classic 12-1 cross-sectional momentum on the 9 original SPDR sector ETFs (1999-2026). Headline finding: no robust alpha after costs. Net of 10 bps per trade, momentum ranking fails to beat a naive equal-weight portfolio of the same nine sectors (Sharpe 0.53 vs. 0.56); regressed directly on that benchmark the strategy's alpha is +0.56% a year with a t-stat of 0.43 (p = 0.67), and Carhart 4-factor alpha is statistically zero (t-stat -0.14). I publish that null result as-is. The deliverable here is a backtest methodology that survives scrutiny, which I think is worth more than a tuned Sharpe ratio.

Tests License: MIT Python 3.11+

Results

From the committed run in examples/report.md: 9 sector ETFs, long the top 3 by 12-1 momentum, monthly rebalancing, 10 bps one-way transaction cost. Prices are pulled from 1999-01; after the momentum formation period the strategy is invested from 2000-03 through 2026-06 (316 months). Every figure below comes from that one 316-month sample -- including Sharpe, which needs a risk-free rate. Ken French publishes his factor file about two months in arrears, so the evaluation window is trimmed to the months RF actually covers rather than letting each metric quietly choose its own sample.

CAGR Ann. Vol Sharpe Max Drawdown
Momentum strategy (net of costs) 9.13% 15.01% 0.53 -43.10%
Momentum strategy (gross) 9.43% 15.02% 0.55 -42.99%
Equal-weight universe (benchmark) 9.40% 14.67% 0.56 -49.12%
SPY buy & hold (benchmark) 8.55% 15.19% 0.49 -50.78%

Equity curve: momentum strategy vs. benchmarks, log scale

Momentum beats SPY buy-and-hold here, but that is a low bar: equal-weighting the nine sectors beats SPY too. Against the benchmark that actually matters, the ranking adds nothing measurable. The equal-weight portfolio ends with a higher CAGR and a higher Sharpe. What the strategy does deliver is a smaller maximum drawdown (-43.10% vs. -49.12% and -50.78%). Carhart 4-factor attribution (Mkt-RF, SMB, HML, Mom -- FF3 plus momentum) over the same 316 months tells the same story: monthly alpha of -0.02% (annualized -0.20%, t-stat -0.14), with the returns almost fully explained by market beta (0.930) plus a momentum-factor loading of 0.215 (R² = 0.799).

Does the ranking add anything? (spanning test)

The Sharpe comparison is the weakest available form of that question: 0.53 vs. 0.56 is a gap between two point estimates with no standard error, and it cannot tell "the ranking adds nothing" apart from "this sample is too small to see what it adds". So the strategy's monthly excess return is regressed straight onto the benchmark's:

(r_strategy - rf) = alpha + beta * (r_equal_weight - rf) + e
Estimate t-stat p-value
Alpha (monthly) 0.0462% 0.43 0.671
Alpha (annualized) 0.56% -- --
Beta (equal-weight) 0.909 21.72 --

n = 316 months, R² = 0.787, Newey-West (HAC) standard errors with 3 lags -- the same convention as the factor attribution, so the two t-stats in this repo mean the same thing.

The null result survives the stronger test: p = 0.67 is nowhere near significance, and the equal-weight benchmark spans the strategy. One detail worth flagging rather than burying, because it cuts against the headline table: the point estimate of alpha is positive (+0.56% a year) even though the strategy's CAGR is below the benchmark's. Beta is 0.909, so the strategy carries about 9% less benchmark exposure than the benchmark itself, and adjusting for that turns a raw shortfall into a small positive intercept. Both readings support the same conclusion only because neither is distinguishable from zero -- a t-stat of 0.43 is what noise looks like. Quoting the raw gap alone would understate the strategy; quoting the positive alpha without its t-stat would overstate it.

Two things in the output I found genuinely interesting:

  • The in-sample/out-of-sample split (at 2013-01-01) looks dramatic at first glance: net Sharpe 0.20 before, 0.94 after. But every series improves in the second window (SPY goes from 0.08 to 0.94), so this is a regime effect, not evidence the strategy "got better".
  • Costs are the whole margin. Annualized one-way turnover of 279.07% turns a 9.43% gross CAGR into 9.13% net, a drag of 0.30 pp per year. Gross of costs the strategy and the equal-weight benchmark are a coin flip (9.43% vs. 9.40% CAGR, and the benchmark still has the better Sharpe at 0.56 vs. 0.55); net of them the ranking is behind by 0.27 pp. Whatever the ranking picks up, it is smaller than the turnover needed to harvest it.

Why I built it

This is one of my portfolio projects for the ESADE MSc in Finance. The question that interested me was never "can I compute a Sharpe ratio". It was: which of the classic backtest failure modes can I make mechanically checkable? Most hobby backtests die quietly on one of look-ahead bias, ignored transaction costs, parameters tuned on the test data, survivorship bias, or a flattering benchmark. Each of those gets an explicit answer in this repo:

  • Look-ahead: tests/test_no_lookahead.py takes a synthetic monthly-returns frame, truncates it to a prefix, and requires the signal, the target weights, the applied weights, turnover and both return series to come out bit-identical on every row the two runs share. A hidden .shift(-1) in signals.py or backtest.py would trip it. The honest limit of that claim: the test starts at a returns frame, so data.py is outside it -- yfinance's auto-adjusted closes (dividend and split factors applied retroactively across the whole history) and the month-end resampling are not covered by any truncation test. Signal-to-trade timing is proven; the data layer feeding it is argued for in the limitations instead.
  • Costs: turnover is computed from actual weight drift between rebalances (a naive diff of target weights understates it a little every month), then charged at a configurable bps rate.
  • Tuning: parameters follow the Jegadeesh & Titman (1993) 12-1 convention and were never tuned on this data (the formation window is one month longer than J&T's -- see Signal and timing). The IS/OOS split only checks stability.
  • Attribution: real Ken French factor data, run as a Carhart 4-factor regression (FF3 + momentum -- a momentum strategy has to be priced against the momentum factor), OLS with Newey-West (HAC, 3-lag) standard errors so autocorrelated monthly returns don't inflate the alpha t-stat.
  • Flattering benchmark: the comparison that decides the verdict is not SPY but an equal-weight portfolio of the same nine sectors -- the thing the ranking has to beat to be worth its turnover. And the comparison is a spanning regression with a t-stat, not two Sharpe ratios side by side.
  • Survivorship: can't be fully fixed with free data, so it is documented prominently instead (see Limitations).

Method

Universe

The 9 original SPDR sector ETFs (XLB, XLE, XLF, XLI, XLK, XLP, XLU, XLV, XLY), all launched 1998-12-22. I excluded XLRE (2015) and XLC (2018) on purpose: mixing inception dates inside one cross-sectional ranking either shortens the whole backtest to the youngest ticker or silently changes the size of the ranked universe mid-sample. Nine tickers with 25+ years of common history avoids both problems.

Signal and timing

At ranking month m, assets are ranked by their compounded return over months [m-12, m-1]; month m's own return is excluded to avoid short-term reversal contamination. The portfolio is long the top 3 of 9, equal-weighted; a long-short variant exists via --n-short. The backtest engine then shifts target weights forward one more month before applying them to realized returns.

Those two shifts compose, so it is worth writing the result out end-to-end rather than leaving it to be reconstructed from --lookback 12 --skip 1. The weights held during month t are formed from return months t-13 through t-2, and month t-1 is skipped entirely:

Formation window Skipped Length
This repo (weights held in month t) t-13 ... t-2 t-1 12 months
Jegadeesh & Titman (1993), "prior 2-12" t-12 ... t-2 t-1 11 months

The one-month gap between formation and holding -- the part that does the work against short-term reversal and against look-ahead -- is the classic convention exactly. The formation window itself is one month longer than J&T's eleven. I measured this rather than derived it: shocking one asset in one return month and checking which holding months' weights move gives exactly the window above, no more and no less.

I am not changing the strategy to close that one-month difference. Adjusting a parameter after seeing the results is the data-snooping this repo spends a section arguing against, and the honest fix for a documentation error is to fix the documentation. Exact mechanics are in the signals.py and backtest.py docstrings; the timing proof is tests/test_no_lookahead.py.

Data

yfinance auto-adjusted daily closes, resampled to month-end (versions pinned in requirements.txt). Fama-French factors come live from Ken French's public Data Library; if that download fails, the report says so explicitly and skips attribution rather than filling the gap with made-up numbers.

Transaction costs

A flat charge per unit of one-way turnover (default 10 bps, configurable). Prior weights are drifted forward by each period's realized returns before comparison with the new targets, so cost is not understated by ignoring price drift between rebalances.

Benchmarks

An equal-weight, monthly-rebalanced portfolio of the same 9 sectors (does ranking beat naive diversification?) and SPY buy-and-hold. Both are gross of costs while the strategy is net -- a disclosed asymmetry, discussed in the limitations. The equal-weight comparison is settled by the spanning regression above rather than by eyeballing the metrics table.

Quick start

pip install -r requirements.txt
python run_backtest.py                                    # full default run
python run_backtest.py --start 2010-01-01 --cost-bps 20   # sensitivity check

Developed on Python 3.14 (Windows launcher: py -3.14); CI runs the test suite on 3.11, 3.12, 3.13 and 3.14, and the pinned versions in requirements.txt install on all four. Each run writes examples/report.md and examples/equity_curve.png.

Tests

python -m pytest -q                                                  # offline, deterministic
QUANT_EDGE_LIVE_TESTS=1 python -m pytest tests/test_live_smoke.py -v # real network calls

37 offline tests cover: the engine against synthetic prices with hand-computed expected values, every metric formula against hand-computed numbers, truncation-invariance (no look-ahead), cost monotonicity in bps, signal and weight construction, attribution recovering known alpha/betas from noiseless synthetic data, the spanning regression on synthetic series where the answer is known by construction (a strategy identical to the benchmark must return alpha 0 and beta 1; a strategy that is only levered benchmark must return alpha 0 and its leverage as beta), and the Ken French CSV parser against a hand-built fixture. The two live smoke tests hit real yfinance and Ken French endpoints and are opt-in.

Project structure

run_backtest.py                CLI entry point
src/quant_edge/
  data.py                      yfinance price loading + Ken French factor loading/parsing
  signals.py                   12-1 momentum signal + target-weight construction
  backtest.py                  engine: weight shifting, turnover, transaction costs
  metrics.py                   CAGR, vol, Sharpe, Max Drawdown, Calmar, hit rate, turnover
  attribution.py               Carhart 4-factor + benchmark-spanning OLS, Newey-West errors
  report.py                    metrics tables, equity-curve plot, markdown report assembly
tests/                         offline deterministic tests + 2 opt-in live smoke tests
examples/                      committed example run (report.md + equity_curve.png)
NOTES.md                       build decisions and their rationale

Limitations

The full version is generated into every report (examples/report.md) and kept in sync with the code -- read it there. Short version: yfinance's current sector-ETF universe is not point-in-time, so some survivorship bias remains (smaller than for individual stocks, but nonzero); there is no slippage or market-impact modeling beyond the flat bps cost; nine assets is a small cross-section, so any alpha t-stat deserves skepticism; benchmarks are gross of costs while the strategy is net (disclosed above); and fixing parameters from academic convention avoids data-snooping on this sample but does not by itself mean the strategy would generalize to live trading.

License

MIT -- see LICENSE. Copyright (c) 2026 Hadan Aslan.


Built by Hadan Aslan as part of an ESADE MSc in Finance portfolio. For research and demonstration purposes only -- not investment advice.

About

12-1 sector-momentum backtest (9 SPDR ETFs, 1999-2026): no alpha after costs. Look-ahead-tested, cost-modeled, Fama-French attribution with Newey-West errors.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages