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.

Fig. — Spread del par B (cointegrado, arriba) y residuo del control E en logaritmos (correlado, no cointegrado, abajo), 2014–2025, en desviaciones típicas
Lecciones del nivel 2
  1. 2.1 · Estadística y econometría para trading

    1. 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
    2. 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
    3. 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
    4. 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
    5. 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
    6. 2.1.6 Filtro de Kalman para hedge ratios dinámicos
    7. 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
    8. 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
  2. 2.2 · Anatomía de un sistema de trading

    1. 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
    2. 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
    3. 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
    4. 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
  3. 2.3 · Familias de estrategias

    1. 2.3.1 Seguimiento de tendencia y momentum
    2. 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
    3. 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
    4. 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
    5. 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
    6. 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
    7. 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
  4. 2.4 · Backtesting: motores y paradigmas

    1. 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
    2. 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
    3. 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
    4. 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
    5. 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
    6. 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
    7. 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
    8. 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
  5. 2.5 · Sesgos y validación

    1. 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
    2. 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
    3. 2.5.3 Validación cruzada combinatoria purgada para estrategias
    4. 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
    6. 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
    7. 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
    8. 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
  6. 2.6 · Métricas de rendimiento

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index
    2. 2.6.2 Win rate, payoff, expectativa, alpha, beta, information ratio, turnover y capacidad
    3. 2.6.3 Informes con QuantStats o pyfolio-reloaded y tearsheets propios

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 β\beta tal que el spread ln⁡yt−βln⁡xt\ln y_t - \beta \ln x_t 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 (1,−β)(1, -\beta) 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:

ln⁡yt=α+βln⁡xt+ut,ut∼I(0).\ln y_t = \alpha + \beta \ln x_t + u_t, \qquad u_t \sim I(0).
sínosínoPrecios I(1)CorreladosSpread I(0)Cubrir a cortoPoca relaciónPar que revierteSe separan
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.

IN [01]python
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"]]
OUT [01]Los ocho pares y su verdad
(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.025

Cada 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:

IN [02]python
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)
OUT [02]Correlación frente a Engle-Granger
                         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.219
Fig. — Correlación de los rendimientos diarios frente al estadístico de Engle-Granger en los ocho pares: la correlación no ordena la cointegración

Los 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:

  1. Estimar por mínimos cuadrados ln⁡yt=α+βln⁡xt+ut\ln y_t = \alpha + \beta \ln x_t + u_t.
  2. Contrastar si el residuo u^t\hat u_t tiene raíz unitaria con una regresión de Dickey-Fuller aumentada, Δu^t=γu^t−1+∑i=1kciΔu^t−i+et\Delta \hat u_t = \gamma \hat u_{t-1} + \sum_{i=1}^{k} c_i \Delta \hat u_{t-i} + e_t. La hipótesis nula es γ=0\gamma = 0: el residuo es un paseo aleatorio y no hay cointegración.
sínoln y, ln xMCO: y sobre xResiduo uADF de ut bajo el críticoCointegradosSin evidencia
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:

IN [03]python
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)
OUT [03]Los dos pasos a mano y el valor crítico
                 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.338

En B, MCO recupera la relación plantada (α\alpha = 0,7 y β\beta = 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 YtY_t de nn series, aquí los logaritmos de los precios:

ΔYt=ΠYt−1+∑i=1kΓiΔYt−i+εt.\Delta Y_t = \Pi Y_{t-1} + \sum_{i=1}^{k} \Gamma_i \Delta Y_{t-i} + \varepsilon_t .

Si las series no están cointegradas, Π=0\Pi = 0 y el modelo es un VAR en diferencias. Si hay rr relaciones de cointegración, Π=αβ′\Pi = \alpha \beta' tiene rango rr: las rr columnas de β\beta son las combinaciones estacionarias y α\alpha 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 r=2r = 2:

IN [04]python
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)
OUT [04]Johansen con cuatro series
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.8

La 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:

IN [05]python
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)
OUT [05]Los vectores de cointegración
        vector_1  vector_2
activo
PZAY       1.000    -0.040
PZAX      -1.501     0.065
PZBY       0.070     1.000
PZBX      -0.059    -0.812

El primer vector es casi exactamente la relación de A, ln⁡y−1,5ln⁡x\ln y - 1{,}5 \ln x (−1,501), con pesos pequeños en B; el segundo, casi la de B, ln⁡y−0,8ln⁡x\ln y - 0{,}8 \ln x (−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:

IN [06]python
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())
OUT [06]Una serie ajena y los tríos de controles
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:

dSt=θ(μ−St) dt+σ dWt.dS_t = \theta (\mu - S_t)\,dt + \sigma\, dW_t .

θ\theta es la velocidad de reversión: la distancia esperada a la media cae como e−θte^{-\theta t}. Muestreado una vez por sesión, es un AR(1), St=c+φSt−1+εtS_t = c + \varphi S_{t-1} + \varepsilon_t con φ=e−θ\varphi = e^{-\theta}, y la vida media (el tiempo en que la distancia esperada a la media se reduce a la mitad) es

h=ln⁡2θ=−ln⁡2ln⁡φ.h = \frac{\ln 2}{\theta} = -\frac{\ln 2}{\ln \varphi} .

Se estima con la regresión de la variación del spread sobre su nivel anterior, ΔSt=c+b St−1+et\Delta S_t = c + b\, S_{t-1} + e_t, con φ=1+b\varphi = 1 + b. En los pares cointegrados se aplica al residuo de Engle-Granger:

IN [07]python
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)
OUT [07]Vida media estimada y verdadera
   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        NaN

En 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 φ\varphi 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.

IN [08]python
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)
OUT [08]El sesgo del estimador, por simulación
      p5  mediana   p95
5    4.4      4.9   5.6
15  11.8     14.7  17.8
40  27.3     37.2  51.2
Fig. — Vida media de los pares cointegrados: estimada en los datos (rombo), verdadera (raya) y banda del 90 % de 400 simulaciones de la misma longitud

La 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 φ^\hat\varphi 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.

IN [09]python
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}")
OUT [09]H antes y después de la ruptura
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.0205
Fig. — Spread del par H con el β estimado antes de la ruptura, 2014–2025: cointegrado hasta el 15 de junio de 2021, paseo aleatorio desde entonces

Antes 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:

IN [10]python
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)
OUT [10]Rechazos en ventanas de un año
                   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.070

Tres 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:

IN [11]python
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()
OUT [11]Criba de las 120 parejas
Parejas de pares distintos: 112
p_logs       4
p_niveles    2
dtype: int64

En 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

  1. 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
IN [12]python
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}")
OUT [12]Engle-Granger en niveles
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.508

Los 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.

  1. 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
IN [13]python
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}")
OUT [13]Vida media de B por tramos
2014 2016 sesiones 756 vida media 9.7
2023 2025 sesiones 752 vida media 13.5

Con 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.

  1. 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

  1. 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.

  2. ¿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.

  3. 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.

  4. 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.

  5. 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

Documentación