Saltar a contenido

Estadísticas de corridas y jobs

Qué vas a aprender

  • Qué es un run_id, qué contiene su directorio en results/runs/ y por qué se considera inmutable.
  • Cómo consultar el histórico de corridas con GET /v1/stats/runs: filtros, agrupación, medianas, P95 (y cuándo es fiable) y huella de máquina.
  • Cómo se decide si un speedup se publica: las nueve puertas y los motivos de rechazo.
  • Cómo listar jobs con GET /v1/jobs y encadenarlo con el seguimiento de un job.

1. La idea: un archivo notarial de corridas

Cada vez que la API ejecuta un caso (un backtest, un candidato de un barrido), deja un acta en disco: la firma completa, las métricas, los tiempos por etapa y las tablas. GET /v1/stats/runs es el archivero: no ejecuta nada, lee actas y las resume.

flowchart LR
    B[POST /v1/backtest] --> RUN
    S[POST /v1/sweep<br/>un run por candidato] --> RUN
    RUN[(results/runs/«run_id»/<br/>run_metrics.json<br/>intents / fills / positions / equity .parquet)]
    RUN --> ST[GET /v1/stats/runs<br/>filtra, agrupa, resume]
    ST --> SP{¿9 puertas<br/>en verde?}
    SP -- sí --> P[speedup.published = true]
    SP -- no --> N[published = false<br/>reason = MIXED_… / NO_SPEEDUP_…]

2. El run_id y su directorio

2.1 Anatomía

Un run_id real (de la respuesta de 01_backtest_p01_both):

NVDA_5min_P01-dc9260_VTA_CPU_LEDGER-4a894cc067c94483-20260917T151218071527091
└──────────── prefijo ─────────────┘ └─ hash firma ─┘ └──── timestamp (ns) ────┘
 activo_timeframe_estrategia_adaptador

docs/RUNNER.md lo define como <prefijo>-<hash de firma>-<timestamp>:

Parte Para qué
Prefijo Legible: activo, timeframe, strategy_id, adaptador.
Hash de firma Dos corridas del mismo caso comparten prefijo y hash, así que se agrupan al ordenar y se encuentran con un glob, sin índice aparte.
Timestamp Separa las repeticiones: repetir una medida es justo lo que pide el protocolo, y sobrescribir la anterior perdería las muestras crudas.

2.2 Qué hay dentro

results/runs/<run_id>/
├── run_metrics.json    # firma, métricas, stage_timings, memoria, estado, paridad…
├── intents.parquet
├── fills.parquet
├── positions.parquet
├── equity.parquet
└── parity.json         # si procede

La respuesta del backtest ya te daba estas rutas en tables.*.path y artifacts_dir.

2.3 ¿Por qué «inmutable»?

Tres garantías

  1. Escritura atómica. Todo se escribe en <root>/.tmp-<run_id>/ y se renombra al final. Un directorio a medio escribir parecería una corrida completa y ningún lector podría distinguirlo.
  2. Nunca se sobrescribe. El timestamp en el nombre hace que cada ejecución tenga su propio directorio.
  3. La firma va entera, también con sus valores por defecto. Un resultado sin cache_mode no es «uno con caché desconocida»: es uno que no se puede comparar con nada.

Analogía: es como un asiento contable. No se borra ni se corrige; si te equivocaste, haces otro asiento. Por eso puedes citar un run_id en un informe y cualquiera puede volver a sus tablas meses después.

results/ frente a runs/

El servicio monta ./results:/results y ./runs:/runs. Las corridas que produce la API (y que lee /v1/stats/runs) viven en results/runs/<run_id>/. El volumen runs/ lo usan otras herramientas del runner para artefactos auxiliares (p. ej. informes de paridad escritos a mano, ver docs/RUNNER.md).


3. GET /v1/stats/runs

3.1 Filtros y parámetros

Todos opcionales (firma de la función en finazbench/api/routers/execution.py):

Grupo Parámetros
Qué estrategia strategy_id, template_id
Qué datos asset_id, timeframe, data_hash
Qué motor engine, adapter
Cómo se midió cache_mode, output_mode, profile, formula_version, host_fingerprint_hash
Resultado status
Cuándo since, until (se derivan del timestamp del run_id)
Agregación group_by (lista; por defecto engine, adapter), metrics (lista)
Speedup include_speedup (por defecto true)
Paginación limit (1..1000, defecto 100), cursor

group_by y metrics son listas: se repite el parámetro (?group_by=engine&group_by=adapter).

BASE=http://localhost:8000
curl -sS -G "$BASE/v1/stats/runs" \
  --data-urlencode "strategy_id=P01-dc9260" \
  --data-urlencode "asset_id=NVDA" \
  --data-urlencode "timeframe=5min" \
  --data-urlencode "cache_mode=WARM_FEATURES" \
  --data-urlencode "status=PASS" \
  --data-urlencode "group_by=engine" \
  --data-urlencode "group_by=adapter" \
  | jq '.payload | {groups: [.groups[] | {key, n_runs, n_samples, median_wall_ns, p95_wall_ns, p95_reliable}], speedup}'
import requests
BASE = "http://localhost:8000"
params = [
    ("strategy_id", "P01-dc9260"), ("asset_id", "NVDA"), ("timeframe", "5min"),
    ("cache_mode", "WARM_FEATURES"), ("status", "PASS"),
    ("group_by", "engine"), ("group_by", "adapter"),
]
p = requests.get(f"{BASE}/v1/stats/runs", params=params).json()["payload"]
for g in p["groups"]:
    ms = (g["median_wall_ns"] or 0) / 1e6
    print(g["key"], g["n_runs"], f"{ms:.2f} ms", "P95 fiable" if g["p95_reliable"] else "P95 NO fiable")
sp = p["speedup"]
print("speedup:", sp["value"] if sp["published"] else f"no publicado ({sp['reason']})")

3.2 La respuesta

No hay un fichero de ejemplo regenerado para esta ruta en examples/api/; esta es la forma del contrato (docs/API.md §6.13), con los valores que dependen de la máquina marcados como "<medido>":

{
  "payload": {
    "filters_applied": { "template_id": "P01", "asset_id": "NVDA", "timeframe": "5min",
                         "cache_mode": "WARM_FEATURES", "status": "PASS" },
    "group_by": ["engine", "adapter"],
    "groups": [
      { "key": { "engine": "vectorta", "adapter": "VTA_CPU_LEDGER" },
        "n_runs": 20, "n_samples": 400,
        "median_wall_ns": "<medido>", "p95_wall_ns": "<medido>", "iqr_wall_ns": "<medido>",
        "peak_rss_bytes_median": "<medido>", "bars_per_second_median": "<medido>",
        "p95_reliable": true, "p95_reliable_reason": "…" },
      { "key": { "engine": "nautilus", "adapter": "NT_FEATURES" },
        "…": "…", "events_per_second_median": "<medido>" }
    ],
    "speedup": {
      "published": true,
      "value": "<medido>",
      "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 },
      "conditions": { "…": "…" }
    }
  }
}

Campos de cada grupo (según collect_run_stats en finazbench/runner/from_api.py):

Campo Significado
key Valores de los campos de group_by para ese grupo.
n_runs Corridas (directorios) del grupo.
n_samples Muestras de tiempo: las repeticiones medidas de cada corrida, o su end_to_end_wall_ns si no hubo repeticiones.
median_wall_ns Mediana del tiempo de pared. Es la cifra de referencia (robusta a picos).
p95_wall_ns Percentil 95: «el caso lento típico».
iqr_wall_ns Rango intercuartílico: cuánto se dispersan las medidas.
p95_reliable / p95_reliable_reason Si el P95 es una estimación sólida y por qué.
peak_rss_bytes_median Mediana del pico de memoria residente.
bars_per_second_median / events_per_second_median Rendimiento; VectorTA en barras, Nautilus también en eventos.
warmup Repeticiones de calentamiento descartadas.

3.3 Mediana, P95 y fiabilidad

¿Por qué mediana y no media?

En el backtest real del capítulo 3, VectorTA tuvo una mediana de 8,33 ms pero un P95 de 21,74 ms en 20 repeticiones. Un par de ejecuciones lentas (el sistema operativo haciendo otra cosa) arrastrarían la media hacia arriba; la mediana no se inmuta. El P95 te cuenta la otra mitad de la historia: cómo de mala es la cola.

El P95 siempre se devuelve, pero con menos de 20 muestras (P95_MIN_SAMPLES, decisión D-04) se marca p95_reliable: false con una explicación explícita. El texto real de finazbench/bench/stats.py:

«NO es una estimación sólida: N muestras, §8.4 pide 20. Con pocas repeticiones el P95 tiende al máximo observado y no describe una cola medida.»

Si un grupo no tiene ninguna muestra de tiempo, median_wall_ns y p95_wall_ns son null y la razón lo dice: «el P95 no existe, no es un cero».

Cómo conseguir P95 fiables

Lanza el backtest con "profile": {"repetitions": {"warmup": 3, "measured": 20}}. Una sola ejecución (repetitions: null) es útil para ver resultados, no para publicar tiempos.

3.4 La huella de máquina

Cada corrida registra host_fingerprint en su firma (en los ejemplos regenerados, "x86_64|Linux|py3.12.14"). Sirve para una regla sencilla: los tiempos de dos máquinas distintas no se mezclan. Si agrupas corridas de dos servidores, el speedup se niega:

{
  "speedup": { "published": false, "value": null,
               "reason": "MIXED_HOST_FINGERPRINT",
               "detail": "Las corridas agrupadas provienen de 2 huellas de máquina distintas; los speedups entre servidores se publican como series separadas, nunca mezclados." }
}

Filtra por host_fingerprint_hash para trabajar con una sola máquina.


4. Las nueve puertas del speedup

Un speedup es un cociente de medianas, median_wall_nautilus / median_wall_vta_pipeline. Para que ese cociente signifique algo, los dos numeradores tienen que medir lo mismo. Por eso hay nueve puertas, y basta una en rojo para que no se publique:

# Puerta Qué garantiza Motivo si falla
1 parity = PASS Los dos motores dan el mismo resultado NO_SPEEDUP_WITHOUT_PARITY_PASS
2 same_data_hash Mismos datos MIXED_DATA_HASH
3 same_formula_version Mismas fórmulas MIXED_FORMULA_VERSION
4 same_candidate_list Mismos candidatos MIXED_CANDIDATE_LIST
5 same_execution_contract Mismo contrato SIM-S y costes MIXED_EXECUTION_CONTRACT
6 same_capital_policy Mismo capital y política (sin motivo propio en la lista del contrato)
7 same_cache_mode Mismas etapas dentro del reloj MIXED_CACHE_MODE
8 same_output_mode Misma cantidad de salida MIXED_OUTPUT_MODE
9 same_host_fingerprint Misma máquina MIXED_HOST_FINGERPRINT

A esos motivos se suma INSUFFICIENT_REPETITIONS, que en la implementación de /v1/stats/runs aparece cuando:

  • no agrupas por engine (no hay dos lados que comparar);
  • falta el grupo de uno de los dos motores;
  • algún grupo no tiene mediana (sin muestras).
flowchart TD
    A[GET /v1/stats/runs<br/>include_speedup=true] --> B{¿group_by incluye engine?}
    B -- no --> X1[INSUFFICIENT_REPETITIONS]
    B -- sí --> C{¿Hay grupo vectorta<br/>y grupo nautilus?}
    C -- no --> X1
    C -- sí --> D{¿Ambos con mediana?}
    D -- no --> X1
    D -- sí --> E{¿parity PASS y<br/>8 firmas iguales?}
    E -- no --> X2[published=false<br/>reason = la primera puerta roja]
    E -- sí --> OK[published=true<br/>value = mediana NT / mediana VTA]

El 118× del capítulo 3, puerta a puerta

En 01_backtest_p01_both.response.json las nueve puertas están en verde y el speedup publicado es 118,41 = 986 363 096 ns / 8 330 236 ns. Si mañana repites el caso con cache_mode: COLD_PROCESS_FULL solo en Nautilus y los agrupas juntos, la puerta 7 se pone en rojo y el valor desaparece: comparar un pipeline en frío con otro en caliente no es un speedup, es un error de método.

No compares un replay caliente con un pipeline frío

Es la regla que no se puede romper (contrato §9.1). Si hay preproceso compartido, se publican dos números: extremo a extremo (lo incluye) y replay (lo excluye).


5. GET /v1/jobs: la lista de trabajos

5.1 Qué lista

Filas resumen de los jobs persistidos, del más reciente al más antiguo. Incluye tanto barridos como backtests encolados por D-33 (202). Como las filas se leen de results/jobs/*/job.json, un job creado antes de reiniciar el proceso sigue apareciendo.

Parámetro Valores Defecto
status QUEUED, RUNNING, COMPLETED, COMPLETED_WITH_FAILURES, FAILED, CANCELLED todos
limit 1..200 50

Un status o limit inválido se rechaza con 422 de validación de consulta.

5.2 Ejemplo real

examples/api/10_jobs_list.response.json (payload):

{
  "count": 3,
  "items": [
    { "job_id": "job_B403C1AE94", "status": "RUNNING",
      "requested_candidates": 4, "effective_candidates": 4,
      "created_at": "2026-09-17T15:13:35Z", "finished_at": null,
      "progress": { "total": 4, "completed": 0, "failed": 0, "pending": 4, "percent": 0.0 } },
    { "job_id": "job_FCF18574F7", "status": "COMPLETED",
      "requested_candidates": 1, "effective_candidates": 1,
      "created_at": "2026-09-17T15:13:31Z", "finished_at": "2026-09-17T15:13:31Z",
      "progress": { "total": 1, "completed": 1, "failed": 0, "pending": 0, "percent": 100.0 } },
    { "job_id": "job_AFD547BD23", "status": "COMPLETED",
      "requested_candidates": 16, "effective_candidates": 16,
      "created_at": "2026-09-17T15:13:31Z", "finished_at": "2026-09-17T15:13:33Z",
      "progress": { "total": 16, "completed": 16, "failed": 0, "pending": 0, "percent": 100.0 } }
  ]
}

Reconocerás dos viejos amigos: job_FCF18574F7 es el backtest encolado con 202 del capítulo 1 (1 candidato) y job_AFD547BD23 el barrido 4×4 del capítulo 4 (16 candidatos, ~2 s entre creación y fin).

5.3 De la lista al detalle

sequenceDiagram
    participant C as Cliente
    participant API as bench-api
    C->>API: GET /v1/jobs?status=RUNNING
    API-->>C: items[0].job_id = job_B403C1AE94
    C->>API: GET /v1/jobs/job_B403C1AE94
    API-->>C: status, progress, results_url
    alt terminal
        C->>API: GET /v1/jobs/job_B403C1AE94/results
        API-->>C: candidates[] (o payload.backtest si era un 202 de backtest)
    else sigue corriendo y ya no lo quieres
        C->>API: DELETE /v1/jobs/job_B403C1AE94
        API-->>C: status CANCELLED (parciales conservados)
    end
# Últimos 50 jobs
curl -sS "$BASE/v1/jobs" | jq '.payload.items[] | {job_id, status, requested_candidates}'

# Solo los que siguen corriendo, y seguir el primero
JOB=$(curl -sS "$BASE/v1/jobs?status=RUNNING" | jq -r '.payload.items[0].job_id')
curl -sS "$BASE/v1/jobs/$JOB" | jq '.payload | {status, progress}'
import requests
BASE = "http://localhost:8000"
items = requests.get(f"{BASE}/v1/jobs", params={"limit": 20}).json()["payload"]["items"]
for j in items:
    pr = j["progress"]
    print(f'{j["job_id"]} {j["status"]:<24} {pr["completed"]}/{pr["total"]} ({pr["percent"]:.0f} %)')

Un 202 de backtest se lee igual que un barrido

El job de un backtest encolado por D-33 tiene un solo candidato. Cuando termina, GET /v1/jobs/{id}/results publica la respuesta completa del backtest dentro de payload.backtest, con la misma forma que un 200 síncrono.


6. Juntándolo todo: un flujo de medición honesto

  1. Lanza el caso con engine: "both" y repetitions: {"warmup": 3, "measured": 20}.
  2. Comprueba parity.verdict = PASS antes de mirar tiempos.
  3. Anota el run_id de cada motor: son tus actas.
  4. Consulta GET /v1/stats/runs filtrando por strategy_id, asset_id, timeframe, cache_mode y host_fingerprint_hash, agrupando por engine, adapter.
  5. Publica el speedup solo si published: true, y cítalo con sus conditions.

Resumen

  • Cada corrida deja un directorio results/runs/<run_id>/ con run_metrics.json y las tablas en Parquet; se escribe de forma atómica y nunca se sobrescribe.
  • El run_id = prefijo legible + hash de firma + timestamp: agrupa repeticiones del mismo caso y las separa en el tiempo.
  • GET /v1/stats/runs filtra y agrupa (por defecto engine, adapter) y devuelve mediana, P95, IQR, memoria y rendimiento; el P95 con menos de 20 muestras se marca como no fiable.
  • Un speedup solo se publica con las nueve puertas en verde (paridad + 8 firmas iguales); si no, published: false y un reason.
  • GET /v1/jobs lista jobs persistidos (barridos y backtests 202), del más reciente al más antiguo, filtrables por status.

Para practicar

  1. Ejecuta tres veces el mismo backtest de VectorTA y localiza sus tres directorios en results/runs/. ¿Qué parte del run_id comparten?
  2. Consulta GET /v1/stats/runs para ese caso sin group_by=engine. ¿Qué reason da el bloque speedup?
  3. Lanza un backtest con repetitions: {"warmup": 1, "measured": 5}. ¿Qué dice p95_reliable_reason?
  4. Lanza el barrido 4×4 y, mientras corre, consulta GET /v1/jobs?status=RUNNING. Luego repite con status=COMPLETED.
  5. Explica a un compañero, con la tabla de puertas, por qué no puede publicar el speedup de un caso medido en el portátil contra uno medido en el servidor.