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 :
MarketSnapshotReceivedevent
5.2 Signal Engine¶
- Charge modèle depuis MLflow (version figée au démarrage)
- EnrichmentAPI → FeatureEngineeringAPI → InferenceAPI
- Optionnel : meta-model scoring
- Produit :
SignalComputedevent 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 :
TradeIntentCreatedouSignalRejectedevent
5.4 Risk Engine¶
- Max positions
- Max allocation %
- Drawdown limit
- Cooldown per symbol
- Circuit breaker (kill switch)
- Produit :
OrderAcceptedouRiskBlockedevent
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¶
SessionCreatedSessionStartedSessionPausedSessionResumedSessionStopped
Market data¶
MarketSnapshotReceivedMarketDataError
Signal pipeline¶
SignalComputed— prob_buy, prob_sell, confidence, featuresRegimeEvaluated— compat_score, exploit_score, decisionTradeIntentCreated— direction, size, sl, tp, timeoutSignalRejected— filter_name, reason
Risk¶
RiskCheckPassedRiskBlocked— rule, detailsCircuitBreakerTriggered
Execution¶
OrderSubmitted— order_id, side, quantity, priceOrderFilled— fill_price, fill_quantity, feesOrderRejected— reasonPositionOpened— position_id, entry detailsPositionClosed— exit details, pnl
Monitoring¶
Heartbeat— equity, open_positions, healthMetricsSnapshot— 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 fanoutlock: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 actorPOST /runtime/sessions/{id}/stop— arrête proprementPOST /runtime/sessions/{id}/pause— suspend le pollingGET /runtime/sessions/{id}/health— heartbeat + statusGET /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 :
- Lire
trading_sessions WHERE status IN ('PAPER_RUNNING', 'LIVE_RUNNING') - Pour chaque session active :
a. Charger le
deployment_id→ récupérer model_uri, config b. Lire les events depuistrading_events WHERE session_id = X ORDER BY sequencec. Reconstruire l'état : positions ouvertes, equity, dernières features d. Reprendre le polling market data - Publier
SessionResumedevent
Temps de recovery estimé : < 30s pour 5 sessions.
10. Promotion paper → live (ADR-42)¶
Séquence :
- Opérateur review les métriques paper dans le front
POST /api/sessions/{paper_id}/promote→ Control Plane- Control Plane :
a. Crée une nouvelle session
mode=liveavec le mêmedeployment_idb. Status paper →PAPER_REVIEWEDc. Status live →LIVE_RUNNINGd. Le runtime démarre le session actor avecLiveExecutionAdapter - 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)¶
- Une session active = un seul session actor. Pas de double-start, vérifié par lock Redis.
- 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.
- Aucune promotion ne reset une autre session. Ajouter CRV ne touche pas UNI. Promouvoir UNI en live ne casse pas CRV paper.
- Paper et live partagent le même kernel. Signal Engine, Decision Engine, Risk Engine identiques. Seul l'ExecutionAdapter change.
- 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.
- 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.
- 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_statusest écrit par le Control Plane (API)observed_statusest écrit par le Runtime (via heartbeat)- Le frontend affiche
observed_statusmais montre la transition sidesired ≠ observed - Si
desired ≠ observedpendant 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_status→DEADpour 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_status→RUNNING
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 :
OrderRejectedevent - Effet : Position non ouverte. Trade intent annulé.
- Réaction :
- Log
OrderRejectedavec 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_status→CRASHED- Event
SessionCrashedpersisté 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_status→SETUP_FAILED- Event
SessionSetupFailedavec détail - Pas de retry automatique (erreur de config, pas transitoire)
- Recovery : Opérateur vérifie le deployment artifact, corrige, relance