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,cachey 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:
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) |
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_FEATURESlas EMA llegan precalculadas desde VectorTA ("shared_from": "vectorta"): el carril compara la ejecución, no la semilla del indicador nativo. - El checksum de
fillses idéntico en los dos motores (3d8554f9d550490a), y también el depositionsyequity. El deintentsdifiere porque cada carril los serializa a su manera; lo que se compara son las señales, capa a capa. stage_timingsdesglosa 18 etapas. En VectorTA mandametrics_ns(7,7 ms); en Nautilus,simulate_ns(664 ms) másconvert_native_ns(31 ms) yload_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.
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) yP01-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}
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:
- 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). - 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.
- Cambiar de activo cambia el signo. El mismo
P01-dc9260sobre 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:
status→signature→window_resolved→parity→ 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¶
- Repite el backtest con
"cost_scenario": "zero"y calcula cuánto del resultado se llevaban los costes. - Cambia
enginea"vectorta"y comprueba que la respuesta ya no trae bloqueparitynispeedup. - Lanza el barrido 4×4 y cancela el job con
DELETE /v1/jobs/{id}mientras está enRUNNING. ¿Qué estado queda? - Lee
fills.parquetde un run y comprueba cuántas filas cumplenfill_index = signal_index + 1. Las que no, ¿caen tras una barra no operable (D-25)? - Repite el barrido sobre KO. ¿Sigue ganando 20/100?