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>
478 lines
22 KiB
TypeScript
478 lines
22 KiB
TypeScript
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>
|
||
)
|
||
}
|