Skip to content

Design Document — POC Runtime v1

Issue : #417 ADR : 39-42 Statut : Draft Date : 2026-03-30


1. Vision

CVNTrade devient un OS de stratégies avec 3 services séparés :

Research Plane          Control Plane           Runtime Plane
(Airflow/ZenML)    →    (cvntrade-api)     →    (cvntrade-runtime)
                        ↕                       ↕
                    Frontend               Execution Adapters
                    Grafana                (Paper / Live / Replay)

Principe fondateur : le runtime ne sait pas s'il est en paper ou en live. Il reçoit un ExecutionAdapter.


2. Services

2.1 cvntrade-research (existant)

Airflow + ZenML + MLflow + W&B. Produit des Deployment Artifacts.

Pas de changement. Continue à fonctionner tel quel.

2.2 cvntrade-api (existant, à adapter)

Rôle : façade de contrôle + lecture. Ne gère plus de threads de trading (ADR-39).

Responsabilités : - CRUD sessions - Lifecycle ops (attach/promote/pause/rollback) - Lecture état runtime (proxy vers runtime service) - WebSocket feed vers frontend - Lecture event store pour historique

Ne fait plus : - ~~EngineManager en process~~ - ~~InMemoryEventPublisher~~ - ~~pt_bridge.py~~

2.3 cvntrade-runtime (nouveau)

Rôle : exécution des sessions de trading. Stateful, résilient, autonome.

Responsabilités : - Session actors (1 par crypto active) - Market data ingestion - Signal + Decision + Risk pipeline - Execution via adapter - Event publishing - Heartbeat + health - Recovery au restart


3. Objets métier

3.1 DeploymentArtifact

Produit par le Research Plane, consommé par le Control Plane.

@dataclass
class DeploymentArtifact:
    deployment_id: str           # "dep_uni_v6_meta2"
    crypto: str                  # "UNIUSDC"
    strategy: str                # "ATR1.2_1.3_H3"
    model_uri: str               # "mlflow://CVNTrade_XGBoost_UNIUSDC_ATR1.2_1.3_H3/6"
    meta_uri: Optional[str]      # "mlflow://CVNTrade_Meta_UNIUSDC/2" or None
    pte_config: dict             # {"sl_atr": 1.2, "tp_atr": 1.3, "horizon": "H3"}
    risk_profile: str            # "default"
    wfrb_verdict: str            # "GO_PAPER" | "REVIEW" | "REJECT"
    regime_compat: float         # 0.71
    regime_exploit: float        # 0.74
    created_at: datetime

3.2 Session

Instance concrète d'un déploiement sur le runtime.

@dataclass
class TradingSession:
    session_id: str              # "sess_uni_paper_001"
    deployment_id: str           # lien vers DeploymentArtifact
    crypto: str                  # "UNIUSDC"
    mode: str                    # "paper" | "live" | "replay"
    desired_status: str          # ce que l'opérateur veut (§16)
    observed_status: str         # ce que le runtime observe (§16)
    config_fingerprint: str      # hash de la config figée au démarrage
    runtime_version: str         # SHA du code runtime
    model_version: str           # "v6"
    meta_version: Optional[str]  # "v2" ou None
    initial_capital: float       # 10000.0
    started_at: Optional[datetime]
    last_heartbeat: Optional[datetime]
    closed_at: Optional[datetime]

3.3 Event

Unité atomique de l'event store. Immuable.

@dataclass
class TradingEvent:
    event_id: str                # UUID
    session_id: str
    event_type: str              # voir catalogue ci-dessous
    timestamp: datetime
    sequence: int                # monotonic per session
    payload: dict                # contenu typé par event_type

3.4 Position

État dérivé (projection des événements PositionOpened/PositionClosed).

@dataclass
class Position:
    position_id: str
    session_id: str
    crypto: str
    side: str                    # "BUY"
    entry_price: float
    entry_time: datetime
    quantity: float
    sl_price: float
    tp_price: float
    timeout_at: datetime
    status: str                  # "OPEN" | "CLOSED_TP" | "CLOSED_SL" | "CLOSED_TIMEOUT"
    exit_price: Optional[float]
    exit_time: Optional[datetime]
    pnl: Optional[float]

4. Lifecycle par crypto

DISCOVERED                  # Research plane a identifié un candidat
  → QUALIFIED               # WFRB verdict = GO_PAPER
  → PAPER_APPROVED          # Opérateur a validé le déploiement
  → PAPER_RUNNING           # Session paper active
  → PAPER_REVIEWED          # Résultats paper analysés
  → LIVE_APPROVED           # Opérateur a validé le passage live
  → LIVE_RUNNING            # Session live active
  → PAUSED                  # Temporairement suspendu (tout mode)
  → ROLLED_BACK             # Revert vers version précédente
  → DECOMMISSIONED          # Retiré définitivement

Transitions autorisées : - QUALIFIED → PAPER_APPROVED : action opérateur - PAPER_RUNNING → PAUSED : action opérateur ou circuit breaker - PAUSED → PAPER_RUNNING : action opérateur - PAPER_REVIEWED → LIVE_APPROVED : action opérateur - LIVE_RUNNING → PAUSED : action opérateur ou circuit breaker - * → DECOMMISSIONED : action opérateur - * → ROLLED_BACK : action opérateur (crée nouvelle session avec version précédente)


5. Trading Runtime Kernel

Le kernel est identique paper/live (ADR-40). 6 modules :

┌─────────────────────────────────────────────────────┐
│                  SESSION ACTOR                       │
│                                                      │
│  ┌──────────────┐    ┌──────────────┐               │
│  │ Market Data   │───→│ Signal Engine │               │
│  │ Ingestor      │    │ (model+meta)  │               │
│  └──────────────┘    └──────┬───────┘               │
│                             │                        │
│                      ┌──────▼───────┐               │
│                      │ Decision     │               │
│                      │ Engine       │               │
│                      │ (regime,meta)│               │
│                      └──────┬───────┘               │
│                             │                        │
│                      ┌──────▼───────┐               │
│                      │ Risk Engine  │               │
│                      │ (limits,     │               │
│                      │  cooldown,   │               │
│                      │  circuit     │               │
│                      │  breaker)    │               │
│                      └──────┬───────┘               │
│                             │                        │
│                      ┌──────▼───────┐               │
│                      │ Execution    │               │
│                      │ Adapter      │◄── Paper/Live  │
│                      └──────┬───────┘               │
│                             │                        │
│                      ┌──────▼───────┐               │
│                      │ Event Store  │               │
│                      └──────────────┘               │
└─────────────────────────────────────────────────────┘

5.1 Market Data Ingestor

  • Polling OHLCV Binance (configurable interval, default 60s)
  • Buffer circulaire de N candles (warmup indicateurs)
  • Heartbeat publication
  • Produit : MarketSnapshotReceived event

5.2 Signal Engine

  • Charge modèle depuis MLflow (version figée au démarrage)
  • EnrichmentAPI → FeatureEngineeringAPI → InferenceAPI
  • Optionnel : meta-model scoring
  • Produit : SignalComputed event avec prob_buy, prob_sell, confidence

5.3 Decision Engine

  • Applique le pipeline LdP post-inference (8 filtres)
  • Intègre regime compatibility/exploitability (diagnostic, pas gate)
  • Produit : TradeIntentCreated ou SignalRejected event

5.4 Risk Engine

  • Max positions
  • Max allocation %
  • Drawdown limit
  • Cooldown per symbol
  • Circuit breaker (kill switch)
  • Produit : OrderAccepted ou RiskBlocked event

5.5 Execution Adapter

Interface commune :

class ExecutionAdapter(Protocol):
    async def submit_order(self, intent: TradeIntent) -> OrderResult: ...
    async def cancel_order(self, order_id: str) -> bool: ...
    async def get_positions(self) -> List[Position]: ...
    async def check_exits(self, positions: List[Position], market: MarketSnapshot) -> List[ExitSignal]: ...

Implémentations : - PaperExecutionAdapter : fills simulés, slippage configurable - LiveExecutionAdapter : ordres Binance, réconciliation - ReplayExecutionAdapter : replay historique (forensic/debug)

5.6 Event Store

Append-only log par session. Source de vérité.


6. Catalogue d'événements

Session lifecycle

  • SessionCreated
  • SessionStarted
  • SessionPaused
  • SessionResumed
  • SessionStopped

Market data

  • MarketSnapshotReceived
  • MarketDataError

Signal pipeline

  • SignalComputed — prob_buy, prob_sell, confidence, features
  • RegimeEvaluated — compat_score, exploit_score, decision
  • TradeIntentCreated — direction, size, sl, tp, timeout
  • SignalRejected — filter_name, reason

Risk

  • RiskCheckPassed
  • RiskBlocked — rule, details
  • CircuitBreakerTriggered

Execution

  • OrderSubmitted — order_id, side, quantity, price
  • OrderFilled — fill_price, fill_quantity, fees
  • OrderRejected — reason
  • PositionOpened — position_id, entry details
  • PositionClosed — exit details, pnl

Monitoring

  • Heartbeat — equity, open_positions, health
  • MetricsSnapshot — pnl, drawdown, sharpe, win_rate

7. Stores

7.1 PostgreSQL (source de vérité métier)

Tables :

-- Deployments (produits par Research)
deployments (
    deployment_id, crypto, strategy, model_uri, meta_uri,
    pte_config JSONB, wfrb_verdict, regime_compat, regime_exploit,
    created_at
)

-- Sessions (gérées par Control)
trading_sessions (
    session_id, deployment_id, crypto, mode, status,
    config_fingerprint, runtime_version, model_version,
    initial_capital, started_at, last_heartbeat, closed_at
)

-- Events (append-only, écrit par Runtime)
trading_events (
    event_id, session_id, event_type, sequence,
    timestamp, payload JSONB
)

-- Positions (projection, mise à jour par Runtime)
trading_positions (
    position_id, session_id, crypto, side,
    entry_price, entry_time, quantity,
    sl_price, tp_price, timeout_at,
    status, exit_price, exit_time, pnl
)

-- Lifecycle (état par crypto, géré par Control)
crypto_lifecycle (
    crypto, current_status, deployment_id,
    paper_session_id, live_session_id,
    updated_at
)

7.2 Redis (temps réel)

  • heartbeat:{session_id} — dernier heartbeat (TTL 30s)
  • stream:events:{session_id} — stream d'événements pour WebSocket fanout
  • lock:session:{session_id} — lock de session (éviter double-start)

8. Communication inter-services

Frontend ←WebSocket→ cvntrade-api ←HTTP/gRPC→ cvntrade-runtime
                         ↕                        ↕
                    PostgreSQL                PostgreSQL
                         ↕                        ↕
                       Redis  ←──────────────── Redis

API → Runtime

  • POST /runtime/sessions/{id}/start — démarre un session actor
  • POST /runtime/sessions/{id}/stop — arrête proprement
  • POST /runtime/sessions/{id}/pause — suspend le polling
  • GET /runtime/sessions/{id}/health — heartbeat + status
  • GET /runtime/sessions/{id}/state — état courant (projection)

Runtime → Stores

  • Écrit events dans PostgreSQL (trading_events)
  • Publie events dans Redis stream (fanout WebSocket)
  • Met à jour projections (trading_positions, heartbeat)

API → Frontend

  • REST pour CRUD sessions, lifecycle ops
  • WebSocket pour events temps réel (consomme Redis stream)
  • Grafana lit PostgreSQL directement

9. Recovery (ADR-41)

Au restart du runtime :

  1. Lire trading_sessions WHERE status IN ('PAPER_RUNNING', 'LIVE_RUNNING')
  2. Pour chaque session active : a. Charger le deployment_id → récupérer model_uri, config b. Lire les events depuis trading_events WHERE session_id = X ORDER BY sequence c. Reconstruire l'état : positions ouvertes, equity, dernières features d. Reprendre le polling market data
  3. Publier SessionResumed event

Temps de recovery estimé : < 30s pour 5 sessions.


10. Promotion paper → live (ADR-42)

Séquence :

  1. Opérateur review les métriques paper dans le front
  2. POST /api/sessions/{paper_id}/promote → Control Plane
  3. Control Plane : a. Crée une nouvelle session mode=live avec le même deployment_id b. Status paper → PAPER_REVIEWED c. Status live → LIVE_RUNNING d. Le runtime démarre le session actor avec LiveExecutionAdapter
  4. La session paper reste lisible (historique), mais plus active

Le paper continue à tourner indépendamment si souhaité (A/B paper vs live).


11. Sprint plan

Sprint 1 — Fondation (2 semaines)

  • Tables PostgreSQL (deployments, trading_sessions, trading_events, trading_positions, crypto_lifecycle)
  • Runtime service minimal (FastAPI standalone, port 8030)
  • Session actor avec PaperExecutionAdapter
  • Event store (append + lecture)
  • Recovery basique (rehydrate positions depuis events)
  • Health endpoint + heartbeat
  • UNI ATR1.2_1.3_H3 tourne en paper

Sprint 2 — Control Plane (1 semaine)

  • API endpoints : attach/start/stop/pause/promote
  • Lifecycle state machine par crypto
  • Front : Fleet View + Session View minimales
  • Grafana : sessions actives, PnL, trades

Sprint 3 — Résilience + Meta (1 semaine)

  • Recovery complète (crash + restart automatique)
  • Meta-model optionnel dans Decision Engine
  • Regime evaluation en temps réel dans la boucle
  • Circuit breaker + kill switch

Sprint 4 — Multi-crypto + Live (1 semaine)

  • 2ème crypto (CRV) sans toucher UNI
  • LiveExecutionAdapter (Binance testnet)
  • Promotion paper → live
  • Front : Promotion View

12. Ce qui est réutilisé

Composant existant Réutilisation
CVNTrade_PaperTradingEngine Refactoré en Session Actor
CVNTrade_PaperTradingConfig Simplifié, alimenté par DeploymentArtifact
LdP filter chain (8 filtres) Intégré dans Decision Engine
EnrichmentAPI / FeatureEngineeringAPI / InferenceAPI Signal Engine
CUSUMFilterAPI Intégré dans filter chain
regime_detector.py Decision Engine (compat/exploit)
CVNTrade_MLFlowManager Model loading dans Signal Engine
Session store PostgreSQL Migré vers trading_sessions
Position tracker Projection depuis events

13. Ce qui est déprécié

Composant Raison
api/services/pt_bridge.py ADR-39 : plus d'engine dans l'API
InMemoryEventPublisher ADR-41 : remplacé par event store
CVNTrade_StateManager (JSON) ADR-41 : remplacé par event sourcing
services/paper_trading/routes.py Unifié dans runtime service
PaperTradingClient Remplacé par communication API→Runtime

14. Invariants (non négociables)

  1. Une session active = un seul session actor. Pas de double-start, vérifié par lock Redis.
  2. L'event store est la source de vérité. L'état courant (positions, equity) est toujours une projection. En cas de doute, on reconstruit depuis les events.
  3. Aucune promotion ne reset une autre session. Ajouter CRV ne touche pas UNI. Promouvoir UNI en live ne casse pas CRV paper.
  4. Paper et live partagent le même kernel. Signal Engine, Decision Engine, Risk Engine identiques. Seul l'ExecutionAdapter change.
  5. L'API ne détient aucun état critique de trading. Zéro position, zéro equity, zéro event en mémoire API. Tout passe par les stores.
  6. Le runtime est auto-suffisant. Si l'API est down, le runtime continue à trader et persister les events. L'API se reconnecte quand elle revient.
  7. Chaque session est versionnée. Model version, meta version, config fingerprint, runtime version — figés au démarrage, immuables pendant la session.

15. Ownership des stores (qui écrit quoi)

Store / Table Écrit par Lu par Jamais écrit par
deployments Research (Airflow/ZenML) Control, Runtime API directe
trading_sessions.desired_status Control (API) Runtime, Frontend, Grafana Runtime
trading_sessions.observed_status Runtime (heartbeat) API, Frontend, Grafana Control
crypto_lifecycle Control (API) Frontend, Grafana Runtime
trading_events Runtime uniquement API, Frontend, Grafana Control, Research
trading_positions Runtime uniquement API, Frontend, Grafana Control
heartbeat:* (Redis) Runtime API, Frontend Control
stream:events:* (Redis) Runtime API (WebSocket fanout) Control
lock:session:* (Redis) Runtime Runtime API

Règle : chaque table a un unique writer. Les collisions de responsabilité sont interdites.


16. Desired state vs Observed state

Problème

L'opérateur dit "pause UNI". Le runtime met 5s à s'arrêter. Pendant ce temps, quel est le statut ?

Solution : 2 champs séparés

ALTER TABLE trading_sessions ADD COLUMN desired_status TEXT;  -- ce que l'opérateur veut
ALTER TABLE trading_sessions ADD COLUMN observed_status TEXT;  -- ce que le runtime observe
Scénario desired_status observed_status Interprétation
Session tourne normalement RUNNING RUNNING OK
Opérateur clique pause PAUSED RUNNING Transition en cours
Runtime confirme la pause PAUSED PAUSED Convergé
Runtime crash RUNNING DEAD Anomalie → recovery
Opérateur promote LIVE_RUNNING PAPER_RUNNING Transition en cours
Recovery terminée RUNNING RUNNING Convergé

Invariants desired/observed

  • desired_status est écrit par le Control Plane (API)
  • observed_status est écrit par le Runtime (via heartbeat)
  • Le frontend affiche observed_status mais montre la transition si desired ≠ observed
  • Si desired ≠ observed pendant plus de 60s → alerte "session stuck"

17. Deployment Binding

L'objet qui fait le lien entre une instance runtime, une crypto, un deployment artifact, et un mode.

@dataclass
class DeploymentBinding:
    binding_id: str              # "bind_uni_paper_v6"
    runtime_instance: str        # "cvntrade-runtime-prod"
    crypto: str                  # "UNIUSDC"
    deployment_id: str           # "dep_uni_v6_meta2"
    mode: str                    # "paper" | "live"
    session_id: Optional[str]    # "sess_uni_paper_001" quand actif
    status: str                  # "BOUND" | "ACTIVE" | "PAUSED" | "UNBOUND"
    created_at: datetime
    activated_at: Optional[datetime]
CREATE TABLE deployment_bindings (
    binding_id TEXT PRIMARY KEY,
    runtime_instance TEXT NOT NULL,
    crypto TEXT NOT NULL,
    deployment_id TEXT NOT NULL REFERENCES deployments(deployment_id),
    mode TEXT NOT NULL,           -- 'paper' | 'live'
    session_id TEXT,              -- NULL si pas encore activé
    status TEXT NOT NULL DEFAULT 'BOUND',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    activated_at TIMESTAMPTZ,
    UNIQUE (runtime_instance, crypto, mode)
);

Flux de promotion avec bindings

1. Research produit deployment "dep_uni_v6_meta2"
2. Opérateur: POST /api/bindings/attach
   → crée binding (BOUND, mode=paper, session_id=NULL)
3. Opérateur: POST /api/bindings/{id}/activate
   → crée session, démarre actor, binding → ACTIVE
4. Runtime tourne...
5. Opérateur: POST /api/bindings/{id}/promote
   → crée nouveau binding (mode=live), même deployment
   → ancien binding paper reste ACTIVE (ou PAUSED selon choix)
6. Opérateur: POST /api/bindings/{id}/pause
   → binding → PAUSED, actor suspendu
7. Opérateur: POST /api/bindings/{id}/unbind
   → session stoppée, binding → UNBOUND

Multi-crypto via bindings

runtime-prod:
  binding_1: UNIUSDC  → dep_uni_v6     → paper → ACTIVE
  binding_2: CRVUSDC  → dep_crv_v3     → paper → ACTIVE
  binding_3: AAVEUSDC → dep_aave_v1    → paper → BOUND (pas encore activé)
  binding_4: UNIUSDC  → dep_uni_v6     → live  → ACTIVE (promu)

Chaque binding est indépendant. Pause binding_1 ne touche pas binding_2.


18. Failure modes

F1. Runtime down, API up

  • Détection : API lit heartbeat:{session_id} Redis → TTL expiré (> 30s)
  • Effet : observed_statusDEAD pour toutes les sessions
  • Réaction :
  • Frontend affiche "Runtime offline"
  • Historique reste lisible (events en PostgreSQL)
  • Aucune nouvelle trade
  • Recovery : Runtime redémarre → rehydrate sessions depuis events → observed_statusRUNNING

F2. API down, Runtime up

  • Détection : Runtime ne dépend pas de l'API pour trader
  • Effet : Aucun sur le trading. Les events continuent à être persistés.
  • Réaction : Frontend inaccessible. Grafana continue (lit PostgreSQL directement).
  • Recovery : API redémarre → lit état depuis stores → reconnexion WebSocket

F3. Redis down

  • Détection : Publish échoue, lock échoue
  • Effet :
  • WebSocket feed interrompu (pas d'events temps réel)
  • Heartbeat check dégradé
  • Lock de session non disponible → refuser les nouveaux starts
  • Réaction :
  • Runtime continue à trader (events vont en PostgreSQL)
  • API bascule en mode "polling PostgreSQL" pour les données
  • Recovery : Redis revient → fanout reprend, heartbeat reprend

F4. PostgreSQL slow / down

  • Détection : Write event timeout
  • Effet : CRITIQUE — events non persistés = perte potentielle de vérité
  • Réaction :
  • Runtime buffer les events en mémoire (bounded, max 1000)
  • Si buffer plein → circuit breaker → pause toutes les sessions
  • Alerte immédiate (Slack/SMS si configuré)
  • Recovery : PostgreSQL revient → flush buffer → reprise

F5. LiveExecutionAdapter rejette un ordre

  • Détection : OrderRejected event
  • Effet : Position non ouverte. Trade intent annulé.
  • Réaction :
  • Log OrderRejected avec raison (insufficient balance, invalid symbol, rate limit)
  • Retry si erreur temporaire (rate limit → backoff)
  • Pas de retry si erreur permanente (insufficient balance)
  • Si 3 rejets consécutifs → alerte + pause session
  • Recovery : Opérateur analyse, ajuste, resume

F6. Session actor crash (exception non catchée)

  • Détection : Heartbeat manquant + exception loggée
  • Effet : Un seul actor meurt. Les autres continuent.
  • Réaction :
  • observed_statusCRASHED
  • Event SessionCrashed persisté avec stack trace
  • Si desired_status = RUNNING → auto-restart après 30s
  • Max 3 auto-restarts, ensuite → PAUSED + alerte
  • Recovery : Rehydrate depuis events → reprend

F7. Model version non trouvée dans MLflow

  • Détection : Au setup du session actor
  • Effet : Session ne peut pas démarrer
  • Réaction :
  • observed_statusSETUP_FAILED
  • Event SessionSetupFailed avec détail
  • Pas de retry automatique (erreur de config, pas transitoire)
  • Recovery : Opérateur vérifie le deployment artifact, corrige, relance