diff --git a/backend/routers/risk.py b/backend/routers/risk.py index e36139c..69b33a0 100644 --- a/backend/routers/risk.py +++ b/backend/routers/risk.py @@ -1,3 +1,4 @@ +import math from fastapi import APIRouter, Query from services.database import ( get_portfolio_exposure, @@ -11,6 +12,17 @@ from services.database import ( router = APIRouter(prefix="/api/risk", tags=["risk"]) +def _sanitize(obj): + """Replace NaN/Inf with None recursively for JSON compliance.""" + if isinstance(obj, dict): + return {k: _sanitize(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize(v) for v in obj] + if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)): + return None + return obj + + @router.get("/exposure") def portfolio_exposure(): """Exposure by asset class + risk factor for open positions.""" @@ -49,3 +61,10 @@ def kelly_sizing( def risk_dashboard(): """Full portfolio risk snapshot: concentration, diversification, expected drawdown, recommendation.""" return get_risk_dashboard() + + +@router.get("/radar") +def risk_radar(): + """5-axis risk radar (Concentration/Volatility/Correlation/Exposure/Drawdown) for the real portfolio.""" + from services.portfolio_risk import compute_real_portfolio_risk_radar + return _sanitize(compute_real_portfolio_risk_radar()) diff --git a/backend/services/portfolio_risk.py b/backend/services/portfolio_risk.py index 5e1695f..e00380b 100644 --- a/backend/services/portfolio_risk.py +++ b/backend/services/portfolio_risk.py @@ -232,29 +232,43 @@ def _compute_max_drawdown_pct(snapshots: List[Dict[str, Any]]) -> Optional[float return round(max_dd, 2) -def compute_portfolio_risk_radar() -> Dict[str, Any]: - """5-axis risk radar for the Cockpit's Risk card (replaces the old asset-class donut, - which now lives separately as the allocation breakdown). Axes, each scaled 0-100: +def _compute_max_drawdown_eur(curve: List[Dict[str, Any]]) -> Optional[float]: + """Max peak-to-trough drop (in €) across an ascending cumulative-realized-PnL curve + (e.g. the closed-positions equity curve from services.database.get_positions('closed')).""" + if len(curve) < 2: + return None + peak = curve[0].get("cumulative", 0.0) or 0.0 + max_dd = 0.0 + for pt in curve: + v = pt.get("cumulative", 0.0) or 0.0 + peak = max(peak, v) + max_dd = max(max_dd, peak - v) + return round(max_dd, 2) + + +def _build_risk_radar_axes(trades: List[Dict[str, Any]], drawdown_pct: Optional[float]) -> Dict[str, Any]: + """Shared 5-axis computation (Concentration/Volatility/Correlation/Exposure/Drawdown), + each scaled 0-100, given a list of open trades (needs 'underlying' + a capital/price + field) and a pre-computed drawdown_pct. Used by both the simulated-portfolio and + real-portfolio radars — only the trade source and the drawdown source differ. - Concentration: capital-weighted share of the single largest underlying. - Volatility: capital-weighted average 20d realized vol of open positions. - Correlation: average pairwise return correlation across open positions' underlyings (only positive correlation counts as risk — negative correlation is diversification, not danger). - Exposure: open position count against a soft target of 10 concurrent trades — - a proxy, NOT true margin leverage: trade_entry_prices has no notional/contract-size - column to compute real leverage from, so this measures "how spread thin" instead. - - Drawdown: max peak-to-trough drop in the simulated portfolio's total P&L %, - from services.var_service's snapshot history. + a proxy, NOT true margin leverage (no notional/contract-size column to compute + real leverage from), so this measures "how spread thin" instead. + - Drawdown: max peak-to-trough drop, pre-computed by the caller from whichever + equity-curve source applies to that portfolio. """ from services.data_fetcher import get_quote_with_volatility - from services.var_service import get_pnl_snapshots - trades = get_open_simulation_trades() open_count = len(trades) if not trades: return {"axes": [], "open_count": 0} - weights = [max(t.get("capital_invested") or t.get("entry_price") or 0, 0) for t in trades] + weights = [max(t.get("capital_invested") or t.get("entry_price") or t.get("entry_underlying_price") or 0, 0) for t in trades] total_w = sum(weights) or 1.0 by_underlying_w: Dict[str, float] = {} @@ -286,8 +300,6 @@ def compute_portfolio_risk_radar() -> Dict[str, Any]: exposure_score = min(100.0, open_count / 10 * 100) - drawdown_pct = _compute_max_drawdown_pct(get_pnl_snapshots(200)) - def _scale(v: Optional[float], cap: float) -> Optional[float]: return round(min(100.0, max(0.0, v / cap * 100)), 1) if v is not None else None @@ -301,6 +313,42 @@ def compute_portfolio_risk_radar() -> Dict[str, Any]: return {"axes": axes, "open_count": open_count} +def compute_portfolio_risk_radar() -> Dict[str, Any]: + """5-axis risk radar for the simulated trade log (trade_entry_prices). Drawdown comes + from services.var_service's periodic P&L snapshot history.""" + from services.var_service import get_pnl_snapshots + + trades = get_open_simulation_trades() + drawdown_pct = _compute_max_drawdown_pct(get_pnl_snapshots(200)) + return _build_risk_radar_axes(trades, drawdown_pct) + + +def compute_real_portfolio_risk_radar() -> Dict[str, Any]: + """5-axis risk radar for the REAL portfolio (services.database `portfolio` table). + Same axes/scaling as compute_portfolio_risk_radar(), but the Drawdown axis comes from + the realized equity curve of closed positions (the real portfolio has no periodic + unrealized-PnL snapshot yet), normalized to a % of currently invested capital. + """ + from services.database import get_positions + + trades = get_positions("open") + + closed = sorted(get_positions("closed"), key=lambda p: p.get("close_date") or "") + cumulative = 0.0 + curve: List[Dict[str, Any]] = [] + for p in closed: + if p.get("close_value") is not None: + pnl = (p["close_value"] - p["capital_invested"] + - (p.get("ib_fees_entry") or 0) - (p.get("ib_fees_exit") or 0)) + cumulative += pnl + curve.append({"cumulative": cumulative}) + max_dd_eur = _compute_max_drawdown_eur(curve) + total_capital = sum(max(t.get("capital_invested") or 0, 0) for t in trades) + drawdown_pct = round(max_dd_eur / total_capital * 100, 2) if max_dd_eur is not None and total_capital > 0 else None + + return _build_risk_radar_axes(trades, drawdown_pct) + + def check_new_trade(underlying: str, strategy: str, asset_class: str) -> Dict[str, Any]: """Pre-entry check: would this new trade create conflicts or concentration issues?""" open_trades = get_open_simulation_trades() diff --git a/frontend/src/components/TradeRankList.tsx b/frontend/src/components/TradeRankList.tsx index f461cae..089460a 100644 --- a/frontend/src/components/TradeRankList.tsx +++ b/frontend/src/components/TradeRankList.tsx @@ -5,15 +5,17 @@ import clsx from 'clsx' export default function TradeRankList({ trades, mode, title, linkTo, toggle }: { trades: any[] - mode: 'top' | 'worst' + mode: 'top' | 'worst' | 'recent' title: string linkTo: string toggle?: ReactNode }) { const sorted = [...trades] - .sort((a, b) => mode === 'top' - ? (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999) - : (a.pnl_pct ?? 999) - (b.pnl_pct ?? 999)) + .sort((a, b) => mode === 'recent' + ? (b.entry_date ?? '').localeCompare(a.entry_date ?? '') + : mode === 'top' + ? (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999) + : (a.pnl_pct ?? 999) - (b.pnl_pct ?? 999)) .slice(0, 5) return ( diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 3484229..c95ad4b 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -773,6 +773,15 @@ export const usePortfolioRiskRadar = () => staleTime: 5 * 60_000, }) +// Same 5-axis shape as usePortfolioRiskRadar above, computed on the REAL portfolio +// (services/database.py `portfolio` table) instead of the simulated trade log. +export const useRiskRadar = () => + useQuery({ + queryKey: ['risk-radar'], + queryFn: () => api.get('/risk/radar').then(r => r.data), + staleTime: 5 * 60_000, + }) + export const useTradeCheck = () => useMutation({ mutationFn: (body: { underlying: string; strategy: string; asset_class: string }) => diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 48f1fb1..8e5fe5c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,9 +2,9 @@ import { useMemo, useState, useRef, useLayoutEffect } from 'react' import { useQuery } from '@tanstack/react-query' import { useGeoRiskScore, useAllQuotes, - useEcoCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime, - useTradeMtm, useRiskDashboard, useGeoNews, - useSimPortfolioRisk, usePortfolioRiskRadar, useCycleStatus, useClosedTrades, + useEcoCalendar, usePortfolioSummary, usePortfolioPositions, usePnlHistory, useLastScores, useAllPatterns, useMacroRegime, + useRiskDashboard, useRiskRadar, useGeoNews, + useCycleStatus, useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useInstrumentCatalogIds, useSaxoIvWatchlist, useLatestCycleReport, useWaveletWatchlistSignals, } from '../hooks/useApi' @@ -17,7 +17,7 @@ import { fr } from 'date-fns/locale' import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid, - PieChart, Pie, Cell, BarChart, Bar, ReferenceLine, + Cell, BarChart, Bar, ReferenceLine, } from 'recharts' import { scoreColor } from '../components/TradeIdeas' import { ASSET_CLASS_COLORS } from '../constants/assetColors' @@ -67,45 +67,6 @@ const formatTimeShort = (isoStr: string) => { return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) } -function ViewToggle({ value, onChange }: { value: 'simulated' | 'portfolio'; onChange: (v: 'simulated' | 'portfolio') => void }) { - const click = (v: 'simulated' | 'portfolio') => (e: React.MouseEvent) => { - e.preventDefault(); e.stopPropagation(); onChange(v) - } - return ( -
- - -
- ) -} - -function TradesViewToggle({ value, onChange }: { value: 'top' | 'worst'; onChange: (v: 'top' | 'worst') => void }) { - const click = (v: 'top' | 'worst') => (e: React.MouseEvent) => { - e.preventDefault(); e.stopPropagation(); onChange(v) - } - return ( -
- - -
- ) -} function QuoteRow({ q }: { q: Quote }) { if (!q.price) return null @@ -153,11 +114,9 @@ export default function Dashboard() { const { data: lastScoresData } = useLastScores() const { data: allPatternsData } = useAllPatterns() const { data: macroData } = useMacroRegime() - const { data: tradeMtmData } = useTradeMtm(30) - const { data: closedTradesData } = useClosedTrades(90) + const { data: openPositionsData } = usePortfolioPositions('open') const { data: riskDashboard } = useRiskDashboard() - const { data: simRisk } = useSimPortfolioRisk() - const { data: riskRadarData } = usePortfolioRiskRadar() + const { data: riskRadarData } = useRiskRadar() const { data: cycleStatusData } = useCycleStatus() const { data: geoNews } = useGeoNews() const { data: watchlistItems } = useInstrumentsWatchlist() @@ -175,13 +134,8 @@ export default function Dashboard() { const { data: waveletSignalsData } = useWaveletWatchlistSignals() const { data: saxoIvWatchlistData } = useSaxoIvWatchlist() - // Historical PnL curve + latest VaR snapshot - const { data: pnlHistoryData } = useQuery({ - queryKey: ['dash-pnl-history'], - queryFn: () => fetch('/api/var/pnl/snapshots?limit=200').then(r => r.ok ? r.json() : { snapshots: [] }), - staleTime: 120_000, - retry: 1, - }) + // 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 }), @@ -189,27 +143,6 @@ export default function Dashboard() { retry: 1, }) - const [pnlView, setPnlView] = useState<'simulated' | 'portfolio'>(() => - (localStorage.getItem('dash_pnl_view') as any) ?? 'simulated' - ) - const [riskView, setRiskView] = useState<'simulated' | 'portfolio'>(() => - (localStorage.getItem('dash_risk_view') as any) ?? 'simulated' - ) - - const setPnlViewPersist = (v: 'simulated' | 'portfolio') => { - setPnlView(v); localStorage.setItem('dash_pnl_view', v) - } - const setRiskViewPersist = (v: 'simulated' | 'portfolio') => { - setRiskView(v); localStorage.setItem('dash_risk_view', v) - } - - const [tradesView, setTradesView] = useState<'top' | 'worst'>(() => - (localStorage.getItem('dash_trades_view') as any) ?? 'top' - ) - const setTradesViewPersist = (v: 'top' | 'worst') => { - setTradesView(v); localStorage.setItem('dash_trades_view', v) - } - // 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(null) @@ -262,7 +195,7 @@ export default function Dashboard() { const gauge = riskScore ? riskGauge(riskScore.score) : null const lastCycle = (cycleStatusData as any)?.last_cycle ?? null - const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? [] + const openPositions: any[] = (openPositionsData as any) ?? [] // Patterns from the last cycle only (filter by created_at >= cycle started_at) const cyclePatterns = useMemo(() => { @@ -750,37 +683,8 @@ export default function Dashboard() { {/* ── Command Center: Résumé Opérationnel ── height capped to Options Lab's natural size (config-driven) ── */}
- {/* PnL */} + {/* PnL — real portfolio (services/database.py `portfolio` table), mark-to-market via Black-Scholes */} {(() => { - // Unrealized — open trades only (get_trade_entry_prices excludes closed) - const openTrades = mtmTrades - const openWithPnl = openTrades.filter((t: any) => t.pnl_pct != null) - const openCapital = openTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0) - const openProfit = openWithPnl.reduce((s: number, t: any) => s + ((t.capital_invested ?? t.entry_price ?? 0) * t.pnl_pct / 100), 0) - const openPnlPct = openCapital > 0 ? openProfit / openCapital * 100 : null - const openWinners = openWithPnl.filter((t: any) => t.pnl_pct > 0).length - const openLosers = openWithPnl.filter((t: any) => t.pnl_pct < 0).length - const targetHit = openTrades.filter((t: any) => t.alert_type === 'target_reached').length - const stopHit = openTrades.filter((t: any) => t.alert_type === 'stop_loss').length - - // Realized — from dedicated closed-trades endpoint (pnl_realized in %, capital for EUR) - const closedTrades = (closedTradesData as any)?.trades ?? [] - const closedCapital = closedTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0) - const closedProfitEur = closedTrades.reduce((s: number, t: any) => { - const cap = t.capital_invested ?? t.entry_price ?? 0 - return s + (cap * (t.pnl_realized ?? 0) / 100) - }, 0) - const closedWithPnl = closedTrades.filter((t: any) => t.pnl_realized != null) - const avgClosedPct = closedWithPnl.length > 0 - ? closedWithPnl.reduce((s: number, t: any) => s + t.pnl_realized, 0) / closedWithPnl.length - : null - const closedWinners = closedTrades.filter((t: any) => (t.pnl_realized ?? 0) > 0).length - const closedLosers = closedTrades.filter((t: any) => (t.pnl_realized ?? 0) < 0).length - - const isEstimated = openTrades.some((t: any) => t.capital_invested == null && t.entry_price != null) - const totalCapital = openCapital + closedCapital - const totalProfit = openProfit + closedProfitEur - const pf = portfolio as any const pfPnlPct = pf?.unrealized_pnl_pct ?? null const pfPnl = pf?.unrealized_pnl ?? null @@ -793,10 +697,7 @@ export default function Dashboard() { style={row2Height ? { height: row2Height } : undefined}>
📊 P&L -
- - -
+
{/* ── Side-by-side: Unrealized | Realized ── */} @@ -804,118 +705,64 @@ export default function Dashboard() { {/* Left: Unrealized */}
Open
- {pnlView === 'simulated' ? ( - <> -
= 0 ? 'text-emerald-400' : 'text-red-400')}> - {openPnlPct !== null ? `${openPnlPct >= 0 ? '+' : ''}${openPnlPct.toFixed(1)}%` : '—'} -
-
= 0 ? 'text-emerald-500' : 'text-red-400')}> - {openCapital > 0 ? `${openProfit >= 0 ? '+' : ''}${openProfit.toFixed(0)}€` : '—'} -
-
- {openTrades.length} trades · {openWinners}✓{' '} - {openLosers}✗ -
- - ) : ( - <> -
= 0 ? 'text-emerald-400' : 'text-red-400')}> - {pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'} -
-
= 0 ? 'text-emerald-500' : 'text-red-400')}> - {pfPnl != null ? `${pfPnl >= 0 ? '+' : ''}${pfPnl.toFixed(0)}€` : '—'} -
-
- {pf?.open_positions ?? 0} positions -
- - )} - {(targetHit > 0 || stopHit > 0) && ( -
- {targetHit > 0 && 🎯{targetHit}} - {stopHit > 0 && ⛔{stopHit}} -
- )} +
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'} +
+
= 0 ? 'text-emerald-500' : 'text-red-400')}> + {pfPnl != null ? `${pfPnl >= 0 ? '+' : ''}${pfPnl.toFixed(0)}€` : '—'} +
+
+ {pf?.open_positions ?? 0} positions +
{/* Right: Realized */}
Realized
- {pnlView === 'simulated' ? ( - <> -
= 0 ? 'text-emerald-400' : 'text-red-400')}> - {avgClosedPct !== null - ? `${avgClosedPct >= 0 ? '+' : ''}${avgClosedPct.toFixed(2)}%` - : closedTrades.length === 0 ? '—' : '+0.00%'} -
-
= 0 ? 'text-emerald-500' : 'text-red-400')}> - {closedCapital > 0 - ? `${closedProfitEur >= 0 ? '+' : ''}${closedProfitEur.toFixed(0)}€` - : closedTrades.length > 0 ? 'no capital' : ''} -
-
- {closedTrades.length} closed · {closedWinners}✓{' '} - {closedLosers}✗ -
- - ) : ( - <> -
0 ? 'text-emerald-400' : 'text-red-400')}> - {pfReal != null && pfReal !== 0 - ? `${pfReal >= 0 ? '+' : ''}${pfReal.toFixed(0)}€` - : '—'} -
- {pfNet != null && ( -
= 0 ? 'text-emerald-500/70' : 'text-red-400/70')}> - net {pfNet >= 0 ? '+' : ''}{pfNet.toFixed(0)}€ -
- )} -
- {pf?.closed_positions ?? 0} closed -
- +
0 ? 'text-emerald-400' : 'text-red-400')}> + {pfReal != null && pfReal !== 0 + ? `${pfReal >= 0 ? '+' : ''}${pfReal.toFixed(0)}€` + : '—'} +
+ {pfNet != null && ( +
= 0 ? 'text-emerald-500/70' : 'text-red-400/70')}> + net {pfNet >= 0 ? '+' : ''}{pfNet.toFixed(0)}€ +
)} +
+ {pf?.closed_positions ?? 0} closed +
{/* Total line */}
- Invested{isEstimated ? ' ~' : ''} {pnlView === 'simulated' - ? (totalCapital > 0 ? `${totalCapital.toFixed(0)}€` : '—') - : (pfInvest != null ? `${pfInvest.toFixed(0)}€` : '—')} + Invested {pfInvest != null ? `${pfInvest.toFixed(0)}€` : '—'} - = 0 ? 'text-emerald-400' : 'text-red-400')}> - Total {pnlView === 'simulated' - ? (totalCapital > 0 ? `${totalProfit >= 0 ? '+' : ''}${totalProfit.toFixed(0)}€` : '—') - : (pfNet != null ? `${pfNet >= 0 ? '+' : ''}${pfNet.toFixed(0)}€` : '—')} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + Total {pfNet != null ? `${pfNet >= 0 ? '+' : ''}${pfNet.toFixed(0)}€` : '—'}
- {/* PnL historical sparkline */} + {/* Realized PnL curve — cumulative, from closed portfolio positions */} {(() => { - const snaps: any[] = [...(pnlHistoryData?.snapshots ?? [])].reverse() - if (snaps.length < 2) return null - const chartData = snaps.map(s => ({ - date: s.snapped_at?.slice(5, 10), - pnl: s.total_pnl_pct ?? 0, + const 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 => d.pnl)) - const maxVal = Math.max(...chartData.map(d => d.pnl)) + 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 (
-
Historical PnL curve ({snaps.length} pts)
+
Realized PnL curve ({chartData.length} closed)
@@ -927,11 +774,11 @@ export default function Dashboard() { - + [`${v >= 0 ? '+' : ''}${Number(v).toFixed(3)}%`, 'PnL']} + formatter={(v: any) => [`${v >= 0 ? '+' : ''}${Number(v).toFixed(0)}€`, 'Cumulative PnL']} /> @@ -944,16 +791,17 @@ export default function Dashboard() { ) })()} - {/* Risk — radar of 5 risk factors (Concentration/Volatility/Correlation/Exposure/ - Drawdown), asset-class allocation breakdown unchanged below it */} + {/* 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 = simRisk as any - const alertCount: number = risk?.alerts?.length ?? 0 - const conflictCount: number = risk?.conflicts?.length ?? 0 - const openCount: number = risk?.open_count ?? 0 - const concentration: Record = risk?.concentration ?? {} - const pieData = Object.entries(concentration) - .map(([cls, data]: [string, any]) => ({ name: cls, value: data.pct, bullish: data.bullish, bearish: data.bearish })) + const risk = riskDashboard as any + const alertCount: number = risk?.concentration_alerts?.length ?? 0 + const openCount: number = risk?.open_trades ?? 0 + const byClass: Record = 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 })) @@ -967,7 +815,6 @@ export default function Dashboard() {
0 ? 'text-red-400' : 'text-emerald-400')}> {alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'} - {conflictCount > 0 && {conflictCount} conflict{conflictCount > 1 ? 's' : ''}}
{radarAxes.length > 0 && ( @@ -985,25 +832,19 @@ export default function Dashboard() { {pieData.length > 0 ? ( <>
0 && 'mt-1 pt-1 border-t border-slate-700/30')}> - {pieData.map(d => { - const bias = d.bullish > d.bearish ? 'bullish' : d.bearish > d.bullish ? 'bearish' : 'neutral' - return ( -
- - {d.name} - - {bias === 'bullish' ? '↑' : bias === 'bearish' ? '↓' : '—'} - - {d.value}% -
- ) - })} + {pieData.map(d => ( +
+ + {d.name} + {d.value}% +
+ ))}
- {(risk?.alerts ?? []).length > 0 && ( + {(risk?.concentration_alerts ?? []).length > 0 && (
- {(risk.alerts as any[]).map((a: any, i: number) => ( -
- {a.level === 'danger' ? '●' : '▲'} + {(risk.concentration_alerts as any[]).map((a: any, i: number) => ( +
+ {a.level === 'high' ? '●' : '▲'} {a.message}
))} @@ -1159,8 +1000,7 @@ export default function Dashboard() { {/* ── Command Center: Intelligence & Contexte ── */}
- } /> + {/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each cycle). Single click on the card = Wavelets Simulation overview; double-click a