Files
OpenFin/frontend/src/pages/Dashboard.tsx
2026-07-14 13:22:13 +02:00

999 lines
52 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,
useEcoCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
useTradeMtm, useRiskDashboard, useGeoNews,
useSimPortfolioRisk, useCycleStatus, useClosedTrades,
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useIvBatch, useLatestCycleReport,
} from '../hooks/useApi'
import { Clock, Globe, ShieldAlert, ArrowUpRight, 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,
PieChart, Pie, Cell, BarChart, Bar, ReferenceLine,
} from 'recharts'
import { scoreColor } from '../components/TradeIdeas'
import { ASSET_CLASS_COLORS } from '../constants/assetColors'
import TradeRankList from '../components/TradeRankList'
const riskGauge = (score: number) => {
if (score < 25) return { color: 'text-emerald-400', bg: 'bg-emerald-500', label: 'LOW' }
if (score < 50) return { color: 'text-yellow-400', bg: 'bg-yellow-500', label: 'MODERATE' }
if (score < 75) return { color: 'text-orange-400', bg: 'bg-orange-500', label: 'HIGH' }
return { color: 'text-red-400', bg: 'bg-red-500', label: 'EXTREME' }
}
const assetEmoji: Record<string, string> = {
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
indices: '📊', forex: '💱', crypto: '₿', rates: '📉',
}
const REGIME_LABELS: Record<string, string> = {
goldilocks: 'Goldilocks', desinflation: 'Disinflation', stagflation: 'Stagflation',
recession: 'Recession', crise_liquidite: 'Liq. Crisis', reflation: 'Reflation',
soft_landing: 'Soft Landing', inflation_shock: 'Infl. Shock', incertain: 'Uncertain',
}
const CURRENCY_FLAGS: Record<string, string> = {
USD: '🇺🇸', EUR: '🇪🇺', GBP: '🇬🇧', JPY: '🇯🇵',
AUD: '🇦🇺', CAD: '🇨🇦', NZD: '🇳🇿', CHF: '🇨🇭', CNY: '🇨🇳',
}
const IMPACT_DOT: Record<string, string> = {
high: 'bg-red-500', medium: 'bg-yellow-400', low: 'bg-slate-400',
}
const isTodayISO = (dateStr: string) => {
const t = new Date()
const utc = `${t.getUTCFullYear()}-${String(t.getUTCMonth() + 1).padStart(2, '0')}-${String(t.getUTCDate()).padStart(2, '0')}`
return dateStr === utc
}
const formatDateShort = (dateStr: string) => {
const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
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')}>
Simulated
</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: ecoCalendarData } = useEcoCalendar({ period: 'recent', limit: 30 })
const { data: portfolio } = usePortfolioSummary()
const { data: lastScoresData } = useLastScores()
const { data: allPatternsData } = useAllPatterns()
const { data: macroData } = useMacroRegime()
const { data: tradeMtmData } = useTradeMtm(30)
const { data: closedTradesData } = useClosedTrades(90)
const { data: riskDashboard } = useRiskDashboard()
const { data: simRisk } = useSimPortfolioRisk()
const { data: cycleStatusData } = useCycleStatus()
const { data: geoNews } = useGeoNews()
const { data: watchlistItems } = useInstrumentsWatchlist()
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
const { data: latestCycleReportData } = useLatestCycleReport()
const watchlistTickers: string[] = (watchlistItems ?? []).map((w: any) => w.ticker)
const { data: ivBatchData } = useIvBatch(watchlistTickers.join(','))
// 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 lastCycle = (cycleStatusData as any)?.last_cycle ?? null
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
// 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 (fills the Geopolitical Risk card's available height, no fixed cap)
const topNews = useMemo(() =>
[...(geoNews ?? [])]
.sort((a, b) => b.impact_score - a.impact_score)
.slice(0, 30)
, [geoNews])
// Watchlist radar: change_pct normalized to a 0-100 scale (50 = flat)
const watchlistRadarData = useMemo(() => {
const items: any[] = (watchlistQuotesData as any)?.items ?? []
return items.slice(0, 8).map((it: any) => {
const chg = Math.max(-5, Math.min(5, it.change_pct ?? 0))
return { subject: it.ticker, value: 50 + chg * 10, ref: 50 }
})
}, [watchlistQuotesData])
// Upcoming FF economic events, chronological, grouped by date (fills available height, no fixed cap)
const upcomingEventsGrouped = useMemo(() => {
const events: any[] = (ecoCalendarData as any)?.events ?? []
const todayISO = new Date().toISOString().slice(0, 10)
const sorted = [...events]
.filter((ev: any) => ev.event_date >= todayISO)
.sort((a: any, b: any) => `${a.event_date}${a.event_time ?? ''}`.localeCompare(`${b.event_date}${b.event_time ?? ''}`))
const groups: { date: string; events: any[] }[] = []
for (const ev of sorted) {
const last = groups[groups.length - 1]
if (!last || last.date !== ev.event_date) groups.push({ date: ev.event_date, events: [ev] })
else last.events.push(ev)
}
return groups
}, [ecoCalendarData])
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">OpenFin Cockpit</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 flex flex-col">
<div className="flex items-center justify-between mb-2 shrink-0">
<div className="section-title flex items-center gap-1 mb-0">
<Globe className="w-3 h-3" /> Geopolitical Risk
</div>
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
Geo news <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="flex flex-col flex-1 min-h-0">
<div className="flex items-start justify-between gap-3 shrink-0">
<div>
<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>
<div className="text-right space-y-0.5 mt-1 shrink-0">
{riskScore.top_risks?.map(([cat, val]) => (
<div key={cat} className="flex items-center justify-end gap-1.5 text-[9px]">
<span className="text-slate-500 capitalize truncate max-w-[86px]">{(cat as string).replace('_', ' ')}</span>
<span className="text-slate-400 font-mono w-7 text-right">{Math.round((val as number) * 100)}%</span>
</div>
))}
</div>
</div>
<div className="mt-3 bg-dark-700 rounded-full h-2 shrink-0">
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
</div>
{topNews.length > 0 && (
<div className="mt-2.5 pt-2 border-t border-slate-700/30 flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1 text-[9px] text-slate-600 mb-1 shrink-0">
<Newspaper className="w-2.5 h-2.5" /> Top news
</div>
<div className="space-y-1.5 overflow-y-auto flex-1 min-h-0">
{topNews.map((n, i) => {
const impact = Math.round(n.impact_score * 100)
const impactColor = impact >= 75 ? 'text-red-400' : impact >= 50 ? 'text-orange-400' : impact >= 25 ? 'text-yellow-400' : 'text-emerald-400'
const dotColor = impact >= 75 ? 'bg-red-500' : impact >= 50 ? 'bg-orange-500' : impact >= 25 ? 'bg-yellow-500' : 'bg-emerald-500'
return (
<div key={i} className="flex items-center gap-1.5 text-[10px]">
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', dotColor)} />
<span className="text-slate-300 truncate flex-1 line-clamp-1">{n.title}</span>
<span className={clsx('font-mono font-bold shrink-0', impactColor)}>{impact}</span>
</div>
)
})}
</div>
</div>
)}
</div>
) : <div className="text-slate-500 text-xs">Backend required</div>}
</div>
{/* Watchlist Radar — its natural height (no cap) drives row 1's height */}
<div className="card col-span-1">
<div className="flex items-center justify-between mb-2">
<div className="section-title mb-0">📡 Watchlist Radar</div>
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
Manage <ArrowUpRight className="w-2.5 h-2.5" />
</Link>
</div>
{watchlistRadarData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={130}>
<RadarChart data={watchlistRadarData}>
<PolarGrid stroke="#1e2d4d" />
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
<Radar dataKey="ref" stroke="#334155" strokeDasharray="3 3" fill="transparent" isAnimationActive={false} />
<Radar dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
</RadarChart>
</ResponsiveContainer>
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
<div key={it.ticker} className="flex items-center justify-between text-[10px]">
<span className="text-slate-400 font-mono">{it.ticker}</span>
<div className="flex items-center gap-2">
<span className="text-slate-500 font-mono">{it.price != null ? it.price.toFixed(2) : '—'}</span>
<span className={clsx('font-mono font-bold w-12 text-right', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{it.change_pct != null ? `${it.change_pct >= 0 ? '+' : ''}${it.change_pct.toFixed(2)}%` : '—'}
</span>
</div>
</div>
))}
</div>
</>
) : (
<div className="h-40 flex flex-col items-center justify-center text-slate-600 text-xs gap-1.5">
<span>No instruments watched</span>
<Link to="/config" className="text-blue-400 hover:text-blue-300 underline">Add instruments </Link>
</div>
)}
</div>
{/* 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 ? '↓ low risk' : v < 25 ? '↑ alert' : '↑ crisis',
},
{
key: 'spx_vs_200d', label: 'S&P/200d',
color: (v: number) => v >= 0 ? 'text-emerald-400' : 'text-red-400',
fmt: (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`,
hint: (v: number) => v >= 5 ? 'strong bull' : v >= 0 ? 'mod. bull' : 'bear',
},
{
key: 'slope_10y3m', label: 'Slope 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 ? 'normal' : v >= 0 ? 'flat' : 'inverted',
},
{
key: 'copper', label: 'Copper',
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 ? '↑ growth' : '↓ slowing',
},
{
key: 'gold', label: 'Gold',
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 ? '↑ safe haven' : chg <= -1 ? '↓ risk-on' : '≈ neutral',
},
]
return (
<Link to="/macro" className="card col-span-1 flex flex-col 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]">🌐 Macro Regime</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>
)
})()}
{/* Economic Events — real Forex Factory feed, grouped by date */}
<div className="card col-span-1 flex flex-col">
<div className="section-title flex items-center gap-1 shrink-0"><Clock className="w-3 h-3" /> Economic Events</div>
{upcomingEventsGrouped.length > 0 ? (
<div className="mt-2 space-y-2 overflow-y-auto flex-1 min-h-0">
{upcomingEventsGrouped.map(group => (
<div key={group.date}>
<div className="flex items-center gap-1.5 text-[9px] font-semibold text-slate-300 bg-dark-700/50 rounded px-1.5 py-0.5 mb-1">
{formatDateShort(group.date)}
{isTodayISO(group.date) && <span className="badge-blue text-[8px] px-1 py-0">TODAY</span>}
</div>
<div className="space-y-1 pl-0.5">
{group.events.map((ev: any, i: number) => (
<div key={i} className="flex items-center gap-1.5 text-[10px]">
<span className="shrink-0">{CURRENCY_FLAGS[ev.currency] ?? ev.currency}</span>
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', IMPACT_DOT[ev.impact] ?? 'bg-slate-400')} />
<span className="text-white truncate flex-1 line-clamp-1">{ev.event_name}</span>
{ev.event_time && <span className="text-slate-700 text-[9px] shrink-0 font-mono">{ev.event_time}</span>}
</div>
))}
</div>
</div>
))}
</div>
) : <div className="text-slate-600 text-xs mt-2">No upcoming events</div>}
</div>
</div>
{/* ── Command Center: Résumé Opérationnel ── */}
<div className="grid grid-cols-4 gap-3">
{/* PnL */}
{(() => {
// Unrealized — open trades only (get_trade_entry_prices excludes closed)
const openTrades = mtmTrades
const openWithPnl = openTrades.filter((t: any) => t.pnl_pct != null)
const openCapital = openTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0)
const openProfit = openWithPnl.reduce((s: number, t: any) => s + ((t.capital_invested ?? t.entry_price ?? 0) * t.pnl_pct / 100), 0)
const openPnlPct = openCapital > 0 ? openProfit / openCapital * 100 : null
const openWinners = openWithPnl.filter((t: any) => t.pnl_pct > 0).length
const openLosers = openWithPnl.filter((t: any) => t.pnl_pct < 0).length
const targetHit = openTrades.filter((t: any) => t.alert_type === 'target_reached').length
const stopHit = openTrades.filter((t: any) => t.alert_type === 'stop_loss').length
// Realized — from dedicated closed-trades endpoint (pnl_realized in %, capital for EUR)
const closedTrades = (closedTradesData as any)?.trades ?? []
const closedCapital = closedTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0)
const closedProfitEur = closedTrades.reduce((s: number, t: any) => {
const cap = t.capital_invested ?? t.entry_price ?? 0
return s + (cap * (t.pnl_realized ?? 0) / 100)
}, 0)
const closedWithPnl = closedTrades.filter((t: any) => t.pnl_realized != null)
const avgClosedPct = closedWithPnl.length > 0
? closedWithPnl.reduce((s: number, t: any) => s + t.pnl_realized, 0) / closedWithPnl.length
: null
const closedWinners = closedTrades.filter((t: any) => (t.pnl_realized ?? 0) > 0).length
const closedLosers = closedTrades.filter((t: any) => (t.pnl_realized ?? 0) < 0).length
const isEstimated = openTrades.some((t: any) => t.capital_invested == null && t.entry_price != null)
const totalCapital = openCapital + closedCapital
const totalProfit = openProfit + closedProfitEur
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
const pfNet = pf?.net_pnl ?? null
return (
<Link to="/journal" className="card flex flex-col hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1.5">
<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>
{/* ── Side-by-side: Unrealized | Realized ── */}
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
{/* Left: Unrealized */}
<div className="bg-dark-700/40 rounded px-2.5 py-2 border border-slate-700/30">
<div className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Open</div>
{pnlView === 'simulated' ? (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
openPnlPct === null ? 'text-slate-600' : openPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{openPnlPct !== null ? `${openPnlPct >= 0 ? '+' : ''}${openPnlPct.toFixed(1)}%` : '—'}
</div>
<div className={clsx('text-xs font-mono mt-0.5',
openProfit >= 0 ? 'text-emerald-500' : 'text-red-400')}>
{openCapital > 0 ? `${openProfit >= 0 ? '+' : ''}${openProfit.toFixed(0)}` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{openTrades.length} trades · <span className="text-emerald-500">{openWinners}</span>{' '}
<span className="text-red-400">{openLosers}</span>
</div>
</>
) : (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
pfPnlPct === null ? 'text-slate-600' : pfPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'}
</div>
<div className={clsx('text-xs font-mono mt-0.5', (pfPnl ?? 0) >= 0 ? 'text-emerald-500' : 'text-red-400')}>
{pfPnl != null ? `${pfPnl >= 0 ? '+' : ''}${pfPnl.toFixed(0)}` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{pf?.open_positions ?? 0} positions
</div>
</>
)}
{(targetHit > 0 || stopHit > 0) && (
<div className="mt-1 text-[9px] space-x-1">
{targetHit > 0 && <span className="text-emerald-400">🎯{targetHit}</span>}
{stopHit > 0 && <span className="text-red-400">{stopHit}</span>}
</div>
)}
</div>
{/* Right: Realized */}
<div className="bg-dark-700/40 rounded px-2.5 py-2 border border-slate-700/30">
<div className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Realized</div>
{pnlView === 'simulated' ? (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
avgClosedPct === null ? 'text-slate-600'
: avgClosedPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgClosedPct !== null
? `${avgClosedPct >= 0 ? '+' : ''}${avgClosedPct.toFixed(2)}%`
: closedTrades.length === 0 ? '—' : '+0.00%'}
</div>
<div className={clsx('text-xs font-mono mt-0.5',
closedProfitEur >= 0 ? 'text-emerald-500' : 'text-red-400')}>
{closedCapital > 0
? `${closedProfitEur >= 0 ? '+' : ''}${closedProfitEur.toFixed(0)}`
: closedTrades.length > 0 ? 'no capital' : ''}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{closedTrades.length} closed · <span className="text-emerald-500">{closedWinners}</span>{' '}
<span className="text-red-400">{closedLosers}</span>
</div>
</>
) : (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
pfReal == null || pfReal === 0 ? 'text-slate-600'
: pfReal > 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfReal != null && pfReal !== 0
? `${pfReal >= 0 ? '+' : ''}${pfReal.toFixed(0)}`
: '—'}
</div>
{pfNet != null && (
<div className={clsx('text-xs font-mono mt-0.5', pfNet >= 0 ? 'text-emerald-500/70' : 'text-red-400/70')}>
net {pfNet >= 0 ? '+' : ''}{pfNet.toFixed(0)}
</div>
)}
<div className="text-[10px] text-slate-500 mt-1">
{pf?.closed_positions ?? 0} closed
</div>
</>
)}
</div>
</div>
{/* Total line */}
<div className="mt-1.5 flex items-center justify-between text-[10px] flex-shrink-0">
<span className="text-slate-600">
Invested{isEstimated ? ' ~' : ''} {pnlView === 'simulated'
? (totalCapital > 0 ? `${totalCapital.toFixed(0)}` : '—')
: (pfInvest != null ? `${pfInvest.toFixed(0)}` : '—')}
</span>
<span className={clsx('font-mono font-bold',
(pnlView === 'simulated' ? totalProfit : (pfNet ?? 0)) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
Total {pnlView === 'simulated'
? (totalCapital > 0 ? `${totalProfit >= 0 ? '+' : ''}${totalProfit.toFixed(0)}` : '—')
: (pfNet != null ? `${pfNet >= 0 ? '+' : ''}${pfNet.toFixed(0)}` : '—')}
</span>
</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">Historical PnL curve ({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 — donut chart of asset allocation */}
{(() => {
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 pieData = Object.entries(concentration)
.map(([cls, data]: [string, any]) => ({ name: cls, value: data.pct, bullish: data.bullish, bearish: data.bearish }))
.filter(d => d.value > 0)
.sort((a, b) => b.value - a.value)
return (
<Link to="/risk" className="card flex flex-col 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]">🛡 Risk</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-lg font-bold mt-0.5 flex items-center gap-2', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
{alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'}
{conflictCount > 0 && <span className="text-[10px] text-red-400 font-normal">{conflictCount} conflict{conflictCount > 1 ? 's' : ''}</span>}
</div>
{pieData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={100}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={30} outerRadius={48} paddingAngle={2}>
{pieData.map((d, i) => (
<Cell key={i} fill={ASSET_CLASS_COLORS[d.name] ?? ASSET_CLASS_COLORS.unknown} />
))}
</Pie>
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(value: any, name: any, props: any) => [`${value}% (${props.payload.bullish}${props.payload.bearish}↓)`, name]}
/>
</PieChart>
</ResponsiveContainer>
<div className="mt-1 space-y-0.5">
{pieData.map(d => {
const bias = d.bullish > d.bearish ? 'bullish' : d.bearish > d.bullish ? 'bearish' : 'neutral'
return (
<div key={d.name} className="flex items-center gap-1.5 text-[9px]">
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: ASSET_CLASS_COLORS[d.name] ?? ASSET_CLASS_COLORS.unknown }} />
<span className="text-slate-500 capitalize flex-1 truncate">{d.name}</span>
<span className={clsx('text-[8px] w-3', bias === 'bullish' ? 'text-emerald-500' : bias === 'bearish' ? 'text-red-400' : 'text-slate-700')}>
{bias === 'bullish' ? '↑' : bias === 'bearish' ? '↓' : '—'}
</span>
<span className="text-slate-600 font-mono w-9 text-right">{d.value}%</span>
</div>
)
})}
</div>
{(risk?.alerts ?? []).length > 0 && (
<div className="mt-2 pt-2 border-t border-slate-700/30 space-y-1">
{(risk.alerts as any[]).map((a: any, i: number) => (
<div key={i} className={clsx('flex items-start gap-1 text-[9px] leading-snug', a.level === 'danger' ? 'text-red-400' : 'text-amber-400')}>
<span className="shrink-0 mt-[3px]">{a.level === 'danger' ? '●' : '▲'}</span>
<span className="line-clamp-2">{a.message}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="text-[10px] text-slate-600 mt-1">
{openCount > 0 ? `${openCount} positions` : 'No positions'}
</div>
)}
</Link>
)
})()}
{/* VaR — histogram + rolling trend */}
{(() => {
const snap = latestVarData?.snapshot
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', ' ')
const histogram: any[] = snap?.full_result?.histogram ?? []
const rollingVar: any[] = (snap?.full_result?.rolling_var ?? []).slice(-30)
return (
<Link to="/var" className="card flex flex-col 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]">📉 VaR</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{snap ? (
<>
<div className="text-[9px] text-slate-600 mb-1">VaR 95% · {ts} UTC</div>
<div className="grid grid-cols-3 gap-1 flex-shrink-0">
{[
{ 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>
{histogram.length > 0 && (
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30">
<div className="text-[9px] text-slate-600 mb-0.5">P&L distribution</div>
<ResponsiveContainer width="100%" height={80}>
<BarChart data={histogram}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" vertical={false} />
<XAxis dataKey="x" tick={{ fontSize: 7, fill: '#475569' }}
interval={Math.max(0, Math.floor(histogram.length / 4) - 1)} />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(v: any) => [v, 'count']}
labelFormatter={(l: any) => `${l}%`}
/>
{hv != null && <ReferenceLine x={hv} stroke="#f87171" strokeDasharray="4 2" strokeWidth={1.5} />}
<Bar dataKey="count" radius={[2, 2, 0, 0]}>
{histogram.map((h: any, i: number) => (
<Cell key={i} fill={hv != null && h.x < hv ? '#ef4444' : '#3b82f6'} fillOpacity={0.7} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
{rollingVar.length > 1 && (
<div className="mt-1 flex-1 min-h-0">
<ResponsiveContainer width="100%" height={36}>
<AreaChart data={rollingVar} margin={{ top: 2, right: 0, left: 0, bottom: 0 }}>
<Area type="monotone" dataKey="var_95" stroke="#f59e0b" strokeWidth={1.2} fill="#f59e0b" fillOpacity={0.15} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</>
) : (
<div className="text-[10px] text-slate-600 mt-1">
No VaR snapshot yet run one from <span className="text-blue-400">/var</span>
</div>
)}
</Link>
)
})()}
{/* Options Lab — IV highlights scoped to the watchlist */}
{(() => {
const watchlist: any[] = watchlistItems ?? []
const snapshots: Record<string, any> = (ivBatchData as any)?.snapshots ?? {}
const highlights = watchlist
.map((w: any) => snapshots[w.ticker])
.filter(Boolean)
.sort((a: any, b: any) => Math.abs((b.iv_rank ?? 50) - 50) - Math.abs((a.iv_rank ?? 50) - 50))
return (
<Link to="/options" className="card flex flex-col 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]">🧪 Options Lab</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{watchlist.length === 0 ? (
<div className="text-[10px] text-slate-600 mt-1">
Add instruments to your watchlist (Config) to see IV highlights
</div>
) : highlights.length > 0 ? (
<div className="space-y-1.5 mt-1">
{highlights.map((h: any) => {
const rank = h.iv_rank
const skewPct = h.skew?.skew_pct
const ivCur = h.iv_current_pct
const ivPctl = h.iv_percentile
const flow: string | null = h.options_flow?.flow_bias ?? null
const pcRatio = h.options_flow?.pc_oi_ratio
return (
<div key={h.ticker} className="pb-1 border-b border-slate-700/20 last:border-0">
<div className="flex items-center gap-1.5 text-[10px]">
<span>{rank != null && rank > 80 ? '🔴' : rank != null && rank < 20 ? '🟢' : '⚪'}</span>
<span className="font-mono text-slate-200 font-bold shrink-0">{h.ticker}</span>
<span className="text-slate-500">IVR {rank != null ? rank.toFixed(0) : '—'}</span>
{ivCur != null && <span className="text-slate-600">· IV {ivCur.toFixed(1)}%</span>}
{skewPct != null && Math.abs(skewPct) > 3 && (
<span className={clsx('ml-auto text-[9px]', skewPct > 0 ? 'text-orange-400' : 'text-blue-400')}>
skew {skewPct >= 0 ? '+' : ''}{skewPct.toFixed(1)}
</span>
)}
</div>
{(ivPctl != null || flow != null || pcRatio != null) && (
<div className="text-[8px] text-slate-700 mt-0.5 ml-5 truncate">
{ivPctl != null && <span>Pctl {ivPctl.toFixed(0)}</span>}
{ivPctl != null && pcRatio != null && ' · '}
{pcRatio != null && <span>P/C {pcRatio.toFixed(2)}</span>}
{(ivPctl != null || pcRatio != null) && flow && ' · '}
{flow && <span className="italic">{flow}</span>}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Loading IV data</div>
)}
</Link>
)
})()}
</div>
{/* ── Command Center: Intelligence & Contexte ── */}
<div className="grid grid-cols-4 gap-3">
<TradeRankList trades={mtmTrades} mode="top" title="🏆 Top Trades" linkTo="/journal" />
<TradeRankList trades={mtmTrades} mode="worst" title="📉 Worst Trades" linkTo="/journal" />
{/* Recommandations du jour — patterns from the most recent cycle only */}
<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]">🎯 Recommandations du jour</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{cyclePatterns.length > 0 ? (
<div className="space-y-2.5 mt-1">
{cyclePatterns.map((p: any, i: number) => {
const sp = scoreMap[p.id]
const score: number | null = sp?.score ?? null
const ticker: string | null = sp?.recommended_trade?.underlying ?? null
const dateStr = p.created_at ? p.created_at.slice(0, 10) : null
return (
<div key={p.id}>
<div className="flex items-center gap-1.5">
<span className="text-[9px] 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>
</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 className="text-[8px] text-slate-700 mt-0.5 ml-4 font-mono">
{ticker && <span className="text-slate-500">{ticker}</span>}
{ticker && dateStr && ' · '}
{dateStr}
</div>
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Aucun cycle exécuté aujourd'hui</div>
)}
</Link>
{/* Narration du cycle — GPT-4o context narrative from the latest cycle report */}
{(() => {
const report = (latestCycleReportData as any)?.report
const narrative: string = report?.context_narrative?.narrative ?? ''
const keySignals: string[] = report?.context_narrative?.key_signals ?? []
return (
<Link to="/rapport" 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" /> Cycle Narrative
</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{narrative ? (
<>
<p className="text-[10px] text-slate-400 leading-relaxed line-clamp-6 mt-1">{narrative}</p>
{keySignals.length > 0 && (
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 flex flex-wrap gap-1">
{keySignals.slice(0, 4).map((s: 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">
{s}
</span>
))}
</div>
)}
</>
) : (
<div className="text-[10px] text-slate-600 mt-1">No cycle narrative yet</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">Loading...</div>
)}
</div>
))}
</div>
</div>
)
}