Saltar a contenido

Arquitectura del runtime causal

Qué vas a aprender

  • Por qué existe el runtime causal si ya teníamos un banco de backtesting (finazbench) que funcionaba, y cómo encajan las dos APIs de la plataforma.
  • Las cuatro ideas fuerza de la decisión principal del pack: causalidad, snapshots, publicación transaccional y recuperación.
  • Cómo se organiza el monorepo: 30 paquetes de trabajo (AG-001…AG-030), cada uno con un dueño y unas rutas propias.
  • Cómo se despliega en el servidor finaz-new: un proyecto Compose nuevo (finaz-trading-engine) que se apoya en PostgreSQL, ClickHouse, Redpanda y MinIO del proyecto existente finaz, sin duplicarlos.
  • Qué son los contratos congelados (53 schemas) y por qué no se cambian sin ADR.
  • La lista, igual de importante, de lo que no se construye.

Sobre las fuentes de este capítulo

Todo lo que sigue sale de docs/v2/ARQUITECTURA.md, docs/v2/MOTORES.md, docs/v2/DATOS.md, docs/v2/README.md y del pack de arquitectura (InputExternal/FINAZ_Architecture_Definition_Pack_v1/architecture/00_executive_decisions.md, 16_implementation_roadmap.md). Cuando una cifra depende de una fecha, se indica la fecha.


1. El problema: un buen banco no es una plataforma

En las partes anteriores del manual has usado finazbench: un banco de pruebas cuantitativo con motores (Nautilus, VectorTA), un catálogo de indicadores y la API de backtesting y análisis. Funciona, tiene tests y produce resultados reproducibles en su entorno.

Pero cuando quieres que la plataforma, además de medir y comparar,

  • reciba peticiones de varios usuarios con distintos permisos,
  • ejecute el mismo cálculo en lote, en reproducción histórica y en tiempo real simulado,
  • sobreviva a que un proceso muera a mitad de trabajo,
  • y garantice que nunca ha usado información del futuro,

aparecen preguntas que un banco de pruebas no necesita responder. ¿Quién es el dueño de un resultado? ¿Cuándo se hace visible? ¿Qué pasa si dos procesos creen ser el dueño a la vez? ¿Qué datos exactos vio el cálculo?

Analogía: la cocina de casa y la cocina de un restaurante

En tu cocina puedes probar recetas, repetir, tirar lo que sale mal: es un banco de pruebas. Un restaurante necesita otra cosa: comandas numeradas (admisión), un jefe de partida por plato (dueño único), un pase donde el plato sólo sale cuando está completo (publicación), y un procedimiento si un cocinero se va a mitad de servicio (recuperación). Las recetas pueden ser las mismas; la organización es distinta. El runtime causal es esa organización.

Una plataforma, dos APIs

FinazTradingEngine es una sola plataforma. Sus piezas: el banco de backtesting (NautilusTrader + VectorTA + ledger SIM-S, con el oráculo de paridad), el runtime causal batch/replay/stream, los motores de modelos y los almacenes de datos (PostgreSQL, ClickHouse, Redpanda, MinIO). Hacia fuera hay dos APIs, y no son dos generaciones de lo mismo: responden a preguntas distintas.

API de backtesting y análisis API de plataforma
Servicio y puerto bench-api, :8000 api del proyecto finaz-trading-engine, 127.0.0.1:18300
Rutas /v1/... (más /health, /doctor) /platform/v1/... (más /healthz, /health/*)
Pregunta que responde «¿Qué habría pasado con esta estrategia? ¿Coinciden los motores? ¿Cuánto cuesta?» «Ejecuta este flujo causal, con dueño, snapshot sellado y publicación, y dime su estado»
Qué ejecuta Backtests, paridad, barridos, indicadores, estadísticas de runs Flows, runs, resultados publicados, snapshots, suscripciones
Autenticación Sin token (servicio local de medición) Token Bearer y roles viewer/operator/deployer
Dónde se estudia Parte VI Capítulo 4 de esta parte

Los números que ves en los nombres (/v1, /platform/v1, directorios docs/v2/, rama feat/finaz-v2) son identificadores de versión de contrato o de rama, no «plataforma antigua» y «plataforma nueva».

La decisión principal (pack §00)

El capítulo 00 del pack resume la decisión en una frase: conservar el banco cuantitativo como baseline/oráculo y construir un núcleo contractual nuevo con causalidad, snapshots, publicación transaccional y recuperación. El veredicto literal del pack:

El primer entregable de implementación no es una colección de modelos: es un flujo EMA causal con el mismo resultado lógico en batch, replay y streaming simulado, con snapshot sellado y recuperación demostrada.

Y otra regla que marca el tono de todo el runtime: no live trading. Toda ejecución es simulada y cada capacidad se certifica mediante gates (capítulo 6 de esta parte).

Las cuatro ideas fuerza

Idea Qué significa Dónde vive en el código
Causalidad Ningún cálculo usa un dato antes de su instante de disponibilidad (available_at, known_at). Las etiquetas de entrenamiento sólo se usan cuando están maduras. finaz_clock, finaz_features, finaz_training/labels, selectores PIT de finaz_data
Snapshots Los datos de entrada se congelan en un paquete sellado (SEALED) direccionado por contenido (hash). Un run apunta a un snapshot, no a "la tabla de hoy". finaz_storage, finaz_data/snapshots
Publicación transaccional Un resultado sólo es visible cuando una transacción en PostgreSQL lo publica. Lo que está a medias (staging) es invisible. finaz_control/publication.py
Recuperación Si un proceso muere, otro puede retomar desde el último corte consistente, sin perder ni duplicar. El dueño se decide con leases y fence tokens. finaz_control/leases.py, finaz_runtime/recovery
¿Por qué PostgreSQL es la «única puerta de visibilidad»?

El capítulo 06 del pack explica que no se promete una transacción distribuida entre PostgreSQL, ClickHouse y Redpanda. La garantía es más modesta y más honesta: entrega al menos una vez, publicación lógica idempotente y corte recuperable. Los datos grandes se escriben primero (staging, invisible); después una transacción PG verifica dueño, fence y hashes, y publica. Si el proceso muere antes, lo staged queda huérfano e invisible; si muere después, el resultado ya publicado se restaura, no se recalcula como nuevo.

El banco de backtesting es el oráculo

finazbench queda congelado como referencia: no se borra ni se modifica, y el runtime causal lo usa como verdad. Por ejemplo, la EMA del runtime se compara bit a bit con una foto congelada del cálculo canónico del banco (migration/baseline/oracle/ema_oracle.py, foto de finazbench/features/canonical.py). Si el runtime diera otro número, el fallo es del runtime.

flowchart LR
    subgraph Banco["finazbench, banco de backtesting (CONGELADO)"]
        C[canonical.py<br/>EMA, indicadores]
    end
    subgraph Oráculo["migration/baseline"]
        O[ema_oracle.py<br/>foto pineada]
    end
    subgraph RT["Runtime causal"]
        F[finaz_features<br/>feature.ema.sma_seed.reject_gap]
    end
    C -- "foto (sólo lectura)" --> O
    O -- "comparación bit a bit en tests" --> F

2. El monorepo: 30 paquetes de trabajo

El runtime causal vive en el mismo repositorio que el banco de backtesting, en la rama feat/finaz-v2. El pack divide el trabajo en 30 fichas de agente, AG-001…AG-030. Cada ficha tiene:

  • unas rutas propias (sólo su dueño las toca),
  • unos contratos que consume y produce,
  • una imagen Docker donde corre,
  • y un gate que la certifica.

30 paquetes de trabajo ≠ 30 carpetas finaz_*

En packages/ hay 26 carpetas finaz_*. La cuenta no cuadra con 30 porque algunas fichas no son un paquete Python: AG-001 (baseline y oráculo) vive en migration/baseline/ y tests_v2/legacy/; AG-003 (build) en environments/ y deployment/v2/; AG-029 (QA) en tests_v2/integration, benchmark_specs/v2 y qa_reports/v2. Y a la inversa, una ficha puede tener varios paquetes (AG-005 = finaz_security + finaz_observability).

Mapa por capas

Capa Fichas Paquetes principales
Núcleo contractual y build AG-001, AG-002, AG-003 finaz_contracts, contracts/runtime/, environments/, deployment/v2/
Datos, control y seguridad AG-004 … AG-009 finaz_control, finaz_security, finaz_observability, finaz_instruments, finaz_calendars, finaz_data, finaz_storage
Reloj, features, compilador, runtime AG-010 … AG-017 finaz_clock, finaz_features, finaz_flow, finaz_runtime, finaz_replay, finaz_transport, finaz_api
Training y modelos AG-018 … AG-021, AG-026 … AG-028 finaz_training, finaz_models_tabular, finaz_models_statistics, finaz_models_neural, finaz_models_chronos, finaz_models_timesfm
Riesgo, cartera, discreto AG-022 … AG-025 finaz_risk, finaz_constraints, finaz_allocation, finaz_discrete
Integración, migración, QA AG-029, AG-030 tests_v2/integration, finaz_compat, migration/rollout/

Otras carpetas clave de la raíz

Ruta Qué es Dueño
contracts/runtime/ Schemas JSON congelados (copias byte-idénticas del pack) AG-002 (cambios sólo por ADR)
registry/operators/ Allowlist de operadores del FlowSpec AG-012
migrations/postgres/, migrations/clickhouse/ DDL forward-only e idempotente AG-004/006/007/009/019 + AG-008
environments/{api,core,io,quant,opt,neural-cpu,chronos-cpu,timesfm25-cpu}/ Un proyecto uv con lock por imagen AG-003
tests_v2/*/ Suites por área cada AG
qa_reports/v2/ Evidencia de gates (JUnit, JSON, logs) AG-029
finazbench/, tests/, services/ Banco de backtesting (congelado) nadie (sólo lectura)

Reglas de propiedad que el código hace cumplir

Algunas separaciones no son sólo organizativas: hay tests que fallan si se rompen.

  • AG-024 (allocation) y AG-025 (discreto) nunca construyen un objetivo aprobado. Sólo producen candidatos (PortfolioCandidate, DiscreteCandidate).
  • AG-023 (finaz_constraints) es el productor exclusivo de PortfolioTarget/DiscreteTarget aprobados, y nunca optimiza: sólo valida y veta.
  • El stream no carga modelos foundation, y la API no importa Torch (hay un test de aislamiento).
  • Ningún sink inventa campos, unidades ni timestamps: los huecos se documentan como gaps con ADR.

Analogía: el que cocina no es el que da el visto bueno

En el pase del restaurante, quien emplata no es quien decide si el plato sale. finaz_allocation emplata (propone pesos); finaz_constraints es el jefe de pase (aprueba o veta). Si mañana alguien «optimiza» saltándose el pase, un test lo detecta.


3. Topología de despliegue en finaz-new

El runtime causal se despliega en un único host (finaz-new, Ubuntu 24.04, 24 CPU, 251 GiB de RAM, sin GPU) con Docker Compose. El punto delicado: en ese servidor ya existe un proyecto Compose llamado finaz con PostgreSQL, ClickHouse, Redpanda y MinIO. El runtime no los duplica: se conecta a ellos por la red finaz-net y trabaja en namespaces propios.

flowchart TB
    subgraph FINAZ["Proyecto Compose «finaz» (EXISTENTE, no gestionado aquí)"]
        PG[(finaz-postgres:5432)]
        CH[(finaz-clickhouse:8123/9000)]
        RP[[finaz-redpanda:9092]]
        MN[(finaz-minio:9000)]
    end
    subgraph TE["Proyecto Compose «finaz-trading-engine» (NUEVO)"]
        API[api<br/>127.0.0.1:18300]
        WB[worker-batch<br/>perfil batch]
        WS[worker-stream<br/>perfil stream]
        RPL[replay<br/>job one-shot]
        JOBS[job-quant / job-opt / job-neural<br/>job-chronos / job-timesfm25<br/>jobs S7 one-shot]
        VOL[(volúmenes propios<br/>finaz-te-artifacts, finaz-te-models,<br/>finaz-te-jobs)]
    end
    U((usuario / operador)) --> API
    API -- "BD finaz_trading_engine" --> PG
    WB -- "outbox, leases, publicación" --> PG
    WB -- "staging BD finaz_te" --> CH
    WS -- "topics finaz-te-*" --> RP
    RPL -- "feed simulado" --> RP
    WB --- VOL
    JOBS --- VOL

Namespaces propios

Servicio compartido Namespace del runtime
PostgreSQL base finaz_trading_engine
ClickHouse base finaz_te
Redpanda prefijo de topics finaz-te-
MinIO bucket finaz-te-artifacts

Además, desde el hito de hardening (S1), cada servicio usa su propio rol de base de datos (api → finaz_te_api, worker → finaz_te_worker, migrador → finaz_te_migrator, …) y las credenciales llegan como ficheros montados, nunca como variables visibles en docker inspect.

Perfiles e imágenes

Compose usa perfiles para no levantar todo a la vez:

Perfil Servicio Tipo Imagen
base api servicio (healthcheck) finaz-te-api
batch worker-batch servicio finaz-te-core
stream worker-stream servicio finaz-te-core
replay-job replay job one-shot (run --rm) finaz-te-io
quant, opt, neural, chronos, timesfm25 job-* jobs S7 one-shot (restart: "no") finaz-te-quant, -opt, -neural, -chronos, -timesfm25

Las imágenes se construyen por dependencias, no por función: finaz-te-core lleva NumPy, Numba, SciPy, PyArrow, Polars y el cliente Kafka; finaz-te-neural añade Torch CPU y NeuralForecast. Todas parten de python:3.12-slim, corren como usuario finaz (uid 10000), con sistema de ficheros de sólo lectura y cap_drop: ALL.

Prohibiciones operativas (runbook del pack)

  • Nunca down, build, pull, prune ni migraciones sobre el proyecto finaz.
  • No duplicar PG/CH/Redpanda/MinIO en el Compose nuevo.
  • Sin container_name fijo.
  • Nunca docker compose down -v como rollback (borraría volúmenes).
  • .env y cache/ nunca viajan al servidor.
  • La API sólo escucha en loopback (127.0.0.1:18300), elegido tras verificar con ss -lnt que estaba libre (18099, 18100 y 18430 estaban ocupados).

4. Contratos congelados: 53 schemas

Todo lo que cruza una frontera entre paquetes (un FlowSpec, un RunSpec, un ResultRef, una ForecastDistribution…) tiene un contrato: un JSON Schema en contracts/runtime/*.schema.json y una clase inmutable (frozen dataclass) en packages/finaz_contracts/.

¿53 o 54 ficheros?

En contracts/runtime/ hay 54 ficheros *.schema.json: 53 son los contratos lógicos (FlowSpec, RunSpec, ResultRef, …) y el restante, finaz.v1.schema.json, es el bundle que los agrupa. Además está operator_parameter_schemas.json (parámetros de operadores) y un README.

Una muestra de los contratos

Familia Ejemplos
Referencias temporales y de datos TimeDomainRef, SnapshotRef, SnapshotManifest, DataSelector, ObservationBatch
Flujo y ejecución FlowSpec, CompiledPlan, ExecutionPlan, RunSpec, RunRef, ResultRef, CheckpointManifest, HandoffManifest
Reloj y features ClockSpec, ClockRef, ClockedBatch, FeatureSchema, FeatureBatch
Modelos y training TargetSpec, LabelBatch, TrainingSpec, TrainingRun, ModelRef, ModelArtifact, EvaluationReport, PromotionDecision, ForecastDistribution
Riesgo y cartera AlphaEstimate, RiskEstimate, CovarianceEstimate, OptimizationProblem, PortfolioCandidate, PortfolioTarget, ConstraintReport, DiscreteCandidate, DiscreteTarget

Reglas que el código aplica

El paquete finaz_contracts no se limita a describir: rechaza.

  • Sin NaN ni infinitos en JSON.
  • Sin timestamps numéricos donde el schema exige string (los nanosegundos viajan como texto: "1789655400000000000").
  • kind desconocido → rechazo.
  • Unidades que no casan → UNIT_MISMATCH, sin convertir en silencio.
  • Serialización canónica FINAZ_JSON_V1 (claves ordenadas, sin espacios, UTF-8) para que el mismo objeto dé siempre el mismo hash.
  • FlowSpec sin código arbitrario: sólo operadores de la allowlist (capítulo 2).

Analogía: el formulario oficial

Un contrato es como un formulario oficial: si falta una casilla o pones una fecha en un formato que no es el suyo, la ventanilla no lo «arregla»; lo devuelve. Eso es lo que quiere decir «serializers estrictos sin coerción silenciosa».

Cambios sólo por ADR. Un cambio de schema, unidad, reloj o código de error requiere un Architecture Decision Record, actualizar ejemplos y tests negativos, y compatibilidad antes de integrar. Cuando un paquete necesita algo que el contrato no tiene, no se inventa un campo: se declara un gap y se deriva al dueño (verás varios, como ADR-AG005-errores-auth, en la API).


5. Lo que NO se construye (pack §19)

Una arquitectura también se define por lo que rechaza. Esta lista evita que la plataforma crezca por inercia:

No se construye Por qué (resumen)
Broker live o paper, órdenes reales Fuera del alcance: todo es simulado
Training distribuido, Kubernetes, HA multi-host Un solo host basta; complejidad sin necesidad medida
Ray, Spark, Dask Idem
Bases de datos o brokers propios, Kafka adicional Se reutiliza lo que ya existe en finaz
Redis, Iceberg, Feast, MLflow «Por inercia»: no aportan a los gates
Temporal, Airflow, Prefect El outbox PG + workers cubre la orquestación necesaria
Feature store universal Las features viven en operadores versionados
Reescritura en Rust No es la estrategia de desarrollo
JAX, TimesFM-XReg, TimesFM 3.0 Rechazados explícitamente; se pinea TimesFM 2.5
GPU en imágenes base El servidor no tiene GPU; todo CPU
SQL/Python arbitrario dentro de un flow Seguridad y reproducibilidad: allowlist de operadores

Una regla útil para leer el resto de la parte

Si ves una capacidad que «parece» que debería estar y no está, mira primero esta tabla y, después, docs/v2/GATES.md. Muchas ausencias son decisiones, no olvidos.


6. Cómo encaja todo: el primer vertical

Para cerrar, una vista de extremo a extremo del primer vertical (Flow A), que es el hilo conductor de los capítulos siguientes:

sequenceDiagram
    autonumber
    participant U as Usuario
    participant API as api (:18300)
    participant PG as PostgreSQL<br/>(finaz_trading_engine)
    participant W as worker-batch
    participant CAS as Artefactos (CAS)
    participant CH as ClickHouse (finaz_te)
    U->>API: POST /platform/v1/flows (FlowSpec)
    API->>PG: guarda versión inmutable del flujo
    U->>API: POST /platform/v1/runs (Idempotency-Key)
    API->>PG: api_runs QUEUED + outbox run.requested (1 transacción)
    API-->>U: 202 RunRef
    W->>PG: reclama evento del outbox
    W->>PG: CAS QUEUED → RUNNING
    W->>W: snapshot → clock → EMA → persist
    W->>CAS: FeatureBatch (content-addressed)
    W->>CH: staging de filas
    W->>PG: publica ResultRef + SUCCEEDED
    U->>API: GET /platform/v1/results/{id}
    API->>PG: lee ResultRef publicado
    API-->>U: 200 ResultRef
  • El capítulo 2 explica qué es el FlowSpec y cómo se compila.
  • El capítulo 3, cómo lo ejecutan los workers en batch, replay y stream.
  • El capítulo 4, la API que está delante.
  • El capítulo 5, los jobs de modelos (G4–G8).
  • El capítulo 6, cómo se certifica todo con gates.

Resumen

  • La plataforma tiene dos APIs con roles distintos: backtesting y análisis (:8000, /v1/...) y plataforma (:18300, /platform/v1/..., corridas causales).
  • El runtime causal no sustituye al banco: lo usa como oráculo y construye alrededor un núcleo contractual con causalidad, snapshots, publicación transaccional y recuperación. Sin live trading.
  • El monorepo se reparte en 30 fichas AG con dueños y rutas exclusivas; algunas separaciones (quién optimiza, quién aprueba) están protegidas por tests.
  • En finaz-new, el proyecto finaz-trading-engine reutiliza PG/CH/Redpanda/MinIO del proyecto finaz mediante namespaces y roles propios; la API sólo escucha en 127.0.0.1:18300.
  • Hay 53 contratos lógicos congelados; se cambian sólo por ADR y los serializers rechazan en vez de corregir.
  • La lista de no-objetivos (§19) es parte de la arquitectura.

Para practicar

  1. Abre docs/v2/MOTORES.md y localiza la ficha que produce PortfolioTarget aprobados. ¿Por qué no puede ser la misma que resuelve la optimización?
  2. En contracts/runtime/, cuenta los ficheros *.schema.json y explica la diferencia con «53 contratos».
  3. Dibuja (en papel) qué pasaría si el worker-batch muriera justo antes de la transacción de publicación en PG. ¿Vería el usuario un resultado a medias? (Pista: sección 1, «única puerta de visibilidad».)
  4. Busca en deployment/v2/compose.yaml qué servicios tienen restart: "no" y relaciónalo con la tabla de perfiles.
  5. Elige tres elementos de la tabla de «lo que NO se construye» y escribe, para cada uno, qué pieza de la plataforma cubre su función (por ejemplo: «Airflow → outbox PG + worker»).