What is Pairs Trading?
Pairs trading is the original statistical arbitrage strategy. The idea is simple: find two stocks that historically move together (cointegrated). When they diverge, bet that they'll converge back. Buy the underperformer; short the outperformer; close both positions when the spread reverts to normal. Pioneered by Morgan Stanley's Nunzio Tartaglia team in the 1980s, it's still actively used by hedge funds and prop traders, though edges have shrunk significantly as the technique has become widespread.
This tutorial walks through the full pipeline: pair selection via cointegration testing, spread construction, signal generation, backtesting, and live execution. Code is in Python; we'll use real US equity data. For broader context, see our statistical arbitrage strategies and what is algorithmic trading guides.
The Theory
Two assets X and Y are cointegrated if a linear combination of them is stationary, even if neither X nor Y individually is stationary. Formally:
(Y_t = β X_t + ε_t)
where ε is mean-reverting (stationary).
In practice: if you regress Y on X and the residuals are stationary, you have a cointegrated pair. Trade the residual.
Step 1: Find Candidate Pairs
Start with a pool of stocks in similar industries. Pairs from different sectors rarely cointegrate stably (the relationships break when sector dynamics change).
import yfinance as yf import pandas as pd # Major US bank pairs - similar sector, similar regulatory environment tickers = ['JPM', 'BAC', 'WFC', 'C', 'GS', 'MS'] data = yf.download(tickers, start='2020-01-01', end='2025-12-31')['Adj Close'] data = data.dropna()
Step 2: Test for Cointegration
The standard test is Engle-Granger: regress one series on the other, then test the residuals for stationarity.
from statsmodels.tsa.stattools import coint import numpy as np def find_cointegrated_pairs(data): n = data.shape[1] pvalues = np.ones((n, n)) pairs = [] keys = data.columns for i in range(n): for j in range(i+1, n): S1 = data[keys[i]] S2 = data[keys[j]] result = coint(S1, S2) pvalue = result[1] pvalues[i, j] = pvalue if pvalue < 0.05: pairs.append((keys[i], keys[j], pvalue)) return pvalues, pairs pvalues, pairs = find_cointegrated_pairs(data) print(f"Found {len(pairs)} cointegrated pairs") for p in pairs: print(f" {p[0]}/{p[1]}: p={p[2]:.4f}")
The Engle-Granger test has a null hypothesis of no cointegration. p < 0.05 means we reject the null at the 5% level - i.e., evidence of cointegration.
Important caveats:
- Cointegration found in-sample isn't guaranteed out-of-sample
- Multiple testing: with 6 stocks, you're doing 15 tests. At p<0.05, you'd expect ~0.75 false positives by chance. Adjust your significance threshold.
- Test on different time windows to confirm stability
Step 3: Construct the Spread
For a pair (X, Y), regress Y on X to get the hedge ratio β. The spread is then:
(spread_t = Y_t - β X_t)
import statsmodels.api as sm def construct_spread(s1, s2): # Regress s1 on s2 to get hedge ratio s2_const = sm.add_constant(s2) result = sm.OLS(s1, s2_const).fit() beta = result.params[s2.name] spread = s1 - beta * s2 return spread, beta # Take the first cointegrated pair from above pair = pairs[0] s1 = data[pair[0]] s2 = data[pair[1]] spread, beta = construct_spread(s1, s2)
Step 4: Generate Signals
The standard approach: convert the spread to a z-score, trade when the z-score is extreme.
def compute_zscore(spread, lookback=60): rolling_mean = spread.rolling(lookback).mean() rolling_std = spread.rolling(lookback).std() zscore = (spread - rolling_mean) / rolling_std return zscore zscore = compute_zscore(spread)
Trading rules:
- z-score > +2: spread is too high → short s1, long β units of s2 (bet on convergence)
- z-score < -2: spread is too low → long s1, short β units of s2
- z-score crosses 0: close all positions
These thresholds can be tuned. Tighter thresholds (1.5) trade more often with smaller average wins; wider (3.0) trade rarely with larger wins. Optimal levels depend on the pair's specific behaviour.
Step 5: Backtest
def backtest_pair(s1, s2, beta, zscore, entry=2.0, exit=0.0): positions = pd.DataFrame(index=s1.index, columns=['s1', 's2'], data=0.0) pnl = pd.Series(index=s1.index, data=0.0) in_position = 0 # +1 = long s1 short s2, -1 = short s1 long s2 for t in range(1, len(zscore)): if pd.isna(zscore.iloc[t]): continue # Entry logic if in_position == 0: if zscore.iloc[t] > entry: in_position = -1 positions.iloc[t] = [-1, beta] elif zscore.iloc[t] < -entry: in_position = 1 positions.iloc[t] = [1, -beta] else: positions.iloc[t] = positions.iloc[t-1] # Exit logic else: if (in_position == 1 and zscore.iloc[t] >= exit) or \ (in_position == -1 and zscore.iloc[t] <= exit): in_position = 0 positions.iloc[t] = [0, 0] else: positions.iloc[t] = positions.iloc[t-1] # Compute P&L (simplified - assumes you can short freely with no cost) s1_returns = s1.pct_change() s2_returns = s2.pct_change() pnl = positions['s1'].shift(1) * s1_returns + positions['s2'].shift(1) * s2_returns return positions, pnl positions, pnl = backtest_pair(s1, s2, beta, zscore) print(f"Sharpe: {(pnl.mean() / pnl.std() * np.sqrt(252)):.2f}") print(f"Total return: {pnl.sum():.4f}")
This is a simplified backtest. Real implementation needs to account for:
- Transaction costs. Each entry and exit is two trades. With 10 bps round-trip and 2 round trips per signal, you need ~50 bps return per trade on average to break even.
- Borrow costs. Shorting expensive stocks (especially small-caps, hard-to-borrow names) can cost 1-10%+ per year.
- Slippage. The book may move when you place orders, especially in less liquid stocks.
- Position sizing. β can be large (>2 or 3); your dollar exposure on each leg must be balanced for the strategy to be properly market-neutral.
- Rolling estimation. β should be re-estimated periodically; using a static β for years tends to cause issues.
Step 6: Walk-Forward Validation
Don't trust a single backtest. Walk-forward:
- Estimate β and rolling z-score parameters on training data (e.g., 2020-2022)
- Trade on validation data (2023)
- Retrain on 2020-2023; trade on 2024
- Continue
If returns are similar across windows, the strategy is more likely to be real.
Step 7: Position Sizing for Multiple Pairs
A single pair has limited capacity. Real implementations trade portfolios of pairs.
def size_pair_portfolio(pairs_data, account_value=100_000, vol_target=0.10): """Size each pair's allocation by inverse vol.""" sizes = {} total_inv_vol = 0 for pair_id, (positions, pnl) in pairs_data.items(): vol = pnl.std() * np.sqrt(252) if vol > 0: inv_vol = 1 / vol sizes[pair_id] = inv_vol total_inv_vol += inv_vol # Normalise so that portfolio vol matches target for pair_id in sizes: weight = sizes[pair_id] / total_inv_vol sizes[pair_id] = weight * vol_target * account_value return sizes
This gives you inverse-volatility-weighted allocations across pairs, scaled to a target portfolio volatility.
Common Pitfalls
1. Spurious cointegration
Two random walks can show "cointegration" by chance, especially over short windows. Always test on multiple non-overlapping windows.
2. Cointegration breaks
The relationship between two stocks can change for fundamental reasons (M&A, earnings divergence, sector reallocation). Monitor your pairs; close positions if the relationship appears broken.
3. Edge has decayed
Pairs trading edges from the 1990s and early 2000s have largely been arbitraged away. Modern pairs strategies rely on:
- Smaller / less-followed stocks where competition is lower
- Sophisticated pair selection (sector-conditional, factor-adjusted, ML-driven)
- Faster execution to capture brief divergences
4. Liquidity constraints
You need to short the outperformer. For some stocks (small-cap, hard-to-borrow), this is expensive or impossible. Filter your universe accordingly.
5. Capital efficiency
Each pair requires capital on both legs. With β = 1.5 and 125K of buying power per pair. Multi-pair portfolios can quickly hit margin limits.
Variations
Industry pairs
The traditional pairs (e.g., Coca-Cola vs Pepsi, Visa vs Mastercard) are still used as starting points, though edges are thin.
ETF / underlying basket pairs
Trade an ETF against a basket of its underlying components. The arbitrage is mechanical; market makers do this professionally.
Index futures vs ETFs
The cash-vs-future basis on equity indices is a more capital-efficient version of pairs trading.
Cross-asset pairs
Bonds vs equities of the same issuer (capital structure arbitrage). High potential return but harder to execute.
For more advanced patterns:
Where to Run This in Production
For live trading after backtesting:
- QuantConnect is the most production-ready platform for pairs strategies. Direct broker integration; multi-asset support. See our QuantConnect review.
- Self-hosted Lean for full control without cloud subscription costs.
- Backtrader + custom IB integration if you want pure Python and don't mind some assembly. See our Backtrader Python tutorial and Interactive Brokers API tutorial.
Where to Go From Here
For more strategy implementations:
- Statistical arbitrage strategies - more complex stat arb patterns
- Machine learning for trading tutorial
- What is algorithmic trading
For the technical infrastructure:
Skip the £25k programme - try the alternative
Master's programmes are slow and expensive. Quantt is a self-paced alternative built around the actual skills firms hire for: Python, mathematics, derivatives pricing, options Greeks and live trading-system design. 50+ courses, interactive coding tests, and the same end-state as a one-year MFE - in your evenings.
No prerequisites - start at any level