Saltar a contenido

Q03 · Pairs trading: OLS y cointegración

Campo Valor
Familia Reversión a la media de un par (dos patas, hedge ratio por OLS de formación)
Universo / panel Dos precios sincronizados (align_pair_by_session); pareja preseleccionada NVDA/AMZN (también MSFT/INTC)
Datos Dos series de cierres y aperturas, metadatos de instrumentos
Features log_price, formation_ols, engle_granger, spread_zscore (D-22)
Parámetros y defaults formation = 252, z_window = 60, entry = 2.0, exit = 0.5, stop = 4.0, max_hold = 20, refit_bars = 21
Rejilla formation ∈ {126, 252, 504}, entry ∈ {1.5, 2.0, 2.5}, exit ∈ {0.0, 0.5, 1.0} → 27 candidatos válidos
Restricciones exit < entry < stop; z_window <= formation
Estado Implementada (hito H3) · paridad 3/3 PASS · carril nativo UNSUPPORTED (D-14) · walk-forward B10 con 14 pliegues
Motores implicados finazbench/policy/q03_pairs.py (Q03Pairs, Q03PairState, block_specs), finazbench/bench/walk_forward.py (WalkForwardConfig, build_folds, simulate_fold), statsmodels.coint

Fuentes: catalog/strategy_catalog.json, docs/estrategias/Q03.md (especificación con fixture), finazbench/policy/q03_pairs.py, finazbench/bench/walk_forward.py, runs/parity/summary_weights.json, results/walk_forward/Q03-NVDA-AMZN-B10/summary.json, docs/DECISIONES.md (D-20, D-21, D-22).

Qué vas a aprender

  • Qué es la cointegración y por qué dos precios que no son estacionarios pueden tener una diferencia que sí lo es.
  • Cómo se ajusta un modelo por bloques: formación, calibración, parámetros congelados y refit.
  • La máquina de estados del par: entrada, salida por reversión, stop, tiempo máximo y cierre por refit.
  • A calcular a mano la primera decisión del fixture (z = −2,555 → entrada larga en el residual).
  • A leer un walk-forward honesto con embargo, y por qué sus cifras no acreditan rentabilidad.

La idea en una frase

Si el logaritmo de un precio se explica por el de otro más un residuo que tiende a volver a su media, comprar el residuo cuando está anormalmente bajo y venderlo cuando está anormalmente alto.

Intuición y evidencia académica

Intuición. Dos empresas expuestas a los mismos factores (sector, ciclo, proveedores) no pueden separarse indefinidamente si su relación económica se mantiene. La literatura de pairs trading y de cointegración (el procedimiento en dos pasos de Engle y Granger) formaliza esa idea: si ln Y − beta · ln X es estacionario, sus desviaciones son temporales.

Por qué por bloques. La relación cambia con el tiempo. Reajustar el modelo cada refit_bars barras con las formation barras anteriores permite adaptarse sin mirar el futuro. Durante un bloque los parámetros se congelan: la señal de la barra t se calcula con un modelo que existía antes de t.

Cuándo falla.

  • Ruptura estructural: una de las dos empresas cambia de negocio y la relación desaparece; el residual se va y no vuelve (por eso existe stop).
  • Falsos positivos del test: con muchas ventanas solapadas, algunas pasan p < 0.05 por azar.
  • Selección de la pareja: elegir la pareja mirando todo el histórico ya es una forma de sesgo.

El modelo matemático

Z-score del spread con umbrales de entrada, salida y stop

Formación (las formation barras anteriores al bloque):

ln Y[u] = alpha + beta · ln X[u] + resid[u]          OLS, u en la formación
admitida  si  p_EngleGranger < 0.05  y  0.2 <= beta <= 5
mean, std = media y desviación (ddof = 0) de resid en las últimas z_window barras

Durante el bloque (parámetros congelados):

residual[t] = ln Y[t] − alpha − beta · ln X[t]
z[t]        = (residual[t] − mean) / std

Pesos de las patas (gross = 1):

largo residual:  w_Y = +1 / (1 + |beta|)     w_X = −beta / (1 + |beta|)
corto residual:  los mismos con el signo cambiado

Máquina de estados en cada barra, en este orden:

A  si t es refit y hay posición abierta: cerrar (REFIT-CLOSE); no reabrir en la misma decisión
B  si hay posición:
      salir si |z| < exit        (con exit = 0: salir al cambio de signo de z)
      salir si |z| > stop        (STOP; no autoriza reabrir en la misma decisión)
      salir si holding >= max_hold
C  si no hay posición y el bloque está admitido:
      no abrir si |z| >= stop
      z < −entry → ENTRY-LONG ;  z > +entry → ENTRY-SHORT

Pseudocódigo causal

para cada bloque [b, b + refit_bars):
    formación = barras [b − formation, b)                  # solo pasado
    ajustar OLS, test coint (en formación, nunca sobre todo el periodo)
    calibrar mean, std con las últimas z_window barras de la formación
    para cada t del bloque (tras el watermark del par):
        z[t] con parámetros congelados; máquina de estados A-B-C
        si abre: congelar beta de la posición (pos_beta) y frozen_equity
        q_Y = trunc(w_Y · frozen_equity / close_Y[t]); q_X análogo
        emitir target_qty + model_id, zscore, residual  (extensión con modelo)

Cómo lo hace la plataforma

Pieza Quién la calcula
Alineación del par align_pair_by_session; require_canonical_pair_order(y, x) exige el orden Y/X
Bloques block_specsQ03PairSpecs (inicio, formación y modelo de cada bloque)
OLS de formación Primitiva formation_ols (D-22)
Cointegración Primitiva engle_granger con statsmodels.coint; argumentos y versión de statsmodels dentro del model_id (D-20)
z-score Primitiva spread_zscore contra parámetros congelados
Máquina de estados Q03Pairs (batch) y Q03PairState (online)
Contrato WeightIntentArrays con extensión con modelo: model_id, zscore, residual (D-21)
Walk-forward finazbench/bench/walk_forward.py: WalkForwardConfig, build_folds, block_embargoed, simulate_fold
Familia de modelos relacionada finaz_models_statistics (statsmodels/arch) en la imagen quant: mismo ecosistema, con puertas y diagnósticos
flowchart LR
    D["Par NVDA/AMZN<br/>align_pair_by_session"] --> F["log_price Y, X"]
    F --> M["Formación 252<br/>OLS + Engle-Granger<br/>0.2 <= beta <= 5"]
    M --> C["Calibración<br/>mean, std · últimas 60"]
    C --> Z["z con parámetros congelados"]
    Z --> S["Máquina de estados<br/>entry 2 · exit 0.5 · stop 4<br/>max_hold 20 · refit 21"]
    S --> W["Pesos de patas<br/>±1/(1+abs beta), ∓beta/(1+abs beta)"]
    W --> L["WeightIntentArrays<br/>+ model_id, zscore, residual"]
    L --> E["Ledger de cartera<br/>fill open[t+1]"]

Ejemplo numérico

Del fixture de la especificación (docs/estrategias/Q03.md §6): 16 barras, formation = 8, z_window = 4, refit_bars = 4, entry = 1.0, exit = 0.0, stop = 3.0, max_hold = 3, cash 100.000, sin costes. La admisión por p-valor se declara (el test no se hace a mano); beta sí se comprueba.

Modelo del bloque 1 (formación [0, 8)):

beta  = 0.087212401 / 0.054495168 = 1.600369447     (0.2 <= beta <= 5: cumple)
alpha = 0.124892682
mean  = −0.002390427     std = 0.021354982          (últimas 4 barras de formación)
w_Y (largo residual) = +1 / 2.600369447           = +0.384560741
w_X (largo residual) = −1.600369447 / 2.600369447 = −0.615439259

Primera decisión, t = 8 (close_X = 45,00; close_Y = 473,43):

residual[8] = ln(473.43) − 0.124892682 − 1.600369447 · ln(45.00)
            = 6.160004066 − 6.216959024 = −0.056954957
z[8]        = (−0.056954957 + 0.002390427) / 0.021354982 = −2.555120

|z| = 2.555 < stop = 3.0  → se puede abrir
z  < −entry = −1.0        → ENTRY-LONG del residual (largo Y, corto X)

q_Y = trunc(+0.384560741 · 100000 / 473.43) = +81
q_X = trunc(−0.615439259 · 100000 /  45.00) = −1367

El resto del fixture recorre todos los estados:

t z Acción
9 −3,855 STOP (abs(z) > 3)
10 −1,254 ENTRY-LONG de nuevo (el stop de t = 9 no autoriza reabrir en t = 9, sí en t = 10)
11 −0,214 HOLD
12 +2,638 REFIT-CLOSE (empieza el bloque 2: se cierra antes de usar el modelo nuevo)
13 +4,060 NO-OPEN (abs(z) >= stop)
14 +1,831 ENTRY-SHORT
15 −1,727 EXIT-SIGN (exit = 0: el z cambió de signo)

Equity final 105.930,08 con la última decisión sin fill.

Evidencia de paridad real (runs/parity/summary_weights.json): NVDA/AMZN diario, 2941 barras, first_decision_index = 252, 254 fills idénticos entre el ledger y cada carril Nautilus de pesos (REPLAY, FEATURES, ONLINE): 3 PASS. El carril nativo es UNSUPPORTED: Nautilus no tiene OLS de formación ni test de cointegración.

Walk-forward B10 real (results/walk_forward/Q03-NVDA-AMZN-B10/summary.json): entrenamiento 24 meses, validación 24, paso 6, reserva final 12 (504/504/126/252 sesiones), defaults del catálogo, 5 bps.

Magnitud Valor
Pliegues 14 (tramos efectivos de 2018-01 a 2025-07)
Bloques usados / admitidos 168 / 20
Entradas / salidas 15 / 8
Fills 802
PnL sumado de los pliegues −7.451,25 (pata Y −8.717,27; pata X +1.266,02)
Costes sumados 3.910,87
Sesiones de reserva simuladas 0 (la reserva no se toca)
Test de cointegración en el tramo de test nunca (no_coint_on_test = true)

Por pliegue, los seis tramos que empiezan entre 2020-01 y 2022-07 no admiten ningún bloque (0 entradas): la relación NVDA/AMZN no pasó el test en esas formaciones. Los dos primeros pliegues ganan (+13.767,32 cada uno) y todos los que empiezan desde 2019-01 con alguna entrada pierden.

Cómo leer estas cifras

  • Los pliegues se solapan (paso de 6 meses, tramo efectivo de 12): la suma de PnL cuenta dos veces los mismos días. No es una curva de equity.
  • Dos formaciones consecutivas comparten 231 de 252 barras (91,7 %): los p-valores no son independientes y 20 admisiones no son 20 pruebas.
  • La pareja está preseleccionada y ambas empresas sobrevivieron; el adjclose no es point-in-time.
  • El propio fichero lo dice: el resultado acredita que el algoritmo funciona, no que sea rentable.

El embargo del pliegue 0, dibujado

sesiones:  0 ─────────── 504 ─────────── 756 ─────────── 1008
           [ entrenamiento ][ validación ................... ]
                            [ embargo 252 ][ tramo efectivo  ]
                              bloques cuya formación (252)     bloques que cuentan
                              cruza la frontera: se simulan    en las métricas
                              para que el estado sea continuo
                              pero NO cuentan

En el pliegue 0 hay 24 bloques de validación; 12 quedan embargados y 12 se usan; de esos, 4 se admiten (admitted_fraction = 0,33), con 3 entradas y una beta media de 0,68. La regla literal es b − formation >= inicio_validación: un bloque solo cuenta si toda su formación está dentro de la validación. Detalle completo en Walk-forward.

Cómo lanzarla

Backtest con POST /v1/backtest y data.assets en orden Y, X:

{
  "schema_version": "v1",
  "payload": {
    "strategy": { "strategy_id": "<id de Q03 con defaults>" },
    "data": { "assets": ["NVDA", "AMZN"], "timeframe": "1d",
              "window": { "start": null, "end": null, "mode": "reset_flat", "checkpoint_id": null } },
    "execution": { "lane": "SIM-S", "contract_version": "1", "initial_cash": 100000,
                   "cost_scenario": "hypothetical_5bps", "costs": null },
    "engine": "both"
  }
}

Parámetros que violan exit < entry < stop o z_window <= formation devuelven 422 STRATEGY_PARAMS_INVALID.

Walk-forward B10 (así se generó el summary.json):

python -m finazbench.bench.walk_forward --pair NVDA/AMZN --fee-bps 5 \
    --formation 252 --refit-bars 21 --entry 2.0 --exit 0.5 --stop 4.0 --max-hold 20

Paridad:

docker compose --profile dev run --rm -T dev python -m tools.parity_weights \
    --strategies Q03 --output runs/parity

Riesgos, límites y qué no hace

  • No busca parejas: la pareja está fijada; la minería de parejas se mide aparte y tiene su propio sesgo de selección.
  • Beta congelada: mientras la posición está abierta se usa la beta de la entrada; si la relación deriva dentro del bloque, la cobertura se degrada. Q04 (Kalman) ataca justo eso.
  • Coste online: en el carril NT_ONLINE el refit y el test (coint con autolag='aic') corren dentro del bucle de eventos; su coste no es comparable con un carril que precalcula modelos.
  • Redondeo al tick: los precios ajustados se redondean a 0,01 antes de calcular y ejecutar (el ledger cuenta en enteros).
  • exit = 0 no significa |z| < 0: significa salida al cambio de signo.
  • Sin coste de préstamo de la pata corta.

Resumen

  • Modelo por bloques: OLS + Engle-Granger en formación, z con parámetros congelados, refit cada 21 barras.
  • Máquina de estados con entrada, reversión, stop, tiempo máximo y cierre por refit; pesos de patas con gross 1.
  • Implementada en finazbench/policy/q03_pairs.py; paridad 3/3 PASS (254 fills); walk-forward B10 de 14 pliegues con embargo literal y reserva intacta.
  • Las cifras del walk-forward acreditan el procedimiento, no la rentabilidad.

Para practicar

  1. Con el modelo del bloque 1, calcula z[9] a partir de close_X = 44,00 y close_Y = 444,20 y comprueba que da −3,855.
  2. ¿Por qué en t = 12 se cierra la posición aunque |z| = 2,638 esté por debajo del stop?
  3. En el walk-forward, ¿qué significa que un pliegue tenga 0 bloques admitidos? ¿Es un fallo del software?
  4. Cambia exit de 0,5 a 0,0 en tu cabeza: ¿las posiciones durarán más o menos? Razónalo con la máquina de estados.