Saltar a contenido

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/parity para encontrar la primera divergencia con un contexto de ±5 barras.
  • Cómo lanzar un barrido de parámetros con POST /v1/sweep, qué es candidates.jsonl y por qué cada candidato tiene su strategy_id.
  • El ciclo de vida de un job (QUEUED → RUNNING → …) y cómo seguirlo y cancelarlo con GET / 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:

  1. Eliges exactamente los dos lados (sides), cada uno con su motor y sus engine_options.
  2. 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 Igual que en /v1/backtest.
sides 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:

  1. 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».
  2. 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.
  3. 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 Todos los candidatos terminaron sin fallo.
COMPLETED_WITH_FAILURES Terminó, pero algún candidato falló (y sigue en el denominador).
FAILED El job en sí falló.
CANCELLED Cancelado por el usuario.

Cancelar:

curl -sS -X DELETE "$BASE/v1/jobs/$JOB" | jq '.payload | {status, finished_at, progress}'

Reglas de la cancelación (finazbench/api/jobs.py):

  • Los resultados parciales se conservan.
  • Un job ya terminal no cambia de estado: reescribir un COMPLETED como CANCELLED borraría la historia de una corrida que sí terminó. El DELETE sobre un job terminado simplemente devuelve su estado actual.
  • Un job_id inexistente ⇒ 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/parity elige dos lados arbitrarios (incluso dos adaptadores del mismo motor) y devuelve first_divergence con ±context_bars barras (5 por defecto) y un diagnosis.
  • Sin parity=PASS no hay speedup: reason: NO_SPEEDUP_WITHOUT_PARITY_PASS.
  • POST /v1/sweep responde 202, escribe candidates.jsonl antes de ejecutar y materializa un strategy_id por candidato.
  • requested_candidates y effective_candidates se publican siempre por separado; los fallidos cuentan en el denominador.
  • Los jobs pasan por QUEUED → RUNNING → {COMPLETED, COMPLETED_WITH_FAILURES, FAILED, CANCELLED}; DELETE cancela conservando los parciales.

Para practicar

  1. Lanza POST /v1/parity con NT_FEATURES en el lado B. ¿Cambia el veredicto respecto a NT_INTENT_REPLAY?
  2. Envía tolerances: {"features_rtol": 1e-6}. ¿Qué error recibes y por qué es una buena decisión de diseño?
  3. Construye un barrido de P01 con grid: {"fast": [20, 50], "slow": [50, 100]}. ¿Cuántos candidatos pedidos, efectivos e inválidos esperas? Compruébalo.
  4. Lanza el barrido 4×4 y cancélalo a mitad. ¿Qué estado y qué progress quedan? ¿Qué pasa si vuelves a hacer DELETE?
  5. En los resultados del 4×4, ordena por final_equity y reproduce el mejor candidato como backtest individual usando solo su strategy_id.