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:
@@ -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>
|
||||
)
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user