← Back to roadmap
Phase 4Week 43~120 min read
0%·0/3

Statistical Arbitrage Basics

Pairs, spreads and mean reversion.

Mastery · Markets, Strategies & Getting Hired

Why this week

Statistical arbitrage trades a statistical relationship between instruments, on the expectation that deviations from it will revert. Pairs trading is the simplest form: take two instruments that move together, and when the spread between them widens, sell the rich one and buy the cheap one, then unwind when the spread comes back. You need three things for it. A hedge ratio that says how much of one offsets the other, a spread built from it, and a signal (usually a z-score) that says when the spread is unusually wide.

The mechanics take a day. The engineering takes a few more: O(1) rolling statistics that stay accurate at real price levels, two legs that never fill at the same moment, and position limits on both. The rest of the week is about the lesson that matters most: most backtests of this strategy lie. They estimate the hedge ratio with data from the future, pick parameters by searching until something looks good, choose pairs from the survivors, and ignore the cost of crossing two spreads twice per trade. You'll make several of these mistakes on purpose, so you recognise them later.

⚠️
Warning

Pairs trading has been public for decades. Gatev, Goetzmann and Rouwenhorst (2006) documented its historical returns, and Do and Faff (2010) found that the profitability of the simple version had declined substantially over time. Build it here as an engineering exercise in rolling statistics, two-legged execution and honest evaluation, not as a way to make money. On this week's simulator the pair is cointegrated by construction, so any profit shows only that the code works.

Day 1 — Correlation is not cointegration

Day 01

What relationship are you actually betting on?

Random walks, spurious regressions and stationary spreads

Prices behave roughly like random walks. They're non-stationary: they drift and don't return to any fixed level. That has two consequences:

  • Regressing one price on another can mislead you. Two independent random walks, regressed on each other, often give a "significant" slope and a high R². This is the spurious regression problem (Granger and Newbold, 1974). A good fit between two price series on its own tells you almost nothing.
  • Correlation of returns isn't enough. Two stocks whose daily returns are 0.9 correlated can still drift apart without limit over months. Correlation describes how they co-move from day to day. It says nothing about whether the gap between their levels stays bounded.

What a mean-reversion trade needs is cointegration (Engle and Granger, 1987): two non-stationary series x and y such that some combination y − β·x is stationary. That stationary spread is what you trade. The Engle–Granger two-step check regresses y on x in levels, then tests the residual for a unit root with an augmented Dickey–Fuller test. Use critical values adjusted for the fact that β was estimated, not the standard Dickey–Fuller tables. The Johansen test generalizes this to more than two series.

Statistics alone won't find pairs that keep working, though. You want an economic reason for the link: the same underlying in two forms (an ETF and its basket, a future and its cash index, two share classes of one company, a dual listing). "Two companies in the same industry" is a much weaker link, and it breaks in exactly the situations that cost the most: a merger, a scandal, a change in strategy.

Paperby Robert F. Engle and C. W. J. Granger (Econometrica, 1987)
Co-Integration and Error Correction: Representation, Estimation, and Testing

The idea that a combination of non-stationary series can be stationary, and the error-correction view: deviations from the long-run relationship predict future changes. Read sections 1–2 and the two-step test, and skim the rest.

Paperby C. W. J. Granger and Paul Newbold (Journal of Econometrics, 1974)
Spurious regressions in econometrics

Short and still worth reading. Regressing independent random walks on each other routinely produces 'significant' results. Keep it in mind whenever a price-on-price regression looks too good.

Recall#corr-vs-coint

Two stocks have a 0.9 correlation of daily returns over two years. Does that make the spread between their prices mean-reverting?

Day 2 — Hedge ratios

Day 02

How much of B offsets one unit of A?

OLS, its asymmetry, and the half-life of the spread

The textbook hedge ratio is the OLS slope of y on x: β = cov(x, y) / var(x). The spread is then s = y − β·x − α. Three things are worth knowing before you rely on it:

  • OLS isn't symmetric. Regressing y on x minimizes vertical errors. Regressing x on y minimizes horizontal ones. With noisy data the two slopes aren't reciprocals: their product is R², not 1. Which instrument you call the dependent variable changes the hedge. Total least squares (orthogonal regression) treats the two symmetrically, at the cost of being less familiar.
  • Units matter. β from prices is in units of y per unit of x. Before you size orders, convert to quantities per leg using contract multipliers and lot sizes. Rounding to whole lots leaves a small unhedged remainder that you have to track.
  • The relationship drifts. A β fitted once becomes stale. Rolling-window or recursive estimates (a Kalman filter is the common choice) keep up with the drift, but a noisy β adds noise to the spread. Decide the trade-off deliberately.

The half-life of the spread tells you how fast it reverts. Fit Δs_t = c + λ·s_{t−1} by OLS. If λ is negative, the spread is pulled back toward its mean, and the half-life is about −ln 2 / λ steps. It's the natural scale for your z-score window and expected holding time. If the half-life comes out at months, you're not looking at a high-frequency trade.

Drill

Hedge ratio by least squares

Target: 6/6 cases passNot yet run
/// Ordinary least squares fit of y = alpha + beta · x.
/// beta = cov(x, y) / var(x), alpha = mean(y) − beta · mean(x).
/// Returns (alpha, beta). Assumes x.len() == y.len() ≥ 2 and var(x) > 0.
/// Tip: compute the means first, then sum the centered products — the
/// one-pass Σxy − n·x̄·ȳ form cancels badly for prices.
pub fn ols(x: &[f64], y: &[f64]) -> (f64, f64) {
    todo!()
}

/// Residual spread s_t = y_t − beta · x_t − alpha.
pub fn spread(x: &[f64], y: &[f64], alpha: f64, beta: f64) -> Vec<f64> {
    todo!()
}

/// Half-life of mean reversion of a spread, in steps.
/// Regress the change on the previous level:  Δs_t = c + λ · s_{t−1}
/// (Δs_t = s_t − s_{t−1}, t = 1..n). If λ < 0 the spread pulls back
/// toward its mean and the half-life is −ln 2 / λ; otherwise None.
/// Also None with fewer than 3 points or when s_{t−1} has zero variance.
pub fn half_life(s: &[f64]) -> Option<f64> {
    todo!()
}
Rust
Recall#full-sample-beta

You estimate β, α and the spread's mean and standard deviation on two years of data, then backtest entry and exit signals on the same two years. What's wrong?

Day 3 — The z-score signal

Day 03

Rolling statistics in O(1)

Ring buffers, Welford, and numbers near 1e9

The usual signal is the spread's z-score over a trailing window: z = (s − mean_N) / std_N. A typical rule set: enter when |z| exceeds an entry threshold (sell the spread when it's high, buy when low), exit when |z| falls back inside an exit threshold, and stop out beyond a much wider stop threshold, because "it will revert eventually" is how pairs traders blow up.

On a tick-by-tick path you can't recompute mean and variance over the window on every update. Keep a ring buffer of the window and update running statistics as one value enters and the oldest leaves. That's O(1) per tick. There's a numerical trap here. The schoolbook formula var = (Σx² − (Σx)²/n)/(n − 1) subtracts two enormous, almost equal numbers. With prices stored as integer ticks near 1e9 and a window that moves only a few ticks, it loses every significant digit and can even go negative. Use a sliding Welford update, or subtract a fixed reference value before accumulating, and recompute from the buffer now and then to stop rounding drift.

Drill

Rolling z-score in O(1) per tick

Target: 5/5 cases passNot yet run
/// Rolling mean / standard deviation / z-score over the last `window`
/// values, updated in O(1) per push. Change the fields if you like —
/// the harness only calls the methods.
pub struct RollingZ {
    window: usize,
    buf: Vec<f64>, // ring buffer of the values in the window
    head: usize,   // next slot to overwrite
    count: usize,  // values currently in the window (≤ window)
    mean: f64,
    m2: f64,       // Σ (x − mean)² over the window (Welford)
}

impl RollingZ {
    pub fn new(window: usize) -> Self {
        todo!()
    }

    /// Add `x`; once the window is full, evict the oldest value. O(1).
    /// Returns z = (x − mean) / std of the window INCLUDING x, but only
    /// once the window is full and std > 0; otherwise None.
    ///
    /// Beware: the textbook Σx and Σx² formula loses every significant
    /// digit when values are large and close together (prices around
    /// 1e9 with a spread of a few ticks). Use a sliding Welford update:
    ///   adding x (window not full):
    ///     n += 1; d = x − mean; mean += d / n; m2 += d · (x − mean)
    ///   replacing old with x (window full):
    ///     new_mean = mean + (x − old) / n
    ///     m2 += (x − old) · (x − new_mean + old − mean)
    pub fn push(&mut self, x: f64) -> Option<f64> {
        todo!()
    }

    pub fn mean(&self) -> f64 {
        todo!()
    }

    /// SAMPLE standard deviation (divide by n − 1) of the values in the
    /// window; 0.0 with fewer than 2 values.
    pub fn std(&self) -> f64 {
        todo!()
    }
}
Rust

Pick the window from the half-life, a few half-lives long, not by trying windows until the backtest looks good. The choice of whether the current value counts in its own statistics (the drill includes it) is yours to make. Make it consciously and keep it consistent. The only choice that's always wrong is letting future values into the window.

Recall#naive-variance

Why does Σx² − (Σx)²/n fail for prices near 1e9, even in f64?

Day 4 — From signal to orders

Day 04

Two legs, two spreads, one position

Costs, legging risk and limits

A signal isn't a trade until both legs are on. That raises problems that one-legged strategies don't have:

  • Costs come twice, then twice again. Entering with market orders crosses the spread on both legs, and exiting does it again: four spread crossings plus fees per round trip. Run the numbers. Suppose the spread's standard deviation is 3 ticks, you enter at 2σ (6 ticks) and exit at 0.5σ (1.5 ticks). The gross capture is 4.5 ticks when it reverts. With a one-tick quoted spread on each leg, crossing costs half a tick per leg per side, 2 ticks per round trip before fees. A large share of the edge is gone before you count the trades that never revert.
  • Legging risk. To save the crossing cost you can rest one leg passively and hedge with the other only when it fills. Between those two moments you hold an unhedged position in one instrument, and the passive leg tends to fill exactly when the market is moving against it (week 41's adverse selection). Decide in advance how long you'll stay unhedged and what you do when time runs out: cross to hedge, or unwind the filled leg.
  • Sizing and rounding. β says 0.73 lots of x per lot of y, but you can only trade whole lots. Size the spread position so the rounding error is small relative to it, and track the residual exposure.
  • Limits on both legs and on the spread. Gross exposure per instrument, net exposure of the combined position, and a maximum spread position. Shorting has costs too (borrow for equities, margin for futures) that a price-only backtest leaves out.
Design note#legging-policy

Write your legging policy: which leg you work passively (and why that one), how long you tolerate being unhedged, what triggers crossing the spread to complete the hedge, and what you do if the hedge leg cannot fill at all (halt, limit, gap).

0 chars

Day 5 — Why backtests of this lie

Day 05

Overfitting, look-ahead and survivors

How to fool yourself, and how not to

Each of these mistakes, on its own, can turn a strategy with no edge into a backtest that looks excellent:

  • Look-ahead. Hedge ratio, mean and standard deviation estimated on the full sample (Day 2). Pairs chosen because they reverted over the period you then backtest on.
  • Survivorship. A universe built from today's instrument list leaves out the companies that were delisted, merged or went bust. Those are disproportionately the pairs that broke, and the ones where you'd have lost money.
  • Multiple testing. Try 1,000 pairs × 20 parameter sets and the best of the 20,000 backtests will look excellent by chance alone. Bailey, Borwein, López de Prado and Zhu (2014) show how quickly the out-of-sample performance of the "best" backtest decays as the number of trials grows. The deflated Sharpe ratio (Bailey and López de Prado, 2014) corrects a Sharpe ratio for the number of trials and for non-normal returns. Harvey, Liu and Zhu (2016) make the same argument for academic factor research and propose much stricter significance thresholds.
  • Regime change. Relationships break, and when they do the loss comes all at once rather than gradually. Without a stop, the strategy keeps adding to a losing spread because the z-score says it's cheaper than ever.
  • Costs. Four spread crossings per round trip, fees, borrow, slippage on size, and latency (week 44).

Defences are mostly about discipline:

  • Keep a holdout period you don't look at until the very end, and look at it once.
  • Use walk-forward evaluation: fit on a past window, trade the next one, roll forward.
  • Count and report every configuration you tried.
  • Prefer parameters with a reason behind them (a window from the half-life, thresholds from costs) to parameters chosen because they gave the best PnL.
  • Write down what result would make you abandon the idea before you run it.
Paperby David H. Bailey, Jonathan M. Borwein, Marcos López de Prado and Qiji Jim Zhu (Notices of the AMS, 2014)
Pseudo-Mathematics and Financial Charlatanism: The Effects of Backtest Overfitting on Out-of-Sample Performance

Written for mathematicians, so it's rigorous without heavy finance jargon. The key result: with enough trials, an in-sample Sharpe ratio that looks impressive is what you'd expect from pure noise.

Paperby David H. Bailey and Marcos López de Prado (Journal of Portfolio Management, 2014)
The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting and Non-Normality

How to adjust a Sharpe ratio for how hard you searched to find it. Even if you never compute it, it makes 'how many things did you try?' the first question you ask about any backtest.

Bookby Ernest P. Chan
Algorithmic Trading: Winning Strategies and Their Rationale

The mean-reversion chapters are a practical walk-through of what you built this week: cointegration tests, half-life from the OU regression, z-score bands, and Kalman-filter hedge ratios. Chan is also frank about data-snooping and survivorship bias in backtests.

Recall#best-of-200

You tried 200 parameter combinations and the best has an in-sample Sharpe of 2.1. What should you expect out of sample, and what do you report?

Design note#preregistration

Pre-register your capstone experiment: the parameters you will use and why (from half-life and costs, not PnL), the in-sample and out-of-sample split, the number of seeds, the metrics, and the result that would make you conclude the strategy does not work even on a simulator built for it.

0 chars

Day 6 — Ground truth and broken pairs

Day 06

Simulate a pair you understand, then break it

Testing a strategy against a known answer

On real data you never know the true β or whether the pair is still cointegrated. In a simulator you decide. Build the pair so the truth is known: x follows a random walk, and y is β·x plus an Ornstein–Uhlenbeck spread with a half-life you choose. Then check that your estimated β, half-life and z-scores recover what you put in. That's a test of your estimators, and it's the most valuable thing this simulator can give you.

Then break the pair. At some point in the run, change β, or let the spread follow a random walk instead of reverting. A sound strategy notices through its stop and limits, takes a bounded loss and stops trading. A fragile one keeps adding to the position because the z-score says the spread is cheaper than ever.

Design note#break-test

Describe how your strategy should behave in the 100 ticks after the relationship breaks: which control fires first, what the maximum loss is by construction, and how it decides to resume (or not) afterwards.

0 chars

Capstone

Build a pairs strategy in crates/sim that trades two simulated instruments through the engine (two instruments in week 36's sharded engine, or two engine instances), reusing week 42's order state machine and limits.

The simulated pair. Generate fair values with a known relationship: a random-walk x and y = β·x + OU noise with a chosen half-life. Week 23's simulated traders quote and trade around those fair values. Make β, the half-life and an optional break time configurable.

The strategy. Estimate β and the spread statistics using only past data: a warm-up window, then a rolling or expanding estimate. Maintain the drill's O(1) rolling z-score. Enter, exit and stop on thresholds. Size both legs from β using whole lots and track the residual. Follow your Day 4 legging policy, with a timeout and an explicit way out.

Limits. Per-leg gross exposure, net exposure, a maximum spread position, and a loss limit that pulls everything and latches.

Evaluation. Choose parameters on an in-sample period, report on an out-of-sample period, across many seeds, with and without the break. Report the number of configurations tried. Include a run where the pair isn't cointegrated at all. The honest result there is a small loss equal to roughly the costs, and if you see a profit, look for the bug.

What the repo gives you. sim::pairs (src/pairs.rs) is a smaller version of this capstone, and the rest is yours to build on it:

  • PairMarket is provided. It runs two Engines (instruments 1 and 2) with fair values x, a random walk, and y = β·x + μ + s, where s is an AR(1) process s ← φ·s + σ·ε. φ sets the half-life (about ln 0.5 / ln φ steps), and φ = 1 makes the spread a random walk with no cointegration at all. Instead of week 23's traders, a ladder liquidity provider (account 0) re-quotes a few levels around each fair value every step. There's no break time, so a break test means extending it.
  • RollingStats is Day 3's O(1) window: Welford, plus an exact recompute once every window replacements so rounding drift can't build up. The tests compare it with a naive recompute over 20,000 prices. It reports the population variance (divide by n), and zscore(x) needs at least two values and a non-zero standard deviation.
  • PairsTrader takes β as a fixed parameter (hedge_ratio), not an estimate. It computes z against the previous window spreads before adding the new one, so unlike the drill the current value isn't in its own statistics. The thresholds are entry, exit and a scale-in step up to max_units, and there's no stop. Each step sends one IOC order per leg for the difference between the wanted and the filled position, so a leg that didn't fill is simply retried on the next step. The legs are units × unit_qty lots of y and that times β, rounded, of x in the other direction.

Estimating β from past data, the stop, the loss limit, a legging timeout and the out-of-sample evaluation are your additions. The w43_ tests check the rolling statistics, the signal rules at their thresholds, that leg positions reach the limit but never exceed it, and that the strategy makes money with φ = 0.95 on three seeds and does worse on the same seeds with φ = 1.

Pitfalls: β or spread statistics fitted over the whole run; a z-score window that includes data from after the decision; forgetting the second leg's costs; whole-lot rounding that leaves one leg at zero for small positions; reading profit on a pair that is cointegrated by construction as evidence about markets.

Capstone · in your exchange · crates/sim

Pairs trading on a simulated pair with known ground truth

A pairs strategy in crates/sim on two simulated cointegrated instruments: a rolling z-score from past data only, entry/exit thresholds on a fixed hedge ratio, limits on both legs, and results across seeds against a random-walk control.

cargo xtask test --week 43      # tests named w43_*
cargo xtask bench --week 43     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 43 tests pass w43_*

Feeds into

No later week lists this one as a formal prerequisite, but its ideas come back immediately. Week 44 builds the backtester that should replace this week's quick evaluation: event-driven, latency-aware, and with automatic look-ahead checks. The repo's version replays one instrument, so running a pair through it means extending it to two. Week 45 handles position keeping for a two-legged strategy: per-instrument positions, net and gross exposure, and loss limits computed on every tick.