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

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