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
alphay 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 = 0se opera cada ruido de la predicción.
El modelo matemático¶
Label (retorno de apertura a apertura, ejecutable):
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):
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 |
pronosticar → ForecastDistribution 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.001 → señal +1. Con x = 0.5: pred = 0.00062 → 0 (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:
thresholdestá 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_missingen 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¶
- Repite el ejemplo didáctico con
alpha = 10. ¿Qué señal dax = 1.5? - Con
h = 5, ¿cuántas barras hay que purgar entre el final del entrenamiento y el inicio de la validación? Justifícalo conlabel_available. - ¿Por qué el RMSE de CatBoost en G4 es mucho mayor que el de Ridge? ¿Qué problema sintético invertiría el resultado?
- Explica por qué separar Q08 y Q08B hace más honesta la matriz de capacidades.