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 existentefinaz, 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 dePortfolioTarget/DiscreteTargetaprobados, 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,pruneni migraciones sobre el proyectofinaz. - No duplicar PG/CH/Redpanda/MinIO en el Compose nuevo.
- Sin
container_namefijo. - Nunca
docker compose down -vcomo rollback (borraría volúmenes). .envycache/nunca viajan al servidor.- La API sólo escucha en loopback (
127.0.0.1:18300), elegido tras verificar conss -lntque 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
NaNni infinitos en JSON. - Sin timestamps numéricos donde el schema exige string (los nanosegundos viajan como texto:
"1789655400000000000"). kinddesconocido → 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 proyectofinaz-trading-enginereutiliza PG/CH/Redpanda/MinIO del proyectofinazmediante namespaces y roles propios; la API sólo escucha en127.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¶
- Abre
docs/v2/MOTORES.mdy localiza la ficha que producePortfolioTargetaprobados. ¿Por qué no puede ser la misma que resuelve la optimización? - En
contracts/runtime/, cuenta los ficheros*.schema.jsony explica la diferencia con «53 contratos». - Dibuja (en papel) qué pasaría si el
worker-batchmuriera 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».) - Busca en
deployment/v2/compose.yamlqué servicios tienenrestart: "no"y relaciónalo con la tabla de perfiles. - 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»).