Files
OpenFin/frontend/src/pages/Dashboard.tsx
2026-06-20 06:58:47 +02:00

970 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
useGeoRiskScore, useAllQuotes,
useCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
useTradeMtm, useRiskDashboard, useGeoNews,
useSimPortfolioRisk, useCycleStatus,
} from '../hooks/useApi'
import { Clock, Globe, ShieldAlert, ArrowUpRight, Brain, Newspaper } from 'lucide-react'
import { Link } from 'react-router-dom'
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,
AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid,
} from 'recharts'
import { scoreColor } from '../components/TradeIdeas'
const riskGauge = (score: number) => {
if (score < 25) return { color: 'text-emerald-400', bg: 'bg-emerald-500', label: 'FAIBLE' }
if (score < 50) return { color: 'text-yellow-400', bg: 'bg-yellow-500', label: 'MODÉRÉ' }
if (score < 75) return { color: 'text-orange-400', bg: 'bg-orange-500', label: 'ÉLEVÉ' }
return { color: 'text-red-400', bg: 'bg-red-500', label: 'EXTRÊME' }
}
const assetEmoji: Record<string, string> = {
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
indices: '📊', forex: '💱', crypto: '₿', rates: '📉',
}
const ASSET_COLORS: Record<string, string> = {
energy: '#f97316', metals: '#eab308', agriculture: '#22c55e',
indices: '#3b82f6', equities: '#06b6d4', forex: '#8b5cf6',
rates: '#64748b', unknown: '#334155',
}
const REGIME_LABELS: Record<string, string> = {
goldilocks: 'Goldilocks', desinflation: 'Désinflation', stagflation: 'Stagflation',
recession: 'Récession', crise_liquidite: 'Crise liq.', reflation: 'Reflation',
soft_landing: 'Soft Landing', inflation_shock: 'Infl. Choc', incertain: 'Incertain',
}
function ViewToggle({ value, onChange }: { value: 'simulated' | 'portfolio'; onChange: (v: 'simulated' | 'portfolio') => void }) {
const click = (v: 'simulated' | 'portfolio') => (e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation(); onChange(v)
}
return (
<div className="flex gap-0.5 bg-dark-800 rounded p-0.5 text-[9px]">
<button onClick={click('simulated')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'simulated' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Simulé
</button>
<button onClick={click('portfolio')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'portfolio' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Portfolio
</button>
</div>
)
}
function QuoteRow({ q }: { q: Quote }) {
if (!q.price) return null
const pos = q.change_pct >= 0
return (
<div className="flex items-center justify-between py-1.5 border-b border-slate-700/20 last:border-0">
<span className="text-xs text-white truncate max-w-[140px]">{q.name || q.symbol}</span>
<div className="text-right ml-2">
<div className="text-xs text-white font-mono">{q.price.toFixed(2)}</div>
<div className={clsx('text-xs font-mono', pos ? 'positive' : 'negative')}>
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
</div>
</div>
</div>
)
}
export default function Dashboard() {
const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore()
const { data: allQuotes } = useAllQuotes()
const { data: calendar } = useCalendar()
const { data: portfolio } = usePortfolioSummary()
const { data: lastScoresData } = useLastScores()
const { data: allPatternsData } = useAllPatterns()
const { data: macroData } = useMacroRegime()
const { data: tradeMtmData } = useTradeMtm(30)
const { data: riskDashboard } = useRiskDashboard()
const { data: simRisk } = useSimPortfolioRisk()
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'
)
const [riskView, setRiskView] = useState<'simulated' | 'portfolio'>(() =>
(localStorage.getItem('dash_risk_view') as any) ?? 'simulated'
)
const setPnlViewPersist = (v: 'simulated' | 'portfolio') => {
setPnlView(v); localStorage.setItem('dash_pnl_view', v)
}
const setRiskViewPersist = (v: 'simulated' | 'portfolio') => {
setRiskView(v); localStorage.setItem('dash_risk_view', v)
}
const allPatterns: any[] = allPatternsData ?? []
const scoreMap = useMemo(() => {
const map: Record<string, any> = {}
for (const sp of (lastScoresData?.scored_patterns ?? [])) {
if (sp.pattern_id) map[sp.pattern_id] = sp
}
return map
}, [lastScoresData])
const macroInfo = useMemo(() => {
if (!macroData?.scenarios) return null
const sc = macroData.scenarios
const dom = sc.dominant ?? 'incertain'
const m = sc.meta?.[dom] ?? { label: dom, color: '#94a3b8', emoji: '?' }
const assetBias: Record<string, string> = sc.asset_bias?.[dom] ?? {}
// Top 3 scenario scores (excluding dominant, sorted desc)
const ranked: [string, number][] = (sc.ranked ?? []).slice(0, 4)
return { dominant: dom, label: m.label, color: m.color, emoji: m.emoji, assetBias, ranked }
}, [macroData])
const gauge = riskScore ? riskGauge(riskScore.score) : null
const radarData = riskScore?.breakdown
? Object.entries(riskScore.breakdown).map(([k, v]) => ({ subject: k.replace('_', ' '), score: v }))
: []
// Cycle trades: use scoring_run_id (which differs from cycle run_id)
const lastCycle = (cycleStatusData as any)?.last_cycle ?? null
const scoringRunId: string | null = lastCycle?.scoring_run_id ?? null
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
const cycleTrades = scoringRunId ? mtmTrades.filter((t: any) => t.run_id === scoringRunId) : []
// Patterns from the last cycle only (filter by created_at >= cycle started_at)
const cyclePatterns = useMemo(() => {
const started = lastCycle?.started_at ?? null
if (!started) return []
return [...allPatterns]
.filter(p => p.created_at && p.created_at >= started)
.sort((a, b) => (scoreMap[b.id]?.score ?? -1) - (scoreMap[a.id]?.score ?? -1))
.slice(0, 4)
}, [allPatterns, scoreMap, lastCycle])
// Top impactful news
const topNews = useMemo(() =>
[...(geoNews ?? [])]
.sort((a, b) => b.impact_score - a.impact_score)
.slice(0, 3)
, [geoNews])
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">Cockpit GeoOptions</h1>
<p className="text-xs text-slate-500 mt-0.5">
{format(new Date(), "EEEE d MMMM yyyy · HH:mm", { locale: fr })}
</p>
</div>
<div className="flex items-center gap-3">
{portfolio && portfolio.open_positions > 0 && (
<div className={clsx('text-sm font-bold', portfolio.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
Portfolio: {portfolio.unrealized_pnl >= 0 ? '+' : ''}{portfolio.unrealized_pnl?.toFixed(0)}
</div>
)}
<div className="flex items-center gap-2 text-xs text-slate-500">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
Live
</div>
</div>
</div>
{/* Risk concentration banner */}
{(riskDashboard as any)?.concentration_alerts?.length > 0 && (
<div className="space-y-1.5">
{((riskDashboard as any).concentration_alerts as any[]).slice(0, 2).map((alert: any, i: number) => (
<div key={i} className={clsx('flex items-center gap-2 text-xs px-3 py-2 rounded border', {
'bg-red-900/20 border-red-700/30 text-red-300': alert.level === 'high',
'bg-amber-900/20 border-amber-700/30 text-amber-300': alert.level === 'warning',
})}>
<ShieldAlert className="w-3 h-3 shrink-0" />
{alert.message}
<a href="/risk" className="ml-auto text-slate-500 hover:text-slate-300 underline">Risk Dashboard </a>
</div>
))}
</div>
)}
{/* Top row */}
<div className="grid grid-cols-4 gap-4">
{/* Geo Risk */}
<div className="card col-span-1">
<div className="flex items-center justify-between mb-2">
<div className="section-title flex items-center gap-1 mb-0">
<Globe className="w-3 h-3" /> Risque Géopolitique
</div>
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
News géo <ArrowUpRight className="w-2.5 h-2.5" />
</Link>
</div>
{riskLoading ? (
<div className="animate-pulse h-16 bg-dark-600 rounded"></div>
) : riskScore && gauge ? (
<>
<div className={clsx('text-5xl font-bold', gauge.color)}>{riskScore.score}</div>
<div className={clsx('text-sm font-semibold mt-1', gauge.color)}>{gauge.label}</div>
<div className="mt-3 bg-dark-700 rounded-full h-2">
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
</div>
<div className="mt-2 space-y-1">
{riskScore.top_risks?.map(([cat, val]) => (
<div key={cat} className="flex justify-between text-xs">
<span className="text-slate-500 capitalize">{(cat as string).replace('_', ' ')}</span>
<span className="text-slate-300">{Math.round((val as number) * 100)}%</span>
</div>
))}
</div>
</>
) : <div className="text-slate-500 text-xs">Backend requis</div>}
</div>
{/* Radar */}
<div className="card col-span-1">
<div className="section-title">Cartographie risques</div>
{radarData.length > 0 ? (
<ResponsiveContainer width="100%" height={160}>
<RadarChart data={radarData}>
<PolarGrid stroke="#1e2d4d" />
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
<Radar dataKey="score" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
</RadarChart>
</ResponsiveContainer>
) : <div className="h-40 flex items-center justify-center text-slate-600 text-xs">Chargement...</div>}
</div>
{/* Patterns — AI scores */}
<div className="card col-span-1 overflow-y-auto max-h-64">
<div className="section-title flex items-center gap-1">
<Brain className="w-3 h-3" /> Scores patterns
{allPatterns.length > 0 && <span className="text-slate-600 text-xs ml-auto font-normal">{allPatterns.length} patterns</span>}
</div>
<div className="space-y-1.5 mt-1">
{allPatterns.length === 0 ? (
<div className="text-slate-600 text-xs">Backend requis</div>
) : (
[...allPatterns]
.sort((a, b) => {
const spA = scoreMap[a.id], spB = scoreMap[b.id]
const effA = spA ? Math.max(...[0, ...(spA.trade_rankings ?? []).map((r: any) => (spA.score ?? 0) + (r.score_delta ?? 0))]) : -1
const effB = spB ? Math.max(...[0, ...(spB.trade_rankings ?? []).map((r: any) => (spB.score ?? 0) + (r.score_delta ?? 0))]) : -1
return effB - effA
})
.map(p => {
const sp = scoreMap[p.id]
const rawScore = sp?.score ?? null
const bestEff = sp
? Math.max(rawScore ?? 0, ...(sp.trade_rankings ?? []).map((r: any) =>
Math.max(0, Math.min(100, (rawScore ?? 0) + (r.score_delta ?? 0)))))
: null
const addedDate = p.created_at ? p.created_at.slice(0, 10) : null
return (
<div key={p.id} className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<span className="text-xs text-slate-300 truncate block">{p.name}</span>
{addedDate && <span className="text-[9px] text-slate-700 font-mono">{addedDate}</span>}
</div>
{bestEff !== null ? (
<span className={clsx('text-xs font-bold shrink-0', scoreColor(bestEff))}>{bestEff}</span>
) : (
<span className="text-xs text-slate-600 shrink-0"></span>
)}
</div>
)
})
)}
</div>
</div>
{/* Calendrier */}
<div className="card col-span-1 space-y-3">
<div className="section-title flex items-center gap-1"><Clock className="w-3 h-3" /> Prochains catalyseurs</div>
<div className="space-y-1.5">
{calendar?.slice(0, 4).map((ev, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={clsx('badge', {
'badge-red': ev.importance === 'high',
'badge-yellow': ev.importance === 'medium',
'badge-blue': ev.importance === 'low',
})}>
{'!'.repeat(ev.importance === 'high' ? 3 : ev.importance === 'medium' ? 2 : 1)}
</span>
<div className="min-w-0">
<div className="text-white line-clamp-1">{ev.title}</div>
<div className="text-slate-600">{ev.date?.slice(0, 10)}</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* ── Command Center: Résumé Opérationnel ── */}
<div className="grid grid-cols-4 gap-3">
{/* PnL */}
{(() => {
const trades: any[] = mtmTrades
const withPnl = trades.filter((t: any) => t.pnl_pct != null)
const avgPnl = withPnl.length
? withPnl.reduce((s: number, t: any) => s + t.pnl_pct, 0) / withPnl.length
: null
const winners = withPnl.filter((t: any) => t.pnl_pct > 0).length
const losers = withPnl.filter((t: any) => t.pnl_pct < 0).length
const closedTrades = trades.filter((t: any) => t.status === 'closed')
const openTrades = trades.filter((t: any) => t.status !== 'closed')
// Use capital_invested if set, otherwise entry_price as proxy cost
const totalCapital = trades.reduce((s: number, t: any) =>
s + (t.capital_invested ?? t.entry_price ?? 0), 0)
const totalProfit = withPnl.reduce((s: number, t: any) =>
s + ((t.capital_invested ?? t.entry_price ?? 0) * t.pnl_pct / 100), 0)
const isEstimated = trades.some((t: any) => t.capital_invested == null && t.entry_price != null)
const targetHit = trades.filter((t: any) => t.alert_type === 'target_reached').length
const stopHit = trades.filter((t: any) => t.alert_type === 'stop_loss').length
const pf = portfolio as any
const pfPnlPct = pf?.unrealized_pnl_pct ?? null
const pfPnl = pf?.unrealized_pnl ?? null
const pfInvest = pf?.total_invested ?? null
const pfReal = pf?.realized_pnl ?? null
return (
<Link to="/journal" className="card flex flex-col h-[288px] overflow-hidden hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">📊 P&L</span>
<div className="flex items-center gap-1.5">
<ViewToggle value={pnlView} onChange={setPnlViewPersist} />
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
</div>
{pnlView === 'simulated' ? (
<>
<div className="flex items-end justify-between mt-1">
<div>
<div className={clsx('text-2xl font-bold font-mono',
avgPnl === null ? 'text-slate-500' : avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{trades.length} trades · <span className="text-emerald-500">{winners}</span>{' '}
<span className="text-red-400">{losers}</span>
</div>
</div>
<div className="text-right space-y-0.5">
{targetHit > 0 && <div className="text-[10px] text-emerald-400">🎯 {targetHit} cible{targetHit > 1 ? 's' : ''}</div>}
{stopHit > 0 && <div className="text-[10px] text-red-400"> {stopHit} stop</div>}
<div className="text-[10px] text-slate-600">{withPnl.length} pricés</div>
</div>
</div>
<div className="mt-2 pt-2 border-t border-slate-700/30 flex justify-between items-end">
<div>
<div className="text-[10px] text-slate-500 mb-0.5">
Investi{isEstimated ? ' ~' : ''}
</div>
<div className="text-2xl font-bold font-mono text-slate-300">
{totalCapital > 0 ? `${totalCapital.toFixed(0)}` : '—'}
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-500 mb-0.5">P&L </div>
<div className={clsx('text-2xl font-bold font-mono',
totalCapital === 0 ? 'text-slate-600' : totalProfit >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{totalCapital > 0 ? `${totalProfit >= 0 ? '+' : ''}${totalProfit.toFixed(0)}` : '—'}
</div>
</div>
</div>
</>
) : (
<div className="flex items-end justify-between mt-1">
<div>
<div className={clsx('text-2xl font-bold font-mono',
pfPnlPct === null ? 'text-slate-500' : pfPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{pf?.open_positions ?? 0} positions ouvertes
</div>
</div>
<div className="text-right space-y-0.5">
{pfInvest != null && <div className="text-[10px] text-slate-400 font-mono">{pfInvest.toFixed(0)} investi</div>}
{pfPnl != null && <div className={clsx('text-[10px] font-mono font-bold', pfPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfPnl >= 0 ? '+' : ''}{pfPnl.toFixed(0)}
</div>}
{pfReal != null && pfReal !== 0 && <div className={clsx('text-[10px] font-mono', pfReal >= 0 ? 'text-emerald-600' : 'text-red-600')}>
réal. {pfReal >= 0 ? '+' : ''}{pfReal.toFixed(0)}
</div>}
</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-1.5 pt-1.5 border-t border-slate-700/30">
<div className="text-[9px] text-slate-600 mb-0.5">Courbe PnL historique ({snaps.length} pts)</div>
<ResponsiveContainer width="100%" height={58}>
<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>
)
})()}
{/* Risk */}
{(() => {
const risk = simRisk as any
const alertCount: number = risk?.alerts?.length ?? 0
const conflictCount: number = risk?.conflicts?.length ?? 0
const openCount: number = risk?.open_count ?? 0
const concentration: Record<string, any> = risk?.concentration ?? {}
const sortedClasses = Object.entries(concentration)
.sort(([, a], [, b]) => (b as any).pct - (a as any).pct)
.slice(0, 5)
return (
<Link to="/risk" className="card flex flex-col h-[288px] overflow-hidden hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🛡 Risque</span>
<div className="flex items-center gap-1.5">
<ViewToggle value={riskView} onChange={setRiskViewPersist} />
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
</div>
<div className={clsx('text-lg font-bold mt-0.5', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
{alertCount > 0 ? `${alertCount} alerte${alertCount > 1 ? 's' : ''}` : 'OK'}
</div>
{sortedClasses.length > 0 && (
<div className="mt-2 space-y-1">
{sortedClasses.map(([cls, data]: [string, any]) => (
<div key={cls} className="flex items-center gap-1.5">
<span className="text-[9px] text-slate-500 w-12 truncate capitalize">{cls}</span>
<div className="flex-1 h-2 bg-dark-700 rounded-full overflow-hidden flex">
{data.bullish > 0 && (
<div className="h-full rounded-l-full" title={`${data.bullish} haussier`}
style={{ width: `${(data.bullish / openCount) * 100}%`, background: ASSET_COLORS[cls] ?? '#3b82f6', opacity: 0.9 }} />
)}
{data.bearish > 0 && (
<div className="h-full rounded-r-full" title={`${data.bearish} baissier`}
style={{ width: `${(data.bearish / openCount) * 100}%`, background: '#ef4444', opacity: 0.7 }} />
)}
</div>
<span className="text-[9px] text-slate-600 w-7 text-right font-mono">{data.pct}%</span>
</div>
))}
<div className="flex items-center gap-1 text-[9px] text-slate-700 pt-0.5">
<span className="inline-block w-2 h-1.5 rounded-sm bg-blue-500/60"></span>haussier
<span className="inline-block w-2 h-1.5 rounded-sm bg-red-500/60 ml-1"></span>baissier
{conflictCount > 0 && <span className="ml-auto text-red-400">{conflictCount} conflit{conflictCount > 1 ? 's' : ''}</span>}
</div>
</div>
)}
{sortedClasses.length === 0 && (
<div className="text-[10px] text-slate-600 mt-1">
{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-1.5 pt-1.5 border-t border-slate-700/30">
<div className="text-[9px] text-slate-600 mb-1">VaR 95% · {ts} UTC</div>
<div className="grid grid-cols-3 gap-1">
{[
{ 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 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>
)
})()}
{/* Dernier Cycle — bilan complet */}
{(() => {
const last = lastCycle
const ts = last?.ts
? new Date(last.ts.endsWith('Z') ? last.ts : last.ts + 'Z')
: null
const elapsed = ts ? Math.round((Date.now() - ts.getTime()) / 60_000) : null
const elapsedStr = elapsed === null ? '—'
: elapsed < 60 ? `${elapsed}min`
: elapsed < 1440 ? `${Math.round(elapsed / 60)}h`
: `${Math.round(elapsed / 1440)}j`
const patternsAdded: number = last?.patterns_added ?? 0
// Only count trades whose pattern was added THIS cycle (not older patterns re-scored)
const cyclePatternIds = new Set(cyclePatterns.map((p: any) => p.id))
const cycleTradesFromNewPatterns = cycleTrades.filter((t: any) => cyclePatternIds.has(t.pattern_id))
const tradesLogged: number = cycleTradesFromNewPatterns.length
const tradesNotLogged: number = Math.max(0, patternsAdded - tradesLogged)
const tradesClosed = cycleTradesFromNewPatterns.filter((t: any) => t.status === 'closed').length
const commentary = last?.commentary
? (() => { try { return typeof last.commentary === 'string' ? JSON.parse(last.commentary) : last.commentary } catch { return null } })()
: null
return (
<Link to="/journal" className="card flex flex-col h-[288px] overflow-hidden hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🔄 Dernier Cycle</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className="text-xl font-bold text-blue-400 mt-0.5 font-mono">{elapsedStr}</div>
{last ? (
<>
<div className="mt-2 grid grid-cols-2 gap-1.5 text-center">
<div className="bg-dark-700/60 rounded px-2 py-2">
<div className="text-xl font-bold text-slate-200">+{patternsAdded}</div>
<div className="text-[9px] text-slate-500 mt-0.5">patterns ajoutés</div>
</div>
<div className="bg-dark-700/60 rounded px-2 py-2">
<div className="text-xl font-bold text-blue-400">+{tradesLogged}</div>
<div className="text-[9px] text-slate-500 mt-0.5">trades ajoutés</div>
{tradesNotLogged > 0 && (
<div className="text-[8px] text-slate-700 mt-0.5">{tradesNotLogged} non-loggé{tradesNotLogged > 1 ? 's' : ''}</div>
)}
</div>
<div className="bg-dark-700/60 rounded px-2 py-1.5">
<div className="text-base font-bold text-emerald-400">{tradesLogged}</div>
<div className="text-[9px] text-slate-600">trades loggés</div>
</div>
<div className="bg-dark-700/60 rounded px-2 py-1.5">
<div className="text-base font-bold text-slate-500">{tradesClosed}</div>
<div className="text-[9px] text-slate-600">fermés</div>
</div>
</div>
{commentary?.commentary && (
<div className="mt-1.5 text-[9px] text-slate-500 line-clamp-2 italic leading-tight">
{commentary.commentary.slice(0, 100)}
</div>
)}
</>
) : (
<div className="text-[10px] text-slate-600 mt-1">Aucun cycle enregistré</div>
)}
</Link>
)
})()}
{/* Régime Macro */}
{(() => {
const dom = macroInfo?.dominant
const colorClass = dom === 'growth' || dom === 'goldilocks' || dom === 'soft_landing' ? 'text-emerald-400'
: dom === 'stagflation' || dom === 'recession' || dom === 'crise_liquidite' ? 'text-red-400'
: dom === 'deflation' || dom === 'desinflation' ? 'text-blue-300'
: dom === 'inflation_shock' ? 'text-orange-400'
: 'text-slate-400'
const ranked = macroInfo?.ranked ?? []
// Key gauges from macroData
const gauges = (macroData as any)?.gauges ?? {}
const reasons: string[] = dom ? ((macroData as any)?.scenarios?.reasons?.[dom] ?? []) : []
// 5 key indicators that explain the regime
const KEY_GAUGES = [
{
key: 'vix', label: 'VIX',
color: (v: number) => v < 18 ? 'text-emerald-400' : v < 25 ? 'text-yellow-400' : 'text-red-400',
fmt: (v: number) => v.toFixed(1),
hint: (v: number) => v < 18 ? '↓ risque bas' : v < 25 ? '↑ alerte' : '↑ crise',
},
{
key: 'spx_vs_200d', label: 'S&P/200j',
color: (v: number) => v >= 0 ? 'text-emerald-400' : 'text-red-400',
fmt: (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`,
hint: (v: number) => v >= 5 ? 'bull fort' : v >= 0 ? 'bull mod.' : 'bear',
},
{
key: 'slope_10y3m', label: 'Pente 10Y-3M',
color: (v: number) => v >= 0.5 ? 'text-emerald-400' : v >= 0 ? 'text-yellow-400' : 'text-red-400',
fmt: (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}`,
hint: (v: number) => v >= 0.5 ? 'normale' : v >= 0 ? 'plate' : 'inversée',
},
{
key: 'copper', label: 'Cuivre',
color: (v: number, chg: number) => chg >= 0 ? 'text-emerald-400' : 'text-red-400',
fmt: (v: number, chg: number) => `${chg >= 0 ? '+' : ''}${chg.toFixed(2)}%`,
hint: (v: number, chg: number) => chg >= 0 ? '↑ croissance' : '↓ ralentis.',
},
{
key: 'gold', label: 'Or',
color: (v: number, chg: number) => chg >= 1 ? 'text-amber-400' : chg <= -1 ? 'text-emerald-400' : 'text-slate-400',
fmt: (v: number, chg: number) => `${chg >= 0 ? '+' : ''}${chg.toFixed(2)}%`,
hint: (v: number, chg: number) => chg >= 1 ? '↑ refuge' : chg <= -1 ? '↓ risk-on' : '≈ neutre',
},
]
return (
<Link to="/macro" className="card flex flex-col h-[288px] overflow-hidden hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🌐 Régime Macro</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-base font-bold mt-1', colorClass)}>
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
</div>
{ranked.length > 0 && (
<div className="mt-1.5 space-y-1">
{ranked.slice(0, 4).map(([key, score]: [string, number]) => {
const maxScore = ranked[0]?.[1] ?? 1
const pct = Math.round((score / Math.max(maxScore, 1)) * 100)
const isDom = key === dom
return (
<div key={key} className="flex items-center gap-1.5">
<span className={clsx('text-[9px] w-16 truncate', isDom ? 'text-slate-200 font-semibold' : 'text-slate-500')}>
{REGIME_LABELS[key] ?? key}
</span>
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', isDom ? 'bg-blue-500' : 'bg-slate-600')}
style={{ width: `${pct}%` }}
/>
</div>
<span className={clsx('text-[9px] font-mono w-5 text-right', isDom ? 'text-blue-400' : 'text-slate-600')}>
{score}
</span>
</div>
)
})}
</div>
)}
{/* Key macro gauges */}
{Object.keys(gauges).length > 0 && (
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-0.5">
{KEY_GAUGES.map(({ key, label, color, fmt, hint }) => {
const g = gauges[key]
if (!g) return null
const val: number = g.value ?? 0
const chg: number = g.change_pct ?? 0
const c = color(val, chg)
const h = hint(val, chg)
return (
<div key={key} className="flex items-center gap-1.5">
<span className="text-[9px] text-slate-600 w-16 shrink-0">{label}</span>
<span className={clsx('text-[9px] font-mono font-semibold shrink-0', c)}>
{fmt(val, chg)}
</span>
<span className="text-[8px] text-slate-700 truncate">{h}</span>
</div>
)
})}
</div>
)}
{/* Trigger signals for dominant regime */}
{reasons.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{reasons.slice(0, 4).map((r: string, i: number) => (
<span key={i} className="text-[8px] bg-blue-900/20 text-blue-400/80 border border-blue-800/30 px-1.5 py-0.5 rounded">
{r}
</span>
))}
</div>
)}
</Link>
)
})()}
</div>
{/* ── Command Center: Intelligence & Contexte ── */}
<div className="grid grid-cols-4 gap-3">
{/* Top 5 trades loggés */}
{(() => {
const top5 = [...mtmTrades]
.sort((a, b) => (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999))
.slice(0, 5)
return (
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🏆 Top Trades</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{top5.length > 0 ? (
<div className="space-y-1.5 mt-1">
{top5.map((t: any, i: number) => {
const pnl: number | null = t.pnl_pct ?? null
const isBear = t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
const entryDate = t.entry_date ? t.entry_date.slice(0, 10) : null
return (
<div key={i} className="flex items-center gap-1.5 text-[10px]">
<span className="text-[9px] text-slate-700 font-mono w-3 shrink-0">{i + 1}</span>
<span className="shrink-0">{isBear ? '🐻' : '🐂'}</span>
<span className="font-mono text-slate-200 font-bold shrink-0">{t.underlying ?? '—'}</span>
<span className="text-slate-500 truncate flex-1 text-[9px]">{t.strategy ?? ''}</span>
{entryDate && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{entryDate}</span>}
{pnl !== null ? (
<span className={clsx('font-mono font-bold shrink-0', pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(1)}%
</span>
) : (
<span className="text-slate-700 shrink-0 text-[9px]"></span>
)}
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Aucun trade loggé</div>
)}
</Link>
)
})()}
{/* Trades du dernier cycle — détaillé */}
{(() => {
// Build a map: pattern_id → trade for quick lookup
const tradeByPattern: Record<string, any> = {}
for (const t of cycleTrades) {
if (t.pattern_id) tradeByPattern[t.pattern_id] = t
}
// Show cyclePatterns with their trade status (loggé or not)
const rows = cyclePatterns.length > 0
? cyclePatterns
: cycleTrades.map((t: any) => ({ id: t.pattern_id, name: t.pattern_name }))
return (
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">📥 Trades du cycle</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{rows.length > 0 ? (
<div className="space-y-2 mt-1">
{rows.slice(0, 3).map((p: any, i: number) => {
const t = tradeByPattern[p.id]
const logged = !!t
const ev: number | null = t?.ev_net ?? t?.ev_at_entry ?? null
const score: number | null = t?.score_at_entry ?? scoreMap[p.id]?.score ?? null
const pnl: number | null = t?.pnl_pct ?? null
const isBear = t?.strategy?.toLowerCase().includes('put') || t?.strategy?.toLowerCase().includes('bear')
return (
<div key={i}>
{logged ? (
<>
{/* Trade row — primary */}
<div className="flex items-center gap-1.5 text-[10px]">
<span className="shrink-0">{isBear ? '🐻' : '🐂'}</span>
<span className="font-mono text-slate-200 font-bold w-10 truncate">{t.underlying ?? '—'}</span>
<span className="text-slate-400 truncate flex-1">{t.strategy ?? ''}</span>
{ev !== null && (
<span className={clsx('font-mono shrink-0 text-[9px]', ev >= 0.5 ? 'text-emerald-400' : ev >= 0 ? 'text-yellow-400' : 'text-red-400')}>
EV{ev >= 0 ? '+' : ''}{ev.toFixed(2)}
</span>
)}
{score !== null && (
<span className={clsx('font-mono shrink-0 font-bold', scoreColor(score))}>{score}</span>
)}
{pnl !== null && (
<span className={clsx('font-mono font-bold shrink-0', pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(1)}%
</span>
)}
</div>
{/* Pattern name — secondary */}
<div className="text-[8px] text-slate-600 italic truncate mt-0.5 ml-4">{p.name}</div>
</>
) : (
<>
{/* Suggested trade from scoreMap — primary */}
{(() => {
const sp = scoreMap[p.id]
const rec = sp?.recommended_trade ?? sp?.trade_rankings?.[0]
const underlying = rec?.underlying ?? rec?.ticker ?? null
const strategy = rec?.strategy ?? rec?.trade_type ?? null
const evVal: number | null = rec?.ev_net ?? rec?.ev ?? null
const isBearS = strategy?.toLowerCase().includes('put') || strategy?.toLowerCase().includes('bear')
return (
<>
<div className="flex items-center gap-1.5 text-[10px]">
<span className="text-slate-600 shrink-0">{isBearS ? '🐻' : '🐂'}</span>
<span className={clsx('font-mono font-bold shrink-0', underlying ? 'text-slate-400' : 'text-slate-700')}>
{underlying ?? '—'}
</span>
<span className="text-slate-600 truncate flex-1">{strategy ?? 'trade suggéré'}</span>
{evVal !== null && (
<span className="font-mono text-[9px] text-slate-600">
EV{evVal >= 0 ? '+' : ''}{evVal.toFixed(2)}
</span>
)}
{score !== null && (
<span className={clsx('font-mono shrink-0 font-bold', scoreColor(score))}>{score}</span>
)}
<span className="text-[8px] text-slate-700 shrink-0">non loggé</span>
</div>
<div className="text-[8px] text-slate-700 italic truncate mt-0.5 ml-4">{p.name}</div>
</>
)
})()}
</>
)}
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">
{lastCycle ? 'Aucun pattern ajouté ce cycle' : 'En attente du prochain cycle'}
</div>
)}
</Link>
)
})()}
{/* Patterns du dernier cycle */}
{(() => {
return (
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]"> Pattern du cycle</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{cyclePatterns.length > 0 ? (
<div className="space-y-1.5 mt-1">
{cyclePatterns.map((p, i) => {
const sp = scoreMap[p.id]
const score = sp?.score ?? null
const ticker = sp?.recommended_trade?.underlying ?? null
return (
<div key={p.id} className="flex items-center gap-1.5">
<span className="text-[10px] text-slate-700 w-3 shrink-0 font-mono">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-slate-300 truncate leading-tight">{p.name}</div>
{ticker && <div className="text-[9px] text-slate-600 font-mono">{ticker}</div>}
</div>
{score !== null ? (
<span className={clsx('text-sm font-bold font-mono shrink-0', scoreColor(score))}>{score}</span>
) : (
<span className="text-[9px] text-slate-700 shrink-0"></span>
)}
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">
{lastCycle ? 'Aucun pattern ajouté ce cycle' : 'Aucun scoré — lancer le scoring IA'}
</div>
)}
</Link>
)
})()}
{/* Top news impactantes */}
{(() => {
return (
<Link to="/geo" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px] flex items-center gap-1">
<Newspaper className="w-2.5 h-2.5" /> Top News
</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{topNews.length > 0 ? (
<div className="space-y-2 mt-1">
{topNews.map((n, i) => {
const impact = Math.round(n.impact_score * 100)
const impactColor = impact >= 70 ? 'text-red-400' : impact >= 40 ? 'text-orange-400' : 'text-yellow-400'
const barColor = impact >= 70 ? 'bg-red-500' : impact >= 40 ? 'bg-orange-500' : 'bg-yellow-500'
return (
<div key={i} className="space-y-0.5">
<div className="flex items-start gap-1.5">
<span className="text-[9px] text-slate-700 font-mono mt-0.5 shrink-0">{i + 1}</span>
<p className="text-[10px] text-slate-300 leading-tight line-clamp-2 flex-1">{n.title}</p>
<span className={clsx('text-[10px] font-bold font-mono shrink-0', impactColor)}>{impact}</span>
</div>
<div className="ml-3 bg-dark-700 rounded-full h-0.5">
<div className={clsx('h-0.5 rounded-full', barColor)} style={{ width: `${impact}%` }} />
</div>
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Chargement des news</div>
)}
</Link>
)
})()}
</div>
{/* Markets mini overview */}
<div className="grid grid-cols-4 gap-3">
{['energy', 'metals', 'indices', 'forex'].map(cls => (
<div key={cls} className="card">
<div className="section-title">{assetEmoji[cls]} {cls}</div>
{allQuotes?.[cls]?.map(q => <QuoteRow key={q.symbol} q={q} />) ?? (
<div className="text-xs text-slate-600">Chargement...</div>
)}
</div>
))}
</div>
</div>
)
}