Finance13 min read·

Pairs Trading Tutorial 2026: Strategy, Code and Execution

Step-by-step pairs trading tutorial in Python - from cointegration testing to spread construction, signal generation, position sizing and live execution.

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:

  1. Estimate β and rolling z-score parameters on training data (e.g., 2020-2022)
  2. Trade on validation data (2023)
  3. Retrain on 2020-2023; trade on 2024
  4. 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 50Kperleg,youreusing50K per leg, you're using 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:


Where to Go From Here

For more strategy implementations:

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