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.05por azar. - Selección de la pareja: elegir la pareja mirando todo el histórico ya es una forma de sesgo.
El modelo matemático¶
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):
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_specs → Q03PairSpecs (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
adjcloseno 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_ONLINEel refit y el test (cointconautolag='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 = 0no 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¶
- Con el modelo del bloque 1, calcula
z[9]a partir declose_X = 44,00yclose_Y = 444,20y comprueba que da −3,855. - ¿Por qué en
t = 12se cierra la posición aunque|z| = 2,638esté por debajo del stop? - En el walk-forward, ¿qué significa que un pliegue tenga 0 bloques admitidos? ¿Es un fallo del software?
- Cambia
exitde 0,5 a 0,0 en tu cabeza: ¿las posiciones durarán más o menos? Razónalo con la máquina de estados.