1161 lines
62 KiB
TypeScript
1161 lines
62 KiB
TypeScript
import { useMemo, useState, useRef, useLayoutEffect, useEffect } 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, useInstrumentCatalogIds, useSaxoIvWatchlist, useLatestCycleReport,
|
||
useWaveletWatchlistSignals,
|
||
} from '../hooks/useApi'
|
||
import { Clock, Globe, ShieldAlert, 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
|
||
|
||
// Instrument Analysis's catalog keys FX pairs with yfinance's "=X" suffix (EURUSD=X),
|
||
// while the Cockpit watchlist stores the plain ticker (EURUSD) — try both forms before
|
||
// concluding an instrument isn't registered there.
|
||
const resolveCatalogId = (ticker: string, catalogIds?: Set<string>): string | undefined => {
|
||
if (!catalogIds) return undefined
|
||
const upper = ticker.toUpperCase()
|
||
if (catalogIds.has(upper)) return upper
|
||
if (catalogIds.has(`${upper}=X`)) return `${upper}=X`
|
||
return undefined
|
||
}
|
||
|
||
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 { data: instrumentCatalogIds, isError: instrumentCatalogError, error: instrumentCatalogErrorObj } = useInstrumentCatalogIds()
|
||
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()
|
||
|
||
// Diagnostic for the Watchlist card's "open in Instrument Analysis" arrow/double-click:
|
||
// logs whether the catalog query succeeded and how each watchlist ticker resolves
|
||
// against it, so a missing arrow can be traced to "catalog query failed" vs. "ticker
|
||
// genuinely isn't registered in instruments.json" without needing a debugger.
|
||
useEffect(() => {
|
||
if (instrumentCatalogError) {
|
||
console.error('[Watchlist] instrument catalog query failed:', instrumentCatalogErrorObj)
|
||
return
|
||
}
|
||
if (!instrumentCatalogIds || !watchlistQuotesData) return
|
||
const items: any[] = (watchlistQuotesData as any)?.items ?? []
|
||
console.debug('[Watchlist] catalog ids:', Array.from(instrumentCatalogIds as Set<string>))
|
||
console.debug('[Watchlist] ticker -> catalogId resolution:', items.map((it: any) => ({
|
||
ticker: it.ticker,
|
||
resolved: resolveCatalogId(it.ticker, instrumentCatalogIds as Set<string>) ?? null,
|
||
})))
|
||
}, [instrumentCatalogIds, instrumentCatalogError, instrumentCatalogErrorObj, watchlistQuotesData])
|
||
|
||
// 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) ?? []
|
||
|
||
// 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>
|
||
|
||
{/* 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 — 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 catalogId = resolveCatalogId(it.ticker, instrumentCatalogIds as Set<string> | undefined)
|
||
return (
|
||
<div
|
||
key={it.ticker}
|
||
onDoubleClick={() => { if (catalogId) navigate(`/instruments/${encodeURIComponent(catalogId)}`) }}
|
||
className={clsx(
|
||
'flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap rounded px-1 -mx-1 py-0.5 transition-colors',
|
||
it.ticker === activeWatchlistTicker ? 'bg-blue-900/20' : 'hover:bg-dark-700/40',
|
||
catalogId && 'cursor-pointer'
|
||
)}
|
||
title={catalogId ? `Double-click to open ${it.name || it.ticker} in Instrument Analysis` : undefined}
|
||
>
|
||
<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" />
|
||
|
||
{/* 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">
|
||
{signals.slice(0, 5).map((s: any, i: number) => (
|
||
<div
|
||
key={i}
|
||
onDoubleClick={() => navigate(`/instruments/${encodeURIComponent(s.ticker)}?tab=wavelets`)}
|
||
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' : 'text-red-400')}>
|
||
{s.direction === 'up' ? '↑' : '↓'}
|
||
</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.signal_kind}</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>
|
||
)
|
||
}
|