Etapa 3 de 4 · Nivel hedge fund
Pairs trading y cointegración en Python: de la idea al backtest
Construye una estrategia de pairs trading en Python: correlación frente a cointegración, test de Engle-Granger, hedge ratio, z-score del spread, vida media de Ornstein-Uhlenbeck y un backtest honesto con costes.
En este artículo
El pairs trading es una de las estrategias cuantitativas más antiguas y más elegantes. Se atribuye al equipo de Nunzio Tartaglia en Morgan Stanley en los años 80, y la idea es tan sencilla que cabe en una frase: si dos activos suelen moverse juntos y se separan, apuesta a que vuelven a juntarse.
Lo difícil no es la idea, sino hacerlo bien: distinguir una relación real de una casualidad, medir cuánto tardan en volver y hacer un backtest que no te engañe. En este artículo lo construimos de principio a fin en Python.
Es el artículo más avanzado de la ruta. Te ayudará tener claros los conceptos de rentabilidad, volatilidad y Sharpe y algo de regresión lineal.
La idea: una estrategia neutral al mercado
Piensa en Coca-Cola y Pepsi. Venden productos parecidos a clientes parecidos y les afectan las mismas noticias: el precio del azúcar, el consumo, el dólar. Sus acciones tienden a moverse juntas. Si un día Coca-Cola sube un 5% y Pepsi no, o hay una razón nueva que lo justifica, o es un desajuste temporal.
El pairs trading apuesta por lo segundo: compra la barata y vende la cara. Como estás largo de una y corto de la otra, si todo el mercado cae un 10% lo que pierdes por un lado lo ganas por el otro. Solo ganas o pierdes según se mueva la diferencia entre ambas. Por eso se llama estrategia neutral al mercado.
Correlación no es cointegración
Este es el error más común. Dos acciones pueden tener rentabilidades diarias muy correlacionadas y aun así separarse para siempre: la correlación mide si se mueven en la misma dirección cada día, no si sus precios se mantienen cerca a largo plazo.
Lo que necesitamos es cointegración: que exista una combinación de los dos precios que sea estacionaria, es decir, que oscile alrededor de una media fija y siempre vuelva a ella. En logaritmos:
\[ \ln P^A_t = \alpha + \beta \ln P^B_t + \varepsilon_t, \qquad \varepsilon_t \text{ estacionario} \]
A \( \varepsilon_t \) le llamamos el spread, y a \( \beta \) el hedge ratio: cuánto hay que vender de B por cada unidad de A para que la posición sea neutral.
La analogía del borracho y su perro: un borracho pasea sin rumbo (un paseo aleatorio) y su perro también. Por separado son impredecibles. Pero si el perro va atado con una correa, nunca se alejan demasiado: la distancia entre ellos es estacionaria. Eso es la cointegración.
Los datos
import numpy as np
import pandas as pd
import yfinance as yf
precios = yf.download(["KO", "PEP"], start="2015-01-01", end="2025-12-31",
auto_adjust=True)["Close"].dropna()Separamos los datos en dos: un periodo de formación, donde estimamos todo, y un periodo de prueba, que no tocamos hasta el final. Es la regla de oro para no engañarse.
log_p = np.log(precios)
corte = "2021-12-31"
form, prueba = log_p.loc[:corte], log_p.loc[corte:].iloc[1:]
print(f"Formación: {len(form)} días | Prueba: {len(prueba)} días")El test de cointegración de Engle-Granger
El método de Engle-Granger tiene dos pasos: (1) estimar \( \alpha \) y \( \beta \) con una regresión lineal por mínimos cuadrados, y (2) comprobar si los residuos (el spread) son estacionarios con un test de raíz unitaria, el de Dickey-Fuller aumentado (ADF). statsmodels hace ambos pasos con los valores críticos correctos:
import statsmodels.api as sm
from statsmodels.tsa.stattools import coint
A, B = form["KO"], form["PEP"]
estad, p_valor, _ = coint(A, B)
print(f"Test de Engle-Granger: estadístico = {estad:.2f}, p-valor = {p_valor:.4f}")
modelo = sm.OLS(A, sm.add_constant(B)).fit()
alpha, beta = modelo.params
print(f"alpha = {alpha:.3f}, hedge ratio beta = {beta:.3f}")Un p-valor por debajo de 0,05 sugiere que el par está cointegrado. Pero cuidado con dos trampas:
- Si pruebas cien pares y te quedas con los que salen significativos al 5%, unos cinco serán falsos positivos por puro azar. Es el problema de las comparaciones múltiples, y es la forma más rápida de fabricar una estrategia que no funciona.
- La cointegración puede romperse: una fusión, un cambio de negocio o una crisis pueden cortar la correa. Por eso hay que tener una razón económica para el par, no solo una estadística.
¿Cuánto tarda en volver? La vida media
Si el spread es estacionario, se puede modelar como un proceso de Ornstein-Uhlenbeck, el modelo estándar de reversión a la media:
\[ d\varepsilon_t = \theta(\mu - \varepsilon_t)\,dt + \sigma\,dW_t \]
\( \theta \) es la velocidad de reversión. En la práctica se estima con una regresión de los cambios del spread sobre su nivel anterior, \( \Delta\varepsilon_t = a + b\,\varepsilon_{t-1} + u_t \), y de ahí sale la vida media: el tiempo que tarda una desviación en reducirse a la mitad.
\[ \text{vida media} = -\frac{\ln 2}{\ln(1 + b)} \approx -\frac{\ln 2}{b} \]
spread_form = A - (alpha + beta * B)
delta = spread_form.diff().dropna()
retardo = spread_form.shift(1).dropna()
b = sm.OLS(delta, sm.add_constant(retardo)).fit().params.iloc[1]
vida_media = -np.log(2) / np.log(1 + b)
print(f"b = {b:.4f} -> vida media ≈ {vida_media:.1f} días")La vida media es muy útil para elegir parámetros: si el spread tarda unos 20 días en recorrer la mitad del camino, no tiene sentido usar una ventana de 2 días ni mantener una posición 6 meses. Una regla habitual es usar una ventana del orden de la vida media.
La señal: el z-score del spread
Para decidir cuándo el spread está «demasiado lejos» lo normalizamos: le restamos su media y lo dividimos por su desviación típica, ambas calculadas con una ventana móvil para usar solo información pasada.
\[ z_t = \frac{\varepsilon_t - \bar\varepsilon_t}{s_t} \]
Reglas de trading clásicas:
- Si \( z_t > 2 \): el spread está caro. Vende A y compra \( \beta \) de B (posición −1).
- Si \( z_t < -2 \): el spread está barato. Compra A y vende \( \beta \) de B (posición +1).
- Si \( |z_t| < 0{,}5 \): el spread ha vuelto. Cierra la posición.
# En el periodo de prueba usamos alpha y beta estimados en formación: nada del futuro
spread = prueba["KO"] - (alpha + beta * prueba["PEP"])
ventana = int(np.clip(round(vida_media), 10, 60))
# La ventana se "calienta" con el final del periodo de formación
spread_total = pd.concat([spread_form.iloc[-ventana:], spread])
media = spread_total.rolling(ventana).mean()
desv = spread_total.rolling(ventana).std()
z = ((spread_total - media) / desv).loc[spread.index]
entrada, salida = 2.0, 0.5
pos = np.zeros(len(z))
for t in range(1, len(z)):
p = pos[t - 1]
if p == 0:
if z.iloc[t] > entrada:
p = -1
elif z.iloc[t] < -entrada:
p = 1
elif abs(z.iloc[t]) < salida:
p = 0
pos[t] = p
pos = pd.Series(pos, index=z.index)
print(f"Ventana: {ventana} días | días con posición abierta: {(pos != 0).mean():.0%}")Un backtest honesto
Aquí es donde la mayoría de estrategias mueren, y conviene que mueran aquí y no con dinero real. Tres detalles que marcan la diferencia:
- Nada de mirar el futuro. La posición que decides con el cierre de hoy solo gana o pierde a partir de mañana: por eso se desplaza un día con
shift(1). - Costes de transacción. Cada vez que cambias la posición pagas comisiones y el diferencial entre compra y venta. Aquí suponemos 5 puntos básicos por pata.
- Normalizar el capital. La posición mueve \( 1 + |\beta| \) unidades de capital; dividimos por eso para medir la rentabilidad sobre lo invertido.
ret_A = prueba["KO"].diff()
ret_B = prueba["PEP"].diff()
ret_spread = (ret_A - beta * ret_B) / (1 + abs(beta))
coste = 0.0005 # 5 pb por pata
cambios = pos.diff().abs().fillna(0)
ret_estrategia = pos.shift(1) * ret_spread - cambios * coste * 2
ret_estrategia = ret_estrategia.fillna(0)
riqueza = np.exp(ret_estrategia.cumsum())
años = len(ret_estrategia) / 252
cagr = riqueza.iloc[-1] ** (1 / años) - 1
vol = ret_estrategia.std() * np.sqrt(252)
sharpe = ret_estrategia.mean() * 252 / vol if vol > 0 else float("nan")
max_dd = (riqueza / riqueza.cummax() - 1).min()
operaciones = int((cambios > 0).sum())
print(f"CAGR {cagr:.2%} | vol {vol:.2%} | Sharpe {sharpe:.2f} | "
f"máx. DD {max_dd:.2%} | operaciones {operaciones}")Como trabajamos con logaritmos, las rentabilidades son logarítmicas y se acumulan con la exponencial de la suma. Para ver el conjunto, dibuja el z-score con los umbrales y la curva de capital:
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
z.plot(ax=ax1, lw=0.8, title="z-score del spread (periodo de prueba)")
for nivel in (entrada, -entrada):
ax1.axhline(nivel, color="red", ls="--", lw=0.8)
ax1.axhline(0, color="gray", lw=0.5)
riqueza.plot(ax=ax2, title="Curva de capital de la estrategia")
plt.tight_layout()
plt.show()Sé escéptico con el resultado, sea bueno o malo. Con un solo par y un solo periodo de prueba hay muy pocas operaciones, y el intervalo de confianza del Sharpe es enorme. Un fondo real opera decenas o cientos de pares a la vez precisamente para que la estadística juegue a su favor.
Cómo se mejora en la práctica
- Hedge ratio dinámico. \( \beta \) no es constante. El filtro de Kalman permite estimarlo en tiempo real y adaptarse a cambios graduales en la relación.
- Stop-loss por ruptura. Si el z-score se va a 4 o 5, puede que la cointegración se haya roto. Hay que tener una regla de salida para ese caso, o una sola ruptura se come los beneficios de años.
- Cestas en lugar de pares. El test de Johansen generaliza la cointegración a más de dos activos: por ejemplo, un banco frente a una cesta de sus competidores.
- Selección con criterio económico. Mismo sector, mismo país, mismo modelo de negocio. La estadística confirma la relación; no debería ser la que la descubre.
- Validación walk-forward. Reestimar periódicamente con una ventana móvil y medir siempre en datos posteriores, repitiendo el proceso a lo largo de toda la historia.
Resumen
- El pairs trading compra el activo barato y vende el caro de un par con relación estable: es neutral al mercado.
- Lo que importa es la cointegración (el spread vuelve a su media), no la correlación.
- La vida media de Ornstein-Uhlenbeck dice cuánto tarda en volver y ayuda a elegir la ventana.
- Un backtest honesto separa formación y prueba, no mira el futuro e incluye costes.
Ya tienes una estrategia sistemática completa, de la idea al backtest. Pero hemos supuesto que el hedge ratio \( \beta \) es fijo, y en la realidad la relación entre dos activos cambia. El siguiente paso es dejar que se adapte con el filtro de Kalman.