feat: page VaR Analyse avec approche delta Black-Scholes

- Service var_service.py : calcul VaR Historique / Paramétrique / Monte Carlo
  stressé (vol ×1.5) + CVaR par méthode, deltas BS par position, fallback
  synthétique si yfinance indisponible
- Router /api/var/compute : paramètres confidence, horizon, lookback, IV défaut
- Page VaRAnalysis.tsx : cartes métriques %, montants EUR, histogramme retours,
  VaR glissante 30j, tableau positions + deltas, backtest Kupiec pass/fail
- Route /var + nav sidebar « VaR Analyse »

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-19 23:15:39 +02:00
parent 27846a1b63
commit d64d1029bf
6 changed files with 756 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router
from routers import logs as logs_router
from routers import var as var_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import logging
@@ -99,6 +100,7 @@ app.include_router(options_vol_router.router)
app.include_router(analytics_router.router)
app.include_router(risk_router.router)
app.include_router(logs_router.router)
app.include_router(var_router.router)
@app.get("/")

20
backend/routers/var.py Normal file
View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Query
from services.var_service import compute_var
router = APIRouter(prefix="/api/var", tags=["var"])
@router.get("/compute")
def var_compute(
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(
confidence=confidence,
horizon_days=horizon_days,
lookback_days=lookback_days,
default_iv=default_iv,
)

View File

@@ -0,0 +1,255 @@
"""VaR service — Black-Scholes delta approach with numpy/scipy (no numba dependency)."""
from __future__ import annotations
import numpy as np
import pandas as pd
from scipy.stats import norm
from datetime import datetime, timedelta
from typing import List, Dict
from .database import get_conn
# ─── Option strategy → (type, directional multiplier) ───────────────────────
def _parse_strategy(strategy: str) -> tuple[str, float]:
"""Return (option_type, direction_sign) from strategy name."""
s = strategy.lower()
if "straddle" in s or "strangle" in s:
return "straddle", (1.0 if "long" in s else -1.0)
if "iron condor" in s or "butterfly" in s or "neutral" in s:
return "neutral", 0.0
if "bull" in s:
return "call", 0.5
if "bear" in s:
return "put", -0.5
if "call" in s:
return "call", (1.0 if "long" in s else -1.0)
if "put" in s:
return "put", (1.0 if "long" in s else -1.0)
return "call", 0.5 # default
def _bs_delta(S: float, K: float, T_days: float, sigma: float, opt_type: str, direction: float) -> float:
"""Black-Scholes delta, direction-adjusted."""
r = 0.05
T = max(T_days, 1) / 252.0
d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
if opt_type == "call":
raw = float(norm.cdf(d1))
elif opt_type == "put":
raw = float(norm.cdf(d1) - 1.0)
elif opt_type == "straddle":
# Long straddle: net delta ≈ 0 ATM; represent as small residual
raw = float(norm.cdf(d1) + (norm.cdf(d1) - 1.0)) # ≈ 0
else:
raw = 0.0
return raw * direction
# ─── Market data ─────────────────────────────────────────────────────────────
def _fetch_returns(tickers: List[str], lookback: int) -> pd.DataFrame:
"""Download historical daily returns via yfinance. Returns {} on failure."""
valid = [t for t in tickers if ":" not in t]
if not valid:
return pd.DataFrame()
try:
import yfinance as yf
end = datetime.now()
start = end - timedelta(days=lookback + 60)
raw = yf.download(valid, start=start, end=end, progress=False, auto_adjust=True)
if raw.empty:
return pd.DataFrame()
close = raw["Close"] if len(valid) > 1 else raw[["Close"]].rename(columns={"Close": valid[0]})
return close.pct_change().dropna().tail(lookback)
except Exception:
return pd.DataFrame()
def _synthetic_returns(tickers: List[str], lookback: int, seed: int = 42) -> pd.DataFrame:
"""Fallback: simulate realistic returns when market data unavailable."""
rng = np.random.default_rng(seed)
idx = pd.date_range(end=datetime.now(), periods=lookback, freq="B")
data = {t: rng.normal(0.0002, 0.018, lookback) for t in tickers}
return pd.DataFrame(data, index=idx)
# ─── Core VaR computation ────────────────────────────────────────────────────
def compute_var(
confidence: float = 0.95,
horizon_days: int = 1,
lookback_days: int = 252,
default_iv: float = 0.20,
) -> Dict:
conn = get_conn()
rows = conn.execute(
"SELECT underlying, strategy, entry_price, capital_invested, "
"strike_guidance, expiry_days_at_entry, pattern_name "
"FROM trade_entry_prices WHERE status = 'open'"
).fetchall()
if not rows:
return {"error": "Aucune position ouverte"}
positions = [dict(r) for r in rows]
# Filter positions usable for delta calc
valid = [
p for p in positions
if p.get("underlying") and ":" not in (p["underlying"] or "")
and p.get("entry_price") and p["entry_price"] > 0
]
if not valid:
return {"error": "Aucune position avec données de marché disponibles"}
tickers = list({p["underlying"] for p in valid})
# Fetch or synthesize returns
returns_df = _fetch_returns(tickers, lookback_days)
data_source = "live"
if returns_df.empty:
returns_df = _synthetic_returns(tickers, lookback_days)
data_source = "simulated"
# Align to available history
n = len(returns_df)
# Build delta-weighted portfolio PnL series
weighted_pnl = pd.Series(0.0, index=returns_df.index)
total_notional = 0.0
pos_details = []
for p in valid:
ticker = p["underlying"]
if ticker not in returns_df.columns:
continue
S = float(p["entry_price"])
T = float(p.get("expiry_days_at_entry") or 60)
capital = float(p.get("capital_invested") or S)
strategy = p.get("strategy") or "Long Call"
opt_type, direction = _parse_strategy(strategy)
# Strike: ATM unless guidance specifies otherwise
K = S
delta = _bs_delta(S, K, T, default_iv, opt_type, direction)
weighted_pnl += returns_df[ticker] * delta * capital
total_notional += capital
pos_details.append({
"ticker": ticker,
"pattern": p.get("pattern_name") or "",
"strategy": strategy,
"delta": round(delta, 4),
"notional": round(capital, 2),
})
if total_notional == 0:
return {"error": "Notionnel total nul"}
portfolio_pnl = (weighted_pnl / total_notional).dropna()
pnl = portfolio_pnl.values.astype(float)
alpha = 1.0 - confidence
# ── Historical VaR ──
hist_var_1d = float(np.percentile(pnl, alpha * 100))
hist_var_nd = hist_var_1d * np.sqrt(horizon_days)
tail = pnl[pnl <= hist_var_1d]
hist_cvar = float(np.mean(tail)) if len(tail) > 0 else hist_var_1d
# ── Parametric VaR (Gaussian) ──
mu = float(np.mean(pnl))
sigma = float(np.std(pnl))
z = float(norm.ppf(alpha))
param_var_1d = mu + z * sigma
param_var_nd = param_var_1d * np.sqrt(horizon_days)
# ES closed-form: μ σ·φ(z)/α
param_cvar = mu - sigma * norm.pdf(-z) / alpha
# ── Monte Carlo (stressed: vol × 1.5) ──
rng = np.random.default_rng(42)
stressed_sigma = sigma * 1.5
mc_draws = rng.normal(mu, stressed_sigma, 10_000)
mc_var_1d = float(np.percentile(mc_draws, alpha * 100))
mc_var_nd = mc_var_1d * np.sqrt(horizon_days)
mc_tail = mc_draws[mc_draws <= mc_var_1d]
mc_cvar = float(np.mean(mc_tail)) if len(mc_tail) > 0 else mc_var_1d
# ── Rolling 30-day Historical VaR ──
rolling_var = []
for i in range(30, n):
w = pnl[i - 30:i]
rolling_var.append({
"date": portfolio_pnl.index[i].strftime("%Y-%m-%d"),
"var_95": round(float(np.percentile(w, 5)) * 100, 4),
})
rolling_var = rolling_var[-90:] # last 90 data points max
# ── Returns histogram ──
counts, edges = np.histogram(pnl * 100, bins=30)
histogram = [
{"x": round(float((edges[i] + edges[i + 1]) / 2), 4), "count": int(counts[i])}
for i in range(len(counts))
]
# ── Backtest (Kupiec test) ──
n_breaches = int(np.sum(pnl < hist_var_1d))
breach_rate = round(n_breaches / n * 100, 2) if n > 0 else 0.0
# ── VaR in EUR (based on total notional) ──
def pct_to_eur(pct_val: float) -> float:
return round(pct_val / 100 * total_notional, 2)
return {
"var": {
"historical": {
"var_1d_pct": round(hist_var_1d * 100, 3),
"var_nd_pct": round(hist_var_nd * 100, 3),
"cvar_pct": round(hist_cvar * 100, 3),
"var_1d_eur": pct_to_eur(hist_var_1d * 100),
"var_nd_eur": pct_to_eur(hist_var_nd * 100),
"cvar_eur": pct_to_eur(hist_cvar * 100),
},
"parametric": {
"var_1d_pct": round(param_var_1d * 100, 3),
"var_nd_pct": round(param_var_nd * 100, 3),
"cvar_pct": round(param_cvar * 100, 3),
"var_1d_eur": pct_to_eur(param_var_1d * 100),
"var_nd_eur": pct_to_eur(param_var_nd * 100),
"cvar_eur": pct_to_eur(param_cvar * 100),
},
"monte_carlo": {
"var_1d_pct": round(mc_var_1d * 100, 3),
"var_nd_pct": round(mc_var_nd * 100, 3),
"cvar_pct": round(mc_cvar * 100, 3),
"var_1d_eur": pct_to_eur(mc_var_1d * 100),
"var_nd_eur": pct_to_eur(mc_var_nd * 100),
"cvar_eur": pct_to_eur(mc_cvar * 100),
"stressed": True,
},
},
"portfolio": {
"total_notional_eur": round(total_notional, 2),
"n_positions": len(pos_details),
"horizon_days": horizon_days,
"confidence_pct": round(confidence * 100, 1),
"lookback_days": n,
"data_source": data_source,
},
"positions": pos_details,
"rolling_var": rolling_var,
"histogram": histogram,
"backtest": {
"n_observations": n,
"n_breaches": n_breaches,
"breach_rate_pct": breach_rate,
"expected_breach_rate_pct": round(alpha * 100, 1),
"kupiec_ok": breach_rate <= alpha * 100 * 2,
},
}

View File

@@ -17,6 +17,7 @@ import Analytics from './pages/Analytics'
import AnalyticsAdvanced from './pages/AnalyticsAdvanced'
import RiskDashboard from './pages/RiskDashboard'
import SystemLogs from './pages/SystemLogs'
import VaRAnalysis from './pages/VaRAnalysis'
import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() {
@@ -48,6 +49,7 @@ export default function App() {
<Route path="/analytics" element={<Analytics />} />
<Route path="/analytics-advanced" element={<AnalyticsAdvanced />} />
<Route path="/risk" element={<RiskDashboard />} />
<Route path="/var" element={<VaRAnalysis />} />
<Route path="/logs" element={<SystemLogs />} />
</Routes>
</main>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -20,6 +20,7 @@ const nav = [
{ to: '/analytics', icon: FlaskConical, label: 'Analytics' },
{ to: '/analytics-advanced', icon: Microscope, label: 'Analytics Avancées' },
{ to: '/risk', icon: ShieldAlert, label: 'Risk Dashboard' },
{ to: '/var', icon: Gauge, label: 'VaR Analyse' },
{ to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendrier' },
{ to: '/logs', icon: ScrollText, label: 'Logs Système' },

View File

@@ -0,0 +1,475 @@
import { useState, useMemo } from 'react'
import { useQuery } 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 clsx from 'clsx'
// ─── API ─────────────────────────────────────────────────────────────────────
async function fetchVar(params: Record<string, string | 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())
return r.json()
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function MetricCard({
label, method, var1d, varNd, cvar, horizon, stressed = false, color,
}: {
label: string
method: string
var1d: number
varNd: number
cvar: number
horizon: number
stressed?: boolean
color: string
}) {
const pct = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(3)}%`
const isLoss = (v: number) => v < 0
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>
<div className="text-xs text-slate-500 mb-1">{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="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')}>
{pct(cvar)}
</span>
</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>
)
}
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="text-red-400 font-semibold">VaR: {payload[0]?.value?.toFixed(3)}%</div>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function VaRAnalysis() {
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 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,
})
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
// Color histogram bars: red if x < VaR threshold
const histVarThreshold = varData?.historical?.var_1d_pct ?? 0
return (
<div className="p-6 space-y-6 max-w-screen-xl mx-auto">
{/* Header */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<ShieldAlert className="w-6 h-6 text-red-400" />
<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>
)}
</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)}
/>
</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">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{String(error)}</span>
</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>
</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>
))}
</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>
{/* 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>
{/* 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>}
</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>
))}
</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>
)
}