7. Pairs trading: the difference, not the direction
Level 5: Momentum, Mean-Reversion and Pairs Trading Strategies
Pairs trading takes the idea of mean reversion to two related assets instead of a single one. Rather than looking at whether one price has drifted from its own average, you look at the difference (the spread) between two assets, and bet that this difference — not the price of each one separately — returns to its usual level.
For that bet to make statistical sense, the two assets must be cointegrated: in plain terms, that although each one moves more or less randomly on its own, the difference between them stays stable around a long-run average. In practice, two companies selling products that substitute for each other (Coca-Cola and Pepsi, for example) tend to be cointegrated: both move with the consumption and sector cycle, without drifting far apart from each other in any sustained way.
from statsmodels.tsa.stattools import coint
datos = yf.download(["KO", "PEP"], start="2020-01-01", end="2023-01-01", progress=False)
precio_ko = datos["Close"]["KO"]
precio_pep = datos["Close"]["PEP"]
estadistico, pvalor, valores_criticos = coint(precio_ko, precio_pep)The cointegration test sets up a null hypothesis that there is NO cointegration between the two series, against the alternative hypothesis that there is. A small p-value (usually below 0.05) lets you reject that null hypothesis: there is statistical evidence that the relationship is cointegrated, and therefore the strategy rests on reasonable ground.
With cointegration confirmed, the mechanics are the same as in the previous module, applied to the spread rather than to a single asset's price: when the spread drifts above its average (z-score > 1), you go short the asset that rose relatively more and long the one that rose relatively less; when the spread falls below (z-score < -1), the position is reversed.
spread = precio_pep - precio_ko
def zscore(serie):
return (serie - serie.expanding().mean()) / serie.expanding().std()
z = zscore(spread)The advantage of trading the spread, rather than each asset separately, is that the strategy ends up market neutral: if both shares fall 5% on a macro headline that hits the whole sector, the spread barely moves — but careful, that only holds if the spread is weighted. A raw subtraction of prices is not: with PEP at 180 and KO at 60, a common fall of 5% takes the spread from 120 to 114. You have to use the hedge ratio that the cointegration regression itself estimates (spread = precio_pep - beta * precio_ko), or work with the price ratio or with log prices — the gain or loss comes from the relative difference between the two, not from the general direction of the market.
So that you are not left testing pairs at random, the search can be automated: sweep every possible combination inside a universe of shares and keep only those that pass the cointegration test.
def encontrar_pares_cointegrados(precios):
n = precios.shape[1]
matriz_pvalores = np.ones((n, n))
columnas = precios.columns
pares = []
for i in range(n):
for j in range(i + 1, n):
_, pvalor, _ = coint(precios[columnas[i]], precios[columnas[j]])
matriz_pvalores[i, j] = pvalor
if pvalor < 0.05:
pares.append((columnas[i], columnas[j]))
return matriz_pvalores, paresThe loop for j in range(i + 1, n), starting at i + 1 rather than at 0, avoids two redundancies: comparing an asset with itself, and testing the same pair twice in reverse order (testing A-B is exactly the same as testing B-A as far as cointegration goes). It is a small detail, but in a universe of hundreds of shares, avoiding redundant comparisons saves a real amount of computation.
To go deeper into this lesson
- Momentumin the glossary
- Z-scorein the glossary
- Momentum investing: what it is and its S&P 500 recordguide