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_rmal 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¶
- 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 artol = 1e-9. - Una política
Q04enfinazbench/policy/con estado online por pareja yonline_equals_batchprobado. - El
model_idconq_over_r, la inicialización y la versión del código, para que dos corridas con el mismo id sean idénticas. - Un caso en
tools.parity_weightspara obtener paridad contra los carrilesnt_weight_*. - Levantar
NOT_IMPLEMENTED_YETen 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 = Ihace que las primeras barras tenganSenorme 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_predpuede 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¶
- Con los datos del fixture, calcula
K0yK1det = 6y comprueba+0.0591y+0.2359. - ¿Qué ocurriría con el z de
t = 12siq_over_rfuese 1000 veces mayor? Razónalo sin calcular. - Explica por qué usar el suavizador de Kalman en un backtest es una fuga de información.
- Diseña el
model_idde Q04: ¿qué campos debería incluir para que dos corridas con el mismo id sean idénticas?