From b4f3089c589535f51dfdd527158217a10701ca49 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sat, 20 Jun 2026 06:21:20 +0200 Subject: [PATCH] feat: VaR/PnL schedulers + snapshots DB + page sur bouton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Tables var_snapshots + pnl_snapshots dans SQLite (contexte macro + prix tickers) - var_service.py : save_var_snapshot, save_pnl_snapshot + fonctions get_* - var_scheduler.py : threads APScheduler pour VaR (défaut 6h) et PnL (défaut 1h) - router var.py : /run-now (POST compute+save), /latest, /snapshots, /pnl/run-now, /pnl/latest, /scheduler/status, /scheduler/config - main.py : démarrage des deux schedulers au startup Frontend: - VaRAnalysis.tsx : plus d'auto-fetch ; charge le dernier snapshot DB au mount ; bouton "Calculer" → POST /run-now ; erreur backend = message clair ; historique de snapshots sélectionnables - Config.tsx : section "Schedulers VaR & PnL" dans l'onglet cycle avec toggle enable/disable, intervalle, et boutons "Snapshot maintenant" Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 7 + backend/routers/var.py | 99 +++- backend/services/database.py | 45 ++ backend/services/var_scheduler.py | 124 +++++ backend/services/var_service.py | 211 +++++++- frontend/src/pages/Config.tsx | 146 +++++- frontend/src/pages/VaRAnalysis.tsx | 776 +++++++++++++++-------------- 7 files changed, 1014 insertions(+), 394 deletions(-) create mode 100644 backend/services/var_scheduler.py 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 ── */} +
+

+ Schedulers VaR & PnL +

+

+ 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. +

+
+ {/* VaR scheduler */} +
+
+ + Scheduler VaR + {schedStatus?.var?.alive + ? ● Actif + : ○ Inactif} +
+
+ + +
+
+ +
+ {[1, 3, 6, 12, 24].map(h => ( + + ))} +
+
+
+ + {/* PnL scheduler */} +
+
+ + Scheduler PnL + {schedStatus?.pnl?.alive + ? ● Actif + : ○ Inactif} +
+
+ + +
+
+ +
+ {[1, 2, 4, 8, 24].map(h => ( + + ))} +
+
+
+
+ +
+ + + +
+
)} diff --git a/frontend/src/pages/VaRAnalysis.tsx b/frontend/src/pages/VaRAnalysis.tsx index 2085689..22aaeaa 100644 --- a/frontend/src/pages/VaRAnalysis.tsx +++ b/frontend/src/pages/VaRAnalysis.tsx @@ -1,194 +1,87 @@ -import { useState, useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, LineChart, Line, ResponsiveContainer, Cell, } from 'recharts' -import { ShieldAlert, TrendingDown, Activity, AlertTriangle, Info, RefreshCw } from 'lucide-react' +import { ShieldAlert, TrendingDown, Activity, AlertTriangle, Info, Play, Clock, Database } from 'lucide-react' import clsx from 'clsx' -// ─── API ───────────────────────────────────────────────────────────────────── +const API = 'http://localhost:8000' -async function fetchVar(params: Record) { +// ─── API calls ─────────────────────────────────────────────────────────────── + +async function fetchLatest() { + const r = await fetch(`${API}/api/var/latest`) + if (!r.ok) throw new Error(`Backend inaccessible (${r.status})`) + return r.json() +} + +async function runNow(params: { confidence: number; horizon_days: number; lookback_days: number; default_iv: number }) { const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString() - const r = await fetch(`http://localhost:8000/api/var/compute?${qs}`) - if (!r.ok) throw new Error(await r.text()) + const r = await fetch(`${API}/api/var/run-now?${qs}`, { method: 'POST' }) + if (!r.ok) { + const txt = await r.text() + throw new Error(txt) + } + return r.json() +} + +async function fetchSnapshots() { + const r = await fetch(`${API}/api/var/snapshots?limit=10`) + if (!r.ok) return { snapshots: [] } return r.json() } // ─── Sub-components ─────────────────────────────────────────────────────────── function MetricCard({ - label, method, var1d, varNd, cvar, horizon, stressed = false, color, + label, method, var1d, varNd, cvar, varEur, cvarEur, horizon, stressed = false, colorClass, }: { - label: string - method: string - var1d: number - varNd: number - cvar: number - horizon: number - stressed?: boolean - color: string + label: string; method: string; var1d: number; varNd: number; cvar: number + varEur: number; cvarEur: number; horizon: number; stressed?: boolean; colorClass: string }) { - const pct = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(3)}%` - const isLoss = (v: number) => v < 0 + const pct = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(3)}%` + const eur = (v: number) => `${v < 0 ? '' : '+'}${v.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €` + const displayed = horizon === 1 ? var1d : varNd + const displayedEur = horizon === 1 ? varEur : varEur * Math.sqrt(horizon) return ( -
-
- {label} - {stressed && ( - - Stressed ×1.5 - - )} +
+
+ {label} + {stressed && Stressed ×1.5}
-
{method}
+
{method}
-
- VaR 1J ({horizon === 1 ? '1j' : `${horizon}j`}) - - {pct(horizon === 1 ? var1d : varNd)} - +
+
VaR {horizon}j
+
+ {pct(displayed)} +
+
+ {eur(displayedEur)} +
-
- CVaR (ES) - +
+
CVaR (Expected Shortfall)
+
{pct(cvar)} - +
+
+ {eur(cvarEur)} +
) } -function ControlBar({ - confidence, setConfidence, - horizon, setHorizon, - lookback, setLookback, - iv, setIv, - loading, onRefresh, -}: { - confidence: number - setConfidence: (v: number) => void - horizon: number - setHorizon: (v: number) => void - lookback: number - setLookback: (v: number) => void - iv: number - setIv: (v: number) => void - loading: boolean - onRefresh: () => void -}) { - return ( -
- {/* Confidence */} -
- -
- {[0.90, 0.95, 0.99].map(c => ( - - ))} -
-
- - {/* Horizon */} -
- -
- {[1, 5, 10, 21].map(h => ( - - ))} -
-
- - {/* Lookback */} -
- -
- {[63, 126, 252].map(l => ( - - ))} -
-
- - {/* IV */} -
- -
- {[0.15, 0.20, 0.30, 0.40].map(v => ( - - ))} -
-
- - -
- ) -} - -// ─── Custom tooltip for histogram ───────────────────────────────────────────── - function HistoTooltip({ active, payload }: any) { if (!active || !payload?.length) return null - const { x, count } = payload[0].payload return ( -
-
Ret: {x.toFixed(3)}%
-
Obs: {count}
+
+
Ret: {payload[0].payload.x.toFixed(3)}%
+
Obs: {payload[0].payload.count}
) } @@ -196,48 +89,95 @@ function HistoTooltip({ active, payload }: any) { function RollingTooltip({ active, payload, label }: any) { if (!active || !payload?.length) return null return ( -
-
{label}
+
+
{label}
VaR: {payload[0]?.value?.toFixed(3)}%
) } +// ─── Empty state ────────────────────────────────────────────────────────────── + +function IdleState({ onCompute, computing }: { onCompute: () => void; computing: boolean }) { + return ( +
+ +
+

Aucun snapshot VaR disponible

+

+ 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. +

+
+ +
+ ) +} + // ─── Main page ──────────────────────────────────────────────────────────────── export default function VaRAnalysis() { + const qc = useQueryClient() const [confidence, setConfidence] = useState(0.95) const [horizon, setHorizon] = useState(1) const [lookback, setLookback] = useState(252) const [iv, setIv] = useState(0.20) - const [refreshKey, setRefreshKey] = useState(0) + const [activeSnap, setActiveSnap] = useState(null) // override from history - const params = useMemo(() => ({ - confidence, - horizon_days: horizon, - lookback_days: lookback, - default_iv: iv, - }), [confidence, horizon, lookback, iv, refreshKey]) // eslint-disable-line - - const { data, isLoading, error } = useQuery({ - queryKey: ['var-compute', params], - queryFn: () => fetchVar(params), - staleTime: 60_000, + // Load last snapshot from DB on mount (no heavy compute) + const { data: latestData, isLoading: loadingLatest, error: latestError } = useQuery({ + queryKey: ['var-latest'], + queryFn: fetchLatest, + staleTime: 30_000, + retry: 1, }) - const varData = data?.var - const portfolio = data?.portfolio - const histogram: { x: number; count: number }[] = data?.histogram ?? [] - const rolling: { date: string; var_95: number }[] = data?.rolling_var ?? [] - const positions: any[] = data?.positions ?? [] - const backtest = data?.backtest + const { data: snapshotsData } = useQuery({ + queryKey: ['var-snapshots'], + queryFn: fetchSnapshots, + staleTime: 60_000, + retry: 1, + }) - // Color histogram bars: red if x < VaR threshold + // Button-triggered compute + save + const { mutate: compute, isPending: computing, error: computeError } = useMutation({ + mutationFn: () => runNow({ confidence, horizon_days: horizon, lookback_days: lookback, default_iv: iv }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['var-latest'] }) + qc.invalidateQueries({ queryKey: ['var-snapshots'] }) + setActiveSnap(null) + }, + }) + + // The displayed result: manual history pick OR latest from DB + const displayedResult = activeSnap ?? latestData?.snapshot?.full_result + const displayedMeta = activeSnap + ? null + : latestData?.snapshot + + const varData = displayedResult?.var + const portfolio = displayedResult?.portfolio + const histogram: any[] = displayedResult?.histogram ?? [] + const rolling: any[] = displayedResult?.rolling_var ?? [] + const positions: any[] = displayedResult?.positions ?? [] + const backtest = displayedResult?.backtest const histVarThreshold = varData?.historical?.var_1d_pct ?? 0 + const snapshots: any[] = snapshotsData?.snapshots ?? [] + + const backendDown = latestError && String(latestError).includes('Backend') + return ( -
- {/* Header */} +
+ + {/* Header bar */}
@@ -245,231 +185,295 @@ export default function VaRAnalysis() {

Analyse VaR — Value at Risk

- 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 + )} + )}

- setRefreshKey(k => k + 1)} - /> + + {/* Controls */} +
+ {/* Confidence */} +
+
Confiance
+
+ {[0.90, 0.95, 0.99].map(c => ( + + ))} +
+
+ {/* Horizon */} +
+
Horizon
+
+ {[1, 5, 10, 21].map(h => ( + + ))} +
+
+ {/* Lookback */} +
+
Historique
+
+ {[63, 126, 252].map(l => ( + + ))} +
+
+ {/* IV */} +
+
IV défaut
+
+ {[0.15, 0.20, 0.30, 0.40].map(v => ( + + ))} +
+
+ {/* Compute button */} + +
- {/* Error state */} - {error && ( -
+ {/* Backend down notice */} + {backendDown && ( +
- {String(error)} + Backend inaccessible — vérifiez que le serveur FastAPI est démarré sur le port 8000.
)} - {data?.error && ( -
- - {data.error} + {/* Compute error */} + {computeError && ( +
+ + {String(computeError)}
)} - {/* VaR metric cards */} - {varData && ( -
- - - -
- )} - - {/* EUR amounts */} - {varData && portfolio && ( -
- {[ - { label: 'VaR Hist. 1J', val: varData.historical.var_1d_eur, color: 'blue' }, - { label: 'CVaR Hist.', val: varData.historical.cvar_eur, color: 'blue' }, - { label: 'VaR Param. 1J', val: varData.parametric.var_1d_eur, color: 'violet' }, - { label: 'CVaR Param.', val: varData.parametric.cvar_eur, color: 'violet' }, - { label: 'VaR MC Stressed', val: varData.monte_carlo.var_1d_eur, color: 'orange' }, - { label: 'CVaR MC', val: varData.monte_carlo.cvar_eur, color: 'orange' }, - ].map(({ label, val, color }) => ( -
-
{label}
-
- {val < 0 ? '' : '+'}{val.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} € -
-
+ {/* History picker */} + {snapshots.length > 1 && ( +
+ + Historique : + + {snapshots.slice(1).map((s: any) => ( + ))}
)} - {/* Charts row */} -
- {/* Returns distribution histogram */} -
-
- -

Distribution des Retours

- — trait rouge = VaR hist. -
- {histogram.length > 0 ? ( - - - - - - } /> - - - {histogram.map((entry, i) => ( - - ))} - - - - ) : ( -
- {isLoading ? 'Calcul en cours…' : 'Aucune donnée'} -
- )} -
+ {/* Empty / loading state */} + {loadingLatest && !displayedResult && ( +
Chargement du dernier snapshot…
+ )} - {/* Rolling VaR */} -
-
- -

VaR 95% Glissante (fenêtre 30J)

-
- {rolling.length > 0 ? ( - - - - d.slice(5)} // MM-DD - interval={Math.floor(rolling.length / 6)} - /> - - } /> - - - - - ) : ( -
- {isLoading ? 'Calcul en cours…' : 'Historique insuffisant'} -
- )} -
-
+ {!loadingLatest && !displayedResult && !backendDown && ( + compute()} computing={computing} /> + )} - {/* Bottom row: positions + backtest */} -
- {/* Positions deltas */} -
-

Positions & Deltas

- {positions.length === 0 ? ( -

{isLoading ? 'Chargement…' : 'Aucune position'}

- ) : ( -
- {positions.map((p, i) => ( -
-
- {p.ticker} - {p.strategy} - {p.pattern &&
{p.pattern}
} + {/* ── Results ── */} + {varData && portfolio && ( + <> + {/* Portfolio summary strip */} +
+ {[ + { label: 'Positions', val: String(portfolio.n_positions) }, + { label: 'Notionnel', val: `${portfolio.total_notional_eur.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €` }, + { label: 'Confiance', val: `${portfolio.confidence_pct}%` }, + { label: 'Lookback', val: `${portfolio.lookback_days}j` }, + { label: 'Source', val: portfolio.data_source === 'simulated' ? '⚠ Simulée' : '✓ Live' }, + ].map(({ label, val }) => ( +
+
{label}
+
{val}
+
+ ))} +
+ + {/* 3 VaR method cards */} +
+ + + +
+ + {/* Charts */} +
+ {/* Histogram */} +
+
+ +

Distribution des Retours

+ trait rouge = VaR hist. +
+ {histogram.length > 0 ? ( + + + + + + } /> + + + {histogram.map((e: any, i: number) => ( + + ))} + + + + ) : ( +
Aucune donnée
+ )} +
+ + {/* Rolling VaR */} +
+
+ +

VaR 95% Glissante (fenêtre 30J)

+
+ {rolling.length > 0 ? ( + + + + d.slice(5)} interval={Math.floor(rolling.length / 5)} /> + + } /> + + + + + ) : ( +
Historique insuffisant
+ )} +
+
+ + {/* Positions + Kupiec */} +
+ {/* Positions */} +
+

Positions & Deltas

+
+ {positions.map((p: any, i: number) => ( +
+
+ {p.ticker} + {p.strategy} + {p.pattern &&
{p.pattern}
} +
+
+ 0 ? 'text-emerald-400' : 'text-red-400')}> + Δ {p.delta > 0 ? '+' : ''}{p.delta.toFixed(3)} + + + {p.notional.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} € + +
-
- 0 ? 'text-emerald-400' : 'text-red-400' - )}> - Δ {p.delta > 0 ? '+' : ''}{p.delta.toFixed(3)} - - {p.notional.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} € + ))} +
+
+ + {/* Kupiec backtest */} + {backtest && ( +
+

Backtest — Test de Kupiec

+
+ {[ + { label: 'Observations', val: String(backtest.n_observations) }, + { label: 'Violations VaR', val: String(backtest.n_breaches) }, + { label: 'Taux réel', val: `${backtest.breach_rate_pct}%`, color: backtest.kupiec_ok ? 'text-emerald-400' : 'text-red-400' }, + { label: 'Taux attendu', val: `${backtest.expected_breach_rate_pct}%`, color: 'text-slate-300' }, + ].map(({ label, val, color }) => ( +
+ {label} + {val} +
+ ))} +
+ {backtest.kupiec_ok + ? '✓ Modèle validé — violations dans la tolérance' + : '✗ Excès de violations — modèle sous-estime le risque'} +
+
+ Règle Kupiec : taux réel ≤ {(backtest.expected_breach_rate_pct * 2).toFixed(1)}%
- ))} -
- )} -
- - {/* Backtest Kupiec */} - {backtest && ( -
-

Backtest — Test de Kupiec

-
-
- Observations - {backtest.n_observations}
-
- Violations VaR - {backtest.n_breaches} -
-
- Taux réel - - {backtest.breach_rate_pct}% - -
-
- Taux attendu - {backtest.expected_breach_rate_pct}% -
-
- {backtest.kupiec_ok - ? '✓ Modèle validé — violations dans la tolérance' - : '✗ Excès de violations — modèle sous-estime le risque'} -
-
- Règle : taux réel ≤ 2× taux attendu ({backtest.expected_breach_rate_pct * 2}%) -
-
+ )}
- )} -
+ + )}
) }