Files
OpenFin/frontend/src/pages/VaRAnalysis.tsx
OpenSquared 67546d39af fix: URLs relatives pour VaR et Config (bypass proxy corrigé)
Remplace http://localhost:8000 par des URLs relatives (/api/...)
pour passer par le proxy Vite/Nginx comme toutes les autres pages.
Cause du NetworkError en prod Docker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 06:27:43 +02:00

478 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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-[11px] text-slate-500 mb-3">{method}</div>
<div className="space-y-2">
<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="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)}
</div>
<div className={clsx('text-xs', cvarEur < 0 ? 'text-orange-400/70' : 'text-slate-500')}>
{eur(cvarEur)}
</div>
</div>
</div>
</div>
)
}
function HistoTooltip({ active, payload }: any) {
if (!active || !payload?.length) return null
return (
<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>
)
}
function RollingTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null
return (
<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 [activeSnap, setActiveSnap] = useState<any>(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 (
<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">
<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
{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>
{/* 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>
{/* 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" />
Backend inaccessible vérifiez que le serveur FastAPI est démarré sur le port 8000.
</div>
)}
{/* 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>
)}
{/* 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/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>
)}
{/* Empty / loading state */}
{loadingLatest && !displayedResult && (
<div className="text-center py-16 text-slate-600">Chargement du dernier snapshot</div>
)}
{!loadingLatest && !displayedResult && !backendDown && (
<IdleState onCompute={() => compute()} computing={computing} />
)}
{/* ── 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>
</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>
</>
)}
</div>
)
}