Plan de mise en œuvre — POC Runtime v1¶
Réf : ../design/CVN-N003-poc-runtime-design.md, issue #417, ADR 39-42 Date : 2026-03-30
Objectif¶
UNI ATR1.2_1.3_H3 en paper trading résilient, avec possibilité d'ajouter CRV sans reset, et chemin clair vers le live.
Contrainte¶
Chaque phase est déployable et testable indépendamment. Pas de big bang.
Phase 0 — Préparation (1 jour)¶
0.1 Promouvoir le modèle UNI¶
Le modèle existe déjà (MLflow v6). Il faut le promouvoir en Production.
# Via MLflow API ou UI
mlflow models transition-stage \
--name CVNTrade_XGBoost_UNIUSDC_ATR1.2_1.3_H3 \
--version 6 --stage Production
Vérification : mlflow.tracking.MlflowClient().get_latest_versions("CVNTrade_XGBoost_UNIUSDC_ATR1.2_1.3_H3", stages=["Production"])
0.2 Valider le paper trading existant (embedded)¶
Avant de migrer, s'assurer que le paper trading actuel fonctionne avec UNI :
# Lancer via l'API existante
POST /api/sessions {"name": "UNI-baseline", "symbols": ["UNIUSDC"]}
POST /api/sessions/{id}/start
# Vérifier : signaux générés, positions ouvertes, état cohérent
Objectif : avoir un baseline mesurable avant la migration.
0.3 Créer le DeploymentArtifact UNI manuellement¶
Insérer dans la future table deployments (ou un fichier JSON de référence) :
{
"deployment_id": "dep_uni_v6",
"crypto": "UNIUSDC",
"strategy": "ATR1.2_1.3_H3",
"model_uri": "models:/CVNTrade_XGBoost_UNIUSDC_ATR1.2_1.3_H3/6",
"meta_uri": null,
"pte_config": {"sl_atr": 1.2, "tp_atr": 1.3, "horizon": "H3"},
"wfrb_verdict": "GO_PAPER",
"regime_compat": 0.71,
"regime_exploit": 0.74
}
Livrable : fichier deployments/dep_uni_v6.json ou INSERT SQL.
Phase 1 — Event Store + Tables (2-3 jours)¶
1.1 Migration m013 — tables runtime¶
Fichier : api/stores/migrations/m013_runtime_tables.py
Créer les 5 tables :
- deployments
- trading_sessions (avec desired_status + observed_status)
- trading_events (append-only, JSONB payload)
- trading_positions (projection)
- deployment_bindings
Index critiques :
- trading_events(session_id, sequence) — reconstruction d'état
- trading_sessions(observed_status) — recovery au restart
- deployment_bindings(runtime_instance, crypto, mode) — lookup rapide
Enregistrer dans pg_session_store._run_migrations().
Test : migration idempotente, make test-unit passe.
1.2 Event store module¶
Fichier : src/commun/runtime/event_store.py
class EventStore:
def append(session_id: str, event_type: str, payload: dict) -> str
def get_events(session_id: str, since_sequence: int = 0) -> List[TradingEvent]
def get_latest(session_id: str, event_type: str) -> Optional[TradingEvent]
def rebuild_state(session_id: str) -> SessionState
Implémentation PostgreSQL. Pas d'ORM, psycopg2 direct (pattern existant).
Test : append + read + rebuild sur données synthétiques.
1.3 Session state projection¶
Fichier : src/commun/runtime/session_state.py
@dataclass
class SessionState:
session_id: str
equity: float
open_positions: List[Position]
closed_positions_count: int
total_pnl: float
last_signal_at: Optional[datetime]
last_heartbeat_at: Optional[datetime]
@staticmethod
def from_events(events: List[TradingEvent]) -> "SessionState"
Test : construire un état depuis une séquence d'events simulés.
Phase 2 — Runtime Service minimal (3-4 jours)¶
2.1 Service FastAPI standalone¶
Fichier : services/runtime/app.py
app = FastAPI(title="cvntrade-runtime")
@app.on_event("startup")
async def startup():
recover_active_sessions()
@app.on_event("shutdown")
async def shutdown():
graceful_stop_all_sessions()
Endpoints :
- POST /sessions/{id}/start — démarre un session actor
- POST /sessions/{id}/stop — arrête proprement
- POST /sessions/{id}/pause — suspend le polling
- POST /sessions/{id}/resume — reprend
- GET /sessions/{id}/health — heartbeat + observed_status
- GET /sessions/{id}/state — projection courante
Port : 8030 (déjà réservé par l'ancien standalone).
2.2 Session Actor¶
Fichier : services/runtime/session_actor.py
Refactoring de CVNTrade_PaperTradingEngine en actor isolé :
class SessionActor:
def __init__(self, session_id, deployment, event_store, adapter):
self.session_id = session_id
self.deployment = deployment
self.event_store = event_store
self.adapter = adapter
self._thread = None
def setup(self) -> bool:
# Charger modèle depuis deployment.model_uri
# Initialiser Signal/Decision/Risk engines
# Event: SessionStarted
def start(self):
# Démarrer la boucle dans un thread daemon
self._thread = Thread(target=self._run_loop, daemon=True)
def _run_loop(self):
while self._running:
snapshot = self._ingest_market_data()
signal = self._signal_engine.compute(snapshot)
decision = self._decision_engine.evaluate(signal)
if decision.should_trade:
risk_ok = self._risk_engine.check(decision)
if risk_ok:
result = self.adapter.submit_order(decision.intent)
self.event_store.append(self.session_id, "OrderSubmitted", ...)
self._publish_heartbeat()
sleep(self.interval)
def stop(self):
self._running = False
self._thread.join(timeout=10)
self.event_store.append(self.session_id, "SessionStopped", {})
Réutilise : toute la logique LdP de CVNTrade_PaperTradingEngine._generate_signal_ldp().
2.3 Execution Adapters¶
Fichier : services/runtime/adapters/paper_adapter.py
class PaperExecutionAdapter:
def submit_order(self, intent: TradeIntent) -> OrderResult:
# Simulated fill at current price + slippage
def check_exits(self, positions, market) -> List[ExitSignal]:
# Check TP/SL/Timeout for each position
Fichier : services/runtime/adapters/base.py
class ExecutionAdapter(Protocol):
def submit_order(self, intent: TradeIntent) -> OrderResult: ...
def check_exits(self, positions, market) -> List[ExitSignal]: ...
Le LiveExecutionAdapter sera ajouté en Phase 6.
2.4 Recovery au restart¶
Fichier : dans services/runtime/app.py
def recover_active_sessions():
sessions = db.query("SELECT * FROM trading_sessions WHERE desired_status IN ('RUNNING', 'PAUSED')")
for session in sessions:
state = event_store.rebuild_state(session.session_id)
actor = SessionActor(session.session_id, ...)
actor.restore_from_state(state)
if session.desired_status == "RUNNING":
actor.start()
# observed_status mis à jour par le heartbeat
Test : simuler un crash (kill process), redémarrer, vérifier que les positions sont reconstruites.
2.5 Dockerfile + Helm¶
Fichier : Dockerfile.runtime (ou réutiliser Dockerfile.k8s avec entrypoint différent)
FROM rg.fr-par.scw.cloud/cvntrade/airflow:${TAG}
CMD ["python", "-m", "uvicorn", "services.runtime.app:app", "--host", "0.0.0.0", "--port", "8030"]
Helm : ajouter un deployment cvntrade-runtime dans les values.
Phase 3 — API façade + Control Plane (2-3 jours)¶
3.1 Adapter l'API existante¶
Fichier : api/services/pt_bridge.py → déprécier
Remplacer par un client HTTP vers le runtime :
Fichier : api/services/runtime_client.py
class RuntimeClient:
def __init__(self, base_url="http://cvntrade-runtime:8030"):
self.base_url = base_url
def start_session(self, session_id, config) -> bool
def stop_session(self, session_id) -> bool
def get_health(self, session_id) -> dict
def get_state(self, session_id) -> SessionState
3.2 Endpoints lifecycle¶
Fichier : api/routers/sessions.py — adapter les routes existantes
POST /sessions/{id}/start→RuntimeClient.start_session()POST /sessions/{id}/stop→RuntimeClient.stop_session()GET /sessions/{id}/signals→ liretrading_eventsPostgreSQLGET /sessions/{id}/positions→ liretrading_positionsPostgreSQL
3.3 Endpoints Deployment Binding¶
Fichier : api/routers/bindings.py (nouveau)
POST /bindings/attach— créer un binding (BOUND)POST /bindings/{id}/activate— créer session + démarrer actorPOST /bindings/{id}/pause— pause sessionPOST /bindings/{id}/promote— créer binding livePOST /bindings/{id}/unbind— stop + detachGET /bindings— liste des bindings actifs
3.4 Desired vs Observed sync¶
Fichier : api/services/session_sync.py
Background task (toutes les 10s) :
- Lire heartbeat:{session_id} Redis
- Mettre à jour observed_status dans trading_sessions
- Si desired ≠ observed depuis > 60s → alerte "stuck"
- Si heartbeat TTL expiré → observed_status = DEAD
Phase 4 — Intégration Front + Grafana (2 jours)¶
4.1 Front : Fleet View¶
Adapter la page sessions existante pour afficher :
| Crypto | Mode | Desired | Observed | Equity | PnL | Trades | Heartbeat |
|---|---|---|---|---|---|---|---|
| UNIUSDC | paper | RUNNING | RUNNING | 10,234 | +2.3% | 12 | 3s ago |
Source : trading_sessions + trading_positions + Redis heartbeat.
4.2 Grafana : Runtime dashboard¶
Fichier : infra/grafana/dashboards/runtime_sessions.json
Panels :
- Sessions actives (table)
- Equity par session (time series depuis trading_events type=MetricsSnapshot)
- Trades par jour (bar chart)
- Heartbeat lag (gauge)
- Positions ouvertes (table)
4.3 WebSocket via Redis¶
Fichier : api/ws/session_feed.py — adapter
Au lieu de consommer InMemoryEventPublisher, consommer Redis stream :
async def consume_redis_stream(session_id):
stream_key = f"stream:events:{session_id}"
last_id = "0"
while True:
events = await redis.xread({stream_key: last_id}, block=1000)
for event in events:
yield event
last_id = event.id
Phase 5 — Multi-crypto + Résilience (2-3 jours)¶
5.1 Ajouter CRV sans toucher UNI¶
- Créer
dep_crv_v3.json POST /bindings/attach(crypto=CRVUSDC)POST /bindings/{id}/activate- Vérifier UNI non impacté (heartbeat, equity, positions inchangés)
5.2 Circuit breaker¶
Dans le Risk Engine du session actor :
- Max drawdown session → pause auto
- 3 rejets exchange consécutifs → pause + alerte
- Heartbeat manquant > 60s → observed_status = DEAD + auto-restart (max 3)
5.3 Meta-model optionnel¶
Dans le Decision Engine :
- Si deployment.meta_uri est rempli → charger le meta
- Appliquer comme filtre post-signal
- Event MetaFiltered avec meta_score
Activable par deployment (pas global).
Phase 6 — Live adapter (2 jours)¶
6.1 LiveExecutionAdapter¶
Fichier : services/runtime/adapters/live_adapter.py
class LiveExecutionAdapter:
def __init__(self, api_key, api_secret, testnet=True):
self.client = BinanceClient(api_key, api_secret, testnet)
def submit_order(self, intent):
order = self.client.create_order(
symbol=intent.crypto,
side="BUY",
type="LIMIT",
quantity=intent.quantity,
price=intent.price,
)
return OrderResult(order_id=order["orderId"], ...)
def check_exits(self, positions, market):
# Check TP/SL against current market price
# Return ExitSignal for positions to close
6.2 Promotion paper → live¶
POST /bindings/{paper_binding_id}/promote
→ Crée nouveau binding mode=live avec même deployment
→ Démarre session actor avec LiveExecutionAdapter
→ Paper binding reste ACTIVE (comparaison A/B) ou PAUSED
Ordre des PR¶
| PR | Phase | Contenu | Dépend de |
|---|---|---|---|
| #418 | 0 | ADR + Design doc | — |
| #419 | 0 | Promouvoir UNI v6, baseline paper existant | #418 |
| #420 | 1 | Migration m013 + event store + session state | #418 |
| #421 | 2 | Runtime service + session actor + paper adapter | #420 |
| #422 | 2 | Recovery + heartbeat | #421 |
| #423 | 3 | API façade + runtime client + bindings | #421 |
| #424 | 4 | Front Fleet View + Grafana + WebSocket Redis | #423 |
| #425 | 5 | Multi-crypto + circuit breaker + meta | #422, #423 |
| #426 | 6 | Live adapter + promotion | #425 |
Critères de succès POC¶
- UNI paper tourne en continu > 24h sans crash
- Recovery fonctionne après kill du runtime
- CRV ajouté sans impacter UNI
- Equity + trades visibles dans Grafana
- Front affiche Fleet View avec health
- Promotion paper → live fonctionne (testnet)
Stories (retro-registered in OP — 2026-06-09)¶
Enregistré a posteriori : Epic wp#268 (GH #417, l'umbrella réutilisée), parent Need CVN-N003. Statut Epic : On hold — la POC est partielle : le core est livré, mais les critères de succès POC ci-dessus sont tous décochés (POC non validée).
| Story | Titre | GH · OP | Artefact | Statut |
|---|---|---|---|---|
| CVN-N003-EA-S01 | POC Runtime core — architecture + event store + runtime service | #1159 · wp#269 | PR #418 (Phases 0-2) | Closed |
Phases restantes non complétées (POC parkée — non storifiées) : Phase 3 (API façade + Control Plane) · Phase 4 (Front Fleet View + Grafana) · Phase 5 (multi-crypto + circuit breaker) · Phase 6 (live adapter + promotion) · validation 24h. La table « Ordre des PR » ci-dessus était un plan ; les PRs réels #420+ sont des fixes sans rapport (seul #418 a livré le core). Réouverture via nouvelles Stories si la POC Runtime reprend.