Lección 2.1.5 · 70 min
Cointegración: Engle-Granger, Johansen y vida media de la reversión
Dos precios pueden moverse juntos día a día y separarse sin límite, o moverse con poca correlación y volver siempre a la misma distancia. Esta lección separa las dos cosas con ocho pares sintéticos cuya respuesta se conoce: cuáles están cointegrados, con qué vida media y cuándo se rompe la relación.
- Módulo
- 2.1 · Estadística y econometría para trading
- Duración
- 70 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
Correlación no es cointegración
Un borracho que sale del bar camina sin rumbo: cada paso es independiente del anterior y, con el tiempo, puede acabar en cualquier parte. Si lleva un perro suelto, el perro también vaga. Pero si el perro va atado con una correa larga, los dos vagan sin rumbo y, aun así, nunca se separan más que la correa. Murray (1994) usó esta imagen para explicar la cointegración: dos series que por separado son paseos aleatorios pero cuya distancia es estacionaria.
En trading, los dos caminantes son dos precios. La pregunta que importa para un par (lección 2.3.3) no es si suben y bajan a la vez, sino si la distancia entre ellos vuelve a un nivel. Son preguntas distintas:
- La correlación de rendimientos mide si los dos precios se mueven en la misma dirección el mismo día. Es una propiedad del corto plazo. Dos paseos aleatorios con un factor común tienen una correlación alta y pueden separarse sin límite.
- La cointegración dice que existe un tal que el spread es estacionario: vuelve a su media con una velocidad finita. Es una propiedad del largo plazo, y no exige que los rendimientos diarios estén muy correlados.
Formalmente: dos series integradas de orden 1 (sus diferencias son estacionarias, lección 2.1.2) están cointegradas cuando existe un vector cuya combinación es estacionaria. Con precios se suele trabajar con sus logaritmos, porque lo que vuelve es un cociente y el spread queda en porcentaje:
Fuente Mermaid
flowchart TB
P["Precios I(1)"] --> R{Correlados}
P --> S{"Spread I(0)"}
R -- sí --> C1[Cubrir a corto]
R -- no --> N1[Poca relación]
S -- sí --> C2[Par que revierte]
S -- no --> N2[Se separan]Las dos ramas son independientes: un par puede estar en cualquiera de las cuatro combinaciones. La figura de la cabecera muestra la diferencia con datos: arriba, el spread del par B (cointegrado) cruza el cero sin parar; abajo, el residuo del control E (más correlado que B, no cointegrado) pasa años enteros a un lado del cero.
Los ocho pares del curso
Los datos de esta lección son ocho pares sintéticos (pares2.parquet), sesiones de la NYSE de 2014 a 2025, con la
respuesta conocida en pares2_verdad.csv. Todas las relaciones se plantaron entre logaritmos. Tres pares están
cointegrados con vidas medias distintas (A, B y C); D tiene un β que cambia con el tiempo (lección 2.1.6); E, F y G
son controles: sus rendimientos están muy correlados, pero la relación entre sus logaritmos lleva un paseo aleatorio,
así que no están cointegrados; H está cointegrado hasta el 15 de junio de 2021 y desde ese día deja de estarlo. Son datos inventados con el efecto plantado: sirven para
comprobar si un método recupera lo que se sabe que hay, no como evidencia de que un par real funcione.
import numpy as np
import pandas as pd
pares = pd.read_parquet("pares2.parquet")
verdad = pd.read_csv("pares2_verdad.csv", index_col="par")
precios = pares.pivot(index="fecha", columns="activo",
values="precio")
lp = np.log(precios) # las relaciones son entre logaritmos
print(precios.shape, precios.index[0].date(),
precios.index[-1].date())
verdad[["tipo", "formulacion", "beta", "vida_media_sesiones",
"sigma_spread"]](3018, 16) 2014-01-02 2025-12-31
tipo formulacion beta vida_media_sesiones sigma_spread
par
A cointegrado log 1.5 5.0 0.020
B cointegrado log 0.8 15.0 0.030
C cointegrado log 1.1 40.0 0.045
D beta_variable log NaN 10.0 0.025
E correlado_no_cointegrado log 1.0 NaN NaN
F correlado_no_cointegrado log 0.9 NaN NaN
G correlado_no_cointegrado log 1.2 NaN NaN
H ruptura log 0.9 10.0 0.025Cada par tiene un activo x y un activo y (PZAX y PZAY para A). La columna formulacion dice que la
relación es entre logaritmos (log), y sigma_spread es la desviación típica del spread en logaritmos: 0,02 es un
2 % de distancia típica entre los dos precios. Lo primero es medir lo que suele medir un analista con prisa, la
correlación, junto al contraste de Engle-Granger que se explica en la sección siguiente:
from statsmodels.tsa.stattools import coint
filas = []
for par, v in verdad.iterrows():
y, x = lp[v.activo_y], lp[v.activo_x]
t, p, _ = coint(y, x)
filas.append({"par": par, "tipo": v.tipo,
"corr_rend": y.diff().corr(x.diff()),
"corr_logprecios": y.corr(x),
"t_eg": t, "p_eg": p})
tabla = pd.DataFrame(filas).set_index("par")
tabla.round(3) tipo corr_rend corr_logprecios t_eg p_eg
par
A cointegrado 0.912 1.000 -14.971 0.000
B cointegrado 0.792 0.990 -8.979 0.000
C cointegrado 0.875 0.992 -5.586 0.000
D beta_variable 0.871 0.994 -3.472 0.035
E correlado_no_cointegrado 0.859 0.978 -2.599 0.237
F correlado_no_cointegrado 0.856 0.976 -2.036 0.510
G correlado_no_cointegrado 0.856 0.931 -2.371 0.339
H ruptura 0.635 0.941 -2.647 0.219Los tres controles tienen una correlación de rendimientos de 0,86, más que el par B (0,79), y la correlación de sus logaritmos pasa de 0,93. Si el criterio fuera la correlación, E sería mejor candidato que B. El contraste dice lo contrario: B rechaza la hipótesis de «no cointegración» con holgura (t = −8,98, muy por debajo del valor crítico de −3,34 al 5 %) y E, F y G no la rechazan (p entre 0,24 y 0,51). La correlación de los precios es todavía peor guía: dos series con tendencia tienen correlaciones de niveles cercanas a 1 aunque no tengan nada que ver (regresión espuria).
D y H son los casos con trampa. H no rechaza en la muestra completa (p = 0,22): la relación existió y se rompió, y el salto de la ruptura hunde incluso su correlación de rendimientos (0,64). D sí rechaza (p = 0,035), y es un aprobado engañoso: su β verdadero se mueve y la regresión de toda la muestra estima uno medio; el residuo de ese β fijo revierte, pero mucho más despacio que el spread de verdad (se ve con la vida media, más abajo). Los dos se tratan en esta lección y en la 2.1.6.
Engle-Granger en dos pasos
El contraste de Engle y Granger (1987) es la forma más directa de comprobar la definición:
- Estimar por mínimos cuadrados .
- Contrastar si el residuo tiene raíz unitaria con una regresión de Dickey-Fuller aumentada, . La hipótesis nula es : el residuo es un paseo aleatorio y no hay cointegración.
Fuente Mermaid
flowchart TB
A["ln y, ln x"] --> B[MCO: y sobre x]
B --> C[Residuo u]
C --> D[ADF de u]
D --> E{t bajo el crítico}
E -- sí --> F[Cointegrados]
E -- no --> G[Sin evidencia]El detalle que se olvida es el valor crítico. En el primer paso, MCO elige el β que hace mínima la varianza del
residuo, es decir, el que más estacionario lo hace parecer. Por eso el ADF del residuo no puede compararse con las
tablas del ADF de una serie observada: hacen falta valores críticos más negativos, los de MacKinnon para residuos de
cointegración, que son los que usa coint. El paso a mano lo enseña:
import statsmodels.api as sm
from statsmodels.tsa.stattools import adfuller
def dos_pasos(y, x):
mco = sm.OLS(y, sm.add_constant(x)).fit()
adf = adfuller(mco.resid, result_object=True)
eg = coint(y, x)
return {"alfa": mco.params.iloc[0], "beta": mco.params.iloc[1],
"t": adf.statistic, "p_adf": adf.pvalue,
"crit5_adf": adf.critical_values["5%"],
"p_coint": eg[1], "crit5_coint": eg[2][1]}
pd.DataFrame({par: dos_pasos(lp[f"PZ{par}Y"], lp[f"PZ{par}X"])
for par in ["B", "E"]}).round(3) B E
alfa 0.683 0.913
beta 0.805 0.821
t -8.978 -2.598
p_adf 0.000 0.093
crit5_adf -2.862 -2.862
p_coint 0.000 0.237
crit5_coint -3.338 -3.338En B, MCO recupera la relación plantada ( = 0,7 y = 0,8; salen 0,68 y 0,805) y el residuo es
claramente estacionario con cualquier tabla. En E el estadístico es el mismo en las dos columnas (−2,60), pero el ADF
directo da p = 0,09, un rechazo al 10 %, y coint, p = 0,24: el valor crítico al 5 % pasa de −2,86 a −3,34. Con una criba de muchos
pares, usar la tabla equivocada multiplica los falsos positivos.
Engle-Granger tiene dos debilidades conocidas. La primera, que no es simétrico: regresar y sobre x o x sobre y
da estadísticos y p-valores distintos, y en un caso dudoso cada dirección puede llevar a una conclusión diferente. La
segunda, que con más de dos series solo
encuentra una relación, aunque haya varias. Johansen resuelve las dos.
Johansen: cuántas relaciones hay entre varias series
Johansen (1991) parte de un modelo vectorial de corrección del error (VECM) para un vector de series, aquí los logaritmos de los precios:
Si las series no están cointegradas, y el modelo es un VAR en diferencias. Si hay relaciones de cointegración, tiene rango : las columnas de son las combinaciones estacionarias y dice con qué fuerza vuelve cada serie al equilibrio. El contraste de la traza pregunta en secuencia «¿rango ≤ 0?», «¿rango ≤ 1?»… y el rango estimado es el primero que no se rechaza.
Con las cuatro series de A y B, la respuesta conocida es :
import warnings
from statsmodels.tsa.vector_ar.vecm import coint_johansen
# Con numpy 2.5, los autovectores salen como complejos de parte
# imaginaria nula y statsmodels avisa (ComplexWarning) al pasarlos
# a reales: el aviso no cambia ningún número
warnings.filterwarnings("ignore",
category=np.exceptions.ComplexWarning)
def johansen(datos, k_ar_diff=1):
j = coint_johansen(datos, det_order=0, k_ar_diff=k_ar_diff)
n = datos.shape[1]
tabla = pd.DataFrame(
{"traza": j.lr1, "crit95_traza": j.cvt[:, 1],
"max_autov": j.lr2, "crit95_max": j.cvm[:, 1]},
index=[f"rango <= {r}" for r in range(n)])
rango = int((j.lr1 > j.cvt[:, 1]).cumprod().sum())
return tabla, rango, np.real(j.evec)
cuatro = lp[["PZAY", "PZAX", "PZBY", "PZBX"]]
tabla_j, rango, vectores = johansen(cuatro)
print("Rango estimado:", rango)
tabla_j.round(1)Rango estimado: 2
traza crit95_traza max_autov crit95_max
rango <= 0 296.2 47.9 209.6 27.6
rango <= 1 86.6 29.8 80.0 21.1
rango <= 2 6.6 15.5 5.6 14.3
rango <= 3 0.9 3.8 0.9 3.8La traza rechaza «rango ≤ 0» (296,2 frente a 47,9) y «rango ≤ 1» (86,6 frente a 29,8), y no rechaza «rango ≤ 2» (6,6 frente a 15,5): dos relaciones, las plantadas. Los vectores de cointegración no son únicos (cualquier combinación de dos vectores estacionarios también lo es), pero normalizados se reconocen:
v1 = vectores[:, 0] / vectores[0, 0] # normalizado por PZAY
v2 = vectores[:, 1] / vectores[2, 1] # normalizado por PZBY
pd.DataFrame({"vector_1": v1, "vector_2": v2},
index=cuatro.columns).round(3) vector_1 vector_2
activo
PZAY 1.000 -0.040
PZAX -1.501 0.065
PZBY 0.070 1.000
PZBX -0.059 -0.812El primer vector es casi exactamente la relación de A, (−1,501), con pesos pequeños en B; el segundo, casi la de B, (−0,812), con pesos pequeños en A. Aquí salen limpios porque las dos relaciones tienen fuerzas de reversión muy distintas, pero no está garantizado: cualquier combinación de dos vectores estacionarios también lo es, y lo que Johansen identifica de forma única es el número de relaciones y el espacio que generan, no una base concreta.
Si se añade a un par cointegrado una serie que no tiene nada que ver con él, Johansen debería encontrar una sola relación y dar peso nulo a la serie ajena. Y con series que no están cointegradas entre sí debería dar rango 0, salvo por azar (al 5 %, en uno de cada veinte casos). Esta última comprobación depende de la formulación:
from itertools import combinations
tabla_3, rango_3, vec_3 = johansen(lp[["PZAY", "PZAX", "PZEX"]])
print("A + PZEX: rango", rango_3, "vector",
(vec_3[:, 0] / vec_3[0, 0]).round(3))
controles = ["PZEY", "PZEX", "PZFY", "PZFX", "PZGY", "PZGX"]
trios = list(combinations(controles, 3))
falsos = {"niveles": 0, "logaritmos": 0}
for trio in trios:
datos = precios[list(trio)]
falsos["niveles"] += johansen(datos)[1] > 0
falsos["logaritmos"] += johansen(lp[list(trio)])[1] > 0
print(len(trios), "tríos de controles; con rango > 0:", falsos)
crec = precios[controles].iloc[-1] / precios[controles].iloc[0]
print("Precio final / inicial:", crec.round(1).to_dict())A + PZEX: rango 1 vector [ 1. -1.51 0.008]
20 tríos de controles; con rango > 0: {'niveles': 4, 'logaritmos': 0}
Precio final / inicial: {'PZEY': 2.4, 'PZEX': 3.1, 'PZFY': 3.3, 'PZFX': 3.4, 'PZGY': 5.1, 'PZGX': 3.2}Con la serie ajena, rango 1 y peso casi nulo (0,008) para PZEX: correcto. Con los controles, en logaritmos ningún
trío de los 20 da rango mayor que cero, lo esperable (el azar daría uno); en niveles, 4. La razón está en la última
línea: los controles se multiplican por entre 2,4 y 5,1 en doce años. El contraste supone errores de varianza
constante, y en niveles la variación diaria de un precio es proporcional a su nivel: un precio que se ha multiplicado
por 5 tiene variaciones cinco veces mayores al final que al principio, y la distribución de la traza deja de ser la de
las tablas. En logaritmos la variación diaria es un porcentaje y su tamaño no depende del nivel.
Vida media: el spread como proceso de Ornstein-Uhlenbeck
Saber que un spread revierte no basta para operarlo: hay que saber a qué velocidad. El modelo estándar es el proceso de Ornstein-Uhlenbeck, un paseo aleatorio con una fuerza que tira hacia la media:
es la velocidad de reversión: la distancia esperada a la media cae como . Muestreado una vez por sesión, es un AR(1), con , y la vida media (el tiempo en que la distancia esperada a la media se reduce a la mitad) es
Se estima con la regresión de la variación del spread sobre su nivel anterior, , con . En los pares cointegrados se aplica al residuo de Engle-Granger:
def vida_media(spread):
"""Vida media en sesiones del AR(1) dS_t = c + b * S_(t-1)."""
s = pd.Series(spread).dropna()
X = sm.add_constant(s.shift(1).iloc[1:])
b = sm.OLS(s.diff().iloc[1:], X).fit().params.iloc[1]
return -np.log(2) / np.log(1 + b)
def residuo_eg(par):
y, x = lp[f"PZ{par}Y"], lp[f"PZ{par}X"]
return sm.OLS(y, sm.add_constant(x)).fit().resid
vidas = pd.DataFrame({
"estimada": {p: vida_media(residuo_eg(p)) for p in "ABCDEFG"},
"verdadera": verdad["vida_media_sesiones"]}).dropna(
subset=["estimada"])
vidas.round(1) estimada verdadera
A 4.7 5.0
B 13.0 15.0
C 35.4 40.0
D 38.4 10.0
E 162.1 NaN
F 259.4 NaN
G 270.0 NaNEn A, B y C la estimación queda cerca de la verdad pero siempre por debajo: 4,7 frente a 5, 13,0 frente a 15 y 35,4 frente a 40. En E, F y G la función devuelve vidas medias de 162 a 270 sesiones, aunque no hay ninguna: un AR(1) siempre da un menor que 1 en una muestra finita, así que la vida media de un paseo aleatorio sale finita. Una vida media solo tiene sentido después de un contraste de cointegración que la respalde. Y D enseña lo contrario: el contraste aprobó, pero el residuo de su β fijo tiene una vida media de 38,4 sesiones cuando la del spread verdadero es 10. Lo que revierte en D es otra cosa que lo que se estimó: con un β que se mueve, el residuo de un β medio mezcla el spread con la deriva lenta del β (lección 2.1.6).
¿Es mala suerte o sesgo? Se puede responder simulando: generar muchos procesos de Ornstein-Uhlenbeck con la vida media verdadera y la longitud de estos datos, y estimar cada uno igual.
from scipy.signal import lfilter
rng = np.random.default_rng(2028)
n, caminos = len(lp), 400
filas = {}
for h in [5, 15, 40]:
phi = 0.5 ** (1 / h)
e = rng.standard_normal((caminos, n)) * np.sqrt(1 - phi**2)
s = lfilter([1.0], [1.0, -phi], e, axis=1)
ds, s0 = np.diff(s, axis=1), s[:, :-1]
s0c = s0 - s0.mean(axis=1, keepdims=True)
b = (ds * s0c).sum(axis=1) / (s0c**2).sum(axis=1)
est = -np.log(2) / np.log(1 + b)
filas[h] = np.percentile(est, [5, 50, 95])
pd.DataFrame(filas, index=["p5", "mediana", "p95"]).T.round(1) p5 mediana p95
5 4.4 4.9 5.6
15 11.8 14.7 17.8
40 27.3 37.2 51.2La mediana de las simulaciones queda por debajo de la verdad, y más cuanto más lenta es la reversión: es el sesgo del coeficiente autorregresivo en muestras finitas, que empuja hacia abajo. Las tres estimaciones de los datos caen dentro de la banda del 90 %, así que son lo esperable. La banda es lo que más importa: con doce años de datos, la vida media de 40 sesiones podría haberse estimado en 27 o en 51. Un par con vida media de 40 sesiones tiene apenas 75 vidas medias en la muestra, pocas observaciones independientes de la reversión.
La vida media sirve después para dos decisiones del sistema de la lección 2.3.3: cuánto tiempo mantener una posición antes de darla por fallida y qué ventana usar para estimar media y desviación del spread. Una vida media mal estimada estropea las dos.
Cuando la cointegración se rompe: el par H
El par H está cointegrado (vida media 10, spread de desviación 0,025 en logaritmos) hasta el 15 de junio de 2021. Ese día el spread salta cinco desviaciones típicas y desde entonces es un paseo aleatorio. Es la situación real más peligrosa: una relación que funcionó durante años, que cualquier contraste confirma con los datos del pasado, y que deja de existir.
y, x = lp["PZHY"], lp["PZHX"]
antes = lp.index < "2021-06-15"
for nombre, m in [("muestra completa", slice(None)),
("antes de la ruptura", antes),
("después de la ruptura", ~antes)]:
t, p, _ = coint(y[m], x[m])
print(f"{nombre:22} sesiones {len(y[m]):5} t {t:6.2f} "
f"p {p:.4f}")muestra completa sesiones 3018 t -2.65 p 0.2191
antes de la ruptura sesiones 1875 t -5.91 p 0.0000
después de la ruptura sesiones 1143 t -3.66 p 0.0205Antes de la ruptura la cointegración es inequívoca (t = −5,91; p < 0,0001). La muestra completa ya no la detecta (p = 0,22). Y el tramo posterior, que por construcción es un paseo aleatorio, da t = −3,66 y p = 0,021: un falso positivo. Un analista que mirase solo los cuatro últimos años concluiría que la relación sigue viva. Un contraste al 5 % rechaza una de cada veinte veces cuando no hay nada; si se mira un solo tramo, el azar puede tocar.
Una forma de ver el problema completo es contrastar en ventanas móviles de un año, como haría un sistema que reestima la relación cada mes:
def ventanas(y, x, n=252, paso=21):
return np.array([coint(y.iloc[i:i + n], x.iloc[i:i + n],
maxlag=10)[1]
for i in range(0, len(y) - n + 1, paso)])
casos = {"A (vida media 5)": "A", "C (vida media 40)": "C",
"E (control)": "E", "F (control)": "F",
"G (control)": "G"}
filas = {}
for nombre, par in casos.items():
p = ventanas(lp[f"PZ{par}Y"], lp[f"PZ{par}X"])
filas[nombre] = (len(p), (p < 0.05).mean())
p = ventanas(y[~antes], x[~antes])
filas["H tras la ruptura"] = (len(p), (p < 0.05).mean())
pd.DataFrame(filas, index=["ventanas", "rechazo_5pct"]).T.round(3) ventanas rechazo_5pct
A (vida media 5) 132.0 0.977
C (vida media 40) 132.0 0.091
E (control) 132.0 0.038
F (control) 132.0 0.098
G (control) 132.0 0.061
H tras la ruptura 43.0 0.070Tres lecturas. En A, con una vida media de 5 sesiones, un año de datos basta y el contraste rechaza en el 98 % de las ventanas. En C, cointegrado de verdad pero con vida media de 40, un año contiene apenas seis vidas medias y el contraste rechaza en el 9 %, casi como si no hubiera nada: es un problema de potencia, no de ausencia de relación. En los controles la tasa de rechazo va del 3,8 % al 9,8 %, alrededor del 5 % que se espera del azar (las ventanas se solapan, así que esas tasas tienen mucho ruido), y en H tras la ruptura es del 7 % (3 de 43 ventanas). La ventana corta no distingue bien un par lento de uno inexistente; la larga mezcla tramos con y sin relación. No hay una ventana correcta: hay que saber qué se pierde con cada una. La detección de la ruptura en sí es materia de la lección 2.1.7.
Cribar pares: el sesgo de selección
En la práctica nadie contrasta un par: se criban cientos y se quedan los que pasan. Con los dieciséis activos de los datos hay 120 parejas posibles, y solo las de A, B y C (y H en parte de la muestra) tienen relación. Las 112 parejas de activos de pares distintos no están cointegradas por construcción:
filas = []
for a, b in combinations(precios.columns, 2):
filas.append({
"mismo_par": a[:3] == b[:3],
"p_logs": coint(lp[a], lp[b], maxlag=10)[1],
"p_niveles": coint(precios[a], precios[b], maxlag=10)[1]})
criba = pd.DataFrame(filas)
ajenas = criba[~criba["mismo_par"]]
print("Parejas de pares distintos:", len(ajenas))
(ajenas[["p_logs", "p_niveles"]] < 0.05).sum()Parejas de pares distintos: 112
p_logs 4
p_niveles 2
dtype: int64En logaritmos, 4 de las 112 parejas sin relación pasan el contraste al 5 %, y en niveles, 2: los dos números están cerca de los 5,6 que daría el azar, y entre ellos no hay ninguna lección sobre la formulación (las parejas comparten un factor de mercado y no son contrastes independientes, así que el número de rechazos varía mucho de una muestra a otra). Lo que importa es que existen: cualquiera de esas cuatro parejas, presentada sola, parecería un hallazgo. El problema de elegir lo mejor de muchas pruebas tiene nombre y correcciones (lección 2.1.8); la defensa más sencilla es exigir una razón económica antes de mirar el p-valor y validar fuera de la muestra en la que se eligió el par (lección 2.5.2).
Errores típicos con la cointegración
- Estimar β con toda la muestra y operar en ella. El β y la media del spread de un backtest tienen que estimarse con datos anteriores a cada operación; con toda la muestra, el spread revierte «por construcción» (lección 2.5.1).
- Cribar sin corregir. De 112 parejas sin relación, 4 pasan en logaritmos. Con cientos de candidatos, los «pares» que pasan son en buena parte azar.
- Confundir correlación y cointegración. Los controles E, F y G tienen más correlación que B y no revierten.
- Fiarse de un aprobado con β fijo. D pasa Engle-Granger con un β medio, pero el residuo que revierte tiene una vida media cuatro veces mayor que el spread real.
- Elegir mal la formulación. En niveles, Johansen da falsos positivos y Engle-Granger pierde el par A.
- Ignorar la potencia. Un par lento parece no cointegrado en ventanas cortas; un par rápido parece cointegrado en casi cualquier ventana.
- Suponer que dura. La relación de H funcionó siete años y se rompió en un día. El seguimiento del spread y de la estabilidad del β (lecciones 2.1.6 y 2.1.7) es parte del sistema, no un extra.
- Precios sin ajustar. Un split o un dividendo sin ajustar mete un salto en uno de los dos precios y rompe una cointegración que existe (lección 1.4.3).
Ejercicios
- Repite el contraste de Engle-Granger de los pares A, B, C y de los controles E, F y G con los precios en niveles, en lugar de en logaritmos. ¿Cambia alguna conclusión?
Solución
for par in "ABCEFG":
t, p, _ = coint(precios[f"PZ{par}Y"], precios[f"PZ{par}X"])
print(par, f"t {t:6.2f} p {p:.3f}")A t -3.30 p 0.055
B t -8.56 p 0.000
C t -5.44 p 0.000
E t -2.32 p 0.366
F t -1.85 p 0.607
G t -2.04 p 0.508Los controles siguen sin rechazar, B y C siguen rechazando, pero A deja de hacerlo: en niveles su estadístico pasa
de −14,97 a −3,30 y el p-valor a 0,055. A es el par cuyo y más ha crecido (se multiplica por 5,5 en doce años), y una
relación entre logaritmos con β = 1,5 no es lineal en niveles: la distancia en dinero entre los dos precios crece con
el nivel y el residuo de la regresión en niveles se parece a un paseo aleatorio. Es el otro lado del error de
formulación: no solo falsos positivos, también relaciones reales que no se ven.
- Estima la vida media del par B con los tres primeros años (2014-2016) y con los tres últimos (2023-2025), usando en cada tramo su propio β. ¿Cuánto varía? ¿Cae dentro de la banda simulada para una vida media de 15?
Solución
yb, xb = lp["PZBY"], lp["PZBX"]
for desde, hasta in [("2014", "2016"), ("2023", "2025")]:
ys, xs = yb.loc[desde:hasta], xb.loc[desde:hasta]
u = sm.OLS(ys, sm.add_constant(xs)).fit().resid
print(desde, hasta, f"sesiones {len(u)}",
f"vida media {vida_media(u):.1f}")2014 2016 sesiones 756 vida media 9.7
2023 2025 sesiones 752 vida media 13.5Con tres años, unas 750 sesiones, la vida media sale en 9,7 y en 13,5 sesiones, las dos por debajo de 15 y la primera fuera de la banda simulada para 3.018 sesiones (de 11,8 a 17,8): con la cuarta parte de datos la banda se ensancha y el sesgo hacia abajo crece. Además, cada tramo estima su propio β, y un β estimado en poca muestra absorbe parte del spread y lo hace parecer más rápido. Una vida media es una estimación con un error grande: úsala como orden de magnitud.
- Un compañero criba 300 parejas de acciones del mismo sector en niveles, encuentra 24 con p < 0,05 en Engle-Granger y propone operarlas todas. Con lo visto en esta lección, ¿qué le dirías?
Solución
Que con 300 contrastes al 5 % el azar solo ya produce unos 15 rechazos (en los datos del curso, 4 de 112 parejas sin relación), y que en niveles el contraste ni siquiera mide lo que debe: con precios que crecen de forma multiplicativa, da falsos positivos y pierde relaciones reales. Las 24 pueden ser casi todas falsas. Antes de operar: fijar la formulación (logaritmos), exigir una razón económica para cada par, corregir por pruebas múltiples (lección 2.1.8), estimar la vida media y comprobar que es compatible con el horizonte del sistema, y validar fuera de la muestra en la que se cribó, vigilando la estabilidad de la relación como en el par H.
Respuesta plegada en cada pregunta
Autoevaluación
Los rendimientos diarios del par E tienen una correlación de 0,86 y los del par B, de 0,79. ¿Qué se puede concluir sobre su cointegración?
- AQue E está más cointegrado que B
- BNada: la correlación de rendimientos mide el movimiento conjunto de un día, no si la distancia entre los precios vuelve a un nivel
- CQue ninguno de los dos está cointegrado, porque la correlación no llega a 0,9
- DQue los dos están cointegrados, porque la correlación supera 0,8
Ver respuesta
Respuesta B. Nada: la correlación de rendimientos mide el movimiento conjunto de un día, no si la distancia entre los precios vuelve a un nivel
En los datos del curso B está cointegrado (Engle-Granger da t = −8,98) y E no (t = −2,60, p = 0,24), aunque E tiene más correlación. La correlación de rendimientos y la cointegración miden cosas distintas: la primera, el corto plazo; la segunda, un equilibrio a largo plazo.
¿Por qué Engle-Granger no puede usar los valores críticos del ADF habitual en el segundo paso?
- APorque el residuo no tiene media cero
- BPorque el β del primer paso se elige por MCO para que el residuo tenga la menor varianza posible: el residuo parece más estacionario de lo que es, y los valores críticos tienen que ser más negativos
- CPorque el ADF solo funciona con rendimientos
- DPorque el residuo siempre es un paseo aleatorio
Ver respuesta
Respuesta B. Porque el β del primer paso se elige por MCO para que el residuo tenga la menor varianza posible: el residuo parece más estacionario de lo que es, y los valores críticos tienen que ser más negativos
Con los mismos −2,60 de estadístico, el ADF directo sobre el residuo del par E da p = 0,09 y
coint, con los valores críticos de MacKinnon para residuos, p = 0,24. El valor crítico al 5 % pasa de −2,86 a −3,34.Johansen sobre cuatro series (los dos activos de A y los dos de B) da rango 2. ¿Qué significa?
- AQue dos de las cuatro series son estacionarias
- BQue hay dos combinaciones lineales independientes de las cuatro series que son estacionarias: las relaciones de A y de B
- CQue hay que diferenciar dos veces
- DQue el contraste ha fallado
Ver respuesta
Respuesta B. Que hay dos combinaciones lineales independientes de las cuatro series que son estacionarias: las relaciones de A y de B
El rango de la matriz Π del VECM es el número de relaciones de cointegración. Con cuatro logaritmos de precios no estacionarios y dos pares independientes, la respuesta conocida es 2, y es la que sale.
El estimador de la vida media por la regresión de ΔS sobre S tiene sesgo. ¿En qué dirección y cuándo importa más?
- AHacia arriba, y más cuanto más rápida es la reversión
- BHacia abajo (el spread parece revertir más deprisa), y más cuanto más larga es la vida media en relación con la muestra
- CNo tiene sesgo si la muestra es de más de mil sesiones
- DHacia abajo solo si el spread tiene colas gruesas
Ver respuesta
Respuesta B. Hacia abajo (el spread parece revertir más deprisa), y más cuanto más larga es la vida media en relación con la muestra
Es el sesgo del coeficiente autorregresivo en muestras finitas: φ estimado queda por debajo del real. Con 3.018 sesiones, la vida media de 40 del par C sale en 35,4, y la simulación da una mediana de 37,2 con un 90 % de los casos entre 27,3 y 51,2.
Tras la ruptura de 2021, el tramo final del par H da p = 0,021 en Engle-Granger. ¿Qué es?
- ALa prueba de que la cointegración ha vuelto
- BUn falso positivo: desde la ruptura el spread es un paseo aleatorio por construcción, y un tramo de pocos años puede pasar el contraste por azar
- CUn error de los datos
- DLa señal de que el β ha cambiado y hay que reestimarlo
Ver respuesta
Respuesta B. Un falso positivo: desde la ruptura el spread es un paseo aleatorio por construcción, y un tramo de pocos años puede pasar el contraste por azar
La respuesta conocida es que desde el 2021-06-15 el spread de H es un paseo aleatorio. Un contraste al 5 % rechaza por azar una de cada veinte veces; en ventanas de un año tras la ruptura, rechaza en 3 de 43, cerca de lo que se espera del azar.
Ideas clave
- Correlación de rendimientos y cointegración miden cosas distintas: los controles E, F y G tienen una correlación de 0,86 y no están cointegrados; B tiene 0,79 y sí.
- Engle-Granger: MCO de un logaritmo de precio sobre el otro y un ADF del residuo con valores críticos propios (MacKinnon), más exigentes que los del ADF.
- Johansen contrasta el número de relaciones de cointegración entre varias series: rango 2 en las cuatro series de A y B, rango 1 con una serie ajena.
- La vida media del spread, ln 2 / θ, sale de un AR(1); el estimador la infravalora, más cuanto más lenta es la reversión.
- Los errores vienen de la formulación (en niveles, Johansen da rango mayor que 0 en 4 de 20 tríos de controles; en logaritmos, en ninguno), de cribar muchos pares, de β estáticos que aprueban con un spread lento (D) y de tramos cortos tras una ruptura (H).
Recursos
Libros
- Engle, R. F. y Granger, C. W. J. (1987): Co-Integration and Error Correction: Representation, Estimation, and Testing. Econometrica 55(2), 251-276
- Johansen, S. (1991): Estimation and Hypothesis Testing of Cointegration Vectors in Gaussian Vector Autoregressive Models. Econometrica 59(6), 1551-1580
- Murray, M. P. (1994): A Drunk and Her Dog: An Illustration of Cointegration and Error Correction. The American Statistician 48(1), 37-39