A pairs trade, end to end
M2 — Quantitative finance fundamentals
Time to run something through the whole pipeline. This lesson takes one strategy — the classic cointegration pairs trade — from signal to risk, and then charges it real money for trading.
The code below is deliberately standard-library Python: no numpy, no pandas, nothing to install. Real work would use both, but every idea here fits in sixty lines and you can run it immediately. It generates its own cointegrated pair so it is reproducible; swap the first block for real price data and the rest is unchanged.
1 — Signal
The spread is s = y − βx, and the tradeable statement is that s reverts. Convert to a z-score so the entry rule is scale-free, and trade extremes:
z > +2 spread is rich → SHORT the spread (sell y, buy βx)
z < −2 spread is cheap → LONG the spread (buy y, sell βx)
|z| < 0.5 converged → flat
One detail that quietly decides whether the whole exercise is honest: the z-score uses a rolling mean and standard deviation over a trailing window, not the full sample. Using full-sample statistics would let the strategy standardise today's spread using tomorrow's data — lookahead bias, and worth about a Sharpe point of pure fiction. M2.7 has more on how easily this happens.
2 — Sizing
Hold 1 unit of y against β units of x, β from the cointegrating regression. That is what makes the position a bet on the spread rather than on the market: if both legs rise together, the position is flat. It is market-neutral by construction, which is the point of the trade — you have isolated the relationship and discarded the direction.
3 — Execution
Here is where M1 comes back. The estimated half-life is 11 days, which tells you the trade is not a latency problem — you have hours to establish a position, not microseconds, so M1.6 is irrelevant and M1.5 is everything. Pace the entry over a day, keep participation low, and impact stays small. If the half-life had been eleven minutes, the same statistical edge would be an entirely different and much harder business.
4 — Risk
The signal cannot police itself, because a structural break looks exactly like a great entry (M2.5). So the risk rules must come from outside it:
- Stop if |z| exceeds ~4 — that is evidence the relationship broke, not that the opportunity improved.
- Time limit of a few half-lives. A spread that hasn't converged in a month probably isn't going to.
- Re-test cointegration periodically on recent data, and retire the pair when it fails.
The code
import math, random
# ── 1. Two cointegrated series (replace with real prices) ────────────
random.seed(42)
N, BETA, THETA, SIG_S = 1500, 1.5, 0.05, 1.0
x, spread = [100.0], [0.0]
for _ in range(N - 1):
x.append(x[-1] + random.gauss(0, 1.0))
spread.append(spread[-1] + THETA * (0.0 - spread[-1]) + random.gauss(0, SIG_S))
y = [BETA * xi + s + 50.0 for xi, s in zip(x, spread)]
# ── 2. Hedge ratio by OLS on levels ──────────────────────────────────
def ols(xs, ys):
n = len(xs); mx = sum(xs)/n; my = sum(ys)/n
sxy = sum((a-mx)*(b-my) for a, b in zip(xs, ys))
sxx = sum((a-mx)**2 for a in xs)
b = sxy/sxx
return b, my - b*mx
beta_hat, alpha_hat = ols(x, y)
s = [yi - beta_hat*xi - alpha_hat for xi, yi in zip(x, y)]
# ── 3. Half-life: Δs = a + b·s(t-1), half-life = ln2 / -b ──────────
b_hl, _ = ols(s[:-1], [s[i+1]-s[i] for i in range(len(s)-1)])
half_life = math.log(2) / (-b_hl)
# ── 4. Rolling z-score — NOT full-sample (that would be lookahead) ───
W = 60
def zscore(i):
win = s[i-W:i]
m = sum(win)/W
sd = math.sqrt(sum((v-m)**2 for v in win)/(W-1))
return (s[i]-m)/sd if sd > 0 else 0.0
# ── 5. Backtest ──────────────────────────────────────────────────────
ENTRY, EXIT = 2.0, 0.5
def run(cost):
pos, pnl, trades = 0, [], 0
for i in range(W, N-1):
z = zscore(i); new = pos
if pos == 0:
if z > ENTRY: new = -1
elif z < -ENTRY: new = +1
elif abs(z) < EXIT: new = 0
step = (s[i+1]-s[i]) * pos # P&L on yesterday's position
turn = abs(new - pos)
if turn: trades += 1
pnl.append(step - turn*cost)
pos = new
return pnl, trades
def sharpe(p):
n = len(p); m = sum(p)/n
sd = math.sqrt(sum((v-m)**2 for v in p)/(n-1))
return m/sd*math.sqrt(252)
print(f"true beta {BETA} estimated {beta_hat:.3f}")
print(f"half-life of the spread: {half_life:.1f} days\n")
for c in (0.0, 0.25, 0.50, 1.00):
p, t = run(c)
print(f"cost {c:.2f} P&L {sum(p):7.1f} Sharpe {sharpe(p):5.2f} trades {t}")
What it prints
true beta 1.5 estimated 1.454
half-life of the spread: 11.1 days
cost/trade total P&L Sharpe trades
──────────────────────────────────────────────
0.00 66.8 1.39 58
0.25 52.3 1.08 58
0.50 37.8 0.77 58
1.00 8.8 0.17 58
Read the table, not the first row
The signal is identical in every row. Same entries, same exits, same 58 trades. The only thing that changes is what you are charged to trade — and the strategy goes from a genuinely good Sharpe of 1.39 to a rounding error at 0.17.
That is the exercise the roadmap set, and it generalises well beyond pairs trading:
The gross backtest tells you almost nothing. The cost assumption is usually the strategy.
Three things follow, each pointing back at earlier modules:
Costs are not a haircut, they are the business. A cost of 0.5 spread units per trade — half of one standard deviation of daily spread movement, entirely plausible once you add the bid-ask spread of two legs (M0.4) plus impact (M1.5) — removes 45% of the P&L. Anyone showing you a gross backtest is showing you the easy part.
Half-life sets how many times you pay. 58 trades over ~1,400 days is comfortable. Shorten the half-life to a day and you would trade perhaps 600 times for a similar gross edge, paying ten times the cost to earn the same gross — and the same table would show the strategy dead at 0.25. This is why M2.5 insisted half-life is the operationally decisive statistic.
Capacity is a hard ceiling. M1.5's square-root law says impact grows with the fraction of daily volume you take, so cost in that table is not a constant — it is a function of your size. Scale up and you slide down the rows of your own table until you reach the one where the strategy doesn't work. Every strategy has a size at which it stops existing.
Two honest caveats
The estimated half-life (11.1 days) is shorter than the value built into the simulation (ln2/0.05 ≈ 13.9 days). That is not a bug — the spread being tested is an OLS residual, and residuals are fitted to have zero mean, which biases them toward looking more mean-reverting than the truth. Estimated half-lives on estimated spreads run optimistically short, and by exactly the mechanism that ought to make you suspicious of your own backtest.
And the data here is synthetic, generated from a model that guarantees the spread reverts. The strategy cannot fail for the reason real pairs trades fail — the relationship breaking. That is the deepest problem with this backtest, and it is not fixed by better code.
Which is the subject of M2.7.
Source: Ernie Chan, Algorithmic Trading, ch.3–4 for pairs trading as practised, including the Kalman-filter version where β is allowed to drift rather than being fixed by one OLS fit. Run the code, change the seed, and watch how much the Sharpe moves — that variation is M2.1's standard error, made tangible.