Saltar a contenido

Q08 · Predicción walk-forward CPU: Ridge (y Q08B, CatBoost)

Campo Valor
Familia Aprendizaje supervisado: un modelo predice el retorno de los próximos h días y la señal es su signo con umbral
Universo / panel Una serie (un activo) con OHLCV
Datos OHLCV según disponibilidad; labels con fecha de disponibilidad
Features lagged_returns (1/5/20/63), volatility (vol20/63), rsi (RSI14), atr_ratio (ATR14/C), z de precio a 20; volumen normalizado solo con volumen válido; oos_predictions
Parámetros y defaults (Q08) model = "ridge", alpha = 1.0, horizon = 5, training = 1008, refit_bars = 21, threshold = 0.001
Rejilla (Q08) alpha ∈ {0.1, 1, 10, 100}, training ∈ {504, 1008}, threshold ∈ {0, 0.001, 0.002} → 24 candidatos
Q08B model = "catboost", iterations = 500, depth = 6, learning_rate = 0.05, mismo contrato; requiere el extra ml
Estado Q08: especificada, no implementada (specified_not_implemented, H4). Q08B: optional_dependency_missing en el banco de backtesting (catboost no instalado allí)
Motores implicados Ya corren: finaz_models_tabular Ridge exacto (entrenar_ridge, predecir_ridge, ModeloRidge; AG-020, G4 PASS), AdaptadorCatBoost (CatBoost 1.2.8 en la imagen quant, G4 PASS), finaz_training (labels con madurez y embargo, splits walk-forward; AG-018/019)

Estado

Q08 no está implementada como estrategia del banco de backtesting; la API la declara UNSUPPORTED con NOT_IMPLEMENTED_YET. Q08B tiene estado optional_dependency_missing: en el banco POST /v1/backtest responde 501 MISSING_OPTIONAL_DEPENDENCY porque catboost no está instalado en esa imagen. En cambio, los motores de modelo sí corren en la imagen quant: el job g4-quant-001 entrenó, guardó, recargó y predijo con Ridge (PASS) y entrenó CatBoost 1.2.8 (PASS) sobre datos sintéticos. No hay backtest de Q08 ni de Q08B; las cifras reales de esta ficha son las del job G4.

Fuentes: catalog/strategy_catalog.json, catalog/strategy_templates_v1.json (desdoble Q08/Q08B), docs/API.md (Q08/Q08B), docs/DESVIACIONES_INFORME.md (DEV-28), packages/finaz_models_tabular, packages/finaz_training, packages/finaz_runtime/jobs/operaciones/quant.py, qa_reports/v2/runs/2026-09-20/g4-quant-001.result.json, docs/v2/MOTORES.md (AG-018…AG-020).

Qué vas a aprender

  • Cómo se plantea una estrategia como problema de regresión con features causales y una label con fecha de disponibilidad.
  • Qué es Ridge, qué hace alpha y por qué la plataforma lo implementa de forma exacta (Cholesky).
  • Por qué hay que purgar labels solapadas y aplicar embargo en la validación.
  • Qué aporta CatBoost como challenger y por qué se separó en su propio template (Q08B).
  • Qué demostró el job G4 real y qué no.

La idea en una frase

Cada 21 días, entrenar un modelo con los últimos cuatro años de features y retornos futuros ya conocidos, predecir el retorno de los próximos cinco días y posicionarse largo, corto o fuera según esa predicción supere un umbral.

Intuición y evidencia académica

Intuición. Muchas señales técnicas (momentum a varios plazos, volatilidad, RSI, rango verdadero) tienen, cada una, un poder predictivo débil. Un modelo lineal regularizado las combina con pesos aprendidos de los datos. La literatura de aprendizaje automático aplicado a retornos insiste en dos cosas: la regularización es imprescindible (la relación señal/ruido es bajísima) y la validación debe ser cronológica con purga y embargo, porque las labels solapadas filtran información entre entrenamiento y test.

Ridge frente a CatBoost. Ridge es lineal, estable y exacto; CatBoost (árboles con boosting) captura no linealidades e interacciones, a cambio de más varianza y más coste. La receta pone Ridge como modelo principal y CatBoost como challenger bajo el mismo contrato.

Cuándo falla.

  • Relación inestable: los pesos aprendidos en 2015–2019 pueden no servir en 2020.
  • Fuga por labels solapadas: con h = 5, la label de hoy y la de mañana comparten cuatro días; sin purga, la validación sobreestima.
  • Umbral mal calibrado: con threshold = 0 se opera cada ruido de la predicción.

El modelo matemático

Label (retorno de apertura a apertura, ejecutable):

y[t]            = ln( O[t + h + 1] / O[t + 1] )
label_available = momento de O[t + h + 1]        (no se conoce antes)

Entrenamiento en cada refit con corte fit_cutoff:

filas válidas = { t : label_available[t] <= fit_cutoff }     solo labels ya maduras
X             = features causales en t, escaladas con media/desv. de la formación
Ridge:  beta = argmin ||y − X beta||² + alpha · ||beta||²
        solución exacta: (X'X + alpha I) beta = X'y      (Cholesky)

Señal (cada h barras):

pred = X_t · beta
señal = +1 si pred > threshold ; −1 si pred < −threshold ; 0 en otro caso

Pseudocódigo causal

para cada refit (cada refit_bars barras) en t_r:
    fit_cutoff = t_r
    filas = últimas `training` barras con label_available <= fit_cutoff
    purga: quitar filas cuya label solape con el tramo de validación; embargo explícito
    escalador y modelo entrenados SOLO con esas filas
    para cada t de decisión del bloque (cada h barras):
        pred = modelo(features[t])                      # fuera de muestra
        señal; q = trunc(señal · frozen_equity / close[t]); fill en open[t+1]
medir por separado: construcción de X, fit, predict y replay

Predecir no convierte un backtest en entrenamiento: el modelo nunca ve la label de una fila hasta que está disponible.

Cómo lo hace (y lo haría) la plataforma

Pieza Motor Función real Estado
Features técnicas Banco (RSI, ATR, retornos, volatilidad) Primitivas e indicadores VectorTA Existen
Labels con madurez finaz_training/labels labeler, maturity (TargetSpec → labels con madurez y embargo) Existe (G4: 55/60 maduras)
Splits walk-forward finaz_training/validation splits, folds (cronológicos, sin fuga) Existe (3 folds en G4)
Ridge exacto finaz_models_tabular entrenar_ridge(X, y, alpha=…, orden_features=…, hash_datos=…, semilla=…)ModeloRidge; predecir_ridge(modelo, X, orden_columnas) Corre (G4 PASS)
Artefacto finaz_models_tabular guardar_artefacto, cargar_artefacto (JSON con hash), emitir_model_ref_ridge Corre (paridad de recarga)
Distribución de predicción finaz_models_tabular pronosticarForecastDistribution con cuantiles residuales Existe
CatBoost finaz_models_tabular verificar_catboost, AdaptadorCatBoost(iteraciones, semilla, …), pronosticar_catboost Corre en la imagen quant (G4 PASS)
Registro finaz_training/registry TrainingRun → artefactos CAS + ModelRef + EvaluationReport + PromotionDecision Existe (AG-019)
Política Q08 y paridad Banco No existe
flowchart LR
    D["OHLCV · 1 activo"] --> F["Features causales<br/>ret 1/5/20/63 · vol · RSI14 · ATR/C"]
    D --> Y["Labels<br/>ln(O[t+h+1]/O[t+1])<br/>finaz_training/labels"]
    F --> S["Splits walk-forward<br/>purga + embargo<br/>finaz_training/validation"]
    Y --> S
    S --> M["finaz_models_tabular<br/>Ridge exacto (Cholesky)<br/>CatBoost challenger"]
    M --> P["Predicción OOS<br/>ForecastDistribution"]
    P --> G["Señal ±1/0<br/>threshold 0.001"]
    G --> L["Ledger<br/>(política Q08 no implementada)"]

Ejemplo numérico

1. Ejemplo didáctico de Ridge (calculado a mano)

Ejemplo didáctico

Una sola feature, sin intercepto, cuatro filas inventadas.

x = [1.0, 2.0, −1.0, 0.5]         (feature escalada)
y = [0.002, 0.003, −0.001, 0.000] (retorno a 5 días)

sum(x·y) = 0.002 + 0.006 + 0.001 + 0 = 0.009
sum(x²)  = 1 + 4 + 1 + 0.25 = 6.25

OLS   (alpha = 0):  beta = 0.009 / 6.25        = 0.001440
Ridge (alpha = 1):  beta = 0.009 / (6.25 + 1)  = 0.001241

alpha encoge la pendiente hacia cero: con más datos (sum(x²) mayor) el efecto relativo de alpha disminuye. Para una nueva fila con x = 1.5: pred = 1.5 · 0.001241 = 0.00186 > threshold = 0.001señal +1. Con x = 0.5: pred = 0.000620 (fuera).

2. Job G4 real de la plataforma

qa_reports/v2/runs/2026-09-20/g4-quant-001.result.json, imagen quant, estado PASS. Datos sintéticos lineales exactos: 200 muestras, 4 features uniformes en [−2, 2], y = X·[1.5, −2.0, 0.5, 3.0] + 0.7, semilla 7.

Ridge     adapter finaz.tabular.ridge
          n_muestras 200 · n_features 4 · semilla 7
          RMSE 0.016885   (desviación de y 4.611)     rmse_sano true
          paridad_recarga true   (guardar → cargar → predecir da lo mismo)

CatBoost  adapter finaz.tabular.catboost · driver 1.2.8
          RMSE 1.334263                               rmse_sano true
          artefacto .cbm con hash sha256

Estadísticos  ARIMA (1,0,0) gaussiano · ETS monótona   (3 filas de pronóstico cada uno)

Cómo leerlo. Con datos exactamente lineales, Ridge recupera casi perfectamente los pesos (RMSE 0,017 frente a una dispersión de 4,6; el pequeño error es el encogimiento de alpha = 1). CatBoost, con pocos árboles, aproxima una recta con escalones y deja un RMSE de 1,33: no es "peor modelo" en general, es que el problema es lineal. El job acredita que ambos motores entrenan, persisten y predicen de forma reproducible; no dice nada sobre retornos de mercado.

Q08B: el challenger CatBoost

El catálogo original tenía un único Q08 con model como parámetro. La API lo publica desdoblado (DEV-28):

Template Modelo Disponibilidad Si se pide en el banco
Q08 Ridge (model == "ridge", alpha > 0, threshold >= 0) Siempre (NumPy) 501 STRATEGY_UNSUPPORTED_BY_ADAPTER (no implementada)
Q08B CatBoost (model == "catboost") Extra opcional ml 501 MISSING_OPTIONAL_DEPENDENCY ({"template_id":"Q08B","dependency":"catboost"})

El motivo: el soporte no es un parámetro, es una propiedad del template. Con model como parámetro, la matriz de capacidades no podría decir "Q08 sí, Q08 con catboost no".

Contrato común del challenger: CPU, semilla, número de hilos y parámetros fijados; sin fallback silencioso (si catboost falta, CatBoostNoDisponible, nunca un Ridge disfrazado). Hay que medir el fit de CatBoost por separado: es mucho más caro que Ridge.

Cómo lanzarla

Como estrategia, hoy no se puede. Q08 → 501 STRATEGY_UNSUPPORTED_BY_ADAPTER; Q08B → 501 MISSING_OPTIONAL_DEPENDENCY.

El motor sí. Job S7 del rol quant (reproduce el G4; los datos son el sintético fijo de la operación):

echo '{"job_id": "demo-ridge", "rol": "quant", "operacion": "ridge_catboost_stats"}' \
    > /spool/jobs/demo-ridge.json
python -m finaz_runtime.jobs.runner --rol quant --once
cat /spool/jobs/demo-ridge.result.json

La operación ridge_only ejecuta solo Ridge. Como biblioteca, dentro de la imagen quant (ilustrativo):

import numpy as np
from finaz_models_tabular import entrenar_ridge, predecir_ridge

X = np.load("features_formacion.npy"); y = np.load("labels_maduras.npy")
cols = ["ret1", "ret5", "ret20", "ret63", "vol20", "vol63", "rsi14", "atr_c"]
modelo = entrenar_ridge(X, y, alpha=1.0, orden_features=cols,
                        hash_datos="sha256:<hash de X,y>", semilla=7)
pred = predecir_ridge(modelo, X_hoy, orden_columnas=cols)
senal = np.where(pred > 0.001, 1, np.where(pred < -0.001, -1, 0))

La API de plataforma (puerto 18300, /platform/v1/...) es la puerta de control de estos jobs y del registro de entrenamiento.

Riesgos, límites y qué no hace

  • No implementada como estrategia; Ridge y CatBoost sí corren como motores.
  • La evidencia G4 es sintética: acredita el motor, no la predictibilidad del mercado.
  • Fuga temporal: sin purga y embargo el resultado es inválido; el escalador también debe entrenarse solo con la formación.
  • Volumen: normalizar solo con volumen válido; no imputar.
  • Coste: CatBoost con 500 iteraciones y profundidad 6 por refit, 24 o 18 candidatos, cientos de refits… hay que medirlo antes de barrer.
  • Umbral fijo: threshold está en unidades de retorno logarítmico; no se adapta a la volatilidad.

Resumen

  • Label ln(O[t+h+1]/O[t+1]) con fecha de disponibilidad; features causales; Ridge exacto; señal con umbral.
  • Q08 (Ridge) especificada, no implementada; Q08B (CatBoost) optional_dependency_missing en el banco.
  • Los motores corren: G4 PASS con Ridge (RMSE 0,017, paridad de recarga) y CatBoost 1.2.8 (RMSE 1,33) sobre sintético.
  • Lo que falta es la política walk-forward que los conecte al ledger y su paridad.

Para practicar

  1. Repite el ejemplo didáctico con alpha = 10. ¿Qué señal da x = 1.5?
  2. Con h = 5, ¿cuántas barras hay que purgar entre el final del entrenamiento y el inicio de la validación? Justifícalo con label_available.
  3. ¿Por qué el RMSE de CatBoost en G4 es mucho mayor que el de Ridge? ¿Qué problema sintético invertiría el resultado?
  4. Explica por qué separar Q08 y Q08B hace más honesta la matriz de capacidades.