Saltar a contenido

Backtest por API: POST /v1/backtest

Qué vas a aprender

  • Cómo se referencia una estrategia: por strategy_id o en línea con template_id + params.
  • Cómo crear y derivar instancias (POST /v1/strategies, POST /v1/strategies/{id}/derive) antes de lanzarlas.
  • Cada bloque del payload de un backtest: strategy, data (ventana y modo), execution (lane, costes, target_lots), engine, cache, output, profile, engine_options, budget.
  • Cómo leer la respuesta: signature, window_resolved, results[] con run_metrics, stage_timings, tables; y los bloques parity, speedup y cache.
  • Un ejemplo real completo: P01 20/50 sobre NVDA 5min en 2024 con los dos motores.

1. La idea: una «orden de trabajo» completa

Un backtest por API es como encargar un estudio a un laboratorio. No basta con decir «prueba la estrategia de medias»: el laboratorio necesita saber qué estrategia exacta, sobre qué datos, con qué reglas de ejecución y costes, con qué máquina (motor), qué quieres que te devuelvan y cuánto estás dispuesto a esperar. Cada una de esas preguntas es un bloque del payload:

flowchart LR
    P[payload] --> S[strategy<br/>¿qué estrategia?]
    P --> D[data<br/>¿qué activo, frecuencia y ventana?]
    P --> E[execution<br/>¿qué contrato y costes?]
    P --> G[engine<br/>vectorta / nautilus / both]
    P --> C[cache<br/>¿qué se da por calculado?]
    P --> O[output<br/>¿cuánto detalle?]
    P --> F[profile<br/>¿cuántos núcleos y repeticiones?]
    P --> X[engine_options<br/>ajustes propios de cada motor]
    P --> B[budget<br/>¿cuánto tiempo/memoria? D-33]

Solo strategy, data y execution son obligatorios; engine vale vectorta por defecto y el resto tiene valores por defecto razonables.


2. Antes de lanzar: identidad de la estrategia

2.1 Id determinista, nombre libre

Una instancia se identifica por un hash de (template_id, formula_version, params completos y ordenados):

strategy_id = "<template_id>-<hash6>"      p. ej.  P01-dc9260

Consecuencia práctica: si tú y un compañero creáis por separado «EMA 20/50», obtenéis el mismo id sin coordinaros. El nombre (name) es para personas y se puede cambiar; el id nunca. Ids reales verificados por los tests del repositorio:

Plantilla + params strategy_id
P01 {fast:20, slow:50} P01-dc9260
P01 {fast:20, slow:100} P01-09dd21
P02 {period:14, lower:30, upper:70} P02-be06fa
P02 {period:14, lower:25, upper:75} P02-bc0dd2

Los params se completan antes del hash

{"fast":20} y {"fast":20,"slow":50} son la misma estrategia (se rellena con los defaults) y tienen el mismo id.

2.2 Crear y derivar

curl -sS -X POST "$BASE/v1/strategies" -H 'Content-Type: application/json' \
  -d @examples/api/06a_create_p01_20_50.request.json | jq '.payload | {strategy_id, name, params}'

Payload real de la petición:

{ "template_id": "P01", "params": { "fast": 20, "slow": 50 },
  "name": "EMA cross 20/50 base", "slug": "ema-cross-20-50",
  "description": "Punto de partida de la fase A sobre NVDA.", "tags": ["fase-A"] }

Respuesta real (recortada): {"strategy_id": "P01-dc9260", "name": "EMA cross 20/50 base", "params": {"fast": 20, "slow": 50}, "already_existed": false}. Repetirla devolvería already_existed: true, no un duplicado.

curl -sS -X POST "$BASE/v1/strategies/P01-dc9260/derive" -H 'Content-Type: application/json' \
  -d @examples/api/06b_derive_p01_20_100.request.json | jq '.payload'

La petición cambia una sola variable: "param_overrides": {"slow": 100}. Respuesta real (recortada):

{
  "strategy_id": "P01-09dd21",
  "derived_from": "P01-dc9260",
  "param_diff_vs_parent": { "slow": { "from": 50, "to": 100 } },
  "shared_features_with_parent": ["ema_fast"],
  "invalidated_features_vs_parent": ["ema_slow"],
  "cache_forecast": {
    "expected_feature_hits": ["ema_fast"],
    "expected_feature_misses": ["ema_slow"],
    "expected_result_hit": false,
    "explanation": "Cambiar ['slow'] invalida ['ema_slow']; ['ema_fast'] se reutiliza. Política y ledger se rehacen siempre."
  }
}

La Parte V cuenta el lado conceptual en Cómo se define una estrategia y un recorrido completo en Ejemplo completo.


3. La petición, campo a campo

Usamos la petición real examples/api/01_backtest_p01_both.request.json.

3.1 strategy — ¿qué estrategia?

"strategy": { "strategy_id": "P01-dc9260" }

Dos formas excluyentes (dar ambas ⇒ 422 STRATEGY_SELECTOR_AMBIGUOUS):

Forma Ejemplo Cuándo
Por id (canónica) {"strategy_id": "P01-dc9260"} La instancia ya existe.
En línea {"template_id": "P01", "params": {"fast": 20, "slow": 50}, "name": "…", "register": true} Pruebas rápidas; la API resuelve el id y, si register=true, la registra.

Con register: false no se persiste la instancia, pero la respuesta sigue llevando el strategy_id: el id se calcula, no se asigna.

3.2 data — ¿qué datos?

"data": {
  "asset_id": "NVDA", "assets": null, "timeframe": "5min",
  "window": { "start": "2024-01-01T00:00:00Z", "end": "2025-01-01T00:00:00Z",
              "mode": "reset_flat", "checkpoint_id": null }
}
Campo Valores Significado
asset_id / assets uno u otro Un activo, o un universo multiactivo (Q01–Q09). Ambos ⇒ 422 DATA_SELECTOR_AMBIGUOUS.
timeframe 1min, 2min, 5min, 15min, 30min, 1h, 1d Frecuencia de barra.
window.start / end RFC 3339 UTC o null Intervalo semiabierto [start, end) sobre el cierre de barra. null = límite del dataset.
window.mode reset_flat | continue Arranque plano o continuación desde checkpoint.
window.checkpoint_id string Obligatorio con continue.

Los tres significados de «cambiar el periodo»

En la conversación de trading «cambiar el periodo» puede ser tres cosas muy distintas, y la API las separa en tres campos:

Significado Campo Qué se invalida
Ventana de fechas data.window.start/end Con reset_flat, se reutilizan curvas; se rehacen política y contabilidad.
Frecuencia de barra (1min → 5min → 1d) data.timeframe Todo salvo los datos base: re-agregación, features, política, ledger.
Lookback del indicador (EMA20 → EMA50) strategy.params Solo la feature cuyo parámetro cambió.

reset_flat frente a continue:

reset_flat continue
Estado inicial Plano: sin posiciones ni órdenes Recuperado del checkpoint
Qué se resetea Caja a initial_cash, posiciones a 0 y todo el estado de política Nada
Requisito Ninguno Checkpoint compatible (mismo data_hash, formula_version y contrato) o 409 CHECKPOINT_INCOMPATIBLE

3.3 execution — el contrato SIM-S

"execution": {
  "lane": "SIM-S", "contract_version": "1", "initial_cash": 100000,
  "target_lots": [-1, 0, 1], "cost_scenario": "hypothetical_5bps", "costs": null
}
Campo Defecto Significado
lane Solo SIM-S en esta versión del contrato. SIM-R501 LANE_UNSUPPORTED.
contract_version "1": señal con la barra cerrada, fill a la apertura siguiente, posición inicial plana, sin liquidación final, redondeos explícitos.
initial_cash 100000 Capital inicial.
target_lots [-1, 0, 1] Objetivos discretos: corto, plano, largo. Un paso de +1 a −1 es una orden delta de −2.
cost_scenario Escenario con nombre: zero o hypothetical_5bps.
costs Costes explícitos. Dar ambos ⇒ 422 COSTS_AMBIGUOUS.

Costes explícitos y escenarios con nombre:

Campo de costs Significado
fee_rate Fracción del nocional por fill (0.0005 = 5 pb), redondeada al céntimo con ROUND_HALF_EVEN.
half_spread_ticks Medio spread adverso, en ticks, aplicado al precio.
slippage_ticks Deslizamiento adverso adicional, en ticks.
Escenario fee_rate half_spread_ticks slippage_ticks
zero 0 0 0
hypothetical_5bps 0.0005 0 0
fill_price = next_open + sign(delta_q) · (half_spread_ticks + slippage_ticks) · tick_size

Ejemplo de trading

Tu EMA rápida cruza por encima de la lenta al cierre de las 15:30. La señal es +1. No compras a ese cierre (sería ver el futuro de tu propio fill): compras a la apertura de la barra siguiente. Si venías corto (−1), la orden es de +2 lotes. Con hypothetical_5bps pagas 0,05 % del nocional de ese fill.

3.4 engine — ¿qué motor?

Valor Adaptador por defecto Para qué
vectorta VTA_CPU_LEDGER Consultas rápidas y barridos.
nautilus NT_FEATURES (configurable) Realismo de órdenes y ejecución.
both los dos, en procesos aislados Paridad y speedup. Único modo que produce el bloque parity.

Con both, el orden de ejecución se aleatoriza por parejas AB/BA con semilla 1729 y queda registrado en speedup.ab_order.

3.5 cache, output, profile

"cache":   { "cache_mode": "WARM_FEATURES", "write_through": true, "checkpoint_out": null },
"output":  { "output_mode": "metrics", "include_timings": true, "include_parity": true, "tail_rows": 5 },
"profile": { "profile": "one_core", "repetitions": { "warmup": 3, "measured": 20 } }
Bloque Campo clave Resumen
cache cache_mode Qué etapas entran en el reloj: COLD_PROCESS_FULL, WARM_DATA, WARM_FEATURES, INTENT_REPLAY, OPT_FULL, APPEND_CONTINUE.
output output_mode metrics (métricas + checksums), fills (+ intents y fills completos), full (+ posiciones, equity, modelos a Parquet).
output tail_rows 0..500 filas finales de equity inline para inspección humana.
profile profile one_core, outer_parallel o inner_parallel. Nunca workers>1 e internal_threads>1 a la vez (422 PROFILE_OVERSUBSCRIBED).
profile repetitions {"warmup": 3, "measured": 20} para casos cortos; null = una sola ejecución, sin P95.

Estado de la caché de features (DEV-06b-01)

En los ejemplos regenerados, la caché de features no está cableada al runner. Por eso cache.features_hits sale vacío aunque pidas WARM_FEATURES: declarar un hit que no se sirvió sería una cifra falsa. result_hit es siempre false, que es la promesa que sí se cumple.

3.6 engine_options — ajustes que no cambian el «qué»

"engine_options": {
  "vectorta": { "kernel": "scalar", "use_batch": true, "streaming_provider": "vectorta_stream" },
  "nautilus": { "adapter": "NT_FEATURES", "account_type": "MARGIN", "indicator_provider": "native",
                "fill_model": null, "latency_model": null, "use_message_queue": false, "log_level": "ERROR" }
}
Opción Nota
vectorta.kernel scalar siempre disponible; avx2/avx512422 ENGINE_OPTION_UNAVAILABLE con el wheel PyPI; auto cae en escalar sin avisar. La respuesta siempre dice el kernel realmente ejecutado.
nautilus.adapter NT_ONLINE (flujo online completo), NT_FEATURES (defecto: comparte features), NT_INTENT_REPLAY (solo reproducir decisiones).
nautilus.account_type CASH no admite cortos: con -1 en target_lots422 ENGINE_OPTION_ALTERS_CONTRACT.
nautilus.latency_model, fill_model Latencia ≠ 0 o fills probabilísticos alteran SIM-S ⇒ 422 ENGINE_OPTION_ALTERS_CONTRACT.
nautilus.log_level Por encima de ERROR la medida deja de ser comparable (LOGGING_AFFECTS_TIMING).

3.7 budget — y la decisión D-33

"budget": { "max_wall_seconds": 3600, "max_memory_bytes": 1073741824 }

Si el caso cabe: 200. Si no cabe pero está dentro de la política de recursos: 202 con job_id. Si supera la política: 507 RESOURCE_LIMIT (ver capítulo 1, sección 5).


4. Lanzarlo

BASE=http://localhost:8000
curl -sS -X POST "$BASE/v1/backtest" \
  -H 'Content-Type: application/json' \
  -d @examples/api/01_backtest_p01_both.request.json \
  | jq '.payload | {status, window_resolved, parity: .parity.verdict, speedup: .speedup.value}'
import requests

BASE = "http://localhost:8000"
body = {
    "schema_version": "v1",
    "payload": {
        "strategy": {"template_id": "P01", "params": {"fast": 20, "slow": 50}},
        "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",
        "engine_options": {"vectorta": {"kernel": "scalar"},
                           "nautilus": {"adapter": "NT_FEATURES"}},
    },
}
r = requests.post(f"{BASE}/v1/backtest", json=body, timeout=900)
env = r.json()
if r.status_code == 200:
    p = env["payload"]
    for res in p["results"]:
        m = res["run_metrics"]
        print(res["adapter"], m["n_fills"], m["final_equity"])
elif r.status_code == 202:
    print("en cola:", env["payload"]["poll"])
else:
    print(r.status_code, env["error"]["code"], env["error"]["details"])

5. La respuesta, bloque a bloque (ejemplo real)

Todo lo que sigue sale de examples/api/01_backtest_p01_both.response.json.

5.1 status, signature, window_resolved

{
  "status": "PASS",
  "warnings": [],
  "signature": {
    "strategy_id": "P01-dc9260", "template_id": "P01", "strategy_name": "EMA cross 20/50 base",
    "params": { "fast": 20, "slow": 50 },
    "data_hash": "e8b5fc3612fd944a692af21807706f665fb5bec4f1251d1652fbeaabea6595b0",
    "formula_version": "1.0.0",
    "execution_contract": {
      "lane": "SIM-S", "contract_version": "1", "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", "replay_ts_shift_ns": 1 } }
    },
    "cache_mode": "WARM_FEATURES", "output_mode": "metrics",
    "host_fingerprint_hash": "x86_64|Linux|py3.12.14",
    "…": "…"
  },
  "window_resolved": { "start": "2024-01-02T14:30:00Z", "end": "2024-12-31T21:00:00Z",
                       "bars": 19550, "warmup_bars": 49, "first_decision_index": 49 }
}
  • La firma es el DNI del resultado. Dos resultados solo son comparables si sus firmas coinciden en lo que importa.
  • window_resolved traduce tu ventana a barras reales: pediste desde el 1 de enero, pero la primera barra de 5min de 2024 es la de 2024-01-02T14:30:00Z. Hay 19 550 barras y las 49 primeras son calentamiento (la EMA de 50 aún no es válida): la primera decisión se toma en el índice 49.
  • engine_binding declara el truco de Nautilus para el fill a la apertura siguiente: una quote sintética 1 ns después del cierre. Es un artificio de reproducción, no latencia de mercado.

5.2 results[]: un resultado por motor

Comparación real de los dos elementos de results:

Métrica 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>"
end_to_end_wall_ns (una ejecución) 8 384 965 (≈ 8,4 ms) 743 774 161 (≈ 744 ms)
median_wall_ns (20 repeticiones) 8 330 236 986 363 096
p95_wall_ns 21 743 223 1 334 579 996

Lectura de trader: con 1 lote de NVDA, 328 fills en un año de barras de 5 minutos y 35,05 USD de comisiones, la estrategia acaba prácticamente plana (+9,38 USD sobre 100 000) y corta (−1). Los dos motores dan exactamente la misma contabilidad.

\"<medido>\" no es un número

sharpe_daily no tiene definición en el contrato todavía, así que sale como "<medido>". Si alguna vez ves ese literal convertido en número en un informe, es un bug.

Nautilus añade campos propios:

"native_account_balance": "100006.07", "canonical_cash": 100143.67,
"economic_bars": 19550, "effective_events": 39099, "events_per_second": 58857.459

effective_events ≈ el doble de economic_bars porque Nautilus procesa además la quote de apertura sintética. Por eso comparar «eventos/s» de Nautilus con «barras/s» de VectorTA sería un error de lectura.

stage_timings desglosa el reloj en 18 etapas con nombre (read_decode_ns, feature_build_ns, weights_and_policy_ns, convert_native_ns, simulate_ns, metrics_ns, end_to_end_wall_ns…). Un 0 significa «esta etapa no aplica». Extracto real de Nautilus:

{ "convert_native_ns": 30750226, "load_engine_ns": 23926612, "reset_engine_ns": 1279575,
  "simulate_ns": 664299831, "metrics_ns": 20362532, "end_to_end_wall_ns": 743774161, "…": "…" }

La simulación orientada a eventos (simulate_ns ≈ 664 ms) es la que se lleva el tiempo en Nautilus, frente a ≈ 0,46 ms en el ledger vectorial.

tables da, por cada tabla, filas, checksum y ruta Parquet dentro de results/runs/<run_id>/:

"fills":  { "rows": 328,   "checksum": "3d8554f9d550490a", "path": "results/runs/NVDA_5min_P01-dc9260_VTA_CPU_LEDGER-…/fills.parquet" },
"equity": { "rows": 19550, "checksum": "9e70399f11e376d1", "path": "results/runs/…/equity.parquet" }

equity_tail son las últimas tail_rows filas (real, última fila de VectorTA):

{ "timestamp": "2024-12-31T21:00:00Z", "cash_base": 100143.67, "equity_base": 100009.38,
  "gross_exposure": 134.29, "net_exposure": -134.29, "costs_total": 35.05 }

5.3 parity, speedup, cache

"parity": {
  "verdict": "PASS",
  "compared": { "a": "VTA_CPU_LEDGER", "b": "NT_FEATURES" },
  "comparisons": [
    { "what": "features", "mode": "tolerance", "from_index": 49, "verdict": "PASS", "n_compared": 39032, "max_abs_diff": 0.0 },
    { "what": "signals",  "mode": "exact",     "from_index": 49, "verdict": "PASS", "n_compared": 19501 },
    { "what": "fills",    "mode": "exact",     "verdict": "PASS", "n_compared": 328 },
    { "what": "equity",   "mode": "quantized", "verdict": "PASS", "n_compared": 19550, "max_abs_diff_minor": 0 }
  ],
  "first_divergence": null
},
"speedup": {
  "published": true,
  "value": 118.40757420272521,
  "definition": "median_wall_nautilus / median_wall_vta_pipeline",
  "gates": { "parity": "PASS", "same_data_hash": true, "…": "9 puertas en verde" },
  "ab_order": "AB",
  "interpretation": "Un valor > 1 favorece al pipeline VectorTA + ledger para este caso concreto."
},
"cache": { "features_hits": [], "features_misses": ["ema:20", "ema:50"], "result_hit": false, "…": "…" },
"run_id": "NVDA_5min_P01-dc9260_VTA_CPU_LEDGER-4a894cc067c94483-20260917T151218071527091"

El speedup es la división de medianas: 986 363 096 / 8 330 236 ≈ 118,4. Solo existe porque parity.verdict = PASS; si la paridad fallara, published sería false y value null. La paridad y las nueve puertas se estudian en el capítulo 4 y el capítulo 6.

Ese 118× es de este caso

Es la mediana de un caso concreto (P01, NVDA 5min, 2024, WARM_FEATURES, one_core, en la máquina de la regeneración). En otro servidor o con otro caso medirá distinto. No lo cites como «VectorTA es 118 veces más rápido» sin sus condiciones.


6. Variaciones útiles (ejemplos reales)

Ejemplo Qué cambia Resultado real
02a P02-be06fa (RSI 14, 30/70), NVDA 5min, both PASS, 550 fills, equity final 99 921,49 en ambos motores
02c P02-bc0dd2 (umbrales 25/75) PASS, 342 fills, equity 99 942,12; features_misses: ["rsi:14"] (caché no cableada)
04 Mismo P01-dc9260 pero asset_id: "KO", COLD_PROCESS_FULL PASS, 19 551 barras, 389 fills, equity 99 965,88
06d P01-09dd21 (20/100) PASS, warmup_bars: 99, 197 fills, equity 100 064,26

El data_hash de 04 (KO) es el mismo que el de NVDA

examples/api/README.md (paseo 4) dice que al cambiar de NVDA a KO «el data_hash cambia». En los ficheros regenerados no cambia: 01 (NVDA, 19 550 barras) y 04 (KO, 19 551 barras) llevan el mismo e8b5fc36…95b0. El motivo está en el código: finazbench/runner/from_api.py (data_hash()) firma con el data_hash del manifiesto canónico del dataset completo, no con un hash por activo y timeframe. Hoy, por tanto, data_hash identifica la versión del dataset descargado, y lo que distingue NVDA de KO en la firma es asset_id. Es una incoherencia entre el README de ejemplos (y la intención del contrato) y la implementación; no la uses para detectar un cambio de activo.

Cambiar solo los umbrales del RSI

Entre 02a y 02c solo cambian lower/upper, que son parámetros de política, no de feature. El RSI14 es el mismo; cambian las señales y, por tanto, los fills (550 → 342). Un hit del resultado completo sería un bug: compartir indicadores no implica compartir posiciones ni costes.

Errores típicos de POST /v1/backtest

404 STRATEGY_NOT_FOUND, 404 ASSET_NOT_FOUND, 409 STRATEGY_FORMULA_STALE, 409 INSUFFICIENT_HISTORY, 409 CHECKPOINT_INCOMPATIBLE, 422 ENGINE_OPTION_ALTERS_CONTRACT, 422 ENGINE_OPTION_UNAVAILABLE, 422 PROFILE_OVERSUBSCRIBED, 501 STRATEGY_UNSUPPORTED_BY_ADAPTER, 504 TIME_BUDGET_EXCEEDED, 507 RESOURCE_LIMIT.


7. Checklist de lectura de una respuesta

flowchart TD
    A[Respuesta 200] --> B{status == PASS?}
    B -- no --> B1[Lee status y warnings<br/>no mires tiempos]
    B -- sí --> C{engine == both?}
    C -- no --> F[run_metrics + stage_timings]
    C -- sí --> D{parity.verdict == PASS?}
    D -- no --> D1[first_divergence<br/>capítulo 4]
    D -- sí --> E[speedup.published y gates]
    E --> F
    F --> G[run_id → results/runs/…]

Resumen

  • POST /v1/backtest recibe una orden de trabajo completa: strategy, data, execution (obligatorios) y engine, cache, output, profile, engine_options, budget.
  • La estrategia se referencia por strategy_id determinista o en línea (template_id + params); crear y derivar son un POST cada uno.
  • SIM-S (versión actual del contrato): señal al cierre, fill a la apertura siguiente, sin liquidación final, lotes discretos [-1, 0, 1].
  • La respuesta trae signature, window_resolved, un results[] por motor, y con both los bloques parity y speedup.
  • En el ejemplo real P01 20/50 NVDA 5min 2024 los dos motores coinciden al céntimo (328 fills, equity 100 009,38) y el speedup publicado es ≈ 118,4 para ese caso.

Para practicar

  1. Lanza 01_backtest_p01_both.request.json con "engine": "vectorta". ¿Qué bloques desaparecen de la respuesta?
  2. Cambia cost_scenario a "zero". ¿Cambia n_fills? ¿Y final_equity? Razona por qué.
  3. Pide P01 en línea con {"fast": 50, "slow": 20}. ¿Qué error recibes y qué campo señala?
  4. Añade "account_type": "CASH" a las opciones de Nautilus manteniendo target_lots: [-1, 0, 1]. Explica el error.
  5. Compara window_resolved.warmup_bars entre P01 20/50 y P01 20/100. ¿De qué depende?