---
title: "2.3.3 Pairs trading y arbitraje estadístico"
description: "Selección de pares por distancia y cointegración con controles, reglas de z-score, hedge ratio con Kalman, el par roto y por qué hacen falta stops."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/pairs-trading-y-arbitraje-estadistico/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/pairs-trading-y-arbitraje-estadistico.png"
---

# 2.3.3 · 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.

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.3 · Familias de estrategias
- Duración: 75 min
- Requisitos: [1.6.1 · Rentabilidades, volatilidad, correlaciones móviles y drawdown](https://pythonparatrading.com/curso-python-trading/nivel-1/volatilidad-correlaciones-y-drawdown/), [1.1.5 · Costes, liquidación, apalancamiento y margen](https://pythonparatrading.com/curso-python-trading/nivel-1/costes-liquidacion-apalancamiento-y-margen/), [1.4.4 · Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo](https://pythonparatrading.com/curso-python-trading/nivel-1/sesgo-de-supervivencia-y-point-in-time/)

## Al terminar esta lección

- Seleccionar pares con el método de la distancia y con el contraste de Engle-Granger, y contar los falsos positivos de cada uno en un universo con la respuesta conocida.
- Estimar el hedge ratio y la vida media del diferencial en un periodo de formación y operarlo fuera de él con reglas de z-score, costes por acción, préstamo y ejecución al cierre siguiente.
- Explicar por qué un par roto exige stops por nivel y por tiempo, y medir cuánto evitan.
- Seguir un hedge ratio variable con un filtro de Kalman y entender el compromiso entre seguir la beta y comerse el diferencial.
- Leer el resultado de una cartera de pares separando los pares verdaderos de los falsos.

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

$$
s_t = \ln y_t - \alpha - \beta \ln x_t, \qquad z_t = \frac{s_t - \mu_s}{\sigma_s}
$$

Si $s_t$ es estacionario, $y$ y $x$ 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 ($y_t - \alpha - \beta x_t$), 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 periodo[^1]. Esta lección aplica ese método y el de cointegración a datos donde la respuesta está
escrita.

```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]
```

<aside class="callout" data-kind="aviso">

Los datos son **sintéticos y la respuesta está en `pares2_verdad.csv`**: tres pares cointegrados con vidas medias de
5, 15 y 40 sesiones (A, B, C), uno con la beta variable (D), tres correlados sin cointegración (E, F, G) y uno
cointegrado que se rompe el 15 de junio de 2021 (H). Todas las relaciones están en logaritmos. Lo que gana un par
aquí demuestra que el método recupera la relación plantada, no que el pairs trading funcione hoy en acciones reales.

</aside>

## Dieciséis activos, ciento veinte pares

```python
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"]]
```
```output
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.025
```

Diecisé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.

```python
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
```
```output
           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                       cruzado
```

Los 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 $\ln y = \alpha + \beta \ln x + s$ y contrasta si el
residuo $s$ 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:

```python
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)
```
```output
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            cruzado
```

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

```python
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")
```
```output
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 falsos
```

Catorce 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:
$\Delta s_t = \lambda s_{t-1} + e_t$, y $h = -\ln 2 / \ln(1 + \lambda)$ (lección 2.1.5).

```python
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)
```
```output
     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.0
```

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

```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 $V$ USD de $y$ y vender $\beta V$ USD de $x$: con el diferencial en logaritmos, esa
cartera gana $V \cdot \Delta s$ (para movimientos pequeños), porque $\Delta \ln y$ y $\Delta \ln x$ son rendimientos. El
tamaño se fija para que una desviación típica del diferencial valga 1.000 USD: $V = 1.000 / \sigma_s$, unos 50.000 USD
de nominal en $y$ 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`.

```python
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")
```
```output
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 USD
```

El 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 $u_t - 0{,}181 \ln x_t$ más una
constante, y cuando $x$ 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 $\ln x$ 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

```python
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
```
```output
                       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        -5900
```

*Fig. — Resultado neto de cada par de 2018 a 2025, sin stops y con stops por nivel (|z| > 4) y por tiempo (60 sesiones).* Barras horizontales por par, sin stops arriba y con stops abajo, en miles de USD por cada 1.000 USD de σ del diferencial. Los tres cointegrados ganan con y sin stops: A 57,2 y 57,2; B 27,0 y 7,3; C 13,3 y 8,1. D, el de beta variable, 32,7 y 23,1. Los tres falsos pierden sin stops (E −11,7, F −4,4, G −11,4) y quedan entre −1,5 y +2,9 con stops. H, el par roto (destacado), pasa de 3,8 a 6,5.

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

```python
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())
```
```output
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.0
```

*Fig. — El par roto H: z del diferencial (arriba) y resultado acumulado con y sin stops (abajo), 2018-2025.* Arriba, el z semanal del diferencial en logaritmos de H oscila entre −2,3 y +3 hasta el 15 de junio de 2021, salta por encima del stop de 4 y se aleja hasta 18. Abajo, dos líneas del resultado acumulado en miles de USD: sin stops sube hasta unos 15 a mediados de 2021, se hunde con la ruptura hasta −7, recupera algo con el ruido del paseo aleatorio y termina en +3,8; con stops (destacada) llega a unos 12, pierde el salto de la ruptura, se cierra la sesión siguiente y se queda plana en +6,5.

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

<aside class="callout" data-kind="riesgo">

Un stop por nivel en un par no es una protección perfecta: si la ruptura llega de golpe (una OPA, un fraude, unos
resultados), el diferencial pasa por encima del stop en una sesión y la pérdida es la del salto entero, como en H. Lo
que el stop sí evita es seguir expuesto a una relación que ya no existe. Por eso las carteras de pares limitan también el peso de
cada par: ningún par debería poder costar más que unos pocos meses de ganancias de la cartera.

</aside>

## 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`, $x_0$ (README de los datos):

$$
\ln y_t = \alpha + \ln x_0 + \beta_t \, (\ln x_t - \ln x_0) + u_t
$$

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**: $\ln y_t = c + \beta_t (\ln x_t - \ln x_0) + e_t$. Aquí la constante $c = \alpha + \ln x_0$ es fija de
  verdad, porque el generador hace girar la relación justo en $x_0$.
- **Alfa fijo**, la forma habitual: $\ln y_t = \alpha + \beta_t \ln x_t + e_t$ con $\alpha$ fija. Cuando la beta cambia,
  la constante verdadera, $\alpha + (1 - \beta_t)\ln x_0$, 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, $\beta_t = \beta_{t-1} + \eta_t$ con $\eta_t \sim N(0, q)$, y la varianza
del ruido de observación es la del diferencial de la formación ($0{,}024^2$). La varianza $q$ del paso es la decisión
clave; la del generador es $0{,}006^2 = 3{,}6 \times 10^{-5}$.

<aside class="callout" data-kind="aviso">

Centrar en $x_0$ funciona aquí porque el README dice dónde gira la relación. Con datos reales el punto de giro no se
conoce y la constante se mueve: hay que darle su propio paso al filtro o estimar la beta con los rendimientos, que no
necesitan constante (lección 2.1.6). El modelo centrado es el techo de lo que puede dar un Kalman bien especificado,
no una receta.

</aside>

```python
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)
```
```output
      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.621
```

*Fig. — Hedge ratio del par D: la beta real del generador, la de un filtro de Kalman centrado y la beta fija de una regresión de la formación, 2015-2025.* Tres líneas semanales de 2015 a 2025. La beta real se mueve entre 1,02 y 1,49 y sube sobre todo desde 2022. La del filtro de Kalman centrado con varianza del paso de 10 elevado a −6 (destacada) la sigue con algo de retraso y ruido; su error medio es 0,043. La beta fija de la formación, discontinua, vale 1,20 todo el periodo: acierta en 2014-2017 y se queda corta cuando la beta real sube.

La 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 $q = 10^{-6}$ y con la beta fija de la formación.

```python
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
```
```output
                     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.0
```

Con 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 $q = 10^{-6}$ 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.

<aside class="callout" data-kind="nota">

Elegir $q$ por el resultado de la negociación, como hace esta tabla, es ajustar a la muestra. Lo honesto es fijarlo en
la formación con un criterio de trading (por ejemplo, la vida media del diferencial residual) o modelar el diferencial
como un estado más del filtro, con su propia reversión, para que el filtro no lo confunda con la beta (lección 2.1.6).

</aside>

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

```python
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()})")
```
```output
           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

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

```python
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
```
```output
       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.0
```

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

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

```python
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())
```
```output
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        no
```

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

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

[^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.

## Autoevaluación

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

   - A. Que el contraste está mal programado
   - 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
   - C. Que esos pares están cointegrados pero el generador no lo dice
   - D. Que la correlación de sus rendimientos es alta

   *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).

2. 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é?

   - A. Porque no está cointegrado en la formación
   - 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
   - C. Porque su vida media es demasiado corta para medirla
   - D. Por un error de redondeo

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

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

   - A. Evitó la pérdida del salto
   - 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 σ
   - C. Nada: con stops pierde lo mismo
   - D. Cerró la posición antes de la ruptura

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

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

   - A. Porque el filtro está mal programado
   - 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
   - C. Porque el diferencial del par D no revierte
   - D. Porque Kalman solo sirve para series estacionarias

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

5. ¿Por qué el diferencial de estos pares se define en logaritmos y no en precios?

   - A. Porque los logaritmos siempre dan más beneficio
   - 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
   - C. Porque statsmodels no acepta precios
   - D. Porque así no hay costes

   *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

### Research

- [Procesos Estocásticos - Paseos Aleatorios](https://pythonparatrading.com/procesos-estocasticos/) · Research-0005 · 12 min

### Documentación

- [Gatev, Goetzmann y Rouwenhorst: «Pairs Trading: Performance of a Relative Value Arbitrage Rule» (NBER w7032; Review of Financial Studies 19(3), 2006)](https://www.nber.org/papers/w7032) · nber.org
- [Engle y Granger (1987): «Co-Integration and Error Correction: Representation, Estimation, and Testing», Econometrica 55(2)](https://doi.org/10.2307/1913236) · doi.org
- [statsmodels: contraste de cointegración de Engle-Granger (coint)](https://www.statsmodels.org/stable/generated/statsmodels.tsa.stattools.coint.html) · statsmodels.org

### Libros

- [Ernest P. Chan: Algorithmic Trading: Winning Strategies and Their Rationale (Wiley, 2013), capítulo 3 (filtro de Kalman como regresión dinámica)](https://www.wiley.com/en-us/Algorithmic+Trading%3A+Winning+Strategies+and+Their+Rationale-p-9781118460146) · wiley.com

### Datos

- [Datos del nivel 2: ocho pares sintéticos, 2014-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/pares2.parquet) · Descarga · pares2.parquet
- [Datos del nivel 2: la verdad de cada par (CSV)](https://pythonparatrading.com/curso/datos/nivel-2/pares2_verdad.csv) · Descarga · pares2_verdad.csv

Lección anterior: [2.3.2 · Reversión a la media: RSI y Bollinger, reversión intradía y huecos](https://pythonparatrading.com/curso-python-trading/nivel-2/reversion-a-la-media-y-gaps/)

Lección siguiente: [2.3.4 · Carry en divisas, futuros y bonos; estacionalidad y efectos calendario](https://pythonparatrading.com/curso-python-trading/nivel-2/carry-estacionalidad-y-efectos-calendario/)
