Saltar a contenido

Walk-forward: validar sin engañarse

Qué vas a aprender

  • Qué es el sobreajuste y por qué un único backtest sobre toda la historia no prueba nada.
  • Cómo funciona el esquema B10 del proyecto: formación 24 meses, validación 24 meses, avance 6 meses y 12 meses reservados.
  • Qué es el embargo y por qué, en Q03, se come la mitad de cada tramo de validación.
  • Cómo leer el summary.json real de los 14 pliegues de Q03 sobre NVDA–AMZN.
  • Qué guardas comprueban que no se ha mirado el futuro.

1. El problema: el examen con las respuestas delante

Imagina que preparas un examen con las preguntas del examen. Sacarás un 10, pero no sabes nada. Eso es un backtest en el que eliges parámetros mirando los mismos datos con los que luego mides el resultado: el número es alto, pero no dice nada del futuro.

El walk-forward simula lo que harías de verdad:

  1. Ajustas con los datos que tienes hasta hoy (formación / train).
  2. Operas los meses siguientes sin tocar nada (validación).
  3. Avanzas el calendario y repites.
  4. Guardas un último tramo que nadie mira hasta el final (reserva).
flowchart LR
    T1[Formación 1] --> V1[Validación 1]
    T2[Formación 2<br/>+6 meses] --> V2[Validación 2]
    T3[Formación 3<br/>+6 meses] --> V3[Validación 3]
    V3 -.-> DOTS[… 14 pliegues]
    DOTS -.-> R[Reserva 12 meses<br/>NO se simula]

2. El esquema B10

El escenario B10 del plan (ProjectPlan.md §6.2 y decisión D-20) se implementa en finazbench/bench/walk_forward.py. Los tamaños se fijan en meses de calendario y el módulo los convierte con 21 sesiones por mes:

Tramo Meses Sesiones Para qué
Formación (train) 24 504 datos disponibles antes de validar
Validación 24 504 tramo operado "a ciegas"
Avance (step) 6 126 cuánto se desplaza cada pliegue
Reserva final 12 252 no se simula: se deja para el final

La regla que construye los pliegues:

start = 0
while start + train + validation <= n_sessions - reserve:
    validation_start = start + train
    validation_end   = validation_start + validation
    effective_start  = validation_start + formation   # tras el embargo
    folds.append(...)
    start += step

Con la historia de NVDA–AMZN (2.941 sesiones, 2015-01-02 → 2026) y la reserva empezando en la sesión 2.689 (2025-09-12), salen 14 pliegues.

¿Por qué 24 meses de validación y no 6?

El diseño original proponía 24/6/6. Con formation = 252 sesiones, todos los bloques de un tramo de 6 meses tendrían su ventana de formación cruzando la frontera y quedarían embargados: la validación quedaría vacía. La especificación de Q03 (§8.3) lo detectó y la dirección decidió (D-20) 24 meses de validación, que dejan 504 − 252 = 252 sesiones útiles por pliegue. La regla general: los tamaños de diseño se ajustan a la historia y la frecuencia; no se inventa disponibilidad de datos para cumplirlos.

3. El embargo

Q03 reajusta su modelo cada 21 sesiones usando las 252 anteriores. Si un bloque empieza justo después de la frontera formación/validación, su ventana de formación incluye datos de los dos lados: parte del "examen" se ha usado para estudiar.

La regla literal (Q03 §8.3), implementada en block_embargoed:

un bloque de validación que empieza en b está embargado si  b − formation < inicio_validación

Los bloques embargados se simulan (para que el estado sea continuo) pero no cuentan en las métricas del tramo.

gantt
    dateFormat YYYY-MM-DD
    axisFormat %Y
    title Pliegue 0 de Q03 NVDA–AMZN
    section Pliegue 0
    Formación (504 ses.)          :done, f0, 2015-01-02, 2016-12-30
    Validación embargada (252)    :crit, e0, 2017-01-03, 2018-01-03
    Validación efectiva (252)     :active, v0, 2018-01-03, 2019-01-03
    section Pliegue 1 (+6 meses)
    Formación                     :done, f1, 2015-07-06, 2017-07-05
    Validación embargada          :crit, e1, 2017-07-05, 2018-07-05
    Validación efectiva           :active, v1, 2018-07-05, 2019-07-05

Fechas reales del summary.json (train_start_session, validation_start_session, effective_start_session, effective_end_session).

4. Q03 NVDA–AMZN: el resultado real

Fichero: results/walk_forward/Q03-NVDA-AMZN-B10/summary.json (con el detalle por pliegue en folds.parquet). Parámetros: los defaults de Q03 (formation 252, z_window 60, entry 2,0, exit 0,5, stop 4,0, max_hold 20, refit_bars 21), 5 pb por fill, caja inicial 100.000, price_basis = raw_split_adjusted.

Cabecera

{
  "run_id": "Q03-NVDA-AMZN-B10",
  "strategy": "Q03",
  "pair": "NVDA/AMZN",
  "calendar_version": "XNYS-1999-2026.v1",
  "config": { "train_sessions": 504, "validation_sessions": 504, "step_sessions": 126,
              "reserve_sessions": 252, "formation": 252, "refit_bars": 21,
              "train_months": 24, "validation_months": 24, "step_months": 6,
              "reserve_months": 12, "sessions_per_month": 21 },
  "n_sessions": 2941,
  "reserve_start": 2689,
  "reserve_start_session": 20250912,
  "n_folds": 14,
  "guards": { "no_coint_on_test": true, "max_train_cutoff": 2625,
              "reserved_sessions_simulated": 0 },
  
}

Los 14 pliegues

Pliegue Validación efectiva Bloques usados Admitidos Entradas Fills Costes PnL
0 2018-01-03 → 2019-01-03 12 4 3 63 337,99 +13.767,32
1 2018-07-05 → 2019-07-05 12 3 3 63 337,99 +13.767,32
2 2019-01-04 → 2020-01-03 12 1 1 105 469,08 −8.065,53
3 2019-07-08 → 2020-07-06 12 1 1 105 469,08 −8.065,53
4 2020-01-06 → 2021-01-04 12 0 0 105 469,08 0,00
5 2020-07-07 → 2021-07-06 12 0 0 105 469,08 0,00
6 2021-01-05 → 2022-01-03 12 0 0 42 115,19 0,00
7 2021-07-07 → 2022-07-06 12 0 0 42 115,19 0,00
8 2022-01-04 → 2023-01-04 12 0 0 0 0,00 0,00
9 2022-07-07 → 2023-07-07 12 0 0 0 0,00 0,00
10 2023-01-05 → 2024-01-05 12 2 1 8 100,37 −770,05
11 2023-07-10 → 2024-07-09 12 5 3 42 308,59 −8.364,27
12 2024-01-08 → 2025-01-07 12 3 2 42 308,59 −7.594,22
13 2024-07-10 → 2025-07-11 12 1 1 80 410,64 −2.126,29

Totales

Campo Valor
blocks_used / blocks_embargoed 168 / 168
blocks_admitted 20
entry_count / exit_count 15 / 8
n_fills 802
pnl_total −7.451,25 (pata Y NVDA −8.717,27; pata X AMZN +1.266,02)
costs_total 3.910,87
cache.hits / misses 504 / 0

5. Cómo leer este resumen

5.1 Primero las guardas

Antes de mirar el PnL, comprueba que el experimento es limpio:

Guarda Valor Qué garantiza
no_coint_on_test true Ningún test de cointegración usó datos de la reserva
max_train_cutoff 2.625 < reserve_start 2.689 El último dato usado para ajustar está antes de la reserva
reserved_sessions_simulated 0 La reserva no se ha tocado

Si alguna de estas fallase, el resto del fichero no valdría nada.

5.2 Después la admisión

De 168 bloques válidos, solo 20 se admitieron (Engle–Granger con p < 0,05 y β en [0,2, 5]). Desde 2020 hasta 2023 ningún bloque pasa el test: NVDA y AMZN dejaron de comportarse como pareja cointegrada. La estrategia hace exactamente lo que debe: no opera. Eso no es un fallo; es el filtro funcionando.

Fills sin entradas

Algunos pliegues muestran fills y costes con 0 entradas (pliegues 4–7). En walk_forward.py (función que evalúa un pliegue) cada pliegue se simula entero sobre [train_start, validation_end), es decir, formación + validación embargada + validación efectiva. n_fills y costs_total salen directamente del ledger de esa simulación completa (resultado.n_fills, resultado.costs_total_minor), así que incluyen operaciones de la formación y del tramo embargado. En cambio, entry_count, exit_count, pnl_total, pnl_y y pnl_x se recortan a la validación efectiva ([effective_start, effective_end)). Las dos familias de cifras no cubren el mismo periodo y no deben restarse ni dividirse entre sí.

5.3 Por último el PnL, con sus reservas

El PnL agregado es negativo (−7.451,25 sobre 100.000 de caja por pliegue). Los costes (3.910,87) no son comparables con ese PnL: cubren la simulación completa de cada pliegue, incluidas formación y tramo embargado (ver la nota anterior), mientras que el PnL solo cubre la validación efectiva. Hay que leerlo con cuidado:

Los totales suman pliegues que se solapan

Cada tramo efectivo dura 12 meses y el avance es de 6: pliegues consecutivos comparten medio año. totals es una suma directa de las filas por pliegue (así lo calcula walk_forward.py), de modo que un mismo periodo de mercado puede contar dos veces (y n_fills/costs_total, que cubren toda la simulación del pliegue, se solapan todavía más). Los pares 0/1 y 2/3 muestran cifras idénticas de entradas, fills, costes y PnL: es compatible con que toda la actividad caiga en el medio año que comparten, pero el summary.json no lo desglosa y el manual no lo ha verificado barra a barra. El fichero no publica un agregado sin solapes. Úsalo para comparar configuraciones entre sí, no como "rentabilidad de la estrategia".

Y las reservas que el propio fichero declara en warnings (resumidas):

Aviso Significado
FORMATION_OVERLAP Dos modelos consecutivos comparten 231 de 252 barras de formación (91,7 %): sus p-valores no son independientes. Contar admisiones como pruebas independientes sobreestima la evidencia.
EMBARGO Los bloques cuya formación cruza la frontera se calculan pero no cuentan.
PRESELECCION La pareja la eligió el plan: el resultado acredita que el algoritmo funciona, no rentabilidad.
ADJCLOSE_NO_PIT / SUPERVIVENCIA Precios ajustados hacia atrás y activos que sabemos que sobrevivieron.
PRICE_ROUNDING Precios ajustados redondeados a 0,01 porque el ledger cuenta en enteros (D-16).
NT_ONLINE En el carril online, el ajuste y la cointegración se ejecutan dentro del bucle de eventos: su coste no es comparable con un carril que precalcula.

6. Por qué esto evita el sobreajuste

flowchart TD
    A[¿El parámetro se eligió<br/>mirando la validación?] -- sí --> X[sobreajuste]
    A -- no --> B[¿El modelo usó datos<br/>posteriores a la decisión?]
    B -- sí --> Y[fuga de información]
    B -- no --> C[¿Se tocó la reserva?]
    C -- sí --> Z[ya no hay prueba final limpia]
    C -- no --> OK[resultado honesto<br/>aunque sea malo]
  • Los parámetros son los defaults del catálogo, fijados antes de ver los datos.
  • El modelo de cada bloque se ajusta solo con sesiones anteriores y queda congelado durante el bloque.
  • El embargo elimina los bloques contaminados por la frontera.
  • La reserva queda intacta para una prueba final.
  • Los fracasos cuentan: los pliegues sin admisiones y los de PnL negativo están en la tabla, no se filtran.

7. Cómo reproducirlo

cd ~/FinazTradingEngine
python -m finazbench.bench.walk_forward --pair NVDA/AMZN --fee-bps 5 --initial-cash 100000
# salida: results/walk_forward/<run_id>/{folds.parquet, summary.json}

# Leer el resumen
jq '{n_folds, guards, totals}' results/walk_forward/Q03-NVDA-AMZN-B10/summary.json

# Detalle por pliegue
python - <<'PY'
import polars as pl
df = pl.read_parquet("results/walk_forward/Q03-NVDA-AMZN-B10/folds.parquet")
print(df.select("fold", "effective_start_session", "n_blocks_admitted", "pnl_total"))
PY

La otra pareja preseleccionada es MSFT/INTC. Con cualquier otra el módulo avisa: no está en PRESELECTED_PAIRS.

Resumen

  • Un backtest sobre toda la historia con parámetros elegidos a posteriori no prueba nada: hace falta validación fuera de muestra.
  • B10 = formación 24 / validación 24 / avance 6 / reserva 12 meses (504/504/126/252 sesiones).
  • El embargo aparta los bloques cuya formación cruza la frontera; con formation = 252 quedan 252 sesiones útiles por pliegue.
  • Q03 NVDA–AMZN: 14 pliegues, 20 de 168 bloques admitidos, pnl_total sumado −7.451,25 (ventanas efectivas solapadas) y costs_total 3.910,87 (simulación completa de cada pliegue, otro periodo); las guardas están en verde.
  • Lee siempre en este orden: guardas → admisión → PnL → reservas.

Para practicar

  1. Con 2.941 sesiones y 252 de reserva, calcula a mano cuántos pliegues salen con avance 126. ¿Coincide con 14?
  2. ¿Cuántos pliegues saldrían con validación de 6 meses (126 sesiones)? ¿Cuántas sesiones útiles tendría cada uno tras el embargo?
  3. Explica por qué los pliegues 4–9 tienen PnL 0.
  4. Ejecuta el walk-forward sobre MSFT/INTC y compara blocks_admitted.
  5. Propón una forma de agregar el PnL sin contar dos veces los tramos solapados.