Lección 2.3.3 · 75 min
Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
Dos activos que se mueven juntos y un diferencial que vuelve a su media: la idea más vieja del arbitraje estadístico. Esta lección selecciona pares entre 16 activos sintéticos donde se sabe cuáles están cointegrados, cuáles solo correlados y cuál se rompe, y comprueba qué método los encuentra, qué reglas los operan y qué pasa cuando la relación deja de existir.
- Módulo
- 2.3 · Familias de estrategias
- Duración
- 75 min
Lecciones del nivel 2
2.1 · Estadística y econometría para trading
- 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
- 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
- 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
- 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
- 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
- 2.1.6 Filtro de Kalman para hedge ratios dinámicos
- 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
- 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
2.2 · Anatomía de un sistema de trading
- 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
- 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
- 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
- 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
2.3 · Familias de estrategias
- 2.3.1 Seguimiento de tendencia y momentum
- 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
- 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
- 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
- 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
- 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
- 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
2.4 · Backtesting: motores y paradigmas
- 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
- 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
- 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
- 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
- 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
- 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
- 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
- 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
2.5 · Sesgos y validación
- 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
- 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
- 2.5.3 Validación cruzada combinatoria purgada para estrategias
- 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
- 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
- 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
- 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
2.6 · Métricas de rendimiento
Un diferencial que vuelve
El pairs trading es la forma más simple del arbitraje estadístico. Se buscan dos activos cuyo diferencial (la diferencia entre el logaritmo del precio de uno y una cantidad fija del logaritmo del otro) oscila alrededor de un nivel estable. Cuando se separa mucho, se compra el que se ha quedado atrás y se vende el que se ha adelantado; cuando vuelve, se cierra. La posición es casi neutral al mercado: si todo sube o baja a la vez, las dos patas se compensan.
Si es estacionario, y están cointegrados (lección 2.1.5): cada uno es un paseo aleatorio, pero una combinación lineal de los dos no lo es. Esa es la condición que hace funcionar el par. Que los rendimientos de los dos estén muy correlados no basta: dos paseos aleatorios con rendimientos correlados al 0,85 pueden separarse para siempre.
Los logaritmos no son un detalle. En logaritmos el diferencial es un porcentaje: una σ de 0,02 es un desajuste típico del 2 % entre las dos patas, cuesten 20 o 200 USD. En precios (), la σ del diferencial en dólares crece con el nivel de los precios, y un z calculado con la σ de la formación deja de significar lo mismo cuando los precios se duplican.
Gatev, Goetzmann y Rouwenhorst estudiaron en datos reales de EE. UU. de 1962 a 1997 una regla que emparejaba acciones por la mínima distancia entre sus precios normalizados, y encontraron rendimientos anualizados en exceso de hasta el 12 % en carteras autofinanciadas de los mejores pares, que superaban una estimación conservadora de los costes en la mayor parte del periodo1Nota 1 · E. Gatev, W. N. Goetzmann y K. G. Rouwenhorst, «Pairs Trading: Performance of a Relative-Value Arbitrage Rule», Review of Financial Studies 19(3), 2006, 797-827. DOI 10.1093/rfs/hhj020. Versión de trabajo NBER w7032.. Esta lección aplica ese método y el de cointegración a datos donde la respuesta está escrita.
Fuente Mermaid
flowchart TB U[Universo] --> F[Formación 2014-17] F --> S[Selección de pares] S --> P[Parámetros fijos] P --> N[Negocia 2018-25] N --> R[Resultado por par]
Dieciséis activos, ciento veinte pares
import itertools
import warnings
import numpy as np
import pandas as pd
import statsmodels.api as sm
from statsmodels.tsa.stattools import coint
pares = pd.read_parquet("pares2.parquet")
w = pares.pivot(index="fecha", columns="activo", values="precio")
lw = np.log(w) # las relaciones son en log
verdad = pd.read_csv("pares2_verdad.csv").set_index("par")
FIN_FORMACION = "2017-12-31"
form, lform = w.loc[:FIN_FORMACION], lw.loc[:FIN_FORMACION]
print(w.shape[1], "activos,", len(form), "sesiones de formación,",
len(w.loc["2018":]), "de negociación")
print("formulación:", verdad["formulacion"].unique().tolist())
verdad[["activo_y", "activo_x", "tipo", "beta",
"vida_media_sesiones", "sigma_spread"]]16 activos, 1007 sesiones de formación, 2011 de negociación
formulación: ['log']
activo_y activo_x tipo beta vida_media_sesiones sigma_spread
par
A PZAY PZAX cointegrado 1.5 5.0 0.020
B PZBY PZBX cointegrado 0.8 15.0 0.030
C PZCY PZCX cointegrado 1.1 40.0 0.045
D PZDY PZDX beta_variable NaN 10.0 0.025
E PZEY PZEX correlado_no_cointegrado 1.0 NaN NaN
F PZFY PZFX correlado_no_cointegrado 0.9 NaN NaN
G PZGY PZGX correlado_no_cointegrado 1.2 NaN NaN
H PZHY PZHX ruptura 0.9 10.0 0.025Dieciséis activos, 1.007 sesiones de formación (2014-2017) y 2.011 de negociación (2018-2025). La tabla de la verdad
solo se mira para corregir: la selección y los parámetros saldrán de la formación como si no existiera. Todas las
relaciones son en logaritmos (formulacion = log): ln y = α + β·ln x + u, con u estacionario de σ entre 0,020 y
0,045 en los pares verdaderos y un paseo aleatorio en E, F y G. Por eso los contrastes, las regresiones y el
diferencial se calculan sobre lw, los logaritmos de los precios.
Selección por distancia
El método de la distancia normaliza los precios de cada activo a 1 el primer día de la formación y, para cada pareja, suma los cuadrados de la diferencia entre sus trayectorias. Los pares con menor suma son los que más se han parecido. No hay modelo ni contraste: es una medida de parecido.
norm = form / form.iloc[0]
todos = list(itertools.combinations(w.columns, 2))
verdaderos = {(r.activo_x, r.activo_y) for r in verdad.itertuples()}
def etiqueta(a, b):
for r in verdad.itertuples():
if {a, b} == {r.activo_x, r.activo_y}:
return f"{r.Index} ({r.tipo})"
return "cruzado"
dist = pd.Series({(a, b): ((norm[a] - norm[b]) ** 2).sum()
for a, b in todos}).sort_values()
top = dist.head(10).round(2).rename("distancia").to_frame()
top["par"] = [etiqueta(a, b) for a, b in top.index]
top distancia par
PZDX PZDY 0.70 D (beta_variable)
PZBX PZBY 1.31 B (cointegrado)
PZHX PZHY 1.89 H (ruptura)
PZCX PZCY 3.24 C (cointegrado)
PZDX PZEX 3.83 cruzado
PZDY PZEX 4.89 cruzado
PZEX PZEY 6.75 E (correlado_no_cointegrado)
PZFX PZHX 6.93 cruzado
PZHY 7.57 cruzado
PZBY PZHY 9.01 cruzadoLos cuatro pares más cercanos son pares de verdad (D, B, H y C). El quinto y el sexto son cruzados (PZDX con PZEX y
PZDY con PZEX: activos de pares distintos que comparten el factor de mercado y en estos cuatro años fueron juntos), y
el séptimo es E, correlado y no cointegrado. Falta A, el par con la reversión más rápida del universo: su beta es 1,5,
así que y se mueve un 50 % más que x y sus trayectorias normalizadas se separan aunque el diferencial en
logaritmos vuelva cada pocos días. La distancia supone, sin decirlo, una cobertura de uno a uno. Mide parecido: no
distingue «se parecen» de «están obligados a volver», y no ve un par cuya cobertura no sea uno a uno.
Selección por cointegración
El contraste de Engle-Granger estima por mínimos cuadrados y contrasta si el
residuo tiene raíz unitaria con un ADF con valores críticos propios (lección 2.1.5). statsmodels.tsa.stattools.coint lo
hace en una llamada. Se aplica a los logaritmos de los 120 pares del universo en la formación:
filas = []
for a, b in todos:
_, p, _ = coint(lform[a], lform[b])
filas.append({"y": a, "x": b, "p": p, "par": etiqueta(a, b)})
eg = pd.DataFrame(filas).sort_values("p")
print("p < 0,05:", (eg["p"] < 0.05).sum(), "de", len(eg),
" esperados por azar:", round(0.05 * len(eg), 1))
print("p < 0,05/120 (Bonferroni):", (eg["p"] < 0.05 / 120).sum())
eg.head(8).round(4)p < 0,05: 18 de 120 esperados por azar: 6.0
p < 0,05/120 (Bonferroni): 4
y x p par
0 PZAX PZAY 0.0000 A (cointegrado)
29 PZBX PZBY 0.0000 B (cointegrado)
75 PZDX PZDY 0.0000 D (beta_variable)
119 PZHX PZHY 0.0003 H (ruptura)
109 PZFX PZHY 0.0008 cruzado
101 PZEY PZGX 0.0011 cruzado
49 PZBY PZFY 0.0034 cruzado
53 PZBY PZHY 0.0067 cruzadoDieciocho pares pasan el 5 %, tres veces los 6 que se esperan por azar entre 120 contrastes. Los cuatro primeros son de verdad (A, B, D y H, con p de 0,0003 o menos), y la corrección de Bonferroni (p < 0,05/120 ≈ 0,0004) deja exactamente esos cuatro. Los otros catorce son falsos positivos, casi todos cruzados.
¿Por qué tantos? El contraste no está mal: el 5 % es una frecuencia media sobre muchas muestras, y en una sola los 120 contrastes no son independientes, porque comparten series. Si PZFX parece cointegrado con PZHY, también lo parecerá con PZHX, que va pegado a PZHY; en la tabla, PZBY aparece con PZFY y con PZHY. Los falsos positivos llegan en racimos, y su número varía de una muestra a otra mucho más que el de 120 contrastes independientes. Contarlos en las otras dos ventanas de cuatro años lo enseña:
reales = {frozenset((r.activo_x, r.activo_y))
for r in verdad.itertuples() if not r.tipo.startswith("corr")}
for a, b in (("2014", "2017"), ("2018", "2021"), ("2022", "2025")):
tramo = lw.loc[a:b]
sig = [frozenset(p) for p in todos
if coint(tramo[p[0]], tramo[p[1]])[1] < 0.05]
falsos = sum(p not in reales for p in sig)
print(f"{a}-{b}: {len(sig):2d} con p < 0,05, {falsos:2d} falsos")2014-2017: 18 con p < 0,05, 14 falsos
2018-2021: 8 con p < 0,05, 4 falsos
2022-2025: 5 con p < 0,05, 2 falsosCatorce falsos en 2014-2017, cuatro en 2018-2021 y dos en 2022-2025: una media de unos siete por ventana, cerca de los ≈ 5,8 que se esperan entre los 115 pares que no son verdaderos, pero con una ventana que da más del doble. La cifra «esperados por azar» es una media, no un techo. El par C, que está cointegrado, no aparece entre los significativos de la formación: con una vida media de 40 sesiones, cuatro años no bastan para que el contraste distinga su diferencial de un paseo aleatorio. Un contraste con poca potencia también falla hacia el otro lado.
Los ocho pares designados, uno por uno, con la correlación de sus rendimientos, el contraste en la formación y en toda la muestra, y la σ y la vida media estimadas del diferencial. La vida media sale de una regresión AR(1) del diferencial: , y (lección 2.1.5).
def formacion(y, x):
m = sm.OLS(lform[y], sm.add_constant(lform[x])).fit()
a, b = m.params.iloc[0], m.params.iloc[1]
s = lform[y] - a - b * lform[x]
ar = sm.OLS(s.diff().dropna(), s.shift().dropna()).fit()
lam = ar.params.iloc[0]
vida = -np.log(2) / np.log(1 + lam) if lam < 0 else np.inf
return a, b, s.mean(), s.std(), vida
resumen = []
for par, r in verdad.iterrows():
y, x = r["activo_y"], r["activo_x"]
a, b, mu, sd, vida = formacion(y, x)
resumen.append({
"par": par,
"corr_rend": lform[[y, x]].diff().corr().iloc[0, 1],
"p_formacion": coint(lform[y], lform[x])[1],
"p_total": coint(lw[y], lw[x])[1],
"beta_est": b, "beta_real": r["beta"], "sd_est": sd,
"vida_est": vida, "vida_real": r["vida_media_sesiones"]})
pd.DataFrame(resumen).set_index("par").round(3) corr_rend p_formacion p_total beta_est beta_real sd_est vida_est vida_real
par
A 0.900 0.000 0.000 1.503 1.5 0.019 4.423 5.0
B 0.724 0.000 0.000 0.981 0.8 0.025 9.331 15.0
C 0.850 0.187 0.000 1.092 1.1 0.045 43.827 40.0
D 0.830 0.000 0.035 1.204 NaN 0.024 9.749 10.0
E 0.825 0.207 0.237 1.237 1.0 0.057 60.003 NaN
F 0.821 0.314 0.510 0.757 0.9 0.056 69.790 NaN
G 0.821 0.308 0.339 2.117 1.2 0.105 58.862 NaN
H 0.677 0.000 0.219 0.867 0.9 0.027 12.339 10.0Lo que dice la tabla, par a par:
- La correlación no separa nada. Todos los pares, verdaderos y falsos, tienen rendimientos correlados entre 0,68 y 0,90, y dos de los verdaderos (H, 0,68, y B, 0,72) están entre los menos correlados.
- A, B, D y H pasan el contraste en la formación con p ≈ 0. A y H tienen betas casi exactas (1,503 frente a 1,5;
0,867 frente a 0,9) y vidas medias cercanas (4,4 y 12,3 sesiones, frente a 5 y 10). B se estima peor: 0,981 frente a
0,8, y una vida media de 9 sesiones frente a 15. La σ estimada del diferencial (
sd_est) queda cerca de la verdadera en todos (0,019, 0,025 y 0,027, frente a 0,020, 0,030 y 0,025). - C no pasa en la formación (p = 0,19) y sí en toda la muestra (p ≈ 0): la vida media estimada, 44 sesiones, es buena, pero con pocos ciclos el contraste no tiene potencia.
- D, con beta variable, pasa en la formación con una vida media de 9,7 sesiones, casi la verdadera: en esos cuatro años su beta se movió poco y una beta fija de 1,204 sirve. En toda la muestra el contraste ya duda (p = 0,035): la beta ha seguido moviéndose y el diferencial con una beta fija revierte mucho más despacio que el verdadero (el README mide una vida media de 38 sesiones con toda la muestra).
- E, F y G no están cointegrados en ninguno de los dos periodos, como debe ser (p de 0,21 a 0,51). Sus «vidas medias» de 59 a 70 sesiones son lo que da una regresión AR(1) sobre un paseo aleatorio en una muestra finita: un número que parece razonable y no significa nada. La beta de G, 2,117 frente a 1,2, tampoco.
- H pasa en la formación (p ≈ 0) y no en la muestra total (p = 0,22): la ruptura de 2021 está en la parte que no se podía ver al elegirlo.
Reglas de entrada y salida
Con los parámetros de la formación fijos, la regla clásica abre cuando el diferencial se separa más de dos desviaciones típicas y cierra cuando vuelve a su media. Dos salidas más protegen de que la relación haya dejado de existir: un stop por nivel (|z| > 4) y un stop por tiempo (más de 60 sesiones abierta, unas cuatro veces la vida media de un par típico). Tras un stop, la regla no vuelve a entrar hasta que el diferencial regrese a |z| < 0,5: sin esa condición, volvería a abrir al día siguiente en el mismo sitio.
Fuente Mermaid
stateDiagram-v2 [*] --> PLANO PLANO --> LARGO: z menor de -2 PLANO --> CORTO: z mayor de 2 LARGO --> PLANO: z cruza 0 CORTO --> PLANO: z cruza 0 LARGO --> BLOQUEO: stop CORTO --> BLOQUEO: stop BLOQUEO --> PLANO: abs z menor de 0,5
«Largo el diferencial» es comprar USD de y vender USD de : con el diferencial en logaritmos, esa
cartera gana (para movimientos pequeños), porque y son rendimientos. El
tamaño se fija para que una desviación típica del diferencial valga 1.000 USD: , unos 50.000 USD
de nominal en con una σ de 0,02. Las acciones se calculan con los precios del cierre en que se abre y no se tocan
hasta cerrar. Los costes son por acción y lado (0,02 USD: comisión de 0,005, media horquilla de 0,01 y deslizamiento de
0,005, para acciones de un céntimo de tick) más un 0,5 % anual de préstamo sobre el nominal vendido. Como en todo el
nivel, la señal del cierre de t se ejecuta al cierre de t+1.
COSTE_ACCION = 0.02
PRESTAMO = 0.005
def maquina(z, entrada=2.0, stop=None, tiempo=None):
est, cur, dias, armado = np.zeros(len(z)), 0, 0, True
for i, v in enumerate(z):
if not armado and abs(v) < 0.5:
armado = True
if cur == 0 and armado:
cur = -1 if v > entrada else (1 if v < -entrada else 0)
dias = 0
elif cur != 0:
dias += 1
if cur * v > 0: # cruzó la media
cur = 0
elif (stop and abs(v) > stop) or (tiempo and dias > tiempo):
cur, armado = 0, False
est[i] = cur
return est
def opera(y, x, z, beta, sd, **reglas):
est = pd.Series(maquina(z.to_numpy(), **reglas), index=z.index)
pos = est.shift(1).fillna(0.0) # al cierre siguiente
Y, X = w[y].loc[z.index], w[x].loc[z.index]
usd = pos * 1000 / sd # nominal de y
nuevo = pos.diff().fillna(pos) != 0 # acciones fijas al abrir
acc_y = (usd / Y).where(nuevo).ffill().where(pos != 0, 0.0)
acc_x = (-usd * beta / X).where(nuevo).ffill().where(pos != 0, 0.0)
bruto = acc_y.shift(1) * Y.diff() + acc_x.shift(1) * X.diff()
giro = (acc_y.diff().abs() + acc_x.diff().abs()) * COSTE_ACCION
corto = (acc_y.clip(upper=0).abs() * Y
+ acc_x.clip(upper=0).abs() * X) * PRESTAMO / 252
neto = (bruto - giro - corto).fillna(0.0)
return est, neto, bruto.fillna(0.0)
def par(nombre, **reglas):
r = verdad.loc[nombre]
y, x = r["activo_y"], r["activo_x"]
a, b, mu, sd, _ = formacion(y, x)
z = ((lw[y] - a - b * lw[x] - mu) / sd).loc["2018":]
est, neto, bruto = opera(y, x, z, b, sd, **reglas)
return z, est, neto, bruto
z_b, est_b, neto_b, _ = par("B")
cambios = est_b[est_b.diff().fillna(est_b) != 0]
print(cambios.map({1.0: "largo", -1.0: "corto", 0.0: "cierra"})
.head(8).to_string())
print(f"neto del par B, 2018-2025: {neto_b.sum():,.0f} USD")fecha
2018-05-02 largo
2018-09-18 cierra
2018-10-09 largo
2019-03-07 cierra
2019-03-28 largo
2019-04-16 cierra
2019-05-03 corto
2019-07-30 cierra
neto del par B, 2018-2025: 26,962 USDEl par B tiene pocas operaciones y largas: la primera dura de mayo a septiembre de 2018 y la segunda, de octubre de 2018
a marzo de 2019, algo raro para un diferencial con vida media de 15 sesiones. La causa es el
error de la beta: 0,981 en vez de 0,8. Con la beta estimada, el diferencial es más una
constante, y cuando se mueve, ese término deriva y el diferencial se queda meses a un lado de la media de la
formación: una subida de x del 25 % mueve en 0,22 y el diferencial en 0,04, más de una σ y media. El par gana
26.962 USD netos en ocho años, sin stops. En datos reales el problema es el mismo: un error pequeño en el hedge ratio
se convierte en una tendencia del diferencial cuando los precios se mueven mucho.
Resultados por par: verdaderos, falsos y stops
def caida(neto):
acum = neto.cumsum()
return (acum - acum.cummax()).min()
filas = {}
for nombre in verdad.index:
_, e0, n0, b0 = par(nombre)
_, e1, n1, _ = par(nombre, stop=4.0, tiempo=60)
filas[nombre] = {
"tipo": verdad.loc[nombre, "tipo"],
"operaciones": int((e0.diff().fillna(e0) != 0).sum() // 2),
"bruto": b0.sum(), "neto": n0.sum(), "caida": caida(n0),
"neto_stops": n1.sum(), "caida_stops": caida(n1)}
res = pd.DataFrame(filas).T
for c in ["bruto", "neto", "caida", "neto_stops", "caida_stops"]:
res[c] = res[c].astype(float).round(-2).astype(int)
res tipo operaciones bruto neto caida neto_stops caida_stops
A cointegrado 28 59900 57200 -2900 57200 -2900
B cointegrado 12 29100 27000 -11700 7300 -5000
C cointegrado 6 14000 13300 -2100 8100 -2100
D beta_variable 15 34700 32700 -15200 23100 -5400
E correlado_no_cointegrado 1 -10000 -11700 -22500 -1500 -2600
F correlado_no_cointegrado 1 -3800 -4400 -10100 2900 -1600
G correlado_no_cointegrado 1 -10200 -11400 -20300 -1400 -3000
H ruptura 6 5300 3800 -22300 6500 -5900Las cifras son dólares por cada 1.000 USD de σ del diferencial, de 2018 a 2025, netos de costes:
- Los pares verdaderos ganan: A, 57.200 con 28 operaciones; B, 27.000; C, 13.300 con solo 6 (su vida media de 40 sesiones da pocos ciclos). D, el de la beta variable, también gana 32.700 con la beta fija de la formación: es un par de verdad, y esa beta le sirve durante años, aunque con una caída de 15.200.
- Los falsos pierden sin stops: E, −11.700; F, −4.400; G, −11.400, con caídas de hasta 22.500. Cada uno hace una sola operación en ocho años: abre y se queda abierta mientras el diferencial se aleja. Es exactamente el comportamiento de un paseo aleatorio operado como si revirtiera.
- Los stops recortan la cola, no mejoran la media. Con |z| > 4 y 60 sesiones como límite, los tres falsos quedan entre −1.500 y +2.900, con caídas de 1.600 a 3.000: ahorran 27.500 USD entre los tres. En los verdaderos, los stops cuestan casi lo mismo, 34.500 entre B, C y D (A no cambia): cortan operaciones que habrían vuelto. B es el caso extremo, de 27.000 a 7.300, porque con su beta mal estimada las operaciones duran meses y el stop por tiempo las cierra antes de que vuelvan.
- H pasa de 3.800 a 6.500: la sección siguiente lo explica.
La figura reúne los dos resultados. Sumados, los stops casi no cambian el total de esta muestra; lo que cambian es la peor caída de un par, que baja de 22.500 a 5.900. Un stop en pares es un seguro con prima: se paga en los pares que funcionan y protege de los que no, que en datos reales no vienen etiquetados.
El par roto
El par H cumple todo lo que se le puede pedir a un par en la formación: cointegrado con p ≈ 0, vida media corta y hedge ratio estable. Durante la negociación funciona tres años. El 15 de junio de 2021 su diferencial salta cinco desviaciones típicas y desde ese día es un paseo aleatorio: la relación ya no existe. Es lo que pasa en datos reales cuando una de las dos empresas cambia de negocio, es adquirida o sufre un problema propio.
z_h, e_h0, n_h0, _ = par("H")
_, e_h1, n_h1, _ = par("H", stop=4.0, tiempo=60)
anual = pd.DataFrame({"sin_stops": n_h0, "con_stops": n_h1})
print(anual.groupby(anual.index.year).sum().round(-2).astype(int).T
.to_string())
print("z máximo tras la ruptura:",
round(z_h.loc["2021-06-15":].max(), 1))
print("posición sin stops al final:", e_h0.iloc[-1],
" con stops:", e_h1.iloc[-1])
print(pd.DataFrame({"z": z_h, "sin_stops": e_h0, "con_stops": e_h1})
.loc["2021-06-11":"2021-06-17"].round(2).to_string())fecha 2018 2019 2020 2021 2022 2023 2024 2025
sin_stops 3100 1000 8400 -15900 6000 2100 2300 -3200
con_stops 3700 1000 4900 -3200 0 0 0 0
z máximo tras la ruptura: 18.4
posición sin stops al final: -1.0 con stops: 0.0
z sin_stops con_stops
fecha
2021-06-11 2.02 -1.0 -1.0
2021-06-14 1.96 -1.0 -1.0
2021-06-15 6.49 -1.0 0.0
2021-06-16 6.42 -1.0 0.0
2021-06-17 6.20 -1.0 0.0Sin stops, H gana 12.500 USD de 2018 a 2020 y pierde 15.900 en 2021. La regla estaba corta antes de la ruptura (el diferencial rondaba +2), y el 15 de junio el z pasa de 1,96 a 6,49 en una sola sesión. Desde ahí sigue alejándose, hasta 18,4 desviaciones típicas, con la posición abierta hasta el final de 2025. Lo que la versión sin stops gana o pierde de 2022 a 2025 (+6.000, +2.100, +2.300 y −3.200) es ruido: una posición fija sobre un paseo aleatorio.
Con stops, el de nivel salta en el cierre del 15 de junio (la columna con_stops pasa a 0 ese día) y la posición se
cierra en la sesión siguiente. No evitó la pérdida del salto, que pasó por encima del stop de 4 en una sola sesión: 2021
cierra con −3.200. Lo que evitó es todo lo que vino después: el bloqueo impide volver a entrar, porque el diferencial
no regresa a |z| < 0,5, y de 2022 a 2025 no hay nada. La figura enseña los dos caminos: el diferencial antes y después
de la ruptura, y el resultado acumulado con y sin stops.
Hedge ratio variable: el filtro de Kalman
El par D tiene una beta que se mueve como un paseo aleatorio reflejado entre 0,6 y 1,6, con un paso diario de 0,006,
y la relación gira alrededor del primer precio de x, (README de los datos):
En la formación, una beta fija pasa el contraste, pero en toda la muestra ya duda (tabla de los ocho pares). Un filtro de Kalman trata la beta como un estado oculto que cambia un poco cada día y la estima de forma recursiva, con los datos hasta la víspera (lección 2.1.6). Hay dos maneras de escribir la observación, y no son equivalentes:
- Centrado: . Aquí la constante es fija de verdad, porque el generador hace girar la relación justo en .
- Alfa fijo, la forma habitual: con fija. Cuando la beta cambia, la constante verdadera, , también cambia, y el filtro que la supone fija está mal especificado; la lección 2.1.6 lo mide con detalle.
En los dos, la beta sigue un paseo aleatorio, con , y la varianza del ruido de observación es la del diferencial de la formación (). La varianza del paso es la decisión clave; la del generador es .
beta_real = pd.read_csv("pares2_beta_real.csv", parse_dates=["fecha"]
).set_index("fecha")["beta_real"]
_, _, _, sd_D, _ = formacion("PZDY", "PZDX")
def kalman(y, X, q, r=sd_D ** 2):
th, P = np.zeros(2), np.eye(2) * 100.0
Q = np.diag([0.0, q]) # la constante no se mueve
n = len(y)
beta, error = np.zeros(n), np.zeros(n)
for t in range(n):
P = P + Q
H = X[t]
S = H @ P @ H + r
beta[t] = th[1] # antes de ver y_t
error[t] = y[t] - H @ th
K = P @ H / S
th = th + K * error[t]
P = P - np.outer(K, H @ P)
return beta, error
yD, lx = lw["PZDY"].to_numpy(), lw["PZDX"].to_numpy()
uno = np.ones(len(lx))
modelos = {"centrado": np.column_stack([uno, lx - lx[0]]),
"alfa fijo": np.column_stack([uno, lx])}
filas = []
for nombre, X in modelos.items():
for q in (1e-7, 1e-6, 1e-5, 0.006 ** 2):
beta_k, err = kalman(yD, X, q)
rho = pd.Series(err, index=w.index).loc["2015":].autocorr()
e_b = (pd.Series(beta_k, w.index) - beta_real).loc["2015":]
filas.append({"modelo": nombre, "q": f"{q:.1e}",
"error_beta": e_b.abs().mean(), "ac1_dif": rho,
"vida_dif": -np.log(2) / np.log(rho)})
pd.DataFrame(filas).round(3) modelo q error_beta ac1_dif vida_dif
0 centrado 1.0e-07 0.047 0.955 14.965
1 centrado 1.0e-06 0.043 0.930 9.604
2 centrado 1.0e-05 0.047 0.894 6.186
3 centrado 3.6e-05 0.055 0.862 4.671
4 alfa fijo 1.0e-07 0.103 0.891 6.027
5 alfa fijo 1.0e-06 0.109 0.788 2.906
6 alfa fijo 1.0e-05 0.092 0.536 1.110
7 alfa fijo 3.6e-05 0.086 0.328 0.621La tabla enseña dos cosas. La primera es la especificación: con el modelo centrado, el error medio de la beta queda entre 0,043 y 0,055 sobre valores entre 1,0 y 1,5; con la constante fija, entre 0,086 y 0,109, el doble, porque el filtro compensa la constante equivocada moviendo la beta. La segunda es el compromiso entre seguir la beta y conservar el diferencial. En el modelo centrado, al subir q la autocorrelación del residuo baja poco (de 0,955 a 0,862) y su vida media, de 15 a 4,7 sesiones, rodea la verdadera de 10. En el de alfa fijo cae de 0,891 a 0,328: con la q del generador el residuo tiene una vida media de 0,6 sesiones. El filtro atribuye cada desviación del diferencial a un cambio de beta y se come la reversión que se quería operar.
Un detalle: en el modelo centrado el mejor error de la beta (0,043) no está en la q del generador, sino en una cien veces menor. El filtro supone que el ruido de observación es independiente de un día a otro, y el diferencial verdadero es un proceso con memoria (vida media de 10 sesiones): una q grande confunde esas rachas con cambios de beta. La figura compara la beta real con la del filtro centrado de y con la beta fija de la formación.
def opera_dif(dif, beta_t):
sd = dif.rolling(250, min_periods=60).std().shift(1)
z = (dif / sd).loc["2018":]
est, neto, _ = opera("PZDY", "PZDX", z, beta_t.loc[z.index],
sd.loc[z.index], stop=4.0, tiempo=60)
n_op = int((est.diff().fillna(est) != 0).sum() // 2)
return n_op, round(neto.sum(), -2)
a_f, b_f, *_ = formacion("PZDY", "PZDX")
lyD, lxD = lw["PZDY"], lw["PZDX"]
u_real = lyD - 0.3 - lx[0] - beta_real * (lxD - lx[0]) # la verdad
variantes = {"beta fija (OLS)": (lyD - a_f - b_f * lxD,
pd.Series(b_f, index=w.index)),
"beta real": (u_real, beta_real)}
for nombre, q in (("centrado", 1e-6), ("centrado", 0.006 ** 2),
("alfa fijo", 0.006 ** 2)):
beta_k, err = kalman(yD, modelos[nombre], q)
variantes[f"{nombre} q={q:.1e}"] = (pd.Series(err, w.index),
pd.Series(beta_k, w.index))
pd.DataFrame({k: opera_dif(*v) for k, v in variantes.items()},
index=["operaciones", "neto_usd"]).T operaciones neto_usd
beta fija (OLS) 11.0 18400.0
beta real 23.0 56100.0
centrado q=1.0e-06 22.0 39400.0
centrado q=3.6e-05 36.0 29700.0
alfa fijo q=3.6e-05 78.0 12800.0Con la beta real, el diferencial es el proceso de Ornstein-Uhlenbeck del generador y la regla gana 56.100 USD con 23 operaciones: es el techo, inalcanzable porque usa la verdad. Con la beta fija de la formación, 18.400 en 11 operaciones: funciona mientras la beta no se aleja mucho de la de 2014-2017. El Kalman centrado con gana 39.400 con 22 operaciones, y con la q del generador, 29.700 con 36: sigue la beta con más ruido y opera más. El de alfa fijo con esa misma q gana 12.800 con 78 operaciones, menos que la beta fija: opera un diferencial sin memoria y paga costes en cada vuelta. Entre el techo y el mejor filtro quedan 16.700 USD: lo que cuesta no conocer la beta, en el mejor de los casos.
Una cartera de pares elegidos en la formación
En la práctica nadie conoce pares2_verdad.csv: se eligen los pares con los datos de la formación y se operan todos.
La cartera honesta es la de los dieciocho pares que pasan Engle-Granger al 5 % en la formación, falsos positivos
incluidos, con stops:
def par_libre(y, x, **reglas):
a, b, mu, sd, _ = formacion(y, x)
z = ((lw[y] - a - b * lw[x] - mu) / sd).loc["2018":]
return opera(y, x, z, b, sd, **reglas)[1]
elegidos = eg[eg["p"] < 0.05]
cartera = pd.DataFrame({f"{r.y}-{r.x}": par_libre(r.y, r.x, stop=4.0,
tiempo=60)
for r in elegidos.itertuples()})
total = (cartera.sum().round(-2).astype(int)
.rename("neto_usd").to_frame())
total["par"] = elegidos["par"].to_numpy()
print(total.to_string())
falso = total["par"].str.contains("cruzado|correlado").to_numpy()
print(f"cartera: {cartera.sum().sum():,.0f} USD; verdaderos: "
f"{cartera.loc[:, ~falso].sum().sum():,.0f}; falsos: "
f"{cartera.loc[:, falso].sum().sum():,.0f} ({falso.sum()})") neto_usd par
PZAX-PZAY 62700 A (cointegrado)
PZBX-PZBY 10800 B (cointegrado)
PZDX-PZDY 14500 D (beta_variable)
PZHX-PZHY 8000 H (ruptura)
PZFX-PZHY 10300 cruzado
PZEY-PZGX 400 cruzado
PZBY-PZFY -1600 cruzado
PZBY-PZHY 4900 cruzado
PZBY-PZHX 600 cruzado
PZBX-PZHY -8800 cruzado
PZEX-PZEY 5000 E (correlado_no_cointegrado)
PZBY-PZFX 200 cruzado
PZFX-PZHX 2600 cruzado
PZEX-PZGX 1100 cruzado
PZBX-PZFY -2700 cruzado
PZEY-PZGY -1300 cruzado
PZCX-PZGY -2000 cruzado
PZDY-PZGX -2000 cruzado
cartera: 102,610 USD; verdaderos: 96,047; falsos: 6,564 (14)La cartera gana 102.610 USD, y casi todo viene de los cuatro pares verdaderos (96.047; A solo aporta 62.700). Los catorce falsos suman +6.564: con stops, un par falso es sobre todo ruido con costes, unas veces positivo (+10.300 en PZFX-PZHY) y otras negativo (−8.800 en PZBX-PZHY). En este universo los falsos positivos no hunden la cartera porque los stops los mantienen pequeños, pero tampoco aportan nada: son catorce de dieciocho posiciones, con su capital, su préstamo y su riesgo de cola, para un resultado que es cero más ruido. Con más candidatos y sin stops serían la mayoría de la cartera y la mayoría de las pérdidas.
A y B ganan aquí más que en la tabla de los ocho pares (62.700 frente a 57.200 y 10.800 frente a 7.300), y D menos (14.500 frente a 23.100), porque el contraste los eligió con el orden (x, y) invertido: la beta de una regresión de x sobre y no es la inversa exacta de la de y sobre x, y elegir el orden también es una decisión del investigador que mueve el resultado.
Lo que esto cambia en tus backtests
- Formación y negociación separadas. La beta, la media, la σ y la selección salen de un periodo y se operan en otro. Seleccionar los pares con toda la muestra es elegir los que ya se sabe que funcionaron.
- Cuenta los pares que probaste. Con cien o mil candidatos, un contraste al 5 % da decenas de pares «cointegrados» por azar; Bonferroni o FDR antes de operar (lección 2.1.8).
- Correlación no es cointegración. Los pares E, F y G tienen rendimientos correlados al 0,82-0,83 y ningún ancla.
- Logaritmos, no precios. El diferencial en logaritmos es un porcentaje y su σ no cambia con el nivel del precio; el tamaño se fija en nominal, no en acciones.
- Stops por nivel y por tiempo, y bloqueo tras el stop. El riesgo de un par es que la relación deje de existir, y eso no avisa.
- El coste de la pata corta. Préstamo sobre el nominal vendido y disponibilidad de los títulos (lección 2.4.7): en un par de nominal fijo por σ, el préstamo es una fracción constante del tamaño, y pesa más cuanto más dura cada operación.
Ejercicios
- Repite la cartera de los ocho pares con stops cambiando el umbral de entrada a 1,5 y a 2,5. ¿Qué pasa con el número de operaciones y el neto de los pares A y B?
Solución
filas = {}
for entrada in (1.5, 2.0, 2.5):
for nombre in ("A", "B"):
_, e, n, _ = par(nombre, entrada=entrada, stop=4.0, tiempo=60)
filas[(entrada, nombre)] = {
"operaciones": int((e.diff().fillna(e) != 0).sum() // 2),
"neto": round(n.sum(), -2)}
pd.DataFrame(filas).T operaciones neto
1.5 A 57.0 98600.0
B 20.0 6500.0
2.0 A 28.0 57200.0
B 14.0 7300.0
2.5 A 10.0 26700.0
B 12.0 7500.0En A, bajar el umbral a 1,5 duplica las operaciones (57 frente a 28) y casi duplica el neto (98.600 frente a 57.200); subirlo a 2,5 lo deja en 10 operaciones y 26.700. En un par cointegrado de verdad y con la beta bien estimada, entrar antes recoge más ciclos de reversión, y el coste por acción es pequeño frente a la ganancia de cada ciclo. En B el umbral casi no importa (6.500, 7.300 y 7.500, con 20, 14 y 12 operaciones): con su beta mal estimada el diferencial deriva y el resultado lo deciden los stops, no la entrada. Con costes por operación más altos, o en un par falso, la conclusión sería la contraria: cada entrada de más paga costes sin ventaja. Elegir el umbral con estos resultados sería, otra vez, ajustar a la muestra de negociación.
- Vigila el par H con un contraste de Engle-Granger móvil: cada 20 sesiones, el p-valor de las 250 sesiones anteriores. ¿Cuándo detecta la ruptura y cuánto tarda?
Solución
fechas = w.loc["2020-01-01":].index[::20]
p_movil = pd.Series({d: coint(lw["PZHY"].loc[:d].iloc[-250:],
lw["PZHX"].loc[:d].iloc[-250:])[1]
for d in fechas})
print((p_movil.loc["2021-01-01":"2022-06-30"] < 0.05)
.map({True: "coint.", False: "no"}).to_string())2021-01-13 coint.
2021-02-11 coint.
2021-03-12 coint.
2021-04-12 coint.
2021-05-10 coint.
2021-06-08 coint.
2021-07-07 no
2021-08-04 no
2021-09-01 no
2021-09-30 no
2021-10-28 no
2021-11-26 no
2021-12-27 no
2022-01-25 no
2022-02-23 no
2022-03-23 no
2022-04-21 no
2022-05-19 no
2022-06-17 noEl contraste móvil da cointegración en todas las ventanas hasta la del 8 de junio de 2021 y la pierde en la siguiente, el 7 de julio, tres semanas después de la ruptura, y no la recupera en el año que sigue. Como vigilancia funciona, pero no es una alarma inmediata: la ventana de 250 sesiones necesita que el tramo roto pese lo suficiente. Y en sentido contrario, un contraste móvil sobre un par falso dará cointegración en alguna ventana por azar: a 20 sesiones por paso, se hacen trece contrastes al año.
- Un proveedor te ofrece una lista de «200 pares cointegrados del S&P 500» seleccionados con datos de los últimos diez años. ¿Qué preguntas harías antes de operarlos?
Solución
Cuántos pares se probaron para llegar a 200 (con 500 acciones hay casi 125.000 parejas posibles, y al 5 % saldrían unos 6.000 «cointegrados» por azar) y qué corrección por pruebas múltiples se aplicó. Si la selección se hizo con los mismos datos con que se mide el resultado o con un periodo de formación anterior. Con qué universo (los miembros actuales del índice tienen sesgo de supervivencia). Si hay una razón económica para cada par (mismo negocio, misma materia prima, dos clases de acciones de la misma empresa) o solo una coincidencia estadística. Cuánto dura cada relación fuera de la muestra y qué stops y límites por par se usaron. Y los costes reales: horquilla, comisiones y, sobre todo, préstamo y disponibilidad de la pata corta.
Footnotes
-
E. Gatev, W. N. Goetzmann y K. G. Rouwenhorst, «Pairs Trading: Performance of a Relative-Value Arbitrage Rule», Review of Financial Studies 19(3), 2006, 797-827. DOI 10.1093/rfs/hhj020. Versión de trabajo NBER w7032. ↩
Respuesta plegada en cada pregunta
Autoevaluación
En el periodo de formación, el contraste de Engle-Granger al 5 % sobre los 120 pares posibles de 16 activos da 18 pares significativos. Solo cuatro son pares de verdad. ¿Qué explica los otros catorce?
- AQue el contraste está mal programado
- BQue con 120 contrastes al 5 % se esperan unos 6 falsos positivos aunque no haya ninguna relación, y como los contrastes comparten series llegan en racimos: en una muestra concreta pueden ser el doble o más
- CQue esos pares están cointegrados pero el generador no lo dice
- DQue la correlación de sus rendimientos es alta
Ver respuesta
Respuesta B. Que con 120 contrastes al 5 % se esperan unos 6 falsos positivos aunque no haya ninguna relación, y como los contrastes comparten series llegan en racimos: en una muestra concreta pueden ser el doble o más
Un nivel del 5 % significa que, sin relación, uno de cada veinte contrastes sale significativo en promedio. Si PZFX parece cointegrado con PZHY, también lo parecerá con PZHX, que va pegado a PZHY: los falsos positivos no son independientes. En las ventanas de 2018-2021 y 2022-2025 salen 4 y 2. Con la corrección de Bonferroni (0,05/120) solo sobreviven los cuatro verdaderos (lección 2.1.8).
El método de la distancia deja fuera de sus diez primeros al par A, cointegrado y con la vida media más corta del universo. ¿Por qué?
- APorque no está cointegrado en la formación
- BPorque su beta es 1,5: la distancia compara precios normalizados uno a uno, y con una beta distinta de 1 las dos trayectorias se separan aunque el diferencial en logaritmos revierta
- CPorque su vida media es demasiado corta para medirla
- DPor un error de redondeo
Ver respuesta
Respuesta B. Porque su beta es 1,5: la distancia compara precios normalizados uno a uno, y con una beta distinta de 1 las dos trayectorias se separan aunque el diferencial en logaritmos revierta
La distancia es una medida de parecido con una cobertura implícita de uno a uno, no de reversión. A cambio, pone entre sus siete primeros dos pares cruzados y a E, correlado y sin cointegración. Gatev, Goetzmann y Rouwenhorst la usan con periodos de negociación cortos (seis meses), en parte porque la relación de un par por distancia no tiene por qué durar.
El par H se rompe el 15 de junio de 2021: el diferencial salta de menos de 2 a 6,5 σ en una sesión y desde ahí es un paseo aleatorio. La regla estaba corta. ¿Qué hizo el stop por nivel (|z| > 4)?
- AEvitó la pérdida del salto
- BNo evitó la pérdida del salto, que pasó por encima del stop en una sola sesión, pero cerró la posición en la sesión siguiente, y el bloqueo impidió volver a entrar mientras el diferencial se alejaba hasta 18,4 σ
- CNada: con stops pierde lo mismo
- DCerró la posición antes de la ruptura
Ver respuesta
Respuesta B. No evitó la pérdida del salto, que pasó por encima del stop en una sola sesión, pero cerró la posición en la sesión siguiente, y el bloqueo impidió volver a entrar mientras el diferencial se alejaba hasta 18,4 σ
Con stops, 2021 cierra en −3.200 USD y después no hay más operaciones; sin stops, −15.900 en 2021 y una posición corta abierta hasta el final de 2025 sobre una relación que ya no existe. Un stop no protege del salto, sino de lo que viene después.
En el par D, un filtro de Kalman con la constante fija y la varianza del paso del generador (q = 3,6e-5) deja un diferencial residual casi sin memoria. ¿Por qué?
- APorque el filtro está mal programado
- BPorque en ese modelo la constante verdadera se mueve con la beta: el filtro compensa el error moviendo la beta y explica cada desviación del diferencial como un cambio de beta; se come la reversión que se quería operar
- CPorque el diferencial del par D no revierte
- DPorque Kalman solo sirve para series estacionarias
Ver respuesta
Respuesta B. Porque en ese modelo la constante verdadera se mueve con la beta: el filtro compensa el error moviendo la beta y explica cada desviación del diferencial como un cambio de beta; se come la reversión que se quería operar
Con la constante fija, la autocorrelación del residuo cae a 0,33 (vida media de 0,6 sesiones) y la regla gana 12.800 USD en 78 operaciones. Con el regresor centrado en el primer precio de x, la constante sí es fija, el residuo conserva su memoria (0,86) y, con q = 1e-6, la regla gana 39.400 en 22 operaciones.
¿Por qué el diferencial de estos pares se define en logaritmos y no en precios?
- APorque los logaritmos siempre dan más beneficio
- BPorque en logaritmos el diferencial es un porcentaje: su tamaño no cambia con el nivel del precio, mientras que en precios su σ en dólares crece cuando los precios suben y un z con la σ de la formación deja de significar lo mismo
- CPorque statsmodels no acepta precios
- DPorque así no hay costes
Ver respuesta
Respuesta B. Porque en logaritmos el diferencial es un porcentaje: su tamaño no cambia con el nivel del precio, mientras que en precios su σ en dólares crece cuando los precios suben y un z con la σ de la formación deja de significar lo mismo
El generador escribe todas las relaciones en logaritmos (
ln y = α + β·ln x + u). En precios, con una volatilidad proporcional al precio, los contrastes de cointegración rechazan más de lo que deben (el README lo mide con Johansen), y la beta en dólares cambia con el nivel de los dos precios.
Ideas clave
- Un par es una apuesta sobre un diferencial estacionario en logaritmos; la correlación de los rendimientos no lo garantiza: los tres pares correlados sin cointegración tienen correlaciones de 0,82 a 0,83, más que dos de los verdaderos (B y H).
- Con 120 pares posibles, el contraste de Engle-Granger al 5 % da 14 falsos positivos en la formación, más del doble de lo esperado: llegan en racimos. Bonferroni deja solo los cuatro verdaderos. La distancia supone una beta de 1 y deja fuera al par A.
- Se estima en formación y se opera fuera: hedge ratio, media, σ y vida media de 2014-2017, negociación de 2018 a 2025, con ejecución al cierre siguiente.
- Los pares verdaderos ganan y los falsos pierden sin stops (−11.700, −4.400 y −11.400 USD por cada 1.000 USD de σ). Los stops cuestan en los verdaderos casi lo que ahorran en los falsos: no mejoran la media, recortan la cola.
- El par roto H enseña el riesgo de cola de la familia: una relación que funciona años puede desaparecer en un día, y el stop no evita el salto, sino lo que viene después.
- Kalman sigue una beta variable si el modelo está bien planteado; con la constante fija y una beta muy ágil se come el diferencial. La varianza del paso es una decisión de trading, no solo de ajuste.
Recursos
Documentación
- Gatev, Goetzmann y Rouwenhorst: «Pairs Trading: Performance of a Relative Value Arbitrage Rule» (NBER w7032; Review of Financial Studies 19(3), 2006)
- Engle y Granger (1987): «Co-Integration and Error Correction: Representation, Estimation, and Testing», Econometrica 55(2)
- statsmodels: contraste de cointegración de Engle-Granger (coint)