Files
OpenFin/frontend/src/pages/Dashboard.tsx
2026-07-24 20:48:20 +02:00

1164 lines
62 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, useRef, useLayoutEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
useGeoRiskScore, useAllQuotes,
useEcoCalendar, usePortfolioSummary, usePortfolioPositions, usePnlHistory, useLastScores, useAllPatterns, useMacroRegime,
useRiskDashboard, useRiskRadar, useGeoNews,
useCycleStatus,
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport,
useWaveletWatchlistSignals,
} from '../hooks/useApi'
import { Clock, Globe, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react'
import { Link, useNavigate } 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,
Cell, BarChart, Bar, ReferenceLine,
} from 'recharts'
import { scoreColor } from '../components/TradeIdeas'
import { ASSET_CLASS_COLORS } from '../constants/assetColors'
import TradeRankList from '../components/TradeRankList'
import { fmtPrice, fmtAsOf } from '../lib/format'
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-emerald-500',
}
// The Economic Events card's currency filter is a closed set of 4 major economies
// (not every SUPPORTED_CURRENCIES value) — deliberately simple: toggle chips, not a
// full currency picker. GBP as the 4th pick: heavily represented in the feed already
// and a major G7/FX market alongside US/EUR/China.
const ECO_CURRENCY_FILTERS = ['USD', 'EUR', 'CNY', 'GBP'] as const
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' })
}
const formatTimeShort = (isoStr: string) => {
const d = new Date(isoStr.includes('Z') || isoStr.includes('+') ? isoStr : isoStr.replace(' ', 'T') + 'Z')
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
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">{fmtPrice(q.price)}</div>
<div className={clsx('text-xs font-mono', pos ? 'positive' : 'negative')}>
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
</div>
</div>
</div>
)
}
function EcoEventRow({ ev, showActual }: { ev: any; showActual: boolean }) {
return (
<div 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-slate-200 truncate line-clamp-1 max-w-[130px] shrink-0" title={ev.event_name}>{ev.event_name}</span>
<div className="ml-auto flex items-center gap-2.5 shrink-0">
{showActual && ev.actual_value ? (
<span className="text-[10px] font-mono">
<span className="text-white font-semibold">{ev.actual_value}</span>
{ev.forecast_value && <span className="text-slate-400"> /{ev.forecast_value}</span>}
</span>
) : ev.forecast_value ? (
<span className="text-[10px] font-mono text-slate-400">F {ev.forecast_value}</span>
) : null}
{ev.event_time && <span className="text-slate-500 text-[9px] font-mono">{ev.event_time}</span>}
</div>
</div>
)
}
export default function Dashboard() {
const navigate = useNavigate()
const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore()
const { data: allQuotes } = useAllQuotes()
const { data: ecoCalendarData } = useEcoCalendar({ period: 'recent', limit: 150, impacts: 'high,medium,low' })
const { data: portfolio } = usePortfolioSummary()
const { data: lastScoresData } = useLastScores()
const { data: allPatternsData } = useAllPatterns()
const { data: macroData } = useMacroRegime()
const { data: openPositionsData } = usePortfolioPositions('open')
const { data: riskDashboard } = useRiskDashboard()
const { data: riskRadarData } = useRiskRadar()
const { data: cycleStatusData } = useCycleStatus()
const { data: geoNews } = useGeoNews()
const { data: watchlistItems } = useInstrumentsWatchlist()
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
const quickAddInstrument = useQuickAddInstrument()
const [openingTicker, setOpeningTicker] = useState<string | null>(null)
const [ecoImpactFilter, setEcoImpactFilter] = useState<Set<string>>(new Set(['high', 'medium', 'low']))
const [ecoCurrencyFilter, setEcoCurrencyFilter] = useState<Set<string>>(new Set(ECO_CURRENCY_FILTERS))
const [ecoShowNextWeek, setEcoShowNextWeek] = useState(false)
const [watchlistChartTicker, setWatchlistChartTicker] = useState<string | null>(null)
const [watchlistChartPeriod, setWatchlistChartPeriod] = useState('3m')
const activeWatchlistTicker = watchlistChartTicker ?? (watchlistItems as any)?.[0]?.ticker ?? ''
const activeWatchlistName = ((watchlistItems as any) ?? []).find((w: any) => w.ticker === activeWatchlistTicker)?.name || activeWatchlistTicker
const { data: watchlistHistoryData, isLoading: watchlistHistoryLoading } = useWatchlistHistory(activeWatchlistTicker, watchlistChartPeriod)
const { data: latestCycleReportData } = useLatestCycleReport()
const { data: waveletSignalsData } = useWaveletWatchlistSignals()
const { data: saxoIvWatchlistData } = useSaxoIvWatchlist()
// Double-click on a Watchlist row opens it in Instrument Analysis. Always quick-adds
// server-side first (instrument_service.quick_add_instrument_from_watchlist is
// idempotent — a ticker that already resolves, curated or previously quick-added, is
// returned unchanged) rather than gating on a client-cached catalog id list, since that
// cache going stale after a quick-add elsewhere is exactly what made the button silently
// do nothing for tickers added after the Cockpit page's own catalog query last ran.
const handleOpenInAnalysis = (ticker: string) => {
setOpeningTicker(ticker)
quickAddInstrument.mutate(ticker, {
onSuccess: ({ id }) => navigate(`/instruments/${encodeURIComponent(id)}`),
onError: (e) => console.error(`[Watchlist] failed to open ${ticker} in Instrument Analysis:`, e),
onSettled: () => setOpeningTicker(null),
})
}
// Historical PnL curve (realized, from closed portfolio positions) + latest VaR snapshot
const { data: pnlHistoryData } = usePnlHistory()
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,
})
// Row height is dictated by the config-driven watchlist cards (Watchlist Radar for row 1,
// Options Lab for row 2) — the other cards in each row are capped to match, with internal scroll.
const watchlistCardRef = useRef<HTMLDivElement>(null)
const optionsLabCardRef = useRef<HTMLAnchorElement>(null)
const [row1Height, setRow1Height] = useState<number | undefined>(undefined)
const [row2Height, setRow2Height] = useState<number | undefined>(undefined)
// Floors avoid the row collapsing to near-zero while the master card is still loading
useLayoutEffect(() => {
const el = watchlistCardRef.current
if (!el) return
const measure = () => setRow1Height(Math.max(el.offsetHeight, 220))
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
useLayoutEffect(() => {
const el = optionsLabCardRef.current
if (!el) return
const measure = () => setRow2Height(Math.max(el.offsetHeight, 180))
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
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 openPositions: any[] = (openPositionsData as any) ?? []
// Trades Overview shows each position's trading-side underlying (yfinance/futures
// convention: ^GSPC, ^NDX, GC=F, HG=F, EURUSD=X...), which reads as noise next to the
// Cockpit's own friendly Watchlist names for the exact same instrument (SP500, NASDAQ,
// GOLD, COPPER, EURUSD). Rename to the Watchlist ticker when one plausibly matches —
// suffix-stripped (EURUSD=X -> EURUSD) or via a small curated root-ticker alias table —
// and only if that name is actually in the current Watchlist (not just "known"), so this
// stays tied to what's configured rather than a static list.
const UNDERLYING_ALIASES: Record<string, string> = {
'^GSPC': 'SP500', '^NDX': 'NASDAQ', '^DJI': 'DOW', '^RUT': 'RUSSELL2000',
'GC=F': 'GOLD', 'SI=F': 'SILVER', 'HG=F': 'COPPER', 'PL=F': 'PLATINUM',
'CL=F': 'CRUDE', 'BZ=F': 'BRENT', 'NG=F': 'NATGAS',
'ZW=F': 'WHEAT', 'ZC=F': 'CORN', 'ZS=F': 'SOYBEANS',
}
const underlyingDisplayName = (underlying: string): string => {
if (!underlying) return underlying
const upper = underlying.toUpperCase()
const watchlistTickers = new Set(((watchlistItems as any[]) ?? []).map(w => w.ticker.toUpperCase()))
const baseTicker = upper.replace(/(=X|=F)$/, '')
if (watchlistTickers.has(upper)) return upper
if (watchlistTickers.has(baseTicker)) return baseTicker
const alias = UNDERLYING_ALIASES[upper]
if (alias && watchlistTickers.has(alias)) return alias
return underlying
}
// 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])
const watchlistAsOf = useMemo(() => {
const items: any[] = (watchlistQuotesData as any)?.items ?? []
return fmtAsOf(items.map((it: any) => it.timestamp).filter(Boolean).sort().pop())
}, [watchlistQuotesData])
// FF economic events — today (with actual/forecast as they release) + rest of the
// current week (Mon-Sun) + optionally next week, grouped by date. Filtered by impact
// (high/medium/low, toggleable) and currency (closed set of 4 majors, toggleable).
// Past events beyond today are dropped — the point of this card is "am I up to date".
const groupByDate = (evs: any[]) => {
const sorted = [...evs].sort((a, b) => `${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
}
const { todayEvents, restOfWeekGrouped, nextWeekGrouped } = useMemo(() => {
const all: any[] = (ecoCalendarData as any)?.events ?? []
const events = all.filter((ev: any) => ecoImpactFilter.has(ev.impact) && ecoCurrencyFilter.has(ev.currency))
const now = new Date()
const todayISO = now.toISOString().slice(0, 10)
const dow = now.getUTCDay() // 0=Sun..6=Sat
const sunday = new Date(now)
sunday.setUTCDate(now.getUTCDate() + (dow === 0 ? 0 : 7 - dow))
const weekEndISO = sunday.toISOString().slice(0, 10)
const nextSunday = new Date(sunday)
nextSunday.setUTCDate(sunday.getUTCDate() + 7)
const nextWeekEndISO = nextSunday.toISOString().slice(0, 10)
const today = events
.filter((ev: any) => ev.event_date === todayISO)
.sort((a: any, b: any) => (a.event_time ?? '').localeCompare(b.event_time ?? ''))
const restOfWeek = events.filter((ev: any) => ev.event_date > todayISO && ev.event_date <= weekEndISO)
const nextWeek = events.filter((ev: any) => ev.event_date > weekEndISO && ev.event_date <= nextWeekEndISO)
return { todayEvents: today, restOfWeekGrouped: groupByDate(restOfWeek), nextWeekGrouped: groupByDate(nextWeek) }
}, [ecoCalendarData, ecoImpactFilter, ecoCurrencyFilter])
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>
{/* Top row — height capped to Watchlist Radar's natural size (the only card driven by user config) */}
<div className="grid grid-cols-4 gap-4 items-start">
{/* Geo Risk */}
<div className="card col-span-1 flex flex-col overflow-hidden" style={row1Height ? { height: row1Height } : undefined}>
<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-400 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">
{Object.entries(riskScore.breakdown ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.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.replace('_', ' ')}</span>
<span className="text-slate-400 font-mono w-7 text-right">{Math.round(val)}%</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>
{riskScore.computed_at && (
<div className="mt-1.5 text-[9px] text-slate-400 shrink-0">
<span>AI Score frozen at last cycle ({formatTimeShort(riskScore.computed_at)})</span>
{riskScore.rationale && (
<p className="text-slate-500 line-clamp-2 mt-0.5" title={riskScore.rationale}>{riskScore.rationale}</p>
)}
</div>
)}
{topNews.length > 0 && (
<div className="mt-2 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-400 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 — natural height (config-driven), measured and used to cap the other row-1 cards */}
<div ref={watchlistCardRef} className="card col-span-1">
<div className="flex items-center justify-between mb-2">
<div className="section-title mb-0">📡 Watchlist</div>
<div className="flex items-center gap-1.5">
{watchlistAsOf && <span className="text-[9px] text-slate-500" title="Last quote refresh">MAJ {watchlistAsOf}</span>}
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors">
Manage <ArrowUpRight className="w-2.5 h-2.5" />
</Link>
</div>
</div>
{((watchlistQuotesData as any)?.items ?? []).length > 0 ? (
<>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-white truncate max-w-[130px]" title={activeWatchlistTicker}>{activeWatchlistName}</span>
<div className="flex gap-0.5">
{['1w', '1m', '3m', '6m', '1y', '5y', 'max'].map(p => (
<button
key={p}
onClick={() => setWatchlistChartPeriod(p)}
className={clsx('px-1 py-0.5 rounded text-[8px] uppercase font-semibold transition-colors',
watchlistChartPeriod === p ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-300')}
>
{p}
</button>
))}
</div>
</div>
{watchlistHistoryLoading ? (
<div className="h-[110px] flex items-center justify-center text-slate-600 text-[10px]">Loading</div>
) : ((watchlistHistoryData as any)?.bars?.length ?? 0) > 1 ? (
<ResponsiveContainer width="100%" height={110}>
<AreaChart data={(watchlistHistoryData as any).bars} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="wlChartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.35} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<YAxis domain={['auto', 'auto']} hide />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(v: any) => [fmtPrice(v), activeWatchlistName]}
/>
<Area type="monotone" dataKey="close" stroke="#3b82f6" strokeWidth={1.5} fill="url(#wlChartGrad)" isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[110px] flex items-center justify-center text-slate-600 text-[10px]">No chart data for {activeWatchlistName}</div>
)}
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-0.5">
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => {
const isOpening = openingTicker === it.ticker
return (
<div
key={it.ticker}
onDoubleClick={() => handleOpenInAnalysis(it.ticker)}
className={clsx(
'flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap rounded px-1 -mx-1 py-0.5 transition-colors cursor-pointer',
it.ticker === activeWatchlistTicker ? 'bg-blue-900/20' : 'hover:bg-dark-700/40',
isOpening && 'opacity-50'
)}
title={`Double-click to open ${it.name || it.ticker} in Instrument Analysis`}
>
<button
type="button"
onClick={() => setWatchlistChartTicker(it.ticker)}
className="flex items-center gap-1 min-w-0 flex-1 text-left"
>
<span className={clsx('truncate', it.ticker === activeWatchlistTicker ? 'text-white font-bold' : 'text-slate-300')} title={it.ticker}>
{it.name || it.ticker}
</span>
{it.quote_source === 'saxo' && <span className="text-emerald-500 shrink-0" title="Priced from Saxo, not yfinance"></span>}
</button>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-slate-400 font-mono">{fmtPrice(it.price)}</span>
<span className={clsx('font-mono font-bold w-11 text-right shrink-0', (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>
<span className="font-mono text-slate-500 w-16 text-right shrink-0" title="20d realized volatility (annualized) · change vs D-1">
{it.volatility_pct != null ? `${it.volatility_pct.toFixed(1)}%` : '—'}
{it.volatility_change_pct != null && Math.abs(it.volatility_change_pct) >= 0.1 && (
<span className={it.volatility_change_pct > 0 ? 'text-orange-400' : 'text-blue-400'}>
{' '}{it.volatility_change_pct >= 0 ? '+' : ''}{it.volatility_change_pct.toFixed(1)}
</span>
)}
</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 overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
style={row1Height ? { height: row1Height } : undefined}>
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🌐 Macro Regime</span>
<div className="flex items-center gap-1.5">
{fmtAsOf((macroData as any)?.fetched_at) && (
<span className="text-[9px] text-slate-500" title="Gauges computed at">MAJ {fmtAsOf((macroData as any)?.fetched_at)}</span>
)}
<ArrowUpRight className="w-3 h-3 text-slate-500" />
</div>
</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-[10px] w-16 truncate', isDom ? 'text-slate-200 font-semibold' : 'text-slate-400')}>
{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-[10px] font-mono w-5 text-right', isDom ? 'text-blue-400' : 'text-slate-400')}>
{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-[10px] text-slate-400 w-16 shrink-0">{label}</span>
<span className={clsx('text-[10px] font-mono font-semibold shrink-0', c)}>
{fmt(val, chg)}
</span>
<span className="text-[9px] text-slate-500 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: today (actual as it releases) + rest of week (+ next week, optional) */}
<div className="card col-span-1 flex flex-col overflow-hidden" style={row1Height ? { height: row1Height } : undefined}>
<div className="section-title flex items-center gap-1 shrink-0"><Clock className="w-3 h-3" /> Economic Events</div>
<div className="flex items-center justify-between gap-1.5 mt-1.5 shrink-0">
<div className="flex items-center gap-1">
{(['high', 'medium', 'low'] as const).map(imp => (
<button
key={imp}
onClick={() => setEcoImpactFilter(prev => {
const next = new Set(prev)
if (next.has(imp)) next.delete(imp); else next.add(imp)
return next
})}
title={`${imp} impact — click to toggle`}
className={clsx('w-2.5 h-2.5 rounded-full transition-opacity', IMPACT_DOT[imp], ecoImpactFilter.has(imp) ? 'opacity-100' : 'opacity-20')}
/>
))}
</div>
<div className="flex items-center gap-1">
{ECO_CURRENCY_FILTERS.map(cur => (
<button
key={cur}
onClick={() => setEcoCurrencyFilter(prev => {
const next = new Set(prev)
if (next.has(cur)) next.delete(cur); else next.add(cur)
return next
})}
title={`${cur} — click to toggle`}
className={clsx('text-xs leading-none transition-opacity', ecoCurrencyFilter.has(cur) ? 'opacity-100' : 'opacity-25')}
>
{CURRENCY_FLAGS[cur] ?? cur}
</button>
))}
</div>
<button
onClick={() => setEcoShowNextWeek(v => !v)}
className={clsx('text-[8px] px-1.5 py-0.5 rounded shrink-0 transition-colors',
ecoShowNextWeek ? 'bg-blue-600 text-white' : 'text-slate-500 hover:text-slate-300 border border-slate-700/40')}
>
+Next week
</button>
</div>
{(todayEvents.length > 0 || restOfWeekGrouped.length > 0 || (ecoShowNextWeek && nextWeekGrouped.length > 0)) ? (
<div className="mt-2 space-y-2 overflow-y-auto flex-1 min-h-0">
{todayEvents.length > 0 && (
<div>
<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">
Today <span className="badge-blue text-[8px] px-1 py-0">TODAY</span>
</div>
<div className="space-y-1 pl-0.5">
{todayEvents.map((ev: any, i: number) => <EcoEventRow key={i} ev={ev} showActual />)}
</div>
</div>
)}
{restOfWeekGrouped.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)}
</div>
<div className="space-y-1 pl-0.5">
{group.events.map((ev: any, i: number) => <EcoEventRow key={i} ev={ev} showActual={false} />)}
</div>
</div>
))}
{ecoShowNextWeek && nextWeekGrouped.length > 0 && (
<>
<div className="text-[8px] text-slate-600 uppercase tracking-wide pt-1 border-t border-slate-700/30">Next week</div>
{nextWeekGrouped.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)}
</div>
<div className="space-y-1 pl-0.5">
{group.events.map((ev: any, i: number) => <EcoEventRow key={i} ev={ev} showActual={false} />)}
</div>
</div>
))}
</>
)}
</div>
) : <div className="text-slate-500 text-xs mt-2">No events match filters</div>}
</div>
</div>
{/* ── Command Center: Résumé Opérationnel ── height capped to Options Lab's natural size (config-driven) ── */}
<div className="grid grid-cols-4 gap-3 items-start">
{/* PnL — real portfolio (services/database.py `portfolio` table), mark-to-market via Black-Scholes */}
{(() => {
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 overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
style={row2Height ? { height: row2Height } : undefined}>
<div className="flex items-center justify-between mb-1.5">
<span className="section-title mb-0 text-[10px]">📊 P&L</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</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>
<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>
</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>
<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 {pfInvest != null ? `${pfInvest.toFixed(0)}` : '—'}
</span>
<span className={clsx('font-mono font-bold', (pfNet ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
Total {pfNet != null ? `${pfNet >= 0 ? '+' : ''}${pfNet.toFixed(0)}` : '—'}
</span>
</div>
{/* Realized PnL curve — cumulative, from closed portfolio positions */}
{(() => {
const curve: any[] = (pnlHistoryData as any) ?? []
if (curve.length < 2) return null
const chartData = curve.map((s: any) => ({
date: s.date?.slice(5, 10),
pnl: s.cumulative ?? 0,
}))
const minVal = Math.min(...chartData.map((d: any) => d.pnl))
const maxVal = Math.max(...chartData.map((d: any) => 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">Realized PnL curve ({chartData.length} closed)</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 - 1, maxVal + 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(0)}`, 'Cumulative PnL']}
/>
<Area type="monotone" dataKey="pnl" stroke={strokeColor} strokeWidth={1.5}
fill={`url(#${gradId})`} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
)
})()}
</Link>
)
})()}
{/* Risk — real portfolio. 5-axis radar (Concentration/Volatility/Correlation/
Exposure/Drawdown) from services/portfolio_risk.py `compute_real_portfolio_risk_radar()`
(`/api/risk/radar`), concentration alerts + asset-class breakdown from
`get_risk_dashboard()` (`/api/risk/dashboard`) — both built on the `portfolio` table. */}
{(() => {
const risk = riskDashboard as any
const alertCount: number = risk?.concentration_alerts?.length ?? 0
const openCount: number = risk?.open_trades ?? 0
const byClass: Record<string, any> = risk?.exposure_by_class ?? {}
const pieData = Object.entries(byClass)
.map(([cls, data]: [string, any]) => ({ name: cls, value: data.pct_of_portfolio ?? 0 }))
.filter(d => d.value > 0)
.sort((a, b) => b.value - a.value)
const radarAxes = ((riskRadarData as any)?.axes ?? []).map((a: any) => ({ ...a, value: a.value ?? 0 }))
return (
<Link to="/risk" className="card flex flex-col overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
style={row2Height ? { height: row2Height } : undefined}>
<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-[10px] 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'}
</div>
{radarAxes.length > 0 && (
<ResponsiveContainer width="100%" height={180}>
<RadarChart data={radarAxes}>
<PolarGrid stroke="#1e2d4d" />
<PolarAngleAxis dataKey="axis" tick={{ fill: '#94a3b8', fontSize: 9 }} />
<Radar dataKey="value" stroke="#f87171" fill="#f87171" fillOpacity={0.25} isAnimationActive={false} />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(_value: any, _name: any, props: any) => [props.payload.detail, props.payload.axis]}
/>
</RadarChart>
</ResponsiveContainer>
)}
{pieData.length > 0 ? (
<>
<div className={clsx('space-y-0.5', radarAxes.length > 0 && 'mt-1 pt-1 border-t border-slate-700/30')}>
{pieData.map(d => (
<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="text-slate-600 font-mono w-9 text-right">{d.value}%</span>
</div>
))}
</div>
{(risk?.concentration_alerts ?? []).length > 0 && (
<div className="mt-2 pt-2 border-t border-slate-700/30 space-y-1">
{(risk.concentration_alerts as any[]).map((a: any, i: number) => (
<div key={i} className={clsx('flex items-start gap-1 text-[9px] leading-snug', a.level === 'high' ? 'text-red-400' : 'text-amber-400')}>
<span className="shrink-0 mt-[3px]">{a.level === 'high' ? '●' : '▲'}</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 overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
style={row2Height ? { height: row2Height } : undefined}>
<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 — Saxo-only IV highlights, scoped to watchlist instruments that have
a Saxo OPTION symbol linked (Config -> Instruments Watchlist, "Option" picker —
distinct from the "Quote" picker used for Watchlist Radar's price/vol source).
Deliberately not the yfinance-based IV batch — matches Options Lab itself,
which now only shows Saxo. */}
{(() => {
const watchlist: any[] = watchlistItems ?? []
const linked = watchlist.filter((w: any) => w.saxo_option_symbol)
const bySaxoSymbol: Record<string, any> = {}
for (const item of ((saxoIvWatchlistData as any)?.items ?? [])) bySaxoSymbol[item.ticker] = item
const highlights = linked
.map((w: any) => bySaxoSymbol[w.saxo_option_symbol] ? { ...bySaxoSymbol[w.saxo_option_symbol], watchlistTicker: w.ticker, watchlistName: w.name || w.ticker } : null)
.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" ref={optionsLabCardRef} 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>
<div className="flex items-center gap-1 text-[9px] text-slate-600">
<Link2 className="w-2.5 h-2.5" /> Saxo
</div>
</div>
{linked.length === 0 ? (
<div className="text-[10px] text-slate-600 mt-1">
Link a watchlist instrument to a Saxo option symbol (Config Instruments Watchlist "Option") to see IV highlights here
</div>
) : highlights.length > 0 ? (
<div className="space-y-1.5 mt-1">
{highlights.map((h: any) => {
const rank = h.iv_rank
const ivCur = h.iv_current_pct
const ivChg = h.iv_change_1d_pct
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="text-slate-200 font-bold shrink-0 truncate max-w-[90px]" title={h.watchlistTicker}>{h.watchlistName}</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>}
{ivChg != null && Math.abs(ivChg) >= 0.1 && (
<span className={clsx('font-mono', ivChg > 0 ? 'text-orange-400' : 'text-blue-400')}>
{ivChg >= 0 ? '+' : ''}{ivChg.toFixed(1)}pt
</span>
)}
{h.history_days > 0 && (
<span className="ml-auto text-[8px] text-slate-700">{h.history_days}d</span>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Waiting for Saxo snapshots to accumulate</div>
)}
</Link>
)
})()}
</div>
{/* ── Command Center: Intelligence & Contexte ── */}
<div className="grid grid-cols-4 gap-3">
<TradeRankList trades={openPositions} mode="recent" title="🏆 Trades Overview" linkTo="/portfolio" tickerLabel={underlyingDisplayName} />
{/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each
cycle). Single click on the card = Wavelets Simulation overview; double-click a
row = that instrument's Analysis page, Wavelets panel open directly. */}
{(() => {
const signals: any[] = waveletSignalsData?.signals ?? []
const nameByTicker: Record<string, string> = {}
for (const w of ((watchlistItems as any[]) ?? [])) nameByTicker[w.ticker] = w.name || w.ticker
return (
<div className="card">
<Link to="/wavelets-simulation" className="flex items-center justify-between mb-1 hover:opacity-80 transition-opacity">
<span className="section-title mb-0 text-[10px] flex items-center gap-1">
<Waves className="w-2.5 h-2.5" /> Wavelets Signal
</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</Link>
{signals.length > 0 ? (
<div className="space-y-1.5 mt-1 max-h-[190px] overflow-y-auto pr-0.5">
{signals.map((s: any, i: number) => (
<div
key={i}
onDoubleClick={() => quickAddInstrument.mutate(s.ticker, {
onSuccess: ({ id }) => navigate(`/instruments/${encodeURIComponent(id)}?tab=wavelets`),
onError: (e) => console.error(`[WaveletsSignal] failed to open ${s.ticker} in Instrument Analysis:`, e),
})}
title="Double-click to open in Instrument Analysis → Wavelets"
className="flex items-center gap-1.5 text-[10px] rounded px-1 -mx-1 py-0.5 cursor-pointer hover:bg-dark-700/40 transition-colors"
>
<span className={clsx('shrink-0',
s.direction === 'up' ? 'text-emerald-400' : s.direction === 'down' ? 'text-red-400' : 'text-slate-700'
)}>
{s.direction === 'up' ? '↑' : s.direction === 'down' ? '↓' : '·'}
</span>
<span className="text-slate-200 font-bold shrink-0 truncate max-w-[90px]" title={s.ticker}>
{nameByTicker[s.ticker] || s.ticker}
</span>
<span className="text-slate-500 truncate flex-1 text-[9px]">
{s.band_label ? `${s.band_label} · ${s.signal_kind ?? 'veille'}` : 'Pas de données'}
</span>
{s.computed_at && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{s.computed_at.slice(0, 10)}</span>}
</div>
))}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">No wavelet signal detected le prochain cycle en calculera</div>
)}
</div>
)
})()}
{/* 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>
)
}