feat: cockpit

This commit is contained in:
OpenSquared
2026-07-23 22:04:02 +02:00
parent cfc33d1ee3
commit 548bad2dcd
5 changed files with 165 additions and 247 deletions

View File

@@ -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())

View File

@@ -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()

View File

@@ -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 (

View File

@@ -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 }) =>

View File

@@ -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 (
<div className="flex gap-0.5 bg-dark-800 rounded p-0.5 text-[9px]">
<button onClick={click('simulated')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'simulated' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Simulated
</button>
<button onClick={click('portfolio')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'portfolio' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Portfolio
</button>
</div>
)
}
function 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 (
<div className="flex gap-0.5 bg-dark-800 rounded p-0.5 text-[9px]">
<button onClick={click('top')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'top' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Best
</button>
<button onClick={click('worst')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'worst' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Worst
</button>
</div>
)
}
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<HTMLDivElement>(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) ── */}
<div className="grid grid-cols-4 gap-3 items-start">
{/* 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}>
<div className="flex items-center justify-between mb-1.5">
<span className="section-title mb-0 text-[10px]">📊 P&L</span>
<div className="flex items-center gap-1.5">
<ViewToggle value={pnlView} onChange={setPnlViewPersist} />
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
{/* ── Side-by-side: Unrealized | Realized ── */}
@@ -804,118 +705,64 @@ export default function Dashboard() {
{/* Left: Unrealized */}
<div className="bg-dark-700/40 rounded px-2.5 py-2 border border-slate-700/30">
<div className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Open</div>
{pnlView === 'simulated' ? (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
openPnlPct === null ? 'text-slate-600' : openPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{openPnlPct !== null ? `${openPnlPct >= 0 ? '+' : ''}${openPnlPct.toFixed(1)}%` : '—'}
</div>
<div className={clsx('text-xs font-mono mt-0.5',
openProfit >= 0 ? 'text-emerald-500' : 'text-red-400')}>
{openCapital > 0 ? `${openProfit >= 0 ? '+' : ''}${openProfit.toFixed(0)}` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{openTrades.length} trades · <span className="text-emerald-500">{openWinners}</span>{' '}
<span className="text-red-400">{openLosers}</span>
</div>
</>
) : (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
pfPnlPct === null ? 'text-slate-600' : pfPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'}
</div>
<div className={clsx('text-xs font-mono mt-0.5', (pfPnl ?? 0) >= 0 ? 'text-emerald-500' : 'text-red-400')}>
{pfPnl != null ? `${pfPnl >= 0 ? '+' : ''}${pfPnl.toFixed(0)}` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{pf?.open_positions ?? 0} positions
</div>
</>
)}
{(targetHit > 0 || stopHit > 0) && (
<div className="mt-1 text-[9px] space-x-1">
{targetHit > 0 && <span className="text-emerald-400">🎯{targetHit}</span>}
{stopHit > 0 && <span className="text-red-400">{stopHit}</span>}
</div>
)}
<div 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>
{pnlView === 'simulated' ? (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
avgClosedPct === null ? 'text-slate-600'
: avgClosedPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgClosedPct !== null
? `${avgClosedPct >= 0 ? '+' : ''}${avgClosedPct.toFixed(2)}%`
: closedTrades.length === 0 ? '—' : '+0.00%'}
</div>
<div className={clsx('text-xs font-mono mt-0.5',
closedProfitEur >= 0 ? 'text-emerald-500' : 'text-red-400')}>
{closedCapital > 0
? `${closedProfitEur >= 0 ? '+' : ''}${closedProfitEur.toFixed(0)}`
: closedTrades.length > 0 ? 'no capital' : ''}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{closedTrades.length} closed · <span className="text-emerald-500">{closedWinners}</span>{' '}
<span className="text-red-400">{closedLosers}</span>
</div>
</>
) : (
<>
<div className={clsx('text-xl font-bold font-mono leading-none',
pfReal == null || pfReal === 0 ? 'text-slate-600'
: pfReal > 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfReal != null && pfReal !== 0
? `${pfReal >= 0 ? '+' : ''}${pfReal.toFixed(0)}`
: '—'}
</div>
{pfNet != null && (
<div className={clsx('text-xs font-mono mt-0.5', pfNet >= 0 ? 'text-emerald-500/70' : 'text-red-400/70')}>
net {pfNet >= 0 ? '+' : ''}{pfNet.toFixed(0)}
</div>
)}
<div className="text-[10px] text-slate-500 mt-1">
{pf?.closed_positions ?? 0} closed
</div>
</>
<div className={clsx('text-xl font-bold font-mono leading-none',
pfReal == null || pfReal === 0 ? 'text-slate-600'
: pfReal > 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfReal != null && pfReal !== 0
? `${pfReal >= 0 ? '+' : ''}${pfReal.toFixed(0)}`
: '—'}
</div>
{pfNet != null && (
<div className={clsx('text-xs font-mono mt-0.5', pfNet >= 0 ? 'text-emerald-500/70' : 'text-red-400/70')}>
net {pfNet >= 0 ? '+' : ''}{pfNet.toFixed(0)}
</div>
)}
<div className="text-[10px] text-slate-500 mt-1">
{pf?.closed_positions ?? 0} closed
</div>
</div>
</div>
{/* Total line */}
<div className="mt-1.5 flex items-center justify-between text-[10px] flex-shrink-0">
<span className="text-slate-600">
Invested{isEstimated ? ' ~' : ''} {pnlView === 'simulated'
? (totalCapital > 0 ? `${totalCapital.toFixed(0)}` : '—')
: (pfInvest != null ? `${pfInvest.toFixed(0)}` : '—')}
Invested {pfInvest != null ? `${pfInvest.toFixed(0)}` : '—'}
</span>
<span className={clsx('font-mono font-bold',
(pnlView === 'simulated' ? totalProfit : (pfNet ?? 0)) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
Total {pnlView === 'simulated'
? (totalCapital > 0 ? `${totalProfit >= 0 ? '+' : ''}${totalProfit.toFixed(0)}` : '—')
: (pfNet != null ? `${pfNet >= 0 ? '+' : ''}${pfNet.toFixed(0)}` : '—')}
<span 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>
{/* 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 (
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30">
<div className="text-[9px] text-slate-600 mb-0.5">Historical PnL curve ({snaps.length} pts)</div>
<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>
@@ -927,11 +774,11 @@ export default function Dashboard() {
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 8, fill: '#475569' }}
interval={Math.max(0, Math.floor(chartData.length / 5) - 1)} />
<YAxis domain={[minVal - 0.1, maxVal + 0.1]} tick={{ fontSize: 8, fill: '#475569' }} unit="%" />
<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(3)}%`, 'PnL']}
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} />
@@ -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<string, any> = risk?.concentration ?? {}
const pieData = Object.entries(concentration)
.map(([cls, data]: [string, any]) => ({ name: cls, value: data.pct, bullish: data.bullish, bearish: data.bearish }))
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 }))
@@ -967,7 +815,6 @@ export default function Dashboard() {
</div>
<div className={clsx('text-lg font-bold mt-0.5 flex items-center gap-2', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
{alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'}
{conflictCount > 0 && <span className="text-[10px] text-red-400 font-normal">{conflictCount} conflict{conflictCount > 1 ? 's' : ''}</span>}
</div>
{radarAxes.length > 0 && (
<ResponsiveContainer width="100%" height={110}>
@@ -985,25 +832,19 @@ export default function Dashboard() {
{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 => {
const bias = d.bullish > d.bearish ? 'bullish' : d.bearish > d.bullish ? 'bearish' : 'neutral'
return (
<div key={d.name} className="flex items-center gap-1.5 text-[9px]">
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: ASSET_CLASS_COLORS[d.name] ?? ASSET_CLASS_COLORS.unknown }} />
<span className="text-slate-500 capitalize flex-1 truncate">{d.name}</span>
<span className={clsx('text-[8px] w-3', bias === 'bullish' ? 'text-emerald-500' : bias === 'bearish' ? 'text-red-400' : 'text-slate-700')}>
{bias === 'bullish' ? '↑' : bias === 'bearish' ? '↓' : '—'}
</span>
<span className="text-slate-600 font-mono w-9 text-right">{d.value}%</span>
</div>
)
})}
{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?.alerts ?? []).length > 0 && (
{(risk?.concentration_alerts ?? []).length > 0 && (
<div className="mt-2 pt-2 border-t border-slate-700/30 space-y-1">
{(risk.alerts as any[]).map((a: any, i: number) => (
<div key={i} className={clsx('flex items-start gap-1 text-[9px] leading-snug', a.level === 'danger' ? 'text-red-400' : 'text-amber-400')}>
<span className="shrink-0 mt-[3px]">{a.level === 'danger' ? '●' : '▲'}</span>
{(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>
))}
@@ -1159,8 +1000,7 @@ export default function Dashboard() {
{/* ── Command Center: Intelligence & Contexte ── */}
<div className="grid grid-cols-4 gap-3">
<TradeRankList trades={mtmTrades} mode={tradesView} title="🏆 Trades Overview" linkTo="/journal"
toggle={<TradesViewToggle value={tradesView} onChange={setTradesViewPersist} />} />
<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