Saltar a contenido

Q04 · Pairs trading con hedge ratio de Kalman

Campo Valor
Familia Pares con filtro de estado: como Q03, pero alpha y beta evolucionan barra a barra con un filtro de Kalman causal 2×2
Universo / panel Dos precios sincronizados (mismo alineado que Q03)
Datos Dos series de cierres y aperturas
Features kalman_prediction, innovation_variance, filtered_state
Parámetros y defaults q_over_r = 1e-5, entry = 2.0, exit = 0.5, max_hold = 20, initial_training = 252
Rejilla q_over_r ∈ {1e-6, 1e-5, 1e-4, 1e-3}, entry ∈ {1.5, 2.0, 2.5} → 12 candidatos
Coste esperado O(N) por pareja, estado 2×2; bucle temporal secuencial
Estado Especificada, no implementada (specified_not_implemented, hito H3)
Motores implicados Ninguno ejecuta hoy el filtro. Reutilizaría la primitiva formation_ols de Q03 (inicialización), el ledger de cartera y el contrato WeightIntentArrays; la familia natural del filtro es la de modelos estadísticos (finaz_models_statistics, imagen quant)

Estado

Q04 no está implementada. El catálogo la marca specified_not_implemented y la matriz de capacidades de la API de backtesting la declara UNSUPPORTED en todos los carriles con el motivo NOT_IMPLEMENTED_YET: H5 (Q04-Q10) is outside the current scope. Existe una especificación completa con fixture a mano (docs/estrategias/Q04.md); los números de esta ficha vienen de ese fixture, no de ninguna ejecución. Ningún paquete de la plataforma contiene hoy un filtro de Kalman.

Fuentes: catalog/strategy_catalog.json, docs/estrategias/Q04.md (especificación y fixture), finazbench/api/routers/capabilities.py, docs/v2/MOTORES.md (AG-021).

Qué vas a aprender

  • Por qué una beta fija por bloques (Q03) puede quedarse atrás y cómo un filtro de Kalman la actualiza en cada barra.
  • Las dos fases del filtro: predicción y actualización, y por qué la señal se toma entre las dos.
  • Qué es la innovación y su varianza S, y cómo producen un z-score sin ventana.
  • El papel del único hiperparámetro, q_over_r: cuánto se permite moverse a la beta.
  • Qué falta en la plataforma para ejecutarla.

La idea en una frase

Estimar la relación ln Y = alpha + beta · ln X con un filtro que se corrige en cada barra, y operar el par cuando la sorpresa del filtro (la innovación normalizada) es anormalmente grande.

Intuición y evidencia académica

Intuición. En Q03 la beta se reestima cada 21 barras y se congela. Si la relación entre las dos empresas deriva poco a poco, la cobertura se degrada dentro del bloque. El filtro de Kalman trata alpha y beta como un estado oculto que se mueve lentamente (paseo aleatorio) y combina, en cada barra, lo que creía antes con lo que observa ahora, ponderando por la incertidumbre de cada uno.

La literatura de pares con coeficientes dinámicos (modelos de espacio de estados aplicados a spreads) usa esta idea como alternativa a la ventana móvil: no hay que elegir una longitud de ventana, sino una razón de ruidos.

Cuándo falla.

  • q_over_r mal elegido: demasiado grande y la beta persigue el ruido (el spread se vuelve siempre pequeño y no hay señal); demasiado pequeño y equivale a un OLS congelado.
  • Saltos: un salto de un solo activo se interpreta en parte como cambio de beta después de actualizar; por eso la señal se toma antes de actualizar.
  • Sin test de cointegración en la regla: el filtro siempre produce una beta, aunque la relación no exista.

El modelo matemático

Estado theta = [alpha, beta]', transición identidad, observación H_t = [1, ln X_t]:

inicialización (formación de initial_training barras):
    theta0 = OLS de ln Y sobre ln X
    R      = SSR / (n − 2)                         varianza de observación
    Q      = q_over_r · R · I                      ruido del estado
    P0     = I

en cada barra t:
  (1) theta_pred = theta                         (transición identidad)
  (2) P_pred     = P + Q
  (3) H          = [1, ln X_t]
  (4) y_hat      = H · theta_pred
  (5) e          = ln Y_t − y_hat                 innovación
  (6) S          = H · P_pred · H' + R            varianza de la innovación
  (7) z          = e / sqrt(S)
      ---- DECISIÓN con z y beta_pred = theta_pred[1] ----
  (8) K          = P_pred · H' / S                ganancia
  (9) theta      = theta_pred + K · e
 (10) P          = (I − K H) P_pred (I − K H)' + K R K'   forma de Joseph, luego (P + P')/2

Pesos (como Q03, con la beta congelada al abrir): w_Y = pos / (1 + |hedge|), w_X = −pos · hedge / (1 + |hedge|).

Reglas: entrar si |z| > entry; salir si |z| < exit o holding >= max_hold; si beta_pred sale de [0.2, 5], no abrir y cerrar la posición en el siguiente evento ejecutable. El filtro se actualiza siempre, haya posición o no.

Pseudocódigo causal

theta, P, R, Q = inicializar(formación [0, initial_training))
para t desde initial_training (tras el watermark del par):
    predecir (1)-(7) con datos known_at <= t
    decidir con z y beta_pred (la innovación ANTES de absorberla)
    si abre: hedge = beta_pred (congelado mientras dure la posición)
    actualizar (8)-(10); comprobar R > eps y simetría de P
    q = trunc(w · frozen_equity / close[t]); fill en open[t+1]

Nunca se usa el suavizador de Kalman (que mira observaciones futuras) en el periodo evaluado.

El filtro en palabras

Paso Qué significa
Predicción del estado "Mañana alpha y beta serán como hoy" (paseo aleatorio sin deriva)
P_pred = P + Q "…pero estoy un poco menos seguro": la incertidumbre crece con el ruido del estado
y_hat Lo que el modelo espera ver de ln Y dada la ln X observada
Innovación e La sorpresa: lo observado menos lo esperado
S Cuánta sorpresa es "normal": incertidumbre del estado proyectada más ruido de observación
z = e / sqrt(S) La sorpresa en unidades de desviación típica: la señal
Ganancia K Cuánto creer a la observación frente al estado previo
Actualización Mover alpha y beta en la dirección de la sorpresa, en proporción a K

q_over_r: el mando de la velocidad

Q = q_over_r · R · I fija la razón entre el ruido del estado y el de la observación. Es el análogo de la longitud de ventana en un OLS móvil, pero continuo:

q_over_r Comportamiento de la beta Parecido a
1e-6 Casi inmóvil OLS de formación congelado (Q03 sin refit)
1e-5 (default) Deriva lenta Ventana larga
1e-3 Se adapta en pocas barras Ventana corta
muy grande Persigue cada observación; e ≈ 0 Sin señal útil

Que la rejilla recorra cuatro órdenes de magnitud y no valores lineales refleja justo esto: la escala relevante es logarítmica.

Q03 frente a Q04

Aspecto Q03 (OLS por bloques) Q04 (Kalman)
Actualización de beta Cada refit_bars (21) Cada barra
Normalización del z mean, std de las últimas z_window barras sqrt(S) del propio filtro
Test de admisión Engle–Granger p < 0.05 y beta en rango Solo beta en rango
Cierre por refit Sí (REFIT-CLOSE) No hay refit
Stop por abs(z) Sí (stop) No en la receta
Coste O(B·W) por pareja más tests O(N) por pareja, secuencial

Qué haría falta para implementarla

  1. Un módulo de filtro 2×2 con forma de Joseph, simetrización y comprobación R > eps, con tests contra el fixture de la especificación a rtol = 1e-9.
  2. Una política Q04 en finazbench/policy/ con estado online por pareja y online_equals_batch probado.
  3. El model_id con q_over_r, la inicialización y la versión del código, para que dos corridas con el mismo id sean idénticas.
  4. Un caso en tools.parity_weights para obtener paridad contra los carriles nt_weight_*.
  5. Levantar NOT_IMPLEMENTED_YET en la matriz de capacidades solo cuando exista el test que lo acredite.

Cómo lo haría la plataforma

Pieza Quién la cubriría Estado
Alineado del par El mismo de Q03 (align_pair_by_session) Existe
OLS de inicialización Primitiva formation_ols (D-22) Existe (usada por Q03)
Filtro 2×2 Código propio previsto (forma de Joseph, simetrización); familia natural: finaz_models_statistics No existe
Estado online Un PolicyState por pareja, como Q03PairState No existe
Contrato WeightIntentArrays con extensión con modelo (model_id, zscore, residual) Existe
Ejecución Ledger de cartera y carriles nt_weight_* Existe
flowchart LR
    D["Par Y/X<br/>align_pair_by_session"] --> I["Formación 252<br/>formation_ols → theta0, R"]
    I --> K["Filtro Kalman 2×2<br/>(no implementado)"]
    K --> Z["Innovación e, S<br/>z = e / sqrt(S)"]
    Z --> S["Reglas<br/>entry · exit · max_hold<br/>beta en [0.2, 5]"]
    S --> W["Pesos de patas<br/>hedge congelado"]
    W --> V["WeightIntentArrays<br/>+ model_id, zscore"]
    V --> E["Ledger de cartera<br/>fill open[t+1]"]
    K -. "actualizar siempre" .-> K

Ejemplo numérico

Del fixture de la especificación (docs/estrategias/Q04.md §6), calculado a mano. 16 barras, initial_training = 6, q_over_r = 0.1 (ambos fuera de la rejilla para que el cálculo sea legible), entry = 2.0, exit = 0.5.

Fase 0: OLS sobre las barras 0–5.

beta0  = Sxy / Sxx = 0.003014762801 / 0.002344609504 = 1.2858272546
alpha0 = 4.63599706 − 1.2858272546 · 3.93976689      = −0.4298625764
SSR    = 0.000295010600     R = SSR / 4 = 0.000073752650
Q      = 0.1 · R · I = 7.375265e−06 · I      P0 = I

Primera barra del filtro, t = 6 (ln X = 3.98898405, ln Y = 4.70048037):

y_hat = −0.4298625764 + 1.2858272546 · 3.98898405 = 4.69928183
e     = 4.70048037 − 4.69928183 = +0.00119854
S     = 16.9121922068       sqrt(S) = 4.11244358
z     = +0.00029            → no se abre

S es enorme en la primera barra porque P0 = I no es informativa; en t = 7 ya cae a 2.9e−04. La primera observación colapsa la incertidumbre.

El shock de t = 12. Y salta de 112 a 130 mientras X solo pasa de 55 a 57:

e = +0.09668275     S = 0.0002949609     sqrt(S) = 0.01717443
z = +5.62946 > entry = 2.0                → ENTRY-SHORT del residual
hedge = beta_pred = 1.40552639            (la beta ANTERIOR a absorber el shock)
w_Y = −1 / 2.40552639         = −0.415709427
w_X = +1.40552639 / 2.40552639 = +0.584290573      gross = 1

Después de decidir, el filtro se actualiza y la beta salta a 1,815: si la señal se hubiese tomado después de actualizar, parte del shock se habría absorbido como cambio de beta y el z habría sido menor. Con exit = 0.5 la posición sigue abierta al final de la serie (el |z| mínimo entre 13 y 15 es 1,57).

Contraste con Q03. Con OLS congelado de las barras 0–5, el residual de t = 12 sería ln Y − alpha0 − beta0 · ln X; el filtro ha movido la beta de 1,286 a 1,406 antes del shock, así que la cobertura en el momento de entrar refleja la relación reciente, no la de la formación.

Cómo lanzarla

Hoy no se puede lanzar. Lo que devuelve la plataforma:

GET /v1/capabilities
  strategy_support: { "template_id": "Q04",
    "VTA_CPU_LEDGER": "UNSUPPORTED", "NT_FEATURES": "UNSUPPORTED",
    "NT_INTENT_REPLAY": "UNSUPPORTED", "NT_ONLINE": "UNSUPPORTED",
    "unsupported_reason": "NOT_IMPLEMENTED_YET: H5 (Q04-Q10) is outside the current scope (D-15). ..." }

POST /v1/backtest con una instancia de Q04
  → 501 STRATEGY_UNSUPPORTED_BY_ADAPTER

El catálogo sí publica la receta (GET /v1/catalog/strategies/Q04) con su param_schema, defaults y rejilla; lo que no hay es ejecución.

Qué se puede ejecutar hoy de la familia estadística. El job S7 del rol quant ejecuta modelos estadísticos reales (ARIMA y ETS con statsmodels) sobre datos sintéticos fijos:

# petición en el spool (FINAZ_JOB_SPOOL, por defecto /spool/jobs)
echo '{"job_id": "demo-quant-001", "rol": "quant", "operacion": "ridge_catboost_stats"}' \
    > /spool/jobs/demo-quant-001.json
python -m finaz_runtime.jobs.runner --rol quant --once
# resultado: /spool/jobs/demo-quant-001.result.json

El job G4 real (qa_reports/v2/runs/2026-09-20/g4-quant-001.result.json) devolvió estadisticos.arima con orden (1, 0, 0) y estadisticos.ets monótona. Es la evidencia de que la familia funciona; no es un filtro de Kalman ni un backtest de Q04.

Riesgos, límites y qué no hace

  • No implementada: todo lo anterior es diseño y aritmética de especificación.
  • Sensibilidad a q_over_r: la rejilla recorre cuatro órdenes de magnitud por algo; no hay un valor "correcto" universal.
  • Inicialización arbitraria: P0 = I hace que las primeras barras tengan S enorme y z casi nulo; las primeras decisiones son, en la práctica, de calentamiento.
  • Estabilidad numérica: la especificación exige la forma de Joseph y simetrizar P; la forma corta (I − K H) P_pred puede perder la simetría y la definición positiva.
  • Bucle secuencial: no se vectoriza en el tiempo; se paraleliza por parejas o por candidatos.
  • Sin test de cointegración: la admisión solo exige beta en rango.

Resumen

  • Q04 sustituye la beta por bloques de Q03 por un estado [alpha, beta] filtrado barra a barra.
  • La señal es la innovación normalizada z = e / sqrt(S), calculada antes de actualizar el filtro.
  • Un solo hiperparámetro de dinámica: q_over_r.
  • Especificada con fixture; no implementada; la plataforma tiene la inicialización OLS, el contrato y el ledger, pero no el filtro.

Para practicar

  1. Con los datos del fixture, calcula K0 y K1 de t = 6 y comprueba +0.0591 y +0.2359.
  2. ¿Qué ocurriría con el z de t = 12 si q_over_r fuese 1000 veces mayor? Razónalo sin calcular.
  3. Explica por qué usar el suavizador de Kalman en un backtest es una fuga de información.
  4. Diseña el model_id de Q04: ¿qué campos debería incluir para que dos corridas con el mismo id sean idénticas?