Estadísticas de corridas y jobs¶
Qué vas a aprender¶
- Qué es un
run_id, qué contiene su directorio enresults/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/jobsy 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
- 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. - Nunca se sobrescribe. El timestamp en el nombre hace que cada ejecución tenga su propio directorio.
- La firma va entera, también con sus valores por defecto. Un resultado sin
cache_modeno 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}'
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¶
- Lanza el caso con
engine: "both"yrepetitions: {"warmup": 3, "measured": 20}. - Comprueba
parity.verdict = PASSantes de mirar tiempos. - Anota el
run_idde cada motor: son tus actas. - Consulta
GET /v1/stats/runsfiltrando porstrategy_id,asset_id,timeframe,cache_modeyhost_fingerprint_hash, agrupando porengine, adapter. - Publica el speedup solo si
published: true, y cítalo con susconditions.
Resumen¶
- Cada corrida deja un directorio
results/runs/<run_id>/conrun_metrics.jsony 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/runsfiltra y agrupa (por defectoengine, 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: falsey unreason. GET /v1/jobslista jobs persistidos (barridos y backtests 202), del más reciente al más antiguo, filtrables porstatus.
Para practicar¶
- Ejecuta tres veces el mismo backtest de VectorTA y localiza sus tres directorios en
results/runs/. ¿Qué parte delrun_idcomparten? - Consulta
GET /v1/stats/runspara ese caso singroup_by=engine. ¿Quéreasonda el bloquespeedup? - Lanza un backtest con
repetitions: {"warmup": 1, "measured": 5}. ¿Qué dicep95_reliable_reason? - Lanza el barrido 4×4 y, mientras corre, consulta
GET /v1/jobs?status=RUNNING. Luego repite constatus=COMPLETED. - 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.