Saltar a contenido

Un backtest de principio a fin

Qué vas a aprender

  • A recorrer el ciclo completo de trabajo con una estrategia: elegir datos → instanciar → lanzar un backtest con los dos motores → leer la respuesta → diagnosticar paridad → barrer parámetros → interpretar.
  • A leer cada bloque de la respuesta de POST /v1/backtest: signature, window_resolved, results[], parity, speedup, cache y los artefactos en disco.
  • A no sacar conclusiones equivocadas de un barrido.

Todas las cifras son reales

Las peticiones y respuestas de este capítulo son los ficheros de examples/api/ (regenerados el 2026-09-17 contra el servidor real con finaz/bench:0.2.0). Los tiempos son medidas de esa ejecución y variarán en tu máquina; lo que no cambia es la forma de la respuesta ni, con los mismos datos, las cifras económicas.

0. El plan

flowchart LR
    D[Paso 1: Elegir datos<br/>NVDA 5min 2024] --> I[Paso 2: Instanciar<br/>P01 20/50]
    I --> B[Paso 3: POST /v1/backtest<br/>engine = both]
    B --> R[Paso 4: Leer respuesta<br/>métricas, fills, paridad]
    R --> P[Paso 5: POST /v1/parity<br/>si algo no cuadra]
    R --> S[Paso 6: POST /v1/sweep<br/>4×4 → job]
    S --> J[Paso 7: GET /v1/jobs/id/results]
    J --> X[Paso 8: Interpretar<br/>con prudencia]

Necesitas bench-api levantado (ver Primeros pasos con la API de backtesting). En los ejemplos:

BASE=http://localhost:8000
cd ~/FinazTradingEngine    # para usar examples/api/*.json

1. Elegir los datos

Decisión Elección Por qué
Activo NVDA (twelvedata, XNAS) Historia intradía desde 2020, volumen real
Timeframe 5min Suficientes barras (≈19.500 en un año) para que los motores se diferencien en tiempo
Ventana 2024-01-01 → 2025-01-01 Un año natural
Modo reset_flat Empieza sin posición, sin arrastrar estado de antes

P01 sirve en los 7 timeframes de NVDA (docs/DATOS_POR_ESTRATEGIA.md). Con 1d el mismo año solo tiene ≈250 barras; lo veremos al final.

2. Instanciar la estrategia

curl -sS -X POST "$BASE/v1/strategies" -H 'Content-Type: application/json' \
  -d '{"schema_version":"v1","payload":{"template_id":"P01","params":{"fast":20,"slow":50},
       "name":"EMA cross 20/50 base","slug":"ema-cross-20-50","tags":["fase-A"]}}' \
  | jq '.payload | {strategy_id, params, already_existed}'
import requests
BASE = "http://localhost:8000"
r = requests.post(f"{BASE}/v1/strategies", json={
    "schema_version": "v1",
    "payload": {"template_id": "P01", "params": {"fast": 20, "slow": 50},
                "name": "EMA cross 20/50 base", "slug": "ema-cross-20-50", "tags": ["fase-A"]}})
print(r.json()["payload"]["strategy_id"])   # P01-dc9260

Respuesta (real, 06a): "strategy_id": "P01-dc9260", "already_existed": false. Si la repites, obtienes el mismo id con already_existed: true: no se crea un duplicado.

3. Lanzar el backtest con los dos motores

La petición real (01_backtest_p01_both.request.json), campo a campo:

{
  "schema_version": "v1",
  "payload": {
    "strategy": { "strategy_id": "P01-dc9260" },
    "data": {
      "asset_id": "NVDA",
      "timeframe": "5min",
      "window": { "start": "2024-01-01T00:00:00Z", "end": "2025-01-01T00:00:00Z", "mode": "reset_flat" }
    },
    "execution": {
      "lane": "SIM-S", "contract_version": "1", "initial_cash": 100000,
      "target_lots": [-1, 0, 1], "cost_scenario": "hypothetical_5bps"
    },
    "engine": "both",
    "cache":   { "cache_mode": "WARM_FEATURES", "write_through": true },
    "output":  { "output_mode": "metrics", "include_timings": true, "include_parity": true, "tail_rows": 5 },
    "profile": { "profile": "one_core", "repetitions": { "warmup": 3, "measured": 20 } },
    "engine_options": {
      "vectorta": { "kernel": "scalar", "use_batch": true, "streaming_provider": "vectorta_stream" },
      "nautilus": { "adapter": "NT_FEATURES", "account_type": "MARGIN", "indicator_provider": "native",
                    "use_message_queue": false, "log_level": "ERROR" }
    },
    "budget": { "max_wall_seconds": 3600, "max_memory_bytes": 1073741824 }
  }
}
Bloque Lo esencial
strategy la instancia, por id
data activo, timeframe, ventana
execution contrato SIM-S: caja 100.000, lotes {−1,0,+1}, escenario de costes de 5 pb
engine both = VectorTA + ledger y Nautilus, en procesos aislados, para comparar
profile.repetitions 3 calentamientos + 20 medidas: los tiempos son medianas, no una sola ejecución
budget si el caso no cabe, la API responde 202 con un job (D-33)
curl -sS -X POST "$BASE/v1/backtest" -H 'Content-Type: application/json' \
  -d @examples/api/01_backtest_p01_both.request.json \
  | jq '.payload | {status, bars: .window_resolved.bars, parity: .parity.verdict, speedup: .speedup.value}'
import json, requests
body = json.load(open("examples/api/01_backtest_p01_both.request.json"))
p = requests.post(f"{BASE}/v1/backtest", json=body, timeout=600).json()["payload"]
print(p["status"], p["window_resolved"]["bars"], p["parity"]["verdict"])
for r in p["results"]:
    print(r["adapter"], r["run_metrics"]["final_equity"], r["repetitions"]["median_wall_ns"])

4. Leer la respuesta

La respuesta (01_backtest_p01_both.response.json) pesa 14 KB. Vamos bloque a bloque.

4.1 status y signature: qué se ha ejecutado exactamente

"status": "PASS",
"signature": {
  "strategy_id": "P01-dc9260", "template_id": "P01",
  "params": { "fast": 20, "slow": 50 },
  "data_hash": "e8b5fc3612fd944a692af21807706f665fb5bec4f1251d1652fbeaabea6595b0",
  "formula_version": "1.0.0",
  "execution_contract": {
    "lane": "SIM-S", "initial_cash": 100000.0, "target_lots": [-1, 0, 1],
    "auto_liquidate_at_end": false,
    "costs": { "fee_rate": 0.0005, "half_spread_ticks": 0, "slippage_ticks": 0 },
    "engine_binding": { "nautilus": { "bar_execution": false,
      "next_open_mechanism": "synthetic_open_quote_at_close_ts_plus_1ns" } }
  },
  "asset_id": "NVDA", "timeframe": "5min"
}

La firma es la huella del experimento: si dos resultados no comparten data_hash, formula_version y contrato de ejecución, no son comparables. Fíjate en que hypothetical_5bps se ha resuelto a fee_rate: 0.0005 y en cómo Nautilus consigue el next-open: una quote sintética de apertura 1 ns después del cierre.

4.2 window_resolved: la ventana real

"window_resolved": { "start": "2024-01-02T14:30:00Z", "end": "2024-12-31T21:00:00Z",
                     "bars": 19550, "warmup_bars": 49, "first_decision_index": 49 }

Pediste desde el 1 de enero; la primera barra de sesión es el 2 de enero a las 09:30 de Nueva York (14:30 UTC). Las primeras 49 barras son calentamiento: la EMA50 no es válida hasta la barra 49.

4.3 results[]: un resultado por motor

Campo VectorTA (VTA_CPU_LEDGER) Nautilus (NT_FEATURES)
n_bars 19.550 19.550
n_intents / n_fills 328 / 328 328 / 328
final_equity 100.009,38 100.009,38
final_cash 100.143,67 100.143,67
final_position −1 −1
costs_total 35,05 35,05
max_drawdown 0,0002946 0,0002946
sharpe_daily "<medido>" "<medido>"
mediana de tiempo (20 medidas) 8,33 ms 986 ms
bars_per_second 42,7 M 29,4 k

Lectura económica:

  • La estrategia termina con +9,38 sobre 100.000 tras pagar 35,05 de comisiones: el resultado bruto fue ≈ +44,43. Opera una sola acción (lotes de ±1), así que las cifras absolutas son pequeñas: lo que se mide aquí es el motor, no una cartera real.
  • 328 fills en un año de 5 minutos: más de uno por día. Con 5 pb por fill, los costes se comen casi el 80 % del bruto.
  • final_position = −1: termina corto y no se liquida (auto_liquidate_at_end: false); se valora al último cierre.
  • sharpe_daily = "<medido>" no es un número: es el marcador literal de "el runner no lo mide todavía". Si lo ves en un informe como si fuera una cifra, es un error.

Otros bloques de cada resultado:

"feature_providers": {
  "ema_fast": { "provider": "vectorta", "provider_version": "0.2.8", "primitive": "ema",
                "variant": "vta_running_mean_masked", "kernel": "scalar", "first_valid_index": 19 },
  "ema_slow": { "provider": "vectorta", "primitive": "ema", "first_valid_index": 49,  }
},
"tables": {
  "fills": { "rows": 328, "checksum": "3d8554f9d550490a",
             "path": "results/runs/NVDA_5min_P01-dc9260_VTA_CPU_LEDGER-…/fills.parquet" },
  
}
  • En NT_FEATURES las EMA llegan precalculadas desde VectorTA ("shared_from": "vectorta"): el carril compara la ejecución, no la semilla del indicador nativo.
  • El checksum de fills es idéntico en los dos motores (3d8554f9d550490a), y también el de positions y equity. El de intents difiere porque cada carril los serializa a su manera; lo que se compara son las señales, capa a capa.
  • stage_timings desglosa 18 etapas. En VectorTA manda metrics_ns (7,7 ms); en Nautilus, simulate_ns (664 ms) más convert_native_ns (31 ms) y load_engine_ns (24 ms).

4.4 parity: ¿dicen lo mismo los dos motores?

"parity": {
  "verdict": "PASS",
  "compared": { "a": "VTA_CPU_LEDGER", "b": "NT_FEATURES" },
  "tolerances_used": { "features": { "ema_fast": { "rtol": 1e-10, "atol": 1e-12 },  },
                       "signals": "exact", "fills": "exact", "equity": "exact_minor_units" },
  "comparisons": [
    { "what": "features", "from_index": 49, "verdict": "PASS", "n_compared": 39032, "max_abs_diff": 0.0 },
    { "what": "signals",  "from_index": 49, "verdict": "PASS", "n_compared": 19501 },
    { "what": "fills", "fields": ["signal_index","fill_index","event_ts_ns","side","qty","price_ticks","commission_minor"],
      "verdict": "PASS", "n_compared": 328 },
    { "what": "equity", "mode": "quantized", "verdict": "PASS", "n_compared": 19550, "max_abs_diff_minor": 0 }
  ],
  "first_divergence": null
}

La paridad se comprueba capa a capa: features (con tolerancia) → señales (exactas) → fills (exactos en lado, cantidad, precio en ticks, comisión en céntimos) → equity (al céntimo). Aquí las 328 órdenes coinciden campo a campo y la equity no difiere ni un céntimo en ninguna de las 19.550 barras.

Regla de oro: primero la paridad, luego el tiempo

Mira parity.verdict antes que cualquier tiempo. Un motor 100 veces más rápido que da otro resultado no es más rápido: está haciendo otra cosa.

4.5 speedup: solo con las nueve puertas en verde

"speedup": {
  "published": true,
  "value": 118.40757420272521,
  "definition": "median_wall_nautilus / median_wall_vta_pipeline",
  "gates": { "parity": "PASS", "same_data_hash": true, "same_formula_version": true,
             "same_candidate_list": true, "same_execution_contract": true,
             "same_capital_policy": true, "same_cache_mode": true,
             "same_output_mode": true, "same_host_fingerprint": true },
  "caveat": "Nautilus procesa además la quote de apertura sintética: ver economic_bars vs effective_events.",
  "interpretation": "Un valor > 1 favorece al pipeline VectorTA + ledger para este caso concreto."
}

El pipeline VectorTA + ledger es ≈118 veces más rápido que Nautilus en este caso. La advertencia es honesta: Nautilus procesa 39.099 eventos efectivos (barras + quotes sintéticas) para 19.550 barras económicas.

4.6 cache y artefactos

"cache": { "features_hits": [], "features_misses": ["ema:20", "ema:50"], "result_hit": false,
           "explanation": "WARM_FEATURES: la caché de features no está cableada al runner de WP-06b; …" },
"run_id": "NVDA_5min_P01-dc9260_VTA_CPU_LEDGER-4a894cc067c94483-20260917T151218071527091",
"artifacts_dir": "results/runs/NVDA_5min_P01-dc9260_VTA_CPU_LEDGER-4a894cc067c94483-20260917T151218071527091/"

La caché de features aún no está conectada al runner (desviación declarada DEV-06b-01), así que todo cuenta como miss: la API no inventa aciertos. En artifacts_dir quedan intents.parquet, fills.parquet, positions.parquet, equity.parquet y run_metrics.json: un run es inmutable y se puede auditar después.

import polars as pl
d = "results/runs/NVDA_5min_P01-dc9260_VTA_CPU_LEDGER-4a894cc067c94483-20260917T151218071527091/"
fills = pl.read_parquet(d + "fills.parquet")
print(fills.head())          # signal_index, fill_index, side, qty, price_ticks, commission_minor…

5. Si algo no cuadra: POST /v1/parity

engine=both te dice si hay divergencia. POST /v1/parity te dice dónde: compara dos "lados" (incluso dos adaptadores del mismo motor) y devuelve la primera divergencia con ±5 barras de contexto.

{
  "schema_version": "v1",
  "payload": {
    "strategy": { "strategy_id": "P01-dc9260" },
    "data": { "asset_id": "NVDA", "timeframe": "5min",
              "window": { "start": "2024-01-01T00:00:00Z", "end": "2025-01-01T00:00:00Z", "mode": "reset_flat" } },
    "execution": { "lane": "SIM-S", "contract_version": "1", "initial_cash": 100000, "cost_scenario": "zero" },
    "sides": [
      { "label": "A", "engine": "vectorta", "engine_options": { "vectorta": { "kernel": "scalar", "use_batch": true } } },
      { "label": "B", "engine": "nautilus", "engine_options": { "nautilus": { "adapter": "NT_INTENT_REPLAY", "account_type": "MARGIN" } } }
    ],
    "tolerances": { "features_rtol": 1e-10, "features_atol": 1e-12, "equity_quantum": 0.01 },
    "context_bars": 5
  }
}

Respuesta ilustrativa del contrato

examples/api/ no incluye una respuesta real de /v1/parity. docs/API.md §6.10 documenta la forma con un caso de divergencia (valores medidos sustituidos por "<medido>"): status: "PARITY_FAIL", first_divergence.layer: "signals", bar_index: 57, contexto de las barras 52–58 y un diagnosis que atribuye la diferencia a semillas de EMA distintas entre proveedores. En ese caso speedup.published = false con reason: "NO_SPEEDUP_WITHOUT_PARITY_PASS".

6. Barrer parámetros: POST /v1/sweep

Un barrido 4×4 sobre la rejilla del catálogo (03_sweep_p01_4x4.request.json, resumido):

{
  "payload": {
    "base_strategy_id": "P01-dc9260",
    "grid": { "fast": [5, 10, 20, 40], "slow": [50, 100, 150, 200] },
    "candidate_policy": { "drop_invalid": true, "drop_duplicates": true, "persist_before_run": true,
                          "register_strategies": true, "name_template": "{template_name} {fast}/{slow}", "seed": 1729 },
    "data": { "asset_id": "NVDA", "timeframe": "5min", "window": {  "mode": "reset_flat" } },
    "execution": {  "cost_scenario": "hypothetical_5bps" },
    "engine": "vectorta",
    "cache": { "cache_mode": "OPT_FULL" },
    "profile": { "profile": "outer_parallel" }
  }
}

La respuesta llega enseguida con 202 y un job (real):

{
  "job_id": "job_AFD547BD23",
  "status": "QUEUED",
  "requested_candidates": 16,
  "effective_candidates": 16,
  "dropped": { "invalid": 0, "duplicates": 0 },
  "candidates_path": "results/jobs/job_AFD547BD23/candidates.jsonl",
  "materialized_strategy_ids": ["P01-c829c1", "P01-81f6fc", , "P01-dc9260", "P01-09dd21", ],
  "factorization_plan": { "distinct_features": 8,
    "note": "16 puntos solicitados, 16 válidos y distintos (0 inválidos, 0 duplicados) y 8 curvas de feature distintas." },
  "resource_estimate": { "estimated_bar_candidate_evaluations": 312800,
                         "policy_max_estimated_bar_candidate_evaluations_default": 100000000 },
  "poll": "/v1/jobs/job_AFD547BD23"
}
  • Cada candidato se materializa como instancia con su id: P01-dc9260 (20/50) y P01-09dd21 (20/100) aparecen porque ya existían con esos parámetros; no se duplican.
  • 16 candidatos, 8 curvas EMA: el plan de factorización calcula cada EMA una vez.

Seguir el job

stateDiagram-v2
    [*] --> QUEUED
    QUEUED --> RUNNING
    RUNNING --> COMPLETED
    RUNNING --> COMPLETED_WITH_FAILURES
    RUNNING --> FAILED
    QUEUED --> CANCELLED: DELETE /v1/jobs/{id}
    RUNNING --> CANCELLED: DELETE /v1/jobs/{id}
JOB=job_AFD547BD23
curl -sS "$BASE/v1/jobs/$JOB" | jq '.payload | {status, progress}'
curl -sS "$BASE/v1/jobs/$JOB/results" | jq '.payload.candidates[] | {name, params, eq: .run_metrics.final_equity}'
import time
job = "job_AFD547BD23"
while (st := requests.get(f"{BASE}/v1/jobs/{job}").json()["payload"]["status"]) in ("QUEUED", "RUNNING"):
    time.sleep(1)
res = requests.get(f"{BASE}/v1/jobs/{job}/results").json()["payload"]

Resultados reales del barrido (NVDA 5min 2024, 5 pb)

fast \ slow 50 100 150 200
5 99.929,44 (660 fills) 100.030,42 (457) 100.046,22 (315) 100.055,22 (268)
10 99.927,52 (500) 100.033,99 (303) 100.033,45 (219) 100.015,12 (204)
20 100.009,38 (328) 100.064,26 (197) 100.039,07 (169) 100.044,14 (142)
40 100.061,57 (214) 100.057,89 (155) 100.041,51 (123) 100.035,73 (106)

quality: 16 completados, 0 fallidos, y denominator_includes_failures: true (un candidato fallido nunca se excluye para mejorar la media). Tiempo total 2,06 s; amortizado 128 ms por candidato.

7. Interpretar con prudencia

flowchart TD
    Q[¿20/100 es 'la mejor'?] --> A{¿Se eligió mirando<br/>los mismos datos?}
    A -- sí --> B[sí: es el máximo<br/>DENTRO de la muestra]
    B --> C[validar fuera de muestra<br/>walk-forward]
    Q --> D{¿La diferencia es<br/>relevante?}
    D --> E[+64 sobre 100.000<br/>con 1 acción: ruido]
    Q --> F{¿Cambia con el activo?}
    F --> G[KO 5min con 20/50:<br/>99.965,88]

Tres observaciones sobre la tabla:

  1. Menos operaciones, menos coste. Solo dos combinaciones quedan en pérdidas: 5/50 (660 fills, 70,43 de costes) y 10/50 (500 fills, 53,49). Las lentas operan 106–204 veces y pagan 11,93–22,38 de costes. En 5 minutos, el coste por transacción pesa mucho (cifras de examples/api/03_sweep_p01_4x4.results.response.json).
  2. El "mejor" es el máximo de 16 intentos sobre los mismos datos. Elegirlo así es exactamente el sobreajuste que explica el capítulo de walk-forward.
  3. Cambiar de activo cambia el signo. El mismo P01-dc9260 sobre KO 5min (04_backtest_p01_ko) termina en 99.965,88 con 389 fills y 24,46 de costes; paridad también PASS.

Derivar y comparar: 20/50 frente a 20/100

06c y 06d ejecutan con engine=both la base y su derivada. 20/100 (P01-09dd21): 197 fills, equity 100.064,26, costes 20,99, paridad PASS, speedup publicado 86,1. La derivada anunció en cache_forecast que reutilizaría ema_fast; el backtest informa honestamente de misses porque la caché no está cableada.

8. ¿Y en diario?

Con timeframe: "1d" el mismo año tiene unas 250 barras. El ejemplo 07_backtest_p01_queued_202 lanza P01 sobre NVDA 1d con un presupuesto imposible (max_memory_bytes: 1) para forzar la ruta D-33: en vez de 200 responde 202 con job_id y resource_estimate, y el resultado completo se publica en GET /v1/jobs/{id}/results (payload.backtest). Si el caso hubiera superado la política de recursos (10⁸ evaluaciones barra×candidato), la respuesta habría sido 507 RESOURCE_LIMIT sin recortar la ventana.

Resumen

  • El ciclo es: datos → instancia → backtest both → paridad → sweep → interpretación.
  • Lee la respuesta en este orden: statussignaturewindow_resolvedparity → métricas → speedup → artefactos.
  • P01 20/50 en NVDA 5min 2024: 328 fills, equity 100.009,38, costes 35,05, paridad exacta entre motores y speedup ≈118×.
  • Un barrido materializa una instancia por candidato y reutiliza curvas (16 candidatos, 8 EMA).
  • El mejor candidato de un barrido no es una conclusión: es una hipótesis que hay que validar fuera de muestra.

Para practicar

  1. Repite el backtest con "cost_scenario": "zero" y calcula cuánto del resultado se llevaban los costes.
  2. Cambia engine a "vectorta" y comprueba que la respuesta ya no trae bloque parity ni speedup.
  3. Lanza el barrido 4×4 y cancela el job con DELETE /v1/jobs/{id} mientras está en RUNNING. ¿Qué estado queda?
  4. Lee fills.parquet de un run y comprueba cuántas filas cumplen fill_index = signal_index + 1. Las que no, ¿caen tras una barra no operable (D-25)?
  5. Repite el barrido sobre KO. ¿Sigue ganando 20/100?