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, Play, Clock, Database } from 'lucide-react'
import clsx from 'clsx'
// ─── API calls ───────────────────────────────────────────────────────────────
async function fetchLatest() {
const r = await fetch('/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(`/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/var/snapshots?limit=10')
if (!r.ok) return { snapshots: [] }
return r.json()
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function MetricCard({
label, method, var1d, varNd, cvar, varEur, cvarEur, horizon, stressed = false, colorClass,
}: {
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 eur = (v: number) => `${v < 0 ? '' : '+'}${v.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €`
const displayed = horizon === 1 ? var1d : varNd
const displayedEur = horizon === 1 ? varEur : varEur * Math.sqrt(horizon)
return (
{label}
{stressed && Stressed ×1.5 }
{method}
VaR {horizon}j
{pct(displayed)}
{eur(displayedEur)}
CVaR (Expected Shortfall)
{pct(cvar)}
{eur(cvarEur)}
)
}
function HistoTooltip({ active, payload }: any) {
if (!active || !payload?.length) return null
return (
Ret: {payload[0].payload.x.toFixed(3)}%
Obs: {payload[0].payload.count}
)
}
function RollingTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null
return (
{label}
VaR: {payload[0]?.value?.toFixed(3)}%
)
}
// ─── Empty state ──────────────────────────────────────────────────────────────
function IdleState({ onCompute, computing }: { onCompute: () => void; computing: boolean }) {
return (
Aucun snapshot VaR disponible
Le calcul VaR ne se lance pas automatiquement pour éviter des appels réseau inutiles.
Cliquez sur "Calculer maintenant" ou configurez le scheduler dans la Configuration.
{computing ? 'Calcul en cours…' : 'Calculer maintenant'}
)
}
// ─── 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 [activeSnap, setActiveSnap] = useState(null) // override from history
// 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 { data: snapshotsData } = useQuery({
queryKey: ['var-snapshots'],
queryFn: fetchSnapshots,
staleTime: 60_000,
retry: 1,
})
// Button-triggered compute + save
const { mutate: compute, isPending: computing, error: computeError } = useMutation({
mutationFn: () => runNow({ confidence, horizon_days: horizon, lookback_days: lookback, default_iv: iv }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['var-latest'] })
qc.invalidateQueries({ queryKey: ['var-snapshots'] })
setActiveSnap(null)
},
})
// The displayed result: manual history pick OR latest from DB
const displayedResult = activeSnap ?? latestData?.snapshot?.full_result
const displayedMeta = activeSnap
? null
: latestData?.snapshot
const varData = displayedResult?.var
const portfolio = displayedResult?.portfolio
const histogram: any[] = displayedResult?.histogram ?? []
const rolling: any[] = displayedResult?.rolling_var ?? []
const positions: any[] = displayedResult?.positions ?? []
const backtest = displayedResult?.backtest
const histVarThreshold = varData?.historical?.var_1d_pct ?? 0
const snapshots: any[] = snapshotsData?.snapshots ?? []
const backendDown = latestError && String(latestError).includes('Backend')
return (
{/* Header bar */}
Analyse VaR — Value at Risk
Approche delta Black-Scholes
{displayedMeta && (
· snapshot du {displayedMeta.computed_at?.slice(0, 16).replace('T', ' ')} UTC
{displayedMeta.data_source === 'simulated' && (
⚠ données simulées
)}
)}
{/* Controls */}
{/* Confidence */}
Confiance
{[0.90, 0.95, 0.99].map(c => (
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)}%
))}
{/* Horizon */}
Horizon
{[1, 5, 10, 21].map(h => (
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
))}
{/* Lookback */}
Historique
{[63, 126, 252].map(l => (
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'}
))}
{/* IV */}
IV défaut
{[0.15, 0.20, 0.30, 0.40].map(v => (
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)}%
))}
{/* Compute button */}
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"
>
{computing ? 'Calcul…' : 'Calculer'}
{/* Backend down notice */}
{backendDown && (
Backend inaccessible — vérifiez que le serveur FastAPI est démarré sur le port 8000.
)}
{/* Compute error */}
{computeError && (
)}
{/* History picker */}
{snapshots.length > 1 && (
Historique :
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
{snapshots.slice(1).map((s: any) => (
{
fetch(`/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', ' ')}
))}
)}
{/* Empty / loading state */}
{loadingLatest && !displayedResult && (
Chargement du dernier snapshot…
)}
{!loadingLatest && !displayedResult && !backendDown && (
compute()} computing={computing} />
)}
{/* ── Results ── */}
{varData && portfolio && (
<>
{/* Portfolio summary strip */}
{[
{ label: 'Positions', val: String(portfolio.n_positions) },
{ label: 'Notionnel', val: `${portfolio.total_notional_eur.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €` },
{ label: 'Confiance', val: `${portfolio.confidence_pct}%` },
{ label: 'Lookback', val: `${portfolio.lookback_days}j` },
{ label: 'Source', val: portfolio.data_source === 'simulated' ? '⚠ Simulée' : '✓ Live' },
].map(({ label, val }) => (
))}
{/* 3 VaR method cards */}
{/* Charts */}
{/* Histogram */}
Distribution des Retours
trait rouge = VaR hist.
{histogram.length > 0 ? (
} />
{histogram.map((e: any, i: number) => (
|
))}
) : (
Aucune donnée
)}
{/* Rolling VaR */}
VaR 95% Glissante (fenêtre 30J)
{rolling.length > 0 ? (
d.slice(5)} interval={Math.floor(rolling.length / 5)} />
} />
) : (
Historique insuffisant
)}
{/* Positions + Kupiec */}
{/* Positions */}
Positions & Deltas
{positions.map((p: any, i: number) => (
{p.ticker}
{p.strategy}
{p.pattern &&
{p.pattern}
}
0 ? 'text-emerald-400' : 'text-red-400')}>
Δ {p.delta > 0 ? '+' : ''}{p.delta.toFixed(3)}
{p.notional.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €
))}
{/* Kupiec backtest */}
{backtest && (
Backtest — Test de Kupiec
{[
{ label: 'Observations', val: String(backtest.n_observations) },
{ label: 'Violations VaR', val: String(backtest.n_breaches) },
{ label: 'Taux réel', val: `${backtest.breach_rate_pct}%`, color: backtest.kupiec_ok ? 'text-emerald-400' : 'text-red-400' },
{ label: 'Taux attendu', val: `${backtest.expected_breach_rate_pct}%`, color: 'text-slate-300' },
].map(({ label, val, color }) => (
{label}
{val}
))}
{backtest.kupiec_ok
? '✓ Modèle validé — violations dans la tolérance'
: '✗ Excès de violations — modèle sous-estime le risque'}
Règle Kupiec : taux réel ≤ {(backtest.expected_breach_rate_pct * 2).toFixed(1)}%
)}
>
)}
)
}