feat: courbe PnL historique + VaR dans les cards Dashboard

- Card PnL : area chart historique depuis pnl_snapshots (gradient couleur
  selon PnL positif/négatif, axes date + %, tooltip)
- Card Risque : bloc VaR 95% (Historique / CVaR / Monte Carlo ×1.5)
  depuis le dernier var_snapshot, avec horodatage du calcul

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 06:41:17 +02:00
parent 4a1dc76d26
commit bcd1a3a0d5

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
useGeoRiskScore, useAllQuotes,
useCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
@@ -11,7 +12,10 @@ import clsx from 'clsx'
import type { Quote } from '../types'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer } from 'recharts'
import {
RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer,
AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid,
} from 'recharts'
import { scoreColor } from '../components/TradeIdeas'
const riskGauge = (score: number) => {
@@ -88,6 +92,20 @@ export default function Dashboard() {
const { data: cycleStatusData } = useCycleStatus()
const { data: geoNews } = useGeoNews()
// Historical PnL curve + latest VaR snapshot
const { data: pnlHistoryData } = useQuery({
queryKey: ['dash-pnl-history'],
queryFn: () => fetch('/api/var/pnl/snapshots?limit=200').then(r => r.ok ? r.json() : { snapshots: [] }),
staleTime: 120_000,
retry: 1,
})
const { data: latestVarData } = useQuery({
queryKey: ['dash-var-latest'],
queryFn: () => fetch('/api/var/latest').then(r => r.ok ? r.json() : { snapshot: null }),
staleTime: 120_000,
retry: 1,
})
const [pnlView, setPnlView] = useState<'simulated' | 'portfolio'>(() =>
(localStorage.getItem('dash_pnl_view') as any) ?? 'simulated'
)
@@ -400,6 +418,46 @@ export default function Dashboard() {
</div>
</div>
)}
{/* PnL historical sparkline */}
{(() => {
const snaps: any[] = [...(pnlHistoryData?.snapshots ?? [])].reverse()
if (snaps.length < 2) return null
const chartData = snaps.map(s => ({
date: s.snapped_at?.slice(5, 10),
pnl: s.total_pnl_pct ?? 0,
}))
const minVal = Math.min(...chartData.map(d => d.pnl))
const maxVal = Math.max(...chartData.map(d => d.pnl))
const isPositive = chartData[chartData.length - 1]?.pnl >= 0
const strokeColor = isPositive ? '#34d399' : '#f87171'
const gradId = 'pnlGrad'
return (
<div className="mt-2 pt-2 border-t border-slate-700/30">
<div className="text-[9px] text-slate-600 mb-1">Courbe PnL historique ({snaps.length} pts)</div>
<ResponsiveContainer width="100%" height={72}>
<AreaChart data={chartData} margin={{ top: 2, right: 0, left: -38, bottom: 0 }}>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={strokeColor} stopOpacity={0.25} />
<stop offset="95%" stopColor={strokeColor} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 8, fill: '#475569' }}
interval={Math.max(0, Math.floor(chartData.length / 5) - 1)} />
<YAxis domain={[minVal - 0.1, maxVal + 0.1]} tick={{ fontSize: 8, fill: '#475569' }} unit="%" />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
labelStyle={{ color: '#64748b' }}
formatter={(v: any) => [`${v >= 0 ? '+' : ''}${Number(v).toFixed(3)}%`, 'PnL']}
/>
<Area type="monotone" dataKey="pnl" stroke={strokeColor} strokeWidth={1.5}
fill={`url(#${gradId})`} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
)
})()}
</Link>
)
})()}
@@ -457,6 +515,34 @@ export default function Dashboard() {
{openCount > 0 ? `${openCount} positions` : 'Aucune position'}
</div>
)}
{/* Latest VaR snapshot */}
{(() => {
const snap = latestVarData?.snapshot
if (!snap) return null
const hv = snap.hist_var_1d_pct
const cv = snap.hist_cvar_pct
const mv = snap.mc_var_1d_pct
const ts = snap.computed_at?.slice(0, 16).replace('T', ' ')
return (
<div className="mt-2 pt-2 border-t border-slate-700/30">
<div className="text-[9px] text-slate-600 mb-1.5">VaR 95% · {ts} UTC</div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ label: 'Hist.', val: hv, color: 'text-blue-400' },
{ label: 'CVaR', val: cv, color: 'text-orange-400' },
{ label: 'MC×1.5', val: mv, color: 'text-red-400' },
].map(({ label, val, color }) => (
<div key={label} className="bg-dark-700/60 rounded px-2 py-1.5 text-center">
<div className="text-[8px] text-slate-600 mb-0.5">{label}</div>
<div className={clsx('text-xs font-bold font-mono', color)}>
{val != null ? `${val >= 0 ? '+' : ''}${val.toFixed(2)}%` : '—'}
</div>
</div>
))}
</div>
</div>
)
})()}
</Link>
)
})()}