Paridad y barridos (sweeps)¶
Qué vas a aprender¶
- Qué es la paridad entre motores y por qué se compara capa a capa: features → señales → fills → equity.
- Cómo usar
POST /v1/paritypara encontrar la primera divergencia con un contexto de ±5 barras. - Cómo lanzar un barrido de parámetros con
POST /v1/sweep, qué escandidates.jsonly por qué cada candidato tiene sustrategy_id. - El ciclo de vida de un job (
QUEUED → RUNNING → …) y cómo seguirlo y cancelarlo conGET/DELETE /v1/jobs/{id}.
Parte A — Paridad¶
1. La idea: dos contables, un mismo libro¶
Imagina que das los mismos extractos bancarios a dos contables distintos. Si al final del año los dos te dan el mismo saldo, bien. Pero si difieren, «el saldo no cuadra» no te dice nada útil. Lo que quieres saber es dónde empezó la diferencia: ¿leyeron distinto un apunte? ¿aplicaron distinta regla? ¿redondearon distinto?
La paridad de FINAZ hace exactamente eso con VectorTA y Nautilus, en cuatro capas y en orden:
flowchart LR
F["1 · features<br/>con tolerancia<br/>rtol=1e-10, atol=1e-12"] --> S["2 · signals<br/>exacta<br/>(objetivo entero por barra)"]
S --> FI["3 · fills<br/>exacta<br/>side, qty, price_ticks,<br/>commission_minor, evento"]
FI --> E["4 · equity<br/>cuantizada a 0,01"]
| Capa | Modo | Detalle |
|---|---|---|
features |
con tolerancia | rtol=1e-10, atol=1e-12, desde el first_valid_index común. |
signals |
exacta | Igualdad entera del objetivo en cada barra. |
fills |
exacta | Lado, cantidad, precio en ticks (entero), comisión en céntimos, evento. |
equity |
cuantizada | Igualdad tras cuantizar a 0,01. |
Se informa la primera capa que falla. Una divergencia de equity sin divergencia de señal significa algo muy distinto (contabilidad, redondeo) de una divergencia de señal (indicador, semilla, regla).
Regla de oro
Nunca un speedup sin parity=PASS. Si los dos motores no dicen lo mismo, comparar su velocidad no tiene sentido: sería medir cuánto tarda cada uno en dar una respuesta distinta.
2. Paridad dentro de un backtest (engine: "both")¶
Ya la viste en el capítulo 3: con engine: "both" el backtest incluye un bloque parity. El real de 01_backtest_p01_both.response.json:
{
"verdict": "PASS",
"compared": { "a": "VTA_CPU_LEDGER", "b": "NT_FEATURES" },
"tolerances_used": {
"features": { "ema_fast": { "primitive": "ema", "rtol": 1e-10, "atol": 1e-12 },
"ema_slow": { "primitive": "ema", "rtol": 1e-10, "atol": 1e-12 } },
"signals": "exact", "fills": "exact", "equity": "exact_minor_units"
},
"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",
"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
}
Fíjate en los contadores: 39 032 valores de feature (EMA rápida desde su primer valor válido, índice 19: 19 531; EMA lenta desde el 49: 19 501), 19 501 señales, 328 fills y 19 550 puntos de equity. Todo coincide.
3. POST /v1/parity: el modo diagnóstico¶
POST /v1/parity es un backtest con engine=both orientado a diagnosticar, con dos ventajas:
- Eliges exactamente los dos lados (
sides), cada uno con su motor y susengine_options. - Controlas cuántas barras de contexto quieres alrededor de la divergencia (
context_bars, defecto 5).
Campos del payload (modelo ParityRequestPayload):
| Campo | Obligatorio | Significado |
|---|---|---|
strategy, data, execution |
sí | Igual que en /v1/backtest. |
sides |
sí | Lista de exactamente dos lados: {label, engine, engine_options}. |
tolerances |
no | Hoy solo se aceptan las de fábrica para features (features_rtol=1e-10, features_atol=1e-12). El servidor solo mira esas dos claves; equity_quantum y otras no se validan. |
context_bars |
no (5) | Barras de contexto a cada lado de la primera divergencia. |
cache, output, profile |
no | Igual que en /v1/backtest. |
No se afloja una tolerancia en silencio
Si envías features_rtol o features_atol distintas de las de fábrica, el servidor responde 422 PARITY_SIDES_INVALID con details.accepted (finazbench/runner/from_api.py, execute_parity): la implementación actual no cablea tolerancias personalizadas y prefiere negarse a relajar una comparación sin declararlo. Ojo: el contrato (docs/API.md §6.10 y §8) solo documenta ese código para «menos de dos lados o firmas de contrato distintas»; este uso por tolerancias no está en el contrato. El mismo código sale también si los dos lados no describen el mismo caso.
Petición (forma del contrato, docs/API.md §6.10):
cat > /tmp/parity.json <<'JSON'
{
"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" } } }
],
"context_bars": 5
}
}
JSON
curl -sS -X POST "$BASE/v1/parity" -H 'Content-Type: application/json' -d @/tmp/parity.json \
| jq '.payload | {status, verdict: .parity.verdict, first: .parity.first_divergence}'
import requests
BASE = "http://localhost:8000"
body = {"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"},
{"label": "B", "engine": "nautilus",
"engine_options": {"nautilus": {"adapter": "NT_INTENT_REPLAY"}}},
],
"context_bars": 5,
}}
p = requests.post(f"{BASE}/v1/parity", json=body, timeout=900).json()["payload"]
fd = p["parity"]["first_divergence"]
if fd is None:
print("PASS en todas las capas")
else:
print(fd["layer"], fd["bar_index"], fd["a_value"], fd["b_value"])
for row in fd["context"]:
print(row)
print(fd["diagnosis"])
4. Leer una primera divergencia¶
El contrato ilustra el caso interesante: una divergencia detectada. Es un ejemplo de contrato, no una ejecución regenerada (lleva campos "<medido>"; forma del contrato, sin respuesta real registrada en examples/api/). Aquí se muestra ya con los nombres de campo que emite el servidor (timestamp_ns, field, fill_position), no con el timestamp de la prosa del contrato:
{
"status": "PARITY_FAIL",
"parity": {
"verdict": "FAIL",
"compared": { "a": "vectorta/VTA_CPU_LEDGER", "b": "nautilus/NT_INTENT_REPLAY" },
"comparisons": [
{ "what": "features", "verdict": "FAIL", "max_abs_diff": 0.048, "first_bad_index": 52,
"note": "Diferencia de semilla: la EMA batch de VectorTA arranca con media corrida (first_valid=0) y el stream con semilla SMA (first_valid=period-1)." },
{ "what": "signals", "verdict": "FAIL", "first_bad_index": 57 },
"…"
],
"first_divergence": {
"layer": "signals",
"bar_index": 57,
"fill_position": null,
"timestamp_ns": "<medido>",
"field": "target",
"a_value": 1,
"b_value": 0,
"context": [
{ "bar_index": 52, "a_signal": 0, "b_signal": 0, "…": "…" },
{ "bar_index": 56, "a_signal": 0, "b_signal": 0, "…": "…" },
{ "bar_index": 57, "a_signal": 1, "b_signal": 0, "…": "…" },
{ "bar_index": 58, "a_signal": 1, "b_signal": 1, "…": "…" }
],
"diagnosis": "La divergencia aparece en la capa de señal, no solo en la equity: el cruce se detecta una barra antes en A. Causa probable: variante de semilla de EMA distinta entre proveedores. Remedio: fijar warmup común posterior al mayor first_valid_index, o usar NT_FEATURES para comparar ejecución sin depender de semillas."
}
},
"speedup": { "published": false, "value": null, "reason": "NO_SPEEDUP_WITHOUT_PARITY_PASS" }
}
Forma exacta en el código
La implementación (finazbench/parity/compare.py, FirstDivergence.as_dict) serializa: layer, bar_index, fill_position (si la capa es fills), timestamp_ns, field (qué campo difiere: price_ticks, target…), a_value, b_value, context (una fila por barra con precios, features, objetivos, equity y fills de ambos lados) y diagnosis. El contrato (docs/API.md §6.10 y §7) usa timestamp y no menciona field ni fill_position; el servidor emite timestamp_ns (el market_close_ns de la barra, entero en nanosegundos, o null), field y fill_position. Programa contra la forma del servidor.
Lectura de trader
En la barra 57 el lado A ve el cruce alcista (+1) y el lado B todavía no (0); en la 58 ambos están en +1. La causa no está en la contabilidad sino antes: en cómo se «siembra» la EMA. El diagnosis es una hipótesis declarada, no un veredicto: te dice qué suele causar esa forma de divergencia y cómo probarlo.
5. ¿Qué adaptador de Nautilus elijo para comparar?¶
| Adaptador | Pregunta que responde | Para paridad |
|---|---|---|
NT_FEATURES |
¿Cuánto cuesta política + ejecución si comparto las features? | La opción justa: no depende de semillas. Es el defecto. |
NT_ONLINE |
¿Cuánto cuesta el flujo online completo? | Sensible a semillas de indicador. |
NT_INTENT_REPLAY |
¿Cuánto cuesta solo reproducir decisiones? | Aísla ejecución y contabilidad. |
Parte B — Barridos (sweeps)¶
6. La idea: una familia de variantes, no una lista anónima¶
Un barrido prueba muchas combinaciones de parámetros de una plantilla. En FINAZ un barrido materializa una instancia por candidato: cada fila tiene su strategy_id definitivo, de modo que cualquier resultado del barrido se puede reproducir luego como un backtest individual por id.
sequenceDiagram
participant C as Cliente
participant API as bench-api
participant JM as JobManager
participant FS as results/jobs/{job_id}/
C->>API: POST /v1/sweep (grid 4x4)
API->>API: expandir rejilla, descartar inválidos y duplicados
API->>API: estimar recursos (política 1e8)
API->>FS: candidates.jsonl (ANTES de ejecutar)
API->>JM: submit(job)
API-->>C: 202 {job_id, materialized_strategy_ids, factorization_plan, poll}
loop hasta estado terminal
C->>API: GET /v1/jobs/{job_id}
API-->>C: status, progress
end
C->>API: GET /v1/jobs/{job_id}/results
API-->>C: candidates[], aggregate_timing, quality
7. La petición: POST /v1/sweep¶
Petición real examples/api/03_sweep_p01_4x4.request.json (payload, sin engine_options):
{
"base_strategy_id": "P01-dc9260",
"template_id": null,
"grid": { "fast": [5, 10, 20, 40], "slow": [50, 100, 150, 200] },
"candidates": null,
"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": { "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": "vectorta",
"cache": { "cache_mode": "OPT_FULL" },
"output": { "output_mode": "metrics", "include_timings": true, "include_parity": false },
"profile": { "profile": "outer_parallel", "repetitions": null },
"budget": { "max_wall_seconds": 3600, "max_memory_bytes": 1073741824 }
}
| Campo | Significado |
|---|---|
base_strategy_id / template_id |
De qué estrategia o plantilla parte el barrido. |
grid |
Rejilla cartesiana de params. |
candidates |
Lista explícita de params (o ruta a un candidates.jsonl previo). Excluyente con grid (422 SWEEP_SELECTOR_AMBIGUOUS). |
candidate_policy.drop_invalid |
Descarta los que violan restricciones (p. ej. fast >= slow), pero cuentan en el denominador. |
candidate_policy.drop_duplicates |
Quita duplicados antes de medir. |
candidate_policy.persist_before_run |
Escribe candidates.jsonl antes del primer candidato. |
candidate_policy.register_strategies |
Registra cada instancia en results/strategies/. |
candidate_policy.name_template |
Nombre legible de cada instancia. |
budget |
Presupuesto; si la estimación supera la política ⇒ 507 RESOURCE_LIMIT antes de empezar. |
8. La respuesta: 202 con acuse¶
Respuesta real (03_sweep_p01_4x4.response.json, recortada):
{
"payload": {
"job_id": "job_AFD547BD23",
"status": "QUEUED",
"requested_candidates": 16,
"effective_candidates": 16,
"dropped": { "invalid": 0, "duplicates": 0, "invalid_reason_counts": {} },
"candidates_path": "results/jobs/job_AFD547BD23/candidates.jsonl",
"materialized_strategy_ids": ["P01-c829c1", "P01-81f6fc", "P01-934311", "…", "P01-dc9260", "P01-09dd21", "…"],
"factorization_plan": {
"distinct_features": 8,
"feature_list": ["ema:10", "ema:100", "ema:150", "ema:20", "ema:200", "ema:40", "ema:5", "ema:50"],
"note": "16 puntos solicitados, 16 válidos y distintos (0 inválidos, 0 duplicados) y 8 curvas de feature distintas."
},
"resource_estimate": {
"estimated_peak_bytes": 40038400, "block_size": 19550, "workers": 1,
"estimated_bar_candidate_evaluations": 312800,
"policy_max_estimated_bar_candidate_evaluations_default": 100000000
},
"poll": "/v1/jobs/job_AFD547BD23"
}
}
Tres lecturas importantes:
- 16 pedidos, 16 efectivos. Siempre se publican por separado, aunque coincidan. En P11 (15 pedidos, 14 válidos) un informe nunca podría decir «15 estrategias distintas».
- 8 curvas, no 32. 16 parejas de EMAs usan solo 8 periodos distintos ({5,10,20,40} ∪ {50,100,150,200}). Ahí, y no en el número de candidatos, está el ahorro potencial.
- Estimación de recursos: 19 550 barras × 16 candidatos = 312 800 evaluaciones, muy por debajo de la política de 1e8.
Y reutiliza identidades: P01-dc9260 y P01-09dd21 aparecen porque la rejilla contiene 20/50 y 20/100 y sus ids deterministas coinciden con las instancias ya registradas. No se crea ningún duplicado.
Cada línea de candidates.jsonl tiene la forma (contrato §4.6):
{"candidate_id":1,"strategy_id":"P01-dc9260","template_id":"P01","params":{"fast":20,"slow":50},"valid":true}
9. Seguir y leer el job¶
JOB=$(curl -sS -X POST "$BASE/v1/sweep" -H 'Content-Type: application/json' \
-d @examples/api/03_sweep_p01_4x4.request.json | jq -r '.payload.job_id')
curl -sS "$BASE/v1/jobs/$JOB" | jq '.payload | {status, progress}'
curl -sS "$BASE/v1/jobs/$JOB/results" | jq '.payload | {status, requested_candidates, effective_candidates, aggregate_timing, quality}'
head -2 results/jobs/$JOB/candidates.jsonl
import time, requests
BASE = "http://localhost:8000"
TERMINAL = {"COMPLETED", "COMPLETED_WITH_FAILURES", "FAILED", "CANCELLED"}
def wait(job_id, every=1.0):
while True:
p = requests.get(f"{BASE}/v1/jobs/{job_id}").json()["payload"]
print(p["status"], p.get("progress"))
if p["status"] in TERMINAL:
return p
time.sleep(every)
wait("job_AFD547BD23")
res = requests.get(f"{BASE}/v1/jobs/job_AFD547BD23/results",
params={"sort_by": "final_equity"}).json()["payload"]
for c in res["candidates"]:
print(c["strategy_id"], c["params"], c["run_metrics"]["final_equity"])
Resultado real (03_sweep_p01_4x4.results.response.json, recortado):
{
"job_id": "job_AFD547BD23",
"status": "COMPLETED",
"requested_candidates": 16,
"effective_candidates": 16,
"aggregate_timing": {
"total_wall_ns": 2055475709,
"amortized_wall_ns_per_candidate": 128467231,
"single_request_latency_ns": 29764492,
"shared_precompute_wall_ns": 0,
"note": "The total includes the sequential execution of every candidate; shared precompute is not wired (0 s measured, not estimated)."
},
"candidates": [
{ "candidate_id": 1, "strategy_id": "P01-c829c1", "name": "Cruce de medias EMA 5/50",
"params": { "fast": 5, "slow": 50 }, "status": "PASS",
"run_metrics": { "n_fills": 660, "final_equity": 99929.44, "costs_total": 70.43, "max_drawdown": 0.00103192, "…": "…" },
"wall_ns": 32716475, "run_id": "NVDA_5min_P01-c829c1_VTA_CPU_LEDGER-10d924c0329499d1-20260917T151331756656446" },
{ "candidate_id": 2, "strategy_id": "P01-81f6fc", "name": "Cruce de medias EMA 5/100",
"params": { "fast": 5, "slow": 100 }, "status": "PASS",
"run_metrics": { "n_fills": 457, "final_equity": 100030.42, "costs_total": 48.23, "…": "…" },
"wall_ns": 52153156, "run_id": "…" },
"…"
],
"dropped_candidates": [],
"quality": { "denominator_includes_failures": true, "completed": 16, "failed": 0,
"note": "Failed candidates stay in the denominator; they are never excluded to improve the mean time." },
"next_cursor": null
}
Latencia amortizada ≠ latencia de una petición
2,06 s / 16 ≈ 128 ms por candidato es una latencia amortizada. La de una petición individual es otro campo (single_request_latency_ns, ≈ 29,8 ms aquí). Y el precompute compartido de las 8 EMAs no está cableado todavía: por eso vale 0, medido, no estimado.
Consultas de /results: limit, cursor, status (filtra candidatos por estado) y sort_by (ordena por una métrica de run_metrics, p. ej. final_equity).
Ejemplo de trading: 5/50 frente a 5/100
Con fast=5, pasar slow de 50 a 100 reduce los fills de 660 a 457 y las comisiones de 70,43 a 48,23 USD, y la equity final pasa de 99 929,44 a 100 030,42. Menos cruces, menos coste. Es el tipo de sensibilidad que un barrido hace visible en una sola llamada.
10. Estados de un job y cancelación¶
stateDiagram-v2
[*] --> QUEUED: POST /v1/sweep<br/>o backtest 202
QUEUED --> RUNNING: el worker lo toma
QUEUED --> CANCELLED: DELETE /v1/jobs/{id}
RUNNING --> COMPLETED: todos PASS
RUNNING --> COMPLETED_WITH_FAILURES: alguno falló
RUNNING --> FAILED: fallo del job
RUNNING --> CANCELLED: DELETE /v1/jobs/{id}
COMPLETED --> [*]
COMPLETED_WITH_FAILURES --> [*]
FAILED --> [*]
CANCELLED --> [*]
| Estado | Terminal | Significado |
|---|---|---|
QUEUED |
no | Creado y persistido; esperando. |
RUNNING |
no | En ejecución; progress avanza. |
COMPLETED |
sí | Todos los candidatos terminaron sin fallo. |
COMPLETED_WITH_FAILURES |
sí | Terminó, pero algún candidato falló (y sigue en el denominador). |
FAILED |
sí | El job en sí falló. |
CANCELLED |
sí | Cancelado por el usuario. |
Cancelar:
Reglas de la cancelación (finazbench/api/jobs.py):
- Los resultados parciales se conservan.
- Un job ya terminal no cambia de estado: reescribir un
COMPLETEDcomoCANCELLEDborraría la historia de una corrida que sí terminó. ElDELETEsobre un job terminado simplemente devuelve su estado actual. - Un
job_idinexistente ⇒404 JOB_NOT_FOUND.
Los jobs sobreviven a reinicios
El estado vive en results/jobs/<job_id>/job.json (escritura atómica). Por eso GET /v1/jobs sigue listando un job creado antes de reiniciar el proceso. El listado se estudia en el capítulo 6.
Resumen¶
- La paridad compara features (tolerancia) → señales (exacta) → fills (exacta) → equity (cuantizada) y reporta la primera capa que falla.
POST /v1/parityelige dos lados arbitrarios (incluso dos adaptadores del mismo motor) y devuelvefirst_divergencecon ±context_barsbarras (5 por defecto) y undiagnosis.- Sin
parity=PASSno hay speedup:reason: NO_SPEEDUP_WITHOUT_PARITY_PASS. POST /v1/sweepresponde202, escribecandidates.jsonlantes de ejecutar y materializa unstrategy_idpor candidato.requested_candidatesyeffective_candidatesse publican siempre por separado; los fallidos cuentan en el denominador.- Los jobs pasan por
QUEUED → RUNNING → {COMPLETED, COMPLETED_WITH_FAILURES, FAILED, CANCELLED};DELETEcancela conservando los parciales.
Para practicar¶
- Lanza
POST /v1/parityconNT_FEATURESen el lado B. ¿Cambia el veredicto respecto aNT_INTENT_REPLAY? - Envía
tolerances: {"features_rtol": 1e-6}. ¿Qué error recibes y por qué es una buena decisión de diseño? - Construye un barrido de P01 con
grid: {"fast": [20, 50], "slow": [50, 100]}. ¿Cuántos candidatos pedidos, efectivos e inválidos esperas? Compruébalo. - Lanza el barrido 4×4 y cancélalo a mitad. ¿Qué estado y qué
progressquedan? ¿Qué pasa si vuelves a hacerDELETE? - En los resultados del 4×4, ordena por
final_equityy reproduce el mejor candidato como backtest individual usando solo sustrategy_id.