feat: VaR/PnL schedulers + snapshots DB + page sur bouton

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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 06:21:20 +02:00
parent d64d1029bf
commit b4f3089c58
7 changed files with 1014 additions and 394 deletions

View File

@@ -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)

View File

@@ -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()}

View File

@@ -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)")

View File

@@ -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()),
},
}

View File

@@ -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

View File

@@ -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() {
</button>
</div>
</div>
{/* ── VaR & PnL Schedulers ── */}
<div className="card">
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
<Gauge className="w-4 h-4 text-red-400" /> Schedulers VaR &amp; PnL
</h2>
<p className="text-xs text-slate-500 mb-4">
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.
</p>
<div className="grid grid-cols-2 gap-6 mb-4">
{/* VaR scheduler */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Gauge className="w-3.5 h-3.5 text-red-400" />
<span className="text-sm font-semibold text-slate-300">Scheduler VaR</span>
{schedStatus?.var?.alive
? <span className="text-[10px] bg-emerald-900/30 text-emerald-400 border border-emerald-700/30 px-1.5 py-0.5 rounded ml-auto"> Actif</span>
: <span className="text-[10px] bg-dark-700 text-slate-600 border border-slate-700/30 px-1.5 py-0.5 rounded ml-auto"> Inactif</span>}
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Activer</label>
<button onClick={() => setVarSchedEnabled(!varSchedEnabled)}
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
'bg-red-700 border-red-600 text-white': varSchedEnabled,
'bg-dark-700 border-slate-700 text-slate-400': !varSchedEnabled,
})}>
{varSchedEnabled ? '✓ Activé' : '○ Désactivé'}
</button>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Intervalle</label>
<div className="flex gap-1">
{[1, 3, 6, 12, 24].map(h => (
<button key={h} onClick={() => setVarSchedHours(h)}
className={clsx('flex-1 py-1.5 rounded text-xs transition-colors', {
'bg-red-700 text-white': varSchedHours === h,
'bg-dark-700 text-slate-400 hover:text-slate-200': varSchedHours !== h,
})}>
{h}h
</button>
))}
</div>
</div>
</div>
{/* PnL scheduler */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<DollarSign className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-sm font-semibold text-slate-300">Scheduler PnL</span>
{schedStatus?.pnl?.alive
? <span className="text-[10px] bg-emerald-900/30 text-emerald-400 border border-emerald-700/30 px-1.5 py-0.5 rounded ml-auto"> Actif</span>
: <span className="text-[10px] bg-dark-700 text-slate-600 border border-slate-700/30 px-1.5 py-0.5 rounded ml-auto"> Inactif</span>}
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Activer</label>
<button onClick={() => setPnlSchedEnabled(!pnlSchedEnabled)}
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
'bg-emerald-700 border-emerald-600 text-white': pnlSchedEnabled,
'bg-dark-700 border-slate-700 text-slate-400': !pnlSchedEnabled,
})}>
{pnlSchedEnabled ? '✓ Activé' : '○ Désactivé'}
</button>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Intervalle</label>
<div className="flex gap-1">
{[1, 2, 4, 8, 24].map(h => (
<button key={h} onClick={() => setPnlSchedHours(h)}
className={clsx('flex-1 py-1.5 rounded text-xs transition-colors', {
'bg-emerald-700 text-white': pnlSchedHours === h,
'bg-dark-700 text-slate-400 hover:text-slate-200': pnlSchedHours !== h,
})}>
{h}h
</button>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => saveSchedConfig({ var_enabled: varSchedEnabled, var_hours: varSchedHours, pnl_enabled: pnlSchedEnabled, pnl_hours: pnlSchedHours })}
disabled={savingSched}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
<Save className="w-4 h-4" />
{savingSched ? 'Sauvegarde…' : 'Appliquer'}
</button>
<button
onClick={() => triggerVarNow()}
disabled={triggeringVar}
className="flex items-center gap-1.5 border border-red-500/50 text-red-400 hover:bg-red-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
<Gauge className={clsx('w-4 h-4', triggeringVar && 'animate-spin')} />
Snapshot VaR maintenant
</button>
<button
onClick={() => triggerPnlNow()}
disabled={triggeringPnl}
className="flex items-center gap-1.5 border border-emerald-500/50 text-emerald-400 hover:bg-emerald-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
<DollarSign className={clsx('w-4 h-4', triggeringPnl && 'animate-spin')} />
Snapshot PnL maintenant
</button>
</div>
</div>
</div>
)}

View File

@@ -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<string, string | number>) {
// ─── 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 (
<div className={clsx('bg-dark-800 rounded-xl border p-4', `border-${color}-700/40`)}>
<div className="flex items-center justify-between mb-3">
<span className={clsx('text-xs font-semibold uppercase tracking-wider', `text-${color}-400`)}>{label}</span>
{stressed && (
<span className="text-xs bg-orange-900/30 text-orange-400 border border-orange-700/40 px-2 py-0.5 rounded">
Stressed ×1.5
</span>
)}
<div className={clsx('bg-dark-800 rounded-xl border p-4', colorClass)}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold uppercase tracking-wider text-slate-300">{label}</span>
{stressed && <span className="text-[10px] bg-orange-900/30 text-orange-400 border border-orange-700/40 px-1.5 py-0.5 rounded">Stressed ×1.5</span>}
</div>
<div className="text-xs text-slate-500 mb-1">{method}</div>
<div className="text-[11px] text-slate-500 mb-3">{method}</div>
<div className="space-y-2">
<div className="flex justify-between items-baseline">
<span className="text-slate-400 text-xs">VaR 1J ({horizon === 1 ? '1j' : `${horizon}j`})</span>
<span className={clsx('text-lg font-bold', isLoss(var1d) ? 'text-red-400' : 'text-emerald-400')}>
{pct(horizon === 1 ? var1d : varNd)}
</span>
<div>
<div className="text-[10px] text-slate-500 mb-0.5">VaR {horizon}j</div>
<div className={clsx('text-2xl font-bold', displayed < 0 ? 'text-red-400' : 'text-emerald-400')}>
{pct(displayed)}
</div>
<div className={clsx('text-xs', displayedEur < 0 ? 'text-red-400/70' : 'text-emerald-400/70')}>
{eur(displayedEur)}
</div>
</div>
<div className="flex justify-between items-baseline">
<span className="text-slate-400 text-xs">CVaR (ES)</span>
<span className={clsx('font-semibold text-sm', isLoss(cvar) ? 'text-orange-400' : 'text-slate-300')}>
<div className="border-t border-slate-700/40 pt-2">
<div className="text-[10px] text-slate-500 mb-0.5">CVaR (Expected Shortfall)</div>
<div className={clsx('text-base font-semibold', cvar < 0 ? 'text-orange-400' : 'text-slate-300')}>
{pct(cvar)}
</span>
</div>
<div className={clsx('text-xs', cvarEur < 0 ? 'text-orange-400/70' : 'text-slate-500')}>
{eur(cvarEur)}
</div>
</div>
</div>
</div>
)
}
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 (
<div className="flex flex-wrap gap-3 items-end">
{/* Confidence */}
<div>
<label className="text-xs text-slate-500 block mb-1">Confiance</label>
<div className="flex gap-1">
{[0.90, 0.95, 0.99].map(c => (
<button
key={c}
onClick={() => setConfidence(c)}
className={clsx(
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
confidence === c
? 'bg-blue-600 text-white'
: 'bg-dark-700 text-slate-400 hover:text-white'
)}
>
{(c * 100).toFixed(0)}%
</button>
))}
</div>
</div>
{/* Horizon */}
<div>
<label className="text-xs text-slate-500 block mb-1">Horizon</label>
<div className="flex gap-1">
{[1, 5, 10, 21].map(h => (
<button
key={h}
onClick={() => setHorizon(h)}
className={clsx(
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
horizon === h
? 'bg-violet-600 text-white'
: 'bg-dark-700 text-slate-400 hover:text-white'
)}
>
{h}j
</button>
))}
</div>
</div>
{/* Lookback */}
<div>
<label className="text-xs text-slate-500 block mb-1">Historique</label>
<div className="flex gap-1">
{[63, 126, 252].map(l => (
<button
key={l}
onClick={() => setLookback(l)}
className={clsx(
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
lookback === l
? 'bg-emerald-700 text-white'
: 'bg-dark-700 text-slate-400 hover:text-white'
)}
>
{l === 63 ? '3M' : l === 126 ? '6M' : '1A'}
</button>
))}
</div>
</div>
{/* IV */}
<div>
<label className="text-xs text-slate-500 block mb-1">IV par défaut</label>
<div className="flex gap-1">
{[0.15, 0.20, 0.30, 0.40].map(v => (
<button
key={v}
onClick={() => setIv(v)}
className={clsx(
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
iv === v
? 'bg-amber-600 text-white'
: 'bg-dark-700 text-slate-400 hover:text-white'
)}
>
{(v * 100).toFixed(0)}%
</button>
))}
</div>
</div>
<button
onClick={onRefresh}
disabled={loading}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold transition-colors',
'bg-slate-700 text-slate-300 hover:bg-slate-600'
)}
>
<RefreshCw className={clsx('w-3.5 h-3.5', loading && 'animate-spin')} />
Recalcul
</button>
</div>
)
}
// ─── Custom tooltip for histogram ─────────────────────────────────────────────
function HistoTooltip({ active, payload }: any) {
if (!active || !payload?.length) return null
const { x, count } = payload[0].payload
return (
<div className="bg-dark-800 border border-slate-700 rounded px-3 py-2 text-xs">
<div className="text-slate-300">Ret: <span className="font-bold">{x.toFixed(3)}%</span></div>
<div className="text-slate-400">Obs: {count}</div>
<div className="bg-dark-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs">
<div className="text-slate-300">Ret: <span className="font-bold">{payload[0].payload.x.toFixed(3)}%</span></div>
<div className="text-slate-400">Obs: {payload[0].payload.count}</div>
</div>
)
}
@@ -196,48 +89,95 @@ function HistoTooltip({ active, payload }: any) {
function RollingTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null
return (
<div className="bg-dark-800 border border-slate-700 rounded px-3 py-2 text-xs">
<div className="text-slate-500 mb-1">{label}</div>
<div className="bg-dark-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs">
<div className="text-slate-500 mb-0.5">{label}</div>
<div className="text-red-400 font-semibold">VaR: {payload[0]?.value?.toFixed(3)}%</div>
</div>
)
}
// ─── Empty state ──────────────────────────────────────────────────────────────
function IdleState({ onCompute, computing }: { onCompute: () => void; computing: boolean }) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-6">
<ShieldAlert className="w-16 h-16 text-slate-700" />
<div className="text-center">
<h2 className="text-lg font-semibold text-slate-400 mb-2">Aucun snapshot VaR disponible</h2>
<p className="text-slate-600 text-sm max-w-md">
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.
</p>
</div>
<button
onClick={onCompute}
disabled={computing}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-6 py-3 rounded-lg font-semibold text-sm"
>
<Play className={clsx('w-4 h-4', computing && 'animate-pulse')} />
{computing ? 'Calcul en cours…' : 'Calculer maintenant'}
</button>
</div>
)
}
// ─── 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<any>(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 (
<div className="p-6 space-y-6 max-w-screen-xl mx-auto">
{/* Header */}
<div className="p-6 space-y-5 max-w-screen-xl mx-auto">
{/* Header bar */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
@@ -245,231 +185,295 @@ export default function VaRAnalysis() {
<h1 className="text-xl font-bold text-white">Analyse VaR Value at Risk</h1>
</div>
<p className="text-slate-400 text-sm">
Approche delta Black-Scholes · {portfolio ? `${portfolio.n_positions} positions · Notionnel ${portfolio.total_notional_eur.toLocaleString('fr-FR')} EUR` : '—'}
{portfolio?.data_source === 'simulated' && (
<span className="ml-2 text-amber-400 text-xs"> Données simulées (marché indisponible)</span>
Approche delta Black-Scholes
{displayedMeta && (
<span className="ml-2 text-slate-500">
· snapshot du {displayedMeta.computed_at?.slice(0, 16).replace('T', ' ')} UTC
{displayedMeta.data_source === 'simulated' && (
<span className="ml-2 text-amber-400"> données simulées</span>
)}
</span>
)}
</p>
</div>
<ControlBar
confidence={confidence} setConfidence={setConfidence}
horizon={horizon} setHorizon={setHorizon}
lookback={lookback} setLookback={setLookback}
iv={iv} setIv={setIv}
loading={isLoading}
onRefresh={() => setRefreshKey(k => k + 1)}
/>
{/* Controls */}
<div className="flex flex-wrap gap-3 items-end">
{/* Confidence */}
<div>
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Confiance</div>
<div className="flex gap-1">
{[0.90, 0.95, 0.99].map(c => (
<button key={c} onClick={() => setConfidence(c)}
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
confidence === c ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
{(c * 100).toFixed(0)}%
</button>
))}
</div>
</div>
{/* Horizon */}
<div>
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Horizon</div>
<div className="flex gap-1">
{[1, 5, 10, 21].map(h => (
<button key={h} onClick={() => setHorizon(h)}
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
horizon === h ? 'bg-violet-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
{h}j
</button>
))}
</div>
</div>
{/* Lookback */}
<div>
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Historique</div>
<div className="flex gap-1">
{[63, 126, 252].map(l => (
<button key={l} onClick={() => setLookback(l)}
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
lookback === l ? 'bg-emerald-700 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
{l === 63 ? '3M' : l === 126 ? '6M' : '1A'}
</button>
))}
</div>
</div>
{/* IV */}
<div>
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">IV défaut</div>
<div className="flex gap-1">
{[0.15, 0.20, 0.30, 0.40].map(v => (
<button key={v} onClick={() => setIv(v)}
className={clsx('px-2.5 py-1.5 rounded text-xs font-semibold',
iv === v ? 'bg-amber-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
{(v * 100).toFixed(0)}%
</button>
))}
</div>
</div>
{/* Compute button */}
<button
onClick={() => compute()}
disabled={computing}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-semibold"
>
<Play className={clsx('w-3.5 h-3.5', computing && 'animate-pulse')} />
{computing ? 'Calcul…' : 'Calculer'}
</button>
</div>
</div>
{/* Error state */}
{error && (
<div className="bg-red-900/20 border border-red-700/40 rounded-xl p-4 text-red-400 flex items-center gap-2">
{/* Backend down notice */}
{backendDown && (
<div className="bg-red-900/20 border border-red-700/40 rounded-xl p-4 text-red-400 flex items-center gap-2 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{String(error)}</span>
Backend inaccessible vérifiez que le serveur FastAPI est démarré sur le port 8000.
</div>
)}
{data?.error && (
<div className="bg-amber-900/20 border border-amber-700/40 rounded-xl p-4 text-amber-400 flex items-center gap-2">
<Info className="w-4 h-4 shrink-0" />
<span>{data.error}</span>
{/* Compute error */}
{computeError && (
<div className="bg-red-900/20 border border-red-700/40 rounded-xl p-3 text-red-400 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{String(computeError)}
</div>
)}
{/* VaR metric cards */}
{varData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard
label="Historique"
method="Percentile empirique"
var1d={varData.historical.var_1d_pct}
varNd={varData.historical.var_nd_pct}
cvar={varData.historical.cvar_pct}
horizon={horizon}
color="blue"
/>
<MetricCard
label="Paramétrique"
method="Distribution normale"
var1d={varData.parametric.var_1d_pct}
varNd={varData.parametric.var_nd_pct}
cvar={varData.parametric.cvar_pct}
horizon={horizon}
color="violet"
/>
<MetricCard
label="Monte Carlo"
method={`${(confidence * 100).toFixed(0)}% · vol stressée ×1.5`}
var1d={varData.monte_carlo.var_1d_pct}
varNd={varData.monte_carlo.var_nd_pct}
cvar={varData.monte_carlo.cvar_pct}
horizon={horizon}
stressed
color="orange"
/>
</div>
)}
{/* EUR amounts */}
{varData && portfolio && (
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
{[
{ 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 }) => (
<div key={label} className={clsx('bg-dark-800 rounded-lg border p-3', `border-${color}-700/30`)}>
<div className="text-xs text-slate-500 mb-1">{label}</div>
<div className={clsx('font-bold text-sm', val < 0 ? 'text-red-400' : 'text-emerald-400')}>
{val < 0 ? '' : '+'}{val.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}
</div>
</div>
{/* History picker */}
{snapshots.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<Database className="w-3.5 h-3.5 text-slate-500" />
<span className="text-xs text-slate-500">Historique :</span>
<button
onClick={() => setActiveSnap(null)}
className={clsx('text-xs px-2 py-1 rounded', !activeSnap ? 'bg-blue-900/40 text-blue-400 border border-blue-700/40' : 'bg-dark-700 text-slate-500 hover:text-slate-300')}
>
Dernier
</button>
{snapshots.slice(1).map((s: any) => (
<button
key={s.id}
onClick={() => {
fetch(`${API}/api/var/snapshots/${s.id}`).then(r => r.json()).then(d => setActiveSnap(d.full_result))
}}
className={clsx('text-xs px-2 py-1 rounded',
activeSnap?.snapshot_id === s.id
? 'bg-violet-900/40 text-violet-400 border border-violet-700/40'
: 'bg-dark-700 text-slate-500 hover:text-slate-300')}
>
{s.computed_at?.slice(5, 16).replace('T', ' ')}
</button>
))}
</div>
)}
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Returns distribution histogram */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-slate-400" />
<h3 className="text-sm font-semibold text-white">Distribution des Retours</h3>
<span className="text-xs text-slate-500 ml-auto"> trait rouge = VaR hist.</span>
</div>
{histogram.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={histogram} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="x" tick={{ fontSize: 10, fill: '#94a3b8' }} />
<YAxis tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip content={<HistoTooltip />} />
<ReferenceLine x={histVarThreshold} stroke="#f87171" strokeDasharray="4 2" strokeWidth={2} />
<Bar dataKey="count" radius={[2, 2, 0, 0]}>
{histogram.map((entry, i) => (
<Cell key={i} fill={entry.x < histVarThreshold ? '#ef4444' : '#3b82f6'} fillOpacity={0.75} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-slate-600 text-sm">
{isLoading ? 'Calcul en cours…' : 'Aucune donnée'}
</div>
)}
</div>
{/* Empty / loading state */}
{loadingLatest && !displayedResult && (
<div className="text-center py-16 text-slate-600">Chargement du dernier snapshot</div>
)}
{/* Rolling VaR */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<div className="flex items-center gap-2 mb-4">
<TrendingDown className="w-4 h-4 text-red-400" />
<h3 className="text-sm font-semibold text-white">VaR 95% Glissante (fenêtre 30J)</h3>
</div>
{rolling.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rolling} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="date"
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={d => d.slice(5)} // MM-DD
interval={Math.floor(rolling.length / 6)}
/>
<YAxis tick={{ fontSize: 10, fill: '#94a3b8' }} unit="%" />
<Tooltip content={<RollingTooltip />} />
<ReferenceLine y={0} stroke="#475569" />
<Line
type="monotone"
dataKey="var_95"
stroke="#f87171"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-slate-600 text-sm">
{isLoading ? 'Calcul en cours…' : 'Historique insuffisant'}
</div>
)}
</div>
</div>
{!loadingLatest && !displayedResult && !backendDown && (
<IdleState onCompute={() => compute()} computing={computing} />
)}
{/* Bottom row: positions + backtest */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Positions deltas */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<h3 className="text-sm font-semibold text-white mb-3">Positions & Deltas</h3>
{positions.length === 0 ? (
<p className="text-slate-600 text-sm">{isLoading ? 'Chargement…' : 'Aucune position'}</p>
) : (
<div className="space-y-2">
{positions.map((p, i) => (
<div key={i} className="flex items-center justify-between text-xs py-1.5 border-b border-slate-700/30 last:border-0">
<div className="min-w-0 flex-1">
<span className="text-white font-semibold">{p.ticker}</span>
<span className="text-slate-400 ml-2">{p.strategy}</span>
{p.pattern && <div className="text-slate-600 truncate">{p.pattern}</div>}
{/* ── Results ── */}
{varData && portfolio && (
<>
{/* Portfolio summary strip */}
<div className="flex gap-4 flex-wrap text-xs">
{[
{ 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 }) => (
<div key={label} className="bg-dark-800 border border-slate-700/30 rounded-lg px-3 py-2">
<div className="text-slate-500">{label}</div>
<div className="text-white font-semibold">{val}</div>
</div>
))}
</div>
{/* 3 VaR method cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard
label="Historique" method="Percentile empirique"
var1d={varData.historical.var_1d_pct} varNd={varData.historical.var_nd_pct}
cvar={varData.historical.cvar_pct}
varEur={varData.historical.var_1d_eur} cvarEur={varData.historical.cvar_eur}
horizon={horizon} colorClass="border-blue-700/40"
/>
<MetricCard
label="Paramétrique" method="Distribution normale"
var1d={varData.parametric.var_1d_pct} varNd={varData.parametric.var_nd_pct}
cvar={varData.parametric.cvar_pct}
varEur={varData.parametric.var_1d_eur} cvarEur={varData.parametric.cvar_eur}
horizon={horizon} colorClass="border-violet-700/40"
/>
<MetricCard
label="Monte Carlo" method="Vol stressée ×1.5"
var1d={varData.monte_carlo.var_1d_pct} varNd={varData.monte_carlo.var_nd_pct}
cvar={varData.monte_carlo.cvar_pct}
varEur={varData.monte_carlo.var_1d_eur} cvarEur={varData.monte_carlo.cvar_eur}
horizon={horizon} colorClass="border-orange-700/40" stressed
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Histogram */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Activity className="w-4 h-4 text-slate-400" />
<h3 className="text-sm font-semibold text-white">Distribution des Retours</h3>
<span className="text-[10px] text-slate-600 ml-auto">trait rouge = VaR hist.</span>
</div>
{histogram.length > 0 ? (
<ResponsiveContainer width="100%" height={210}>
<BarChart data={histogram} margin={{ top: 2, right: 4, left: -22, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="x" tick={{ fontSize: 9, fill: '#64748b' }} />
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} />
<Tooltip content={<HistoTooltip />} />
<ReferenceLine x={histVarThreshold} stroke="#f87171" strokeDasharray="4 2" strokeWidth={1.5} />
<Bar dataKey="count" radius={[2, 2, 0, 0]}>
{histogram.map((e: any, i: number) => (
<Cell key={i} fill={e.x < histVarThreshold ? '#ef4444' : '#3b82f6'} fillOpacity={0.7} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[210px] flex items-center justify-center text-slate-600 text-sm">Aucune donnée</div>
)}
</div>
{/* Rolling VaR */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingDown className="w-4 h-4 text-red-400" />
<h3 className="text-sm font-semibold text-white">VaR 95% Glissante (fenêtre 30J)</h3>
</div>
{rolling.length > 0 ? (
<ResponsiveContainer width="100%" height={210}>
<LineChart data={rolling} margin={{ top: 2, right: 4, left: -22, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" tick={{ fontSize: 8, fill: '#64748b' }}
tickFormatter={d => d.slice(5)} interval={Math.floor(rolling.length / 5)} />
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} unit="%" />
<Tooltip content={<RollingTooltip />} />
<ReferenceLine y={0} stroke="#475569" />
<Line type="monotone" dataKey="var_95" stroke="#f87171" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[210px] flex items-center justify-center text-slate-600 text-sm">Historique insuffisant</div>
)}
</div>
</div>
{/* Positions + Kupiec */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Positions */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<h3 className="text-sm font-semibold text-white mb-3">Positions & Deltas</h3>
<div className="space-y-1.5">
{positions.map((p: any, i: number) => (
<div key={i} className="flex items-center justify-between text-xs py-1 border-b border-slate-700/20 last:border-0">
<div className="min-w-0 flex-1">
<span className="text-white font-semibold">{p.ticker}</span>
<span className="text-slate-400 ml-2">{p.strategy}</span>
{p.pattern && <div className="text-slate-600 truncate text-[11px]">{p.pattern}</div>}
</div>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className={clsx('font-mono font-semibold',
Math.abs(p.delta) < 0.05 ? 'text-slate-500'
: p.delta > 0 ? 'text-emerald-400' : 'text-red-400')}>
Δ {p.delta > 0 ? '+' : ''}{p.delta.toFixed(3)}
</span>
<span className="text-slate-500 font-mono">
{p.notional.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}
</span>
</div>
</div>
<div className="flex items-center gap-4 shrink-0 ml-4">
<span className={clsx(
'font-mono font-semibold',
Math.abs(p.delta) < 0.05 ? 'text-slate-500'
: p.delta > 0 ? 'text-emerald-400' : 'text-red-400'
)}>
Δ {p.delta > 0 ? '+' : ''}{p.delta.toFixed(3)}
</span>
<span className="text-slate-500 font-mono">{p.notional.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} </span>
))}
</div>
</div>
{/* Kupiec backtest */}
{backtest && (
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<h3 className="text-sm font-semibold text-white mb-3">Backtest Test de Kupiec</h3>
<div className="space-y-2.5">
{[
{ 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 }) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-slate-400">{label}</span>
<span className={clsx('font-semibold', color ?? 'text-white')}>{val}</span>
</div>
))}
<div className={clsx('flex items-center gap-2 p-2.5 rounded-lg text-xs mt-2',
backtest.kupiec_ok
? 'bg-emerald-900/20 border border-emerald-700/40 text-emerald-400'
: 'bg-red-900/20 border border-red-700/40 text-red-400')}>
{backtest.kupiec_ok
? '✓ Modèle validé — violations dans la tolérance'
: '✗ Excès de violations — modèle sous-estime le risque'}
</div>
<div className="text-[10px] text-slate-600">
Règle Kupiec : taux réel {(backtest.expected_breach_rate_pct * 2).toFixed(1)}%
</div>
</div>
))}
</div>
)}
</div>
{/* Backtest Kupiec */}
{backtest && (
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<h3 className="text-sm font-semibold text-white mb-3">Backtest Test de Kupiec</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-slate-400 text-sm">Observations</span>
<span className="text-white font-semibold">{backtest.n_observations}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-400 text-sm">Violations VaR</span>
<span className="text-white font-semibold">{backtest.n_breaches}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-400 text-sm">Taux réel</span>
<span className={clsx('font-semibold', backtest.kupiec_ok ? 'text-emerald-400' : 'text-red-400')}>
{backtest.breach_rate_pct}%
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-400 text-sm">Taux attendu</span>
<span className="text-slate-300">{backtest.expected_breach_rate_pct}%</span>
</div>
<div className={clsx(
'flex items-center gap-2 p-3 rounded-lg text-sm',
backtest.kupiec_ok
? 'bg-emerald-900/20 border border-emerald-700/40 text-emerald-400'
: 'bg-red-900/20 border border-red-700/40 text-red-400'
)}>
{backtest.kupiec_ok
? '✓ Modèle validé — violations dans la tolérance'
: '✗ Excès de violations — modèle sous-estime le risque'}
</div>
<div className="text-xs text-slate-600 mt-1">
Règle : taux réel 2× taux attendu ({backtest.expected_breach_rate_pct * 2}%)
</div>
</div>
)}
</div>
)}
</div>
</>
)}
</div>
)
}