Saltar a contenido

Primeros pasos con la API de backtesting y análisis (bench-api)

Qué vas a aprender

  • Qué es bench-api y por qué existe una API «intermedia» entre tú y los dos motores (VectorTA y Nautilus).
  • Cómo levantar el servicio con Docker Compose y comprobar que está vivo.
  • Dónde está la documentación interactiva (Swagger UI y ReDoc) y el contrato formal.
  • Cómo es el sobre común (envelope) que envuelve cada petición y cada respuesta.
  • Qué significan los códigos HTTP 200 / 202 / 507 en POST /v1/backtest (decisión D-33).
  • Cómo leer un error: los 36 códigos del contrato (37 en el servidor, que añade ENDPOINT_NOT_IMPLEMENTED) y el campo retriable.
  • Para qué sirven /health y /doctor.

1. La idea: un contrato común, dos motores

Imagina que tienes dos calculadoras financieras muy distintas:

  • VectorTA es una calculadora vectorial en Rust: recorre el array entero de barras de golpe. Es rapidísima, ideal para barridos de parámetros.
  • Nautilus es un simulador de mercado orientado a eventos: órdenes, cuenta, comisiones, fills. Es más lento, pero es el que tiene «realismo de ejecución».

Si cada motor tuviera su propia API, comparar resultados sería como comparar precios en dos monedas sin tipo de cambio. bench-api resuelve eso con un único contrato: los datos, la estrategia, la ventana, los costes, las tablas de salida, las etapas cronometradas y los estados son idénticos para los dos motores. Lo que es propio de cada motor vive aislado en engine_options.<motor> y, por regla, nunca cambia la semántica del cálculo, solo el cómo.

flowchart LR
    C[Cliente<br/>curl / Python / notebook] -->|HTTP JSON<br/>envelope v1| API[bench-api<br/>FastAPI :8000]
    API --> R[finazbench.runner]
    R --> V[VectorTA<br/>VTA_CPU_LEDGER]
    R --> N[Nautilus<br/>NT_FEATURES / NT_ONLINE / NT_INTENT_REPLAY]
    R --> RES[(results/runs/«run_id»/<br/>results/jobs/«job_id»/)]
    API -. futuro .-> RP[(Redpanda<br/>finaz.*.v1)]

Los diez principios del contrato (docs/API.md §1) se pueden resumir en cuatro frases que conviene memorizar:

Principio En una frase
Un contrato, dos motores Lo común es común; lo específico va en engine_options.
Firma completa Todo resultado lleva data_hash, formula_version, params, contrato de ejecución, cache_mode, output_mode, versiones y huella de máquina. Sin firma no hay comparación.
Nunca un speedup sin paridad Un «VectorTA es N veces más rápido» solo se publica si los dos motores dan el mismo resultado (parity=PASS) y las firmas coinciden.
Nada de números inventados Lo que el runner no mide se devuelve como null o "<medido>", nunca como un cero ni como una estimación.

¿Por qué «intermedia»?

El contrato se llama FINAZ intermediate API — v1. Es intermedia porque está entre tú y los motores del banco de backtesting. No es la única API de la plataforma: la API de plataforma (:18300, /platform/v1/...) controla las corridas causales; esta (:8000, /v1/...) lanza backtests, paridad, barridos e indicadores. Son dos puertas con roles distintos, no dos generaciones. Además está diseñada para que el payload de cada mensaje sea exactamente el mensaje que viajará mañana por los tópicos de Redpanda.


2. Levantar el servicio

bench-api es un servicio del docker-compose.yml del repositorio, dentro del perfil bench-api. Los perfiles hacen que un docker compose up sin argumentos no arranque nada nuevo, de modo que no interfiere con servicios que ya estén en marcha.

# Desde la raíz del repositorio FinazTradingEngine
docker compose --profile bench-api up -d bench-api

# ¿Está vivo?
curl -s localhost:8000/health | jq

Qué hace esa definición de servicio (resumida de docker-compose.yml):

Aspecto Valor Por qué
Imagen ${BENCH_IMAGE:-finaz/bench:0.2.0-x86-64-v3} La misma imagen que los perfiles dev y bench.
Puerto 8000:8000 Todas las URLs de este capítulo usan http://localhost:8000.
Volúmenes ./data:/data:ro, ./cache:/cache, ./results:/results, ./runs:/runs Los datos se montan solo lectura; los resultados se escriben fuera del contenedor.
Comando uvicorn finazbench.api.main:app --host 0.0.0.0 --port 8000 --workers 1 Un solo worker y sin --reload.
CPU/memoria cpuset ${BENCH_CPUSET:-0-3}, cpus ${BENCH_CPUS:-4}, mem_limit ${BENCH_MEM_LIMIT:-4g} Presupuesto parametrizable en .env.

Un solo worker, a propósito

El proceso que sirve la petición es el mismo que mide. Un recargador automático o varios workers falsearían los tiempos por etapa y la atribución de memoria. No «optimices» esto añadiendo --workers 4: estarías rompiendo el banco de medida.

Para pararlo, nómbralo siempre:

docker compose --profile bench-api down bench-api

Cuidado con docker compose down a secas

El compose conserva, sin perfil, los servicios heredados vectorta-service (:8001) y nautilus-service (:8002), que atienden a clientes antiguos. Un docker compose down sin argumentos los pararía también.


3. Documentación interactiva: Swagger, ReDoc y OpenAPI

FastAPI genera la documentación a partir del propio código, así que siempre está sincronizada con lo que el servidor acepta:

URL Qué es Cuándo usarla
http://localhost:8000/docs Swagger UI: formulario interactivo, botón Try it out Para probar una ruta sin escribir curl.
http://localhost:8000/redoc ReDoc: la misma especificación, en formato de lectura Para leer esquemas largos (el de /v1/backtest lo es).
http://localhost:8000/openapi.json Especificación OpenAPI en JSON Para generar clientes o validar en CI.
http://localhost:8000/ Índice mínimo Te dice versión, schema_version y dónde está el contrato.

La raíz devuelve (código de finazbench/api/main.py):

{
  "service": "bench-api",
  "api_version": "<versión de la API>",
  "schema_version": "v1",
  "package_version": "<versión del paquete>",
  "contract": "docs/API.md",
  "openapi": "/openapi.json",
  "docs": "/docs"
}

En el repositorio hay además dos documentos de referencia:

  • docs/API.md — el contrato en prosa (en inglés), con la justificación de cada decisión.
  • docs/openapi_v1.yaml — la especificación formal, contra la que se validan los ejemplos de examples/api/.

Las rutas se agrupan por etiquetas en Swagger:

Etiqueta Rutas Capítulo
ops GET /health, GET /doctor este
capabilities GET /v1/capabilities este
catalog GET /v1/catalog/strategies[/{id}], GET /v1/catalog/assets 2
indicators GET /v1/indicators[/{name}] 2
help GET /v1/help[/{name}] 2
strategies POST /v1/strategies, POST /v1/strategies/{id}/derive, GET/PATCH /v1/strategies… 3
compute POST /v1/indicators, /v1/backtest, /v1/parity, /v1/sweep, GET /v1/stats/runs 36
jobs GET /v1/jobs, GET/DELETE /v1/jobs/{id}, GET /v1/jobs/{id}/results 4, 6

4. El sobre común (envelope)

Piensa en el sobre como en un sobre postal: fuera lleva los datos de envío (quién, para qué hilo, qué versión), dentro va la carta (payload). Todas las peticiones POST y todas las respuestas de la API van dentro del mismo sobre.

4.1 Petición

{
  "request_id": "9a4e2b10-0c3f-4c8e-b0d7-5f1e2a9c6d33",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": { "…": "cuerpo propio de cada endpoint" }
}
Campo Tipo ¿Obligatorio? Significado
request_id UUIDv4 Opcional en la petición (si falta, lo genera el servidor); siempre en la respuesta Identifica una petición. Se devuelve tal cual.
correlation_id string o null Opcional Hilo de negocio que atraviesa varios mensajes. Hoy se propaga sin interpretar; en Redpanda será la clave de correlación.
schema_version "v1" literal Cualquier otro valor ⇒ 400 SCHEMA_VERSION_UNSUPPORTED.
payload objeto Lo único que cambia entre HTTP y Redpanda.

4.2 Respuesta

La respuesta repite los tres campos de cabecera y añade dos más al mismo nivel que payload. Este es el sobre real de la respuesta de examples/api/01_backtest_p01_both.response.json (sin el payload):

{
  "request_id": "9a4e2b10-0c3f-4c8e-b0d7-5f1e2a9c6d33",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": { "…": "…" },
  "served_at_ns": 1789657938275241827,
  "server": { "api_version": "1.0.0", "image_tag": "finaz/bench:0.2.0", "git_sha": null }
}
  • served_at_ns: instante en que se sirvió, en nanosegundos desde la época Unix.
  • server: qué versión de API e imagen respondió. Si git_sha no se conoce, es null (no se inventa).

Cuando hay error, payload se sustituye por error (sección 6).

4.3 El mismo sobre, mañana en Redpanda

sequenceDiagram
    participant Cli as Cliente
    participant API as bench-api (HTTP)
    participant T as Tópico finaz.backtest.request.v1
    participant W as Consumidor
    Cli->>API: POST /v1/backtest {request_id, schema_version, payload}
    API-->>Cli: 200 {request_id, payload, served_at_ns, server}
    Note over Cli,W: Mañana (misma carta, otro cartero)
    Cli->>T: key = correlation_id ?? request_id<br/>value = envelope completo
    T->>W: mismo payload, byte a byte
Operación HTTP Tópico de petición Tópico de resultado Clave de partición
POST /v1/indicators finaz.indicator.request.v1 finaz.indicator.result.v1 asset_id
POST /v1/backtest finaz.backtest.request.v1 finaz.backtest.result.v1 strategy_id
POST /v1/parity finaz.parity.request.v1 finaz.parity.result.v1 strategy_id
POST /v1/sweep finaz.sweep.request.v1 finaz.sweep.result.v1 job_id
GET /v1/jobs/{id} finaz.job.progress.v1 job_id

Las rutas de solo lectura (/v1/capabilities, /v1/catalog/*, /v1/indicators en GET, /v1/help, /v1/jobs, /v1/stats/runs, /health, /doctor) son solo HTTP.

Las rutas GET no llevan sobre de petición

Un GET no tiene cuerpo, así que no hay sobre que enviar. Pero la respuesta de las rutas /v1/... sí viene envuelta (payload). Las dos excepciones son /health y /doctor, que son sondas de infraestructura y responden sin sobre.

4.4 Validación estricta: un typo es un error

Los modelos de petición prohíben campos desconocidos (extra="forbid" en RequestModel). Si escribes "initial_cahs" en lugar de "initial_cash", la petición falla; no se ejecuta con el valor por defecto. El motivo es sutil pero importante: si se ejecutara, la respuesta llevaría una firma que no corresponde a lo que creías pedir, y cualquier comparación posterior por firma sería falsa.


5. 200, 202 o 507: la decisión D-33

POST /v1/backtest puede tardar milisegundos o minutos según la ventana y el motor. La decisión D-33 (docs/DECISIONES.md, 2026-09-17) fija cómo responde:

Código Cuándo Qué recibes
200 El caso cabe en el budget declarado (o no declaras ninguno) El resultado completo, síncrono.
202 Excede el budget declarado pero está dentro de la política de recursos (max_estimated_bar_candidate_evaluations_default = 1e8) Un acuse con job_id, resource_estimate y poll. El resultado llega luego por /v1/jobs/{id}/results.
507 Excede la política de recursos 507 RESOURCE_LIMIT, antes de ejecutar y sin recortar la ventana en silencio.
flowchart TD
    A[POST /v1/backtest] --> B[Estimar evaluaciones<br/>barra x candidato]
    B --> C{¿Supera la política<br/>1e8 evaluaciones?}
    C -- sí --> E[507 RESOURCE_LIMIT<br/>no se ejecuta]
    C -- no --> D{¿Cabe en el budget<br/>declarado?}
    D -- sí / sin budget --> F[200 resultado síncrono]
    D -- no --> G[202 + job_id<br/>se ejecuta en segundo plano]
    G --> H["GET /v1/jobs/{id}/results<br/>payload.backtest"]

Ejemplo real: examples/api/07_backtest_p01_queued_202.request.json pide un presupuesto imposible, "budget": {"max_wall_seconds": null, "max_memory_bytes": 1}, para forzar el camino en cola. La respuesta real (recortada) es:

{
  "request_id": "7c1d2e3f-4051-4627-8899-aabbccddeeff",
  "schema_version": "v1",
  "payload": {
    "job_id": "job_FCF18574F7",
    "status": "QUEUED",
    "requested_candidates": 1,
    "effective_candidates": 1,
    "materialized_strategy_ids": ["P01-dc9260"],
    "resource_estimate": {
      "estimated_peak_bytes": 1032192,
      "block_size": 504,
      "workers": 1,
      "estimated_bar_candidate_evaluations": 504,
      "policy_max_estimated_bar_candidate_evaluations_default": 100000000
    },
    "poll": "/v1/jobs/job_FCF18574F7"
  },
  "served_at_ns": 1789658011544685030,
  "server": { "api_version": "1.0.0", "image_tag": "finaz/bench:0.2.0", "git_sha": null }
}

Cómo tratar el 202 en tu cliente

No es un error: es «te lo hago, pero no te quedes esperando en la línea». Guarda job_id y consulta poll hasta que el estado sea terminal. Lo vemos a fondo en el capítulo 4 y el capítulo 6.

POST /v1/sweep responde siempre 202 (un barrido es asíncrono por naturaleza), y también puede devolver 507 con la misma política.


6.1 Formato común

Todo error tiene la misma forma: el sobre, con error en lugar de payload. Ejemplo real (examples/api/91_error_strategy_params_invalid.response.json), que se produce al pedir un P01 con fast=20 y slow=20:

{
  "request_id": "1b2c3d4e-5f60-4718-a92b-c3d4e5f60718",
  "correlation_id": null,
  "schema_version": "v1",
  "error": {
    "code": "STRATEGY_PARAMS_INVALID",
    "message": "Los parámetros no cumplen el schema del template P01.",
    "details": {
      "template_id": "P01",
      "violations": [
        { "field": "slow", "rule": "fast < slow", "value": 20,
          "related": { "fast": 20 },
          "message": "slow debe ser estrictamente mayor que fast." }
      ]
    },
    "retriable": false,
    "docs": "docs/API.md#48-validación-de-params"
  }
}
Campo Para qué
code Identificador estable, para programar contra él (if code == "…").
message Texto legible (en español).
details Datos estructurados: qué campo, qué regla, qué valor.
retriable true solo en RESOURCE_LIMIT, TIME_BUDGET_EXCEEDED e INTERNAL_ERROR.
docs Ancla de docs/API.md con la regla incumplida.

Una sola puerta de salida

Todos los errores pasan por manejadores globales (finazbench/api/errors.py). Así ninguno puede «escaparse» como un 200 con ceros: un fallo no previsto sale como 500 INTERNAL_ERROR, nunca como una cifra.

6.2 Los 36 códigos del contrato, agrupados

El catálogo de docs/API.md §8 tiene 36 códigos. Agrupados por familia HTTP:

Código HTTP Cuándo
SCHEMA_VERSION_UNSUPPORTED 400 schema_version distinto de "v1"
MALFORMED_ENVELOPE 400 Falta payload o el JSON no parsea
TEMPLATE_NOT_FOUND 404 template_id no está en el catálogo
STRATEGY_NOT_FOUND 404 strategy_id o slug no registrado
ASSET_NOT_FOUND 404 Activo o timeframe no disponible
JOB_NOT_FOUND 404 job_id inexistente o purgado
INDICATOR_NOT_FOUND 404 Indicador que no existe en ningún registro
HELP_TOPIC_NOT_FOUND 404 Tema de ayuda en ninguna familia
Código Cuándo
STRATEGY_PARAMS_INVALID Params fuera de esquema o que violan una restricción
INDICATOR_PARAMS_INVALID Params de indicador inválidos (o indicador no calculable)
STRATEGY_SELECTOR_AMBIGUOUS strategy_id y template_id a la vez
DATA_SELECTOR_AMBIGUOUS asset_id y assets a la vez
COSTS_AMBIGUOUS cost_scenario y costs a la vez
SWEEP_SELECTOR_AMBIGUOUS grid y candidates a la vez
DERIVE_NO_CHANGE Un /derive que no cambia ningún valor
STRATEGY_IMMUTABLE_FIELD Un PATCH que intenta tocar params/template_id
ENGINE_OPTION_UNKNOWN Clave desconocida en engine_options
ENGINE_OPTION_UNSUPPORTED_VALUE Valor que no existe en el contrato
ENGINE_OPTION_UNAVAILABLE Valor válido, pero no disponible en este build
ENGINE_OPTION_ALTERS_CONTRACT La opción cambiaría la semántica SIM-S
PROFILE_OVERSUBSCRIBED workers>1 e internal_threads>1 a la vez
PARITY_SIDES_INVALID Menos de dos lados o firmas de contrato distintas
UNIVERSE_NOT_AVAILABLE Q01–Q09 sin panel multiactivo descargado
INSUFFICIENT_DISTINCT_CANDIDATES La rejilla no da los K candidatos distintos pedidos
Código Cuándo
STRATEGY_NAME_TAKEN Nombre ya usado por otra instancia
STRATEGY_SLUG_TAKEN Slug ya usado
STRATEGY_ID_AMBIGUOUS Prefijo de id que casa con varias instancias
STRATEGY_FORMULA_STALE La instancia usa una formula_version antigua
CHECKPOINT_INCOMPATIBLE mode=continue con checkpoint ausente o incompatible
INSUFFICIENT_HISTORY No hay barras suficientes para el warmup
Código HTTP Cuándo
LANE_UNSUPPORTED 501 lane distinto de SIM-S
STRATEGY_UNSUPPORTED_BY_ADAPTER 501 Combinación declarada UNSUPPORTED
MISSING_OPTIONAL_DEPENDENCY 501 Plantilla que exige una dependencia ausente (p. ej. Q08B y catboost)
TIME_BUDGET_EXCEEDED 504 Se superó budget.max_wall_seconds
RESOURCE_LIMIT 507 La estimación previa supera la política
INTERNAL_ERROR 500 Fallo inesperado
Un código extra en el código: ENDPOINT_NOT_IMPLEMENTED

El mapa de códigos del servidor (ERROR_HTTP_STATUS en finazbench/api/schemas_v1.py, 37 entradas) tiene uno más que el catálogo de docs/API.md §8 (36): ENDPOINT_NOT_IMPLEMENTED (501). Se usa, por ejemplo, si pides GET /v1/catalog/strategies?supported_by=…: filtrar por adaptador exigiría la matriz real de soporte, y responder con una lista «plausible» sería presentar una conjetura como hecho.

6.3 Dos errores que parecen iguales y no lo son

Los ejemplos 90 y 92 enseñan una distinción clave:

{
  "error": {
    "code": "ENGINE_OPTION_ALTERS_CONTRACT",
    "message": "Una latencia ≠ 0 altera el contrato SIM-S.",
    "details": {
      "field": "nautilus.latency_model",
      "value": { "base_ms": 1 },
      "contract": "SIM-S v1",
      "why": "Sobre datos de barras produce el cierre de la barra siguiente (medido: 111), no la apertura (110)."
    },
    "retriable": false
  }
}

Nunca se «arreglará»: añadir latencia sobre barras cambiaría el precio de ejecución, y eso cambia qué se calcula.

{
  "error": {
    "code": "ENGINE_OPTION_UNAVAILABLE",
    "message": "El kernel avx2 no está disponible en este build de VectorTA.",
    "details": {
      "field": "vectorta.kernel",
      "value": "avx2",
      "accepted": ["scalar", "avx2", "avx512", "auto"],
      "available": ["scalar", "auto"],
      "hint": "usa kernel=scalar, que es lo que se ejecuta hoy"
    },
    "retriable": false
  }
}

El contrato acepta avx2 (accepted), pero este wheel no lo trae (available). Se arregla con otra imagen.


7. Sondas: /health y /doctor

7.1 /health: ¿estás vivo?

Sonda barata para Compose y balanceadores. Sin sobre. Según el código (finazbench/api/routers/system.py) devuelve:

{
  "status": "ok",
  "api_version": "1.0.0",
  "schema_version": "v1",
  "uptime_seconds": 1234,
  "engines": { "vectorta": "ready", "nautilus": "ready" },
  "data_mounted": true,
  "cache_writable": true,
  "results_writable": true,
  "service": "bench-api",
  "python": "3.12.x"
}

(Forma tomada del código; no hay respuesta real registrada en examples/api/ y el servicio no corre en el servidor de producción. Valores de uptime_seconds y versión de Python ilustrativos.)

Si un motor no carga o cache/results no son escribibles, responde 503 con "status": "degraded". La razón: una instancia que arranca pero no puede escribir resultados perdería las corridas en silencio; el balanceador debe poder sacarla de rotación.

7.2 /doctor: inventario completo

Inventario del entorno desde dentro del contenedor, de solo lectura. Es lo que se adjunta a cualquier medición: versiones de librerías, CPU, cgroup, variables de hilos (OMP_NUM_THREADS=1, etc.), kernel activo de VectorTA, volúmenes. Reutiliza tools/doctor.py, así que el JSON por HTTP y el de línea de comandos son el mismo documento.

curl -s localhost:8000/doctor | jq '{python, libraries, vectorta}'
# Sondeo real de kernels (llama a la librería; los flags de CPU no bastan)
curl -s 'localhost:8000/doctor?probe_vectorta_kernels=true' | jq .vectorta_kernels
Parámetro Efecto
probe_imports=true Importa las librerías y lista su API pública.
probe_vectorta_kernels=true Ejecuta la prueba de humo de kernels: comprueba qué kernel SIMD corre de verdad.

7.3 /v1/capabilities: qué puede hacer esta instalación

Es lo primero que debería consultar un cliente «para no pedir lo imposible»: matriz estrategia × adaptador (SUPPORTED, VARIANT, UNSUPPORTED con motivo), versiones, kernels aceptados y disponibles, modos de caché y perfiles.

curl -sS localhost:8000/v1/capabilities | jq '.payload.engines.vectorta | {active_kernel, kernel_available}'

8. Tu primera petición completa

BASE=http://localhost:8000
cd FinazTradingEngine   # el repo, para tener examples/api a mano

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

BASE = "http://localhost:8000"
with open("examples/api/01_backtest_p01_both.request.json") as f:
    body = json.load(f)

r = requests.post(f"{BASE}/v1/backtest", json=body, timeout=600)
r.raise_for_status()
env = r.json()
if r.status_code == 202:
    print("En cola:", env["payload"]["poll"])
else:
    p = env["payload"]
    print(p["status"], p["parity"]["verdict"], p["speedup"]["published"])

En el ejemplo regenerado, la respuesta tiene status: "PASS", parity.verdict: "PASS" y speedup.published: true. En el capítulo 3 la desmontamos campo a campo.


Resumen

  • bench-api es un servicio FastAPI en :8000, perfil bench-api de Compose, con un solo worker porque también mide.
  • La documentación viva está en /docs (Swagger), /redoc y /openapi.json; el contrato razonado en docs/API.md.
  • Todo POST y toda respuesta /v1 va en un sobre: request_id, correlation_id, schema_version: "v1", payload (más served_at_ns y server en la respuesta).
  • D-33: POST /v1/backtest responde 200 si cabe en el presupuesto, 202 + job si no cabe pero está dentro de la política (1e8 evaluaciones), 507 si la supera.
  • Los errores comparten forma (code, message, details, retriable, docs); el contrato define 36 códigos y el servidor emite 37 (añade 501 ENDPOINT_NOT_IMPLEMENTED).
  • /health dice si el servicio está sano (503 si no); /doctor dice en qué entorno estás midiendo.

Para practicar

  1. Levanta el servicio y abre http://localhost:8000/docs. Localiza el esquema de POST /v1/backtest y cuenta cuántos bloques de primer nivel tiene su payload.
  2. Envía 01_backtest_p01_both.request.json cambiando schema_version a "v2". ¿Qué código HTTP y qué error.code recibes?
  3. Añade un campo inventado ("foo": 1) dentro de payload.execution. ¿Se ejecuta? ¿Por qué es deseable que no?
  4. Envía 07_backtest_p01_queued_202.request.json y consulta la URL de poll hasta que el job termine.
  5. Compara accepted y available en el error ENGINE_OPTION_UNAVAILABLE. Explica con tus palabras por qué no es un ENGINE_OPTION_ALTERS_CONTRACT.