diff --git a/backend/main.py b/backend/main.py index dcafbcc..62a1a89 100644 --- a/backend/main.py +++ b/backend/main.py @@ -75,12 +75,19 @@ def startup(): # Start auto-cycle scheduler if enabled from services.auto_cycle import start_scheduler start_scheduler() + # Start VaR + PnL snapshot schedulers + from services.var_scheduler import start_var_scheduler, start_pnl_scheduler + start_var_scheduler() + start_pnl_scheduler() @app.on_event("shutdown") def shutdown(): from services.auto_cycle import stop_scheduler stop_scheduler() + from services.var_scheduler import stop_var_scheduler, stop_pnl_scheduler + stop_var_scheduler() + stop_pnl_scheduler() app.include_router(market_data.router) diff --git a/backend/routers/var.py b/backend/routers/var.py index b74bd6d..19e1a74 100644 --- a/backend/routers/var.py +++ b/backend/routers/var.py @@ -1,20 +1,107 @@ -from fastapi import APIRouter, Query -from services.var_service import compute_var +from fastapi import APIRouter, Query, HTTPException +from pydantic import BaseModel +from services.var_service import ( + compute_var, save_var_snapshot, + get_var_snapshots, get_var_snapshot, get_latest_var_snapshot, + get_pnl_snapshots, get_latest_pnl_snapshot, save_pnl_snapshot, +) +from services.var_scheduler import ( + get_scheduler_status, restart_var_scheduler, restart_pnl_scheduler, +) +from services.database import set_config router = APIRouter(prefix="/api/var", tags=["var"]) -@router.get("/compute") -def var_compute( +@router.get("/latest") +def var_latest(): + """Get most recent saved VaR snapshot (no recompute).""" + snap = get_latest_var_snapshot() + return {"snapshot": snap} + + +@router.get("/snapshots") +def var_snapshots(limit: int = Query(default=20, ge=1, le=100)): + """List recent VaR snapshot summaries.""" + return {"snapshots": get_var_snapshots(limit)} + + +@router.get("/snapshots/{snapshot_id}") +def var_snapshot_detail(snapshot_id: int): + snap = get_var_snapshot(snapshot_id) + if not snap: + raise HTTPException(404, "Snapshot not found") + return snap + + +@router.post("/run-now") +def var_run_now( confidence: float = Query(default=0.95, ge=0.90, le=0.99), horizon_days: int = Query(default=1, ge=1, le=30), lookback_days: int = Query(default=252, ge=60, le=504), default_iv: float = Query(default=0.20, ge=0.05, le=0.80), ): - """Compute portfolio VaR using Black-Scholes delta approach.""" - return compute_var( + """Compute VaR now and save to DB. Returns the full result.""" + result = compute_var( confidence=confidence, horizon_days=horizon_days, lookback_days=lookback_days, default_iv=default_iv, ) + if "error" in result: + raise HTTPException(400, result["error"]) + snapshot_id = save_var_snapshot( + result, confidence, horizon_days, lookback_days, default_iv + ) + return {**result, "snapshot_id": snapshot_id} + + +# ─── PnL endpoints ─────────────────────────────────────────────────────────── + +@router.get("/pnl/latest") +def pnl_latest(): + return {"snapshot": get_latest_pnl_snapshot()} + + +@router.get("/pnl/snapshots") +def pnl_snapshots(limit: int = Query(default=48, ge=1, le=200)): + return {"snapshots": get_pnl_snapshots(limit)} + + +@router.post("/pnl/run-now") +def pnl_run_now(): + try: + row_id = save_pnl_snapshot() + except Exception as e: + raise HTTPException(500, str(e)) + return {"snapshot_id": row_id, "snapshot": get_latest_pnl_snapshot()} + + +# ─── Scheduler status / config ─────────────────────────────────────────────── + +@router.get("/scheduler/status") +def scheduler_status(): + return get_scheduler_status() + + +class SchedulerConfig(BaseModel): + var_enabled: bool | None = None + var_hours: float | None = None + pnl_enabled: bool | None = None + pnl_hours: float | None = None + + +@router.post("/scheduler/config") +def scheduler_config(cfg: SchedulerConfig): + if cfg.var_enabled is not None: + set_config("var_scheduler_enabled", "true" if cfg.var_enabled else "false") + if cfg.var_hours is not None: + set_config("var_scheduler_hours", str(cfg.var_hours)) + if cfg.pnl_enabled is not None: + set_config("pnl_scheduler_enabled", "true" if cfg.pnl_enabled else "false") + if cfg.pnl_hours is not None: + set_config("pnl_scheduler_hours", str(cfg.pnl_hours)) + # Restart scheduler threads to pick up new config + restart_var_scheduler() + restart_pnl_scheduler() + return {"ok": True, "status": get_scheduler_status()} diff --git a/backend/services/database.py b/backend/services/database.py index c74b2a9..d7212a0 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -398,6 +398,51 @@ def init_db(): except Exception: pass + c.execute("""CREATE TABLE IF NOT EXISTS var_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + computed_at TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0.95, + horizon_days INTEGER NOT NULL DEFAULT 1, + lookback_days INTEGER NOT NULL DEFAULT 252, + default_iv REAL NOT NULL DEFAULT 0.20, + hist_var_1d_pct REAL, + hist_cvar_pct REAL, + hist_var_1d_eur REAL, + param_var_1d_pct REAL, + param_cvar_pct REAL, + mc_var_1d_pct REAL, + mc_cvar_pct REAL, + n_positions INTEGER, + total_notional_eur REAL, + data_source TEXT, + breach_rate_pct REAL, + kupiec_ok INTEGER, + macro_regime TEXT, + ticker_prices TEXT, + full_result TEXT + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_var_snap_ts ON var_snapshots(computed_at DESC)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS pnl_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapped_at TEXT NOT NULL, + n_open INTEGER, + n_closed INTEGER, + total_capital_eur REAL, + total_pnl_pct REAL, + total_pnl_eur REAL, + ticker_prices TEXT, + macro_regime TEXT, + trades_snapshot TEXT + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_pnl_snap_ts ON pnl_snapshots(snapped_at DESC)") + except Exception: + pass + try: c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") diff --git a/backend/services/var_scheduler.py b/backend/services/var_scheduler.py new file mode 100644 index 0000000..590fc71 --- /dev/null +++ b/backend/services/var_scheduler.py @@ -0,0 +1,124 @@ +"""Periodic scheduler for VaR and PnL snapshots.""" + +from __future__ import annotations + +import logging +import threading +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +_var_thread: threading.Thread | None = None +_pnl_thread: threading.Thread | None = None +_var_stop = threading.Event() +_pnl_stop = threading.Event() + + +def _var_loop(stop: threading.Event): + while not stop.wait(0): + from .database import get_config + try: + enabled = (get_config("var_scheduler_enabled") or "false").lower() == "true" + hours = float(get_config("var_scheduler_hours") or "6") + except Exception: + enabled, hours = False, 6.0 + + if not enabled: + stop.wait(timeout=300) + continue + + # Run snapshot + try: + from .var_service import compute_var, save_var_snapshot + result = compute_var() + if "error" not in result: + save_var_snapshot(result, confidence=0.95, horizon_days=1, + lookback_days=252, default_iv=0.20) + logger.info("[VaR Scheduler] Snapshot saved") + else: + logger.warning(f"[VaR Scheduler] Compute failed: {result['error']}") + except Exception as e: + logger.error(f"[VaR Scheduler] Exception: {e}") + + stop.wait(timeout=hours * 3600) + + +def _pnl_loop(stop: threading.Event): + while not stop.wait(0): + from .database import get_config + try: + enabled = (get_config("pnl_scheduler_enabled") or "false").lower() == "true" + hours = float(get_config("pnl_scheduler_hours") or "1") + except Exception: + enabled, hours = False, 1.0 + + if not enabled: + stop.wait(timeout=120) + continue + + try: + from .var_service import save_pnl_snapshot + save_pnl_snapshot() + logger.info("[PnL Scheduler] Snapshot saved") + except Exception as e: + logger.error(f"[PnL Scheduler] Exception: {e}") + + stop.wait(timeout=hours * 3600) + + +def start_var_scheduler(): + global _var_thread, _var_stop + _var_stop.clear() + if _var_thread and _var_thread.is_alive(): + return + _var_thread = threading.Thread(target=_var_loop, args=(_var_stop,), + name="var-scheduler", daemon=True) + _var_thread.start() + logger.info("[VaR Scheduler] Started") + + +def start_pnl_scheduler(): + global _pnl_thread, _pnl_stop + _pnl_stop.clear() + if _pnl_thread and _pnl_thread.is_alive(): + return + _pnl_thread = threading.Thread(target=_pnl_loop, args=(_pnl_stop,), + name="pnl-scheduler", daemon=True) + _pnl_thread.start() + logger.info("[PnL Scheduler] Started") + + +def stop_var_scheduler(): + _var_stop.set() + + +def stop_pnl_scheduler(): + _pnl_stop.set() + + +def restart_var_scheduler(): + stop_var_scheduler() + import time; time.sleep(0.2) + start_var_scheduler() + + +def restart_pnl_scheduler(): + stop_pnl_scheduler() + import time; time.sleep(0.2) + start_pnl_scheduler() + + +def get_scheduler_status() -> dict: + from .database import get_config + return { + "var": { + "enabled": (get_config("var_scheduler_enabled") or "false").lower() == "true", + "hours": float(get_config("var_scheduler_hours") or "6"), + "alive": bool(_var_thread and _var_thread.is_alive()), + }, + "pnl": { + "enabled": (get_config("pnl_scheduler_enabled") or "false").lower() == "true", + "hours": float(get_config("pnl_scheduler_hours") or "1"), + "alive": bool(_pnl_thread and _pnl_thread.is_alive()), + }, + } diff --git a/backend/services/var_service.py b/backend/services/var_service.py index bf1cb12..a9a561f 100644 --- a/backend/services/var_service.py +++ b/backend/services/var_service.py @@ -2,11 +2,12 @@ from __future__ import annotations +import json import numpy as np import pandas as pd from scipy.stats import norm from datetime import datetime, timedelta -from typing import List, Dict +from typing import List, Dict, Optional from .database import get_conn @@ -253,3 +254,211 @@ def compute_var( "kupiec_ok": breach_rate <= alpha * 100 * 2, }, } + + +def save_var_snapshot(result: Dict, confidence: float, horizon_days: int, + lookback_days: int, default_iv: float, + macro_regime: Optional[str] = None, + ticker_prices: Optional[str] = None) -> int: + """Persist a VaR result dict to var_snapshots. Returns new row id.""" + if "error" in result: + raise ValueError(result["error"]) + + v = result["var"] + p = result["portfolio"] + bt = result.get("backtest", {}) + computed_at = datetime.utcnow().isoformat(timespec="seconds") + + conn = get_conn() + cur = conn.execute( + """INSERT INTO var_snapshots + (computed_at, confidence, horizon_days, lookback_days, default_iv, + hist_var_1d_pct, hist_cvar_pct, hist_var_1d_eur, + param_var_1d_pct, param_cvar_pct, + mc_var_1d_pct, mc_cvar_pct, + n_positions, total_notional_eur, data_source, + breach_rate_pct, kupiec_ok, + macro_regime, ticker_prices, full_result) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + computed_at, confidence, horizon_days, lookback_days, default_iv, + v["historical"]["var_1d_pct"], v["historical"]["cvar_pct"], v["historical"]["var_1d_eur"], + v["parametric"]["var_1d_pct"], v["parametric"]["cvar_pct"], + v["monte_carlo"]["var_1d_pct"], v["monte_carlo"]["cvar_pct"], + p["n_positions"], p["total_notional_eur"], p["data_source"], + bt.get("breach_rate_pct"), 1 if bt.get("kupiec_ok") else 0, + macro_regime, ticker_prices, + json.dumps(result, ensure_ascii=False), + ) + ) + conn.commit() + row_id = cur.lastrowid + conn.close() + return row_id + + +def get_var_snapshots(limit: int = 20) -> List[Dict]: + """Return most recent VaR snapshots (summary, no full_result).""" + conn = get_conn() + rows = conn.execute( + """SELECT id, computed_at, confidence, horizon_days, lookback_days, + hist_var_1d_pct, hist_cvar_pct, hist_var_1d_eur, + param_var_1d_pct, mc_var_1d_pct, + n_positions, total_notional_eur, data_source, + breach_rate_pct, kupiec_ok + FROM var_snapshots ORDER BY computed_at DESC LIMIT ?""", + (limit,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_var_snapshot(snapshot_id: int) -> Optional[Dict]: + """Return a single snapshot with full_result parsed.""" + conn = get_conn() + row = conn.execute( + "SELECT * FROM var_snapshots WHERE id=?", (snapshot_id,) + ).fetchone() + conn.close() + if not row: + return None + d = dict(row) + if d.get("full_result"): + try: + d["full_result"] = json.loads(d["full_result"]) + except Exception: + pass + return d + + +def get_latest_var_snapshot() -> Optional[Dict]: + """Return the most recent snapshot with full result.""" + conn = get_conn() + row = conn.execute( + "SELECT * FROM var_snapshots ORDER BY computed_at DESC LIMIT 1" + ).fetchone() + conn.close() + if not row: + return None + d = dict(row) + if d.get("full_result"): + try: + d["full_result"] = json.loads(d["full_result"]) + except Exception: + pass + return d + + +# ─── PnL snapshot ──────────────────────────────────────────────────────────── + +def save_pnl_snapshot() -> int: + """Compute live PnL and persist to pnl_snapshots. Returns new row id.""" + from .database import _fetch_live_prices + + conn = get_conn() + rows = conn.execute( + "SELECT * FROM trade_entry_prices WHERE status = 'open'" + ).fetchall() + trades = [dict(r) for r in rows] + + closed_count = conn.execute( + "SELECT COUNT(*) FROM trade_entry_prices WHERE status = 'closed'" + ).fetchone()[0] + + # Fetch live prices + tickers = list({t["underlying"] for t in trades if t.get("underlying") and ":" not in (t["underlying"] or "")}) + prices: Dict = {} + if tickers: + try: + prices = _fetch_live_prices(tickers, timeout=15) or {} + except Exception: + pass + + BEARISH = {"long put", "bear put spread", "short call", "put"} + + def is_bearish(strategy: str) -> bool: + return any(k in strategy.lower() for k in BEARISH) + + enriched = [] + total_capital = 0.0 + total_pnl_eur = 0.0 + + for t in trades: + entry = t.get("entry_price") or 0.0 + capital = t.get("capital_invested") or entry + current = prices.get(t.get("underlying") or "") + pnl_pct = None + if entry and current and entry > 0: + raw = (current - entry) / entry * 100 + pnl_pct = round(-raw if is_bearish(t.get("strategy") or "") else raw, 2) + pnl_eur = round(capital * (pnl_pct / 100), 2) if pnl_pct is not None and capital else None + total_capital += capital + if pnl_eur is not None: + total_pnl_eur += pnl_eur + enriched.append({ + "id": t["id"], + "ticker": t.get("underlying"), + "strategy": t.get("strategy"), + "entry_price": entry, + "current_price": current, + "pnl_pct": pnl_pct, + "pnl_eur": pnl_eur, + "capital_invested": capital, + }) + + total_pnl_pct = round(total_pnl_eur / total_capital * 100, 3) if total_capital > 0 else 0.0 + + # Macro regime snapshot + macro_row = conn.execute( + "SELECT dominant, scores_json FROM macro_regime_history ORDER BY timestamp DESC LIMIT 1" + ).fetchone() + macro_context = json.dumps(dict(macro_row)) if macro_row else None + + snapped_at = datetime.utcnow().isoformat(timespec="seconds") + cur = conn.execute( + """INSERT INTO pnl_snapshots + (snapped_at, n_open, n_closed, total_capital_eur, total_pnl_pct, total_pnl_eur, + ticker_prices, macro_regime, trades_snapshot) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + snapped_at, len(trades), closed_count, + round(total_capital, 2), total_pnl_pct, round(total_pnl_eur, 2), + json.dumps(prices, ensure_ascii=False), + macro_context, + json.dumps(enriched, ensure_ascii=False), + ) + ) + conn.commit() + row_id = cur.lastrowid + conn.close() + return row_id + + +def get_pnl_snapshots(limit: int = 48) -> List[Dict]: + conn = get_conn() + rows = conn.execute( + """SELECT id, snapped_at, n_open, n_closed, + total_capital_eur, total_pnl_pct, total_pnl_eur + FROM pnl_snapshots ORDER BY snapped_at DESC LIMIT ?""", + (limit,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_latest_pnl_snapshot() -> Optional[Dict]: + conn = get_conn() + row = conn.execute( + "SELECT * FROM pnl_snapshots ORDER BY snapped_at DESC LIMIT 1" + ).fetchone() + conn.close() + if not row: + return None + d = dict(row) + for key in ("ticker_prices", "macro_regime", "trades_snapshot"): + if d.get(key): + try: + d[key] = json.loads(d[key]) + except Exception: + pass + return d diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 0d72132..8e8a248 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -1,8 +1,11 @@ import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults } from '../hooks/useApi' -import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock } from 'lucide-react' +import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign } from 'lucide-react' import clsx from 'clsx' +const API = 'http://localhost:8000' + const SOURCE_CATEGORIES = { 'Flux RSS actifs': ['reuters_world', 'reuters_business', 'reuters_energy', 'ap_top', 'aljazeera', 'ft', 'bloomberg'], 'Données économiques': ['newsapi', 'gdelt', 'eia', 'fred', 'usda'], @@ -370,6 +373,41 @@ export default function Config() { } }, [exitDefaultsData]) + // VaR + PnL scheduler state + const qc = useQueryClient() + const { data: schedStatus, refetch: refetchSched } = useQuery({ + queryKey: ['sched-status'], + queryFn: () => fetch(`${API}/api/var/scheduler/status`).then(r => r.json()), + staleTime: 30_000, + retry: 1, + }) + const [varSchedEnabled, setVarSchedEnabled] = useState(false) + const [varSchedHours, setVarSchedHours] = useState(6) + const [pnlSchedEnabled, setPnlSchedEnabled] = useState(false) + const [pnlSchedHours, setPnlSchedHours] = useState(1) + useEffect(() => { + if (schedStatus) { + setVarSchedEnabled(schedStatus.var?.enabled ?? false) + setVarSchedHours(schedStatus.var?.hours ?? 6) + setPnlSchedEnabled(schedStatus.pnl?.enabled ?? false) + setPnlSchedHours(schedStatus.pnl?.hours ?? 1) + } + }, [schedStatus]) + const { mutate: saveSchedConfig, isPending: savingSched } = useMutation({ + mutationFn: (cfg: any) => fetch(`${API}/api/var/scheduler/config`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg), + }).then(r => r.json()), + onSuccess: () => { refetchSched(); setSavedMsg('Schedulers sauvegardés'); setTimeout(() => setSavedMsg(''), 2000) }, + }) + const { mutate: triggerVarNow, isPending: triggeringVar } = useMutation({ + mutationFn: () => fetch(`${API}/api/var/run-now`, { method: 'POST' }).then(r => r.json()), + onSuccess: () => { setSavedMsg('Snapshot VaR sauvegardé'); setTimeout(() => setSavedMsg(''), 2000) }, + }) + const { mutate: triggerPnlNow, isPending: triggeringPnl } = useMutation({ + mutationFn: () => fetch(`${API}/api/var/pnl/run-now`, { method: 'POST' }).then(r => r.json()), + onSuccess: () => { setSavedMsg('Snapshot PnL sauvegardé'); setTimeout(() => setSavedMsg(''), 2000) }, + }) + // Auto-cycle local state const cs = cycleStatus as any const [cycleEnabled, setCycleEnabled] = useState(false) @@ -793,6 +831,112 @@ export default function Config() { + + {/* ── VaR & PnL Schedulers ── */} +
+ Calcule et sauvegarde automatiquement les snapshots VaR (Black-Scholes delta) et PnL à intervalles définis. + Les données sont accessibles depuis la page VaR Analyse avec un historique complet. +
++ Le calcul VaR ne se lance pas automatiquement pour éviter des appels réseau inutiles. + Cliquez sur "Calculer maintenant" ou configurez le scheduler dans la Configuration. +
+- Approche delta Black-Scholes · {portfolio ? `${portfolio.n_positions} positions · Notionnel ${portfolio.total_notional_eur.toLocaleString('fr-FR')} EUR` : '—'} - {portfolio?.data_source === 'simulated' && ( - ⚠ Données simulées (marché indisponible) + Approche delta Black-Scholes + {displayedMeta && ( + + · snapshot du {displayedMeta.computed_at?.slice(0, 16).replace('T', ' ')} UTC + {displayedMeta.data_source === 'simulated' && ( + ⚠ données simulées + )} + )}
{isLoading ? 'Chargement…' : 'Aucune position'}
- ) : ( -