Random matrix theory · purged combinatorial cross-validation · market-impact-aware backtesting · Sharpe deflation
A research framework for cross-sectional equity alpha, built around the question that actually determines whether a signal is worth trading:
A backtest produces a number. How much of that number is real?
Python for research, a C++ core for the one loop that cannot be vectorised.
No paid data, no network access, no GPU. Clone, make, and it runs in five minutes.
git clone https://github.com/elliotgmp/quant-alpha-research
cd quant-alpha-research && pip install -r requirements.txt && makeThe headline pipeline runs on a synthetic equity universe with a known ground-truth alpha, not on live market data. This is deliberate, and it is the entire point.
A backtest on real data cannot tell you whether an estimator is good: there is one realisation of history and the true signal is unobservable. You can only learn that a method worked once. A simulation study with known ground truth answers the question that matters in research — does my method recover the signal that is actually there, and what fraction of the attainable Sharpe do I capture? This is how covariance estimators, cross-validation schemes and overfitting corrections are validated in the literature (Laloux et al. 1999; Ledoit & Wolf 2004; Bailey & López de Prado 2014).
Every result below is therefore a methodological result, true by construction of the
experiment and reproducible with one command. The framework accepts real data through
qalpha.universe.from_csv; the ground-truth diagnostics simply switch off.
Nothing here is a claim about tradable returns.
- Quantitative context
- Headline results
- Installation
- Running the pipeline
- Where the results appear
- Architecture
- Methodology
- Machine learning stack
- Testing
- Known limitations
- References
Cross-sectional equity statistical arbitrage means: rank a universe of stocks every day by an expected-return signal, go long the top and short the bottom, hold a dollar-neutral book, and harvest the spread between them. The strategy class is decades old and heavily crowded, which means the edge is not in the signal — it is in not fooling yourself about the signal.
Three mechanisms systematically inflate a backtest. This project isolates and measures each one.
| Mechanism | Why it inflates | Correction | Measured effect |
|---|---|---|---|
| Covariance noise | portfolio optimisation inverts Σ, and inversion amplifies the smallest eigenvalues — precisely the noise | random matrix theory | bias ratio 2.02 → 1.05 |
| Information leakage | overlapping labels and serial correlation cross the train/test boundary | purging + embargo | −3.93 Sharpe |
| Multiple testing | you try 100 configurations and keep the best; the maximum of 100 draws is not one draw | Deflated Sharpe Ratio | 1.33 → DSR 0.94 |
A fourth mechanism — market impact — does not inflate the backtest but silently destroys the strategy. Costs grow as |Δw|^{3/2} while alpha capture grows roughly linearly, so net Sharpe has an interior maximum in turnover. Trading a signal as hard as the model wants destroys it.
Reproduce with make research (~5 min, single core). Full output in results/report.txt.
Everything between the markers below is generated from
results/summary.jsonbyscripts/update_readme.py. Do not edit it by hand: re-runmake readmeafter any run, so the README can never quote a number that no run produced.
Generated from results/summary.json — run 20260810-161325-seed20260809, 2600 dates x 300 names, 278.2 s. Regenerate with make readme.
Minimum-variance portfolios, 500-day estimation window, 63-day out-of-sample, q = N/T ~ 0.48.
| Estimator | Realised vol | Predicted vol | Bias ratio | Risk reduction |
|---|---|---|---|---|
| Sample covariance | 7.88 % | 3.91 % | 2.02 | — |
| Marchenko–Pastur clipping | 5.85 % | 5.58 % | 1.05 | -25.8 % |
| Rotationally invariant estimator | 6.21 % | 5.33 % | 1.17 | -21.3 % |
| Ledoit–Wolf shrinkage | 7.01 % | 5.55 % | 1.26 | -11.0 % |
The bias ratio is realised ÷ predicted volatility. The sample covariance promises 50% of the risk it delivers — the precise mechanism by which naive Markowitz optimisation fails. Eigenvalue clipping brings it to 1.05 and cuts realised risk by 25.8 %.
Identical features, identical model, identical backtest. Only the validation scheme changes.
| Validation scheme | Sharpe |
|---|---|
| Shuffled K-fold (no purge, no embargo) | 5.26 |
| Purged & embargoed combinatorial CV | 1.33 |
| Phantom Sharpe from leakage | +3.93 |
A Sharpe of 5.26 would look like a discovery. It is entirely an artefact of overlapping labels and serial correlation leaking across the train/test boundary.
CPCV produces a distribution of Sharpe ratios, not a single number: 1.09, 1.16, 1.26, 1.03 (mean 1.14, std 0.10).
| Metric | Value |
|---|---|
| Theoretical ceiling (true alpha, no costs) | 5.28 |
| Fraction of attainable Sharpe captured | 28 % |
| Gross Sharpe | 1.51 |
| Net Sharpe (after costs) | 1.33 |
| Annualised return | 3.90 % |
| Annualised volatility | 2.92 % |
| Maximum drawdown | -5.11 % |
| Hit rate | 53.70 % |
| Average daily turnover | 4.00 % |
| Net edge per unit of turnover | 38.70 bps |
Costs follow the square-root law, so total cost grows as |Δw|^{3/2} while alpha capture grows roughly linearly. Net Sharpe therefore has an interior maximum:
| Turnover cap | Realised turnover | Gross Sharpe | Net Sharpe | Cost (bps/day) |
|---|---|---|---|---|
| 0.5 % | 0.5 % | 1.09 | 1.07 | 0.01 |
| 1.0 % | 1.0 % | 1.29 | 1.25 | 0.03 |
| 2.0 % | 2.0 % | 1.46 | 1.38 ← optimum | 0.08 |
| 4.0 % | 4.0 % | 1.51 | 1.33 | 0.20 |
| 7.0 % | 7.0 % | 1.51 | 1.19 | 0.42 |
| 12.0 % | 12.0 % | 1.64 | 1.02 | 0.91 |
| 20.0 % | 20.0 % | 1.89 | 0.66 | 1.94 |
| 35.0 % | 35.0 % | 2.16 | -0.51 | 4.63 |
Robust to the impact coefficient: Y=0.25: 1.40, Y=0.5: 1.38, Y=1.0: 1.34.
| Observed Sharpe | 1.33 |
| E[max Sharpe] under 100 research trials | 0.82 |
| Probabilistic Sharpe Ratio vs zero | 1.000 |
| Deflated Sharpe Ratio | 0.942 |
A separate experiment ran 8 randomly drawn configurations — random feature subsets, tree depths, learning rates, regularisation — with no idea behind any of them. Median Sharpe +1.13, best +1.36. Pure search on the same data produces a Sharpe comparable to the one we selected.
The DSR of 0.942 sits below the conventional 0.95 bar. The defensible conclusion is that this signal is promising but not established. Reporting 1.33 without that caveat would be the exact failure mode this framework was built to detect.
Mean Decrease Accuracy, measured out of sample under purged CV (baseline information coefficient 0.0334):
| Feature | MDA | t-stat |
|---|---|---|
mom_12_1 |
+0.0158 | +5.75 |
rev_5 |
+0.0131 | +5.27 |
vol_of_vol_63 |
+0.0029 | +1.99 |
| … | ||
amihud_21 |
-0.0004 | -0.40 |
beta_252 |
-0.0012 | -1.09 |
The data-generating process injected alpha into exactly two channels: 12-1 momentum and 5-day residual reversal. MDA recovers both and assigns negative importance to features carrying no signal. Negative MDA is information, not a bug.
Requirements: Python ≥ 3.10, and a C++17 compiler if you want the fast backtest kernel (optional — a numpy fallback produces identical results).
git clone https://github.com/elliotgmp/quant-alpha-research
cd quant-alpha-research
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
bash cpp/build.sh # optional: builds cpp/libportfolio.soDependencies are deliberately minimal — numpy, scipy, scikit-learn, pandas,
matplotlib. No LightGBM, no XGBoost, no PyTorch, no proprietary data feed.
Verify the install:
python3 tests/test_all.py # expect: 12 passed, 0 skipped, 0 failed| Command | What it does | Runtime |
|---|---|---|
make fast |
reduced CV grid, all sections | ~2 min |
make research |
full CPCV (15 splits, 5 paths) | ~5 min |
make figures |
renders the four report figures | ~10 s |
make test |
12 unit tests | ~40 s |
make |
build + test + research + figures | ~6 min |
Useful flags:
python3 scripts/run_research.py --help
--fast fewer CV splits
--seed 20260809 reproducibility
--horizon 5 prediction horizon in days
--turnover-cap 0.04 daily turnover cap, as a fraction of gross
--half-spread-bps 2.0 linear execution cost
--impact-coef 0.5 Y in the square-root impact law
--n-trials 100 research trials assumed for Sharpe deflation
--returns-csv PATH run on real data instead of the simulatorOn real data, supply a wide CSV — dates on the index, tickers as columns, simple daily returns as values:
python3 scripts/run_research.py --returns-csv data/returns.csvThe oracle Sharpe and ground-truth diagnostics are skipped automatically.
Everything is written by the same run.
1 · Console. A live log of each stage, then the full report printed at the end.
2 · results/report.txt — the same report, saved. The human-readable artefact; open it
first.
3 · results/summary.json — every number, machine-readable. Quote from this, not from
the console. Structure:
oracle_sharpe 5.28 theoretical ceiling
rmt.sample.bias_ratio 2.02 realised / predicted vol
rmt.clipped_mp.risk_reduction_vs_sample_pct -25.8
purged.path_sharpes [1.09, 1.16, 1.26, 1.03]
purged.pooled_gross.sharpe 1.51
purged.pooled_net.sharpe 1.33
purged.pooled_net.ann_return 0.0390
purged.pooled_net.ann_vol 0.0292
purged.pooled_net.max_drawdown -0.0511
purged.pooled_net.hit_rate 0.541
purged.pooled_net.avg_daily_turnover 0.040
purged.pooled_net.bps_per_unit_turnover 38.70
naive.sharpe 5.26
sharpe_inflation_from_leakage 3.93 <- the headline number
turnover_sweep[] gross/net Sharpe by turnover cap
optimal_turnover_cap 0.02
impact_sensitivity[] net Sharpe for Y in {0.25, 0.5, 1.0}
trials_study.best 1.36
deflated.sharpe_annual 1.33
deflated.threshold_annual 0.82
deflated.dsr 0.942
mda.importance.<feature>.{mda_mean, t_stat}
runtime_sec, cpp_backend
Read a single value from the shell:
python3 -c "import json;d=json.load(open('results/summary.json'));print(d['sharpe_inflation_from_leakage'])"4 · results/*.png — four figures after make figures:
fig1_spectrum.png (empirical eigenvalues vs Marchenko–Pastur),
fig2_equity.png (purged vs naive equity curves),
fig3_turnover.png (the alpha/impact trade-off),
fig4_mda.png (feature importance).
5 · results/arrays.npz — raw PnL series, the out-of-sample signal panel and returns,
so you can re-analyse without refitting anything.
quant-alpha-research/
├── qalpha/
│ ├── universe.py synthetic panel with known alpha: GARCH(1,1) market factor,
│ │ Student-t idiosyncratic noise, heterogeneous liquidity,
│ │ delistings, exponential alpha decay. Also from_csv().
│ ├── features.py 13 cross-sectional features via cumulative-sum rolling
│ │ statistics — O(T·N) instead of O(T·N·window);
│ │ rank-normalised per date; PCA residual returns
│ ├── labels.py vectorised triple barrier; per-instrument sample uniqueness
│ ├── rmt.py Marchenko–Pastur bounds and density, eigenvalue clipping,
│ │ Ledoit–Wolf, rotationally invariant estimator, evaluation
│ ├── cv.py PurgedKFold, CombinatorialPurgedCV, panel flattening
│ ├── model.py primary regressor + meta-labelling classifier; MDA
│ ├── engine.py ctypes bridge + numpy reference implementation
│ └── metrics.py Sharpe, PSR, E[max SR], Deflated Sharpe Ratio
│
├── cpp/portfolio_engine.cpp signal → weights → PnL, flat C ABI
├── scripts/run_research.py the full study, writes results/
├── scripts/make_figures.py the four report figures
└── tests/test_all.py 12 tests, runs with or without pytest
The rebalancing loop is inherently sequential: weights at t depend on weights at t−1 through the turnover constraint and position drift. It does not vectorise in numpy.
Measured on a 2600 × 300 panel:
| Kernel time | |
|---|---|
| C++ | 12.3 ms |
| numpy reference | 151.7 ms |
| Speed-up | 12× |
End-to-end the gap narrows to ~3× because both paths pay the same array-conversion cost. Over a research session of a few hundred backtests this is the difference between an interactive loop and a coffee break — the only reason the C++ exists. Every other part of the project is already vectorised and would gain nothing.
The numpy implementation is retained as an executable specification and as a regression
test: test_cpp_matches_numpy asserts the two agree to 1e-12 on PnL, cost, turnover and
weights. The optimisation is not permitted to change the semantics.
A flat C ABI compiled to a single .so, called through ctypes. No build dependency, no
ABI matching against a Python version, one bash cpp/build.sh to port. There is one call
per backtest, not one per date, so ctypes overhead is irrelevant. If the call sat inside a
hot loop the answer would be different.
Residual returns. The prediction target is not the raw return but the return after removing the first 5 principal components, estimated on a past 252-day rolling window and re-estimated monthly. We are not trying to predict the market; we are trying to predict what remains once the market is removed. That is the definition of statistical arbitrage.
Triple-barrier labelling. A fixed-horizon return treats a name that drifts up smoothly and a name that collapses 15 % before recovering as the same outcome; in live trading the second would have hit a stop. Barriers at ±2σ plus a 15-day vertical barrier, scaled by each name's own volatility.
Purging and embargo. A label dated t consumes returns up to t₁ > t. If t is in train and t′ < t₁ in test, the model has seen part of the test's future. Purging removes every training observation whose interval [t, t₁] overlaps the test block; the embargo removes a further margin to handle serial correlation in persistent features.
Sample uniqueness. Overlapping labels share information. Each observation is weighted by the inverse of the average number of concurrent labels over its interval — computed per instrument, since two labels on different stocks do not overlap in the López de Prado sense even when they share dates. Mean uniqueness here is 0.137 for labels averaging 8.02 days, and 1/8 = 0.125: the two figures cross-check. Without this weight a model believes it has 650,000 independent observations when it has the equivalent of about 89,000, and overstates its own significance accordingly.
CPCV. Six blocks, two held out, C(6,2) = 15 splits recombining into 5 complete backtest paths. A walk-forward gives one Sharpe and no error bar; CPCV gives a distribution (here 1.09 / 1.16 / 1.26 / 1.03, σ = 0.10), which is what lets you ask whether the number is an accident.
Impact model. Cost = half-spread·|Δw| + Y·σ·|Δw|·√(participation), where participation = |Δw|·AUM/ADV. The square root is the empirical impact law (Almgren et al. 2005; Tóth et al. 2011); Y ≈ 0.3–1.0 on liquid equities. In total-cost terms this is a |Δw|^{3/2} term — the source of the interior turnover optimum.
RIE regularisation. η = 2·N^{−1/2}. Theory prescribes η ∼ N^{−1/2}, but that is an asymptotic result and at finite N the outcome is genuinely sensitive: sweeping η over [0.5, 8]·N^{−1/2} moves the risk reduction from −24 % to +21 %. Documented rather than hidden.
| Component | Model | Rationale |
|---|---|---|
| Primary | HistGradientBoostingRegressor |
predicts the direction: the forward residual return. Histogram-binned, leaf-wise gradient boosting — the LightGBM algorithm without the dependency. Depth 4, 250 iterations, min_samples_leaf=400, L2 = 1.5, max_features=0.7. |
| Meta | HistGradientBoostingClassifier |
predicts the size: "is the primary direction correct?" Takes the feature set plus the primary prediction and its magnitude. Output rescaled as 2·(p − 0.5) clipped at zero, so p = 0.5 means no conviction and zero position. |
| Importance | permutation MDA | measured out of sample under purged CV, scored by Spearman IC. MDI is computed in sample, is biased towards high-cardinality features, and assigns importance to pure noise. |
Why meta-labelling. A single model must simultaneously learn where to go and how hard to bet, from the same signal. Separated, the meta-model can learn to stand down in regimes where the primary is unreliable — typically high volatility. It creates no alpha; it improves the precision/recall trade-off, and precision converts into Sharpe.
Sample weights. Training uses the per-instrument uniqueness weights described above, so overlapping labels do not dominate the fit.
Feature set (13, all rank-normalised cross-sectionally per date):
mom_12_1, mom_6_1, rev_5, rev_21, vol_21, vol_63, beta_252, idio_vol_63,
max_ret_21, skew_63, amihud_21, dist_52w_high, vol_of_vol_63.
python3 tests/test_all.py # or: pytest tests/ -q12 tests. The two that matter:
test_cpp_matches_numpy— the C++ kernel and the numpy specification must agree to 1e-12 on PnL, cost, turnover and weights. The regression guard on the optimisation.test_features_have_no_lookahead— corrupt the future (returns[cut:] += 0.05) and assert that features beforecutdo not move. A one-day indexing shift is invisible on reading and turns a Sharpe of 1 into a Sharpe of 5. This is a proof, not a code review.
Also covered: gross-exposure and per-name-weight constraints, turnover cap binding,
super-linearity of impact cost (doubling turnover must more than double cost — a test of
the model's physics, not its implementation), absence of leakage in PurgedKFold, CPCV
split and path counts, the noise spectrum staying below λ₊, clipping beating the sample
covariance out of sample, triple-barrier label consistency, and monotonicity of the PSR and
DSR.
Stated plainly, because a research project that lists none has not been examined.
- Synthetic data. The headline numbers characterise estimators on a known DGP, not tradable performance. The real-data path exists but has not been run on a licensed dataset.
- The DGP is kind. Factor loadings are stationary, alpha decays smoothly and monotonically, and there are no regime breaks, crises or crowding. Real alpha dies discontinuously.
- Clipping beats the RIE here, the reverse of what the literature usually reports. The likely reason is that this DGP has few, well-separated factors — exactly the regime where the blunt estimator is near-optimal. On a real spectrum with a dense bulk the ranking should invert. Untested.
- No borrow costs, no financing, no short-sale constraints, no capacity analysis. The impact model has no permanent component and no cross-impact.
- The meta-model's contribution is within the CPCV path dispersion on this DGP, so it is not claimed as a result.
n_trials = 100in the DSR is a judgement call, not a measurement. The honest count is every configuration ever evaluated during development, which is not instrumented. Raising it lowers the DSR further.- Single core, single machine. No distributed training, no retraining schedule tuned to the alpha decay rate.
Random matrix theory
- Laloux, Cizeau, Bouchaud & Potters (1999), Noise dressing of financial correlation matrices
- Ledoit & Wolf (2004), A well-conditioned estimator for large-dimensional covariance matrices
- Ledoit & Péché (2011), Eigenvectors of some large sample covariance matrix ensembles
- Bouchaud & Potters (2011), Financial applications of random matrix theory: a short review
- Potters & Bouchaud (2020), A First Course in Random Matrix Theory
Market impact
- Almgren, Thum, Hauptmann & Li (2005), Direct estimation of equity market impact
- Tóth et al. (2011), Anomalous price impact and the critical nature of liquidity
Backtest overfitting
- Bailey & López de Prado (2014), The Deflated Sharpe Ratio
- Bailey, Borwein, López de Prado & Zhu (2014), The probability of backtest overfitting
- Harvey, Liu & Zhu (2016), …and the Cross-Section of Expected Returns
- López de Prado (2018), Advances in Financial Machine Learning, ch. 3–8
Cross-sectional anomalies
- Jegadeesh & Titman (1993), Returns to buying winners and selling losers
- Amihud (2002), Illiquidity and stock returns
- Bali, Cakici & Whitelaw (2011), Maxing out: stocks as lotteries
MIT.



