Files
OpenFin/frontend/src/pages/RiskDashboard.tsx
OpenSquared 3704724b0b feat: risk
2026-07-26 14:10:49 +02:00

599 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk, usePortfolioScenarioExposure } from '../hooks/useApi'
import { ASSET_CLASS_COLORS } from '../constants/assetColors'
import clsx from 'clsx'
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart, Layers } from 'lucide-react'
// ── Gauge component ──────────────────────────────────────────────────────────
function ConcentrationGauge({ label, pct, threshold = 50 }: { label: string; pct: number; threshold?: number }) {
const color = pct > threshold ? 'bg-red-500' : pct > threshold * 0.7 ? 'bg-amber-500' : 'bg-emerald-500'
const textColor = pct > threshold ? 'text-red-400' : pct > threshold * 0.7 ? 'text-amber-400' : 'text-emerald-400'
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-400 capitalize">{label}</span>
<span className={clsx('font-mono font-bold', textColor)}>{pct.toFixed(1)}%</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all', color)} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
{pct > threshold && (
<div className="text-[10px] text-red-400 mt-0.5"> Saturated (&gt;{threshold}%)</div>
)}
</div>
)
}
// ── Equity curve SVG ─────────────────────────────────────────────────────────
function EquityCurve({ timeline }: { timeline: any[] }) {
if (!timeline || timeline.length < 2) {
return (
<div className="h-24 flex items-center justify-center text-slate-600 text-xs">
No P&L data yet (requires mature trades)
</div>
)
}
const values = timeline.map(t => t.cumulative_pnl_abs ?? 0)
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || 1
const W = 400
const H = 80
const pad = 4
const points = values.map((v, i) => {
const x = pad + (i / (values.length - 1)) * (W - pad * 2)
const y = H - pad - ((v - min) / range) * (H - pad * 2)
return `${x},${y}`
}).join(' ')
const finalVal = values[values.length - 1]
const lineColor = finalVal >= 0 ? '#34d399' : '#f87171'
return (
<div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-20">
<defs>
<linearGradient id="curve-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={lineColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={lineColor} stopOpacity="0" />
</linearGradient>
</defs>
{/* Zero line */}
{min < 0 && max > 0 && (
<line
x1={pad} x2={W - pad}
y1={H - pad - ((0 - min) / range) * (H - pad * 2)}
y2={H - pad - ((0 - min) / range) * (H - pad * 2)}
stroke="#475569" strokeWidth="0.5" strokeDasharray="3,3"
/>
)}
<polyline points={points} fill="none" stroke={lineColor} strokeWidth="1.5" />
</svg>
<div className="flex justify-between text-[10px] text-slate-600 mt-0.5">
<span>{timeline[0]?.entry_date?.slice(0, 7)}</span>
<span className={clsx('font-mono font-bold', finalVal >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{finalVal >= 0 ? '+' : ''}{finalVal.toFixed(0)}
</span>
<span>{timeline[timeline.length - 1]?.entry_date?.slice(0, 7)}</span>
</div>
</div>
)
}
// ── Correlation heatmap row ──────────────────────────────────────────────────
function CorrelationPairRow({ pair }: { pair: any }) {
const c = pair.correlation
const abs = Math.abs(c)
const color = abs > 0.7 ? 'text-red-400' : abs > 0.4 ? 'text-amber-400' : 'text-emerald-400'
const bg = abs > 0.7 ? 'bg-red-900/20 border-red-700/30' : abs > 0.4 ? 'bg-amber-900/20 border-amber-700/30' : 'bg-dark-700/30 border-slate-700/20'
return (
<div className={clsx('flex items-center gap-3 px-3 py-2 rounded border text-xs', bg)}>
<div className="flex-1 min-w-0">
<span className="text-slate-300 truncate">{pair.name_a}</span>
<span className="text-slate-600 mx-1"></span>
<span className="text-slate-300 truncate">{pair.name_b}</span>
</div>
<div className={clsx('font-mono font-bold shrink-0', color)}>
{c > 0 ? '+' : ''}{c.toFixed(2)}
</div>
<div className="text-slate-600 text-[10px] shrink-0">{pair.interpretation}</div>
</div>
)
}
// ── Recommendation card ──────────────────────────────────────────────────────
function RecommendationCard({ rec }: { rec: any }) {
if (!rec) return null
const isOk = rec.level === 'ok'
const isDanger = rec.level === 'danger'
return (
<div className={clsx('card', {
'border-red-700/40 bg-red-900/10': isDanger,
'border-amber-700/40 bg-amber-900/10': rec.level === 'warning',
'border-emerald-700/40 bg-emerald-900/10': isOk,
})}>
<div className={clsx('flex items-center gap-2 mb-2 text-sm font-semibold', {
'text-red-400': isDanger,
'text-amber-400': rec.level === 'warning',
'text-emerald-400': isOk,
})}>
{isOk ? <CheckCircle className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
Risk Committee Recommendation
</div>
<div className="space-y-1.5">
{(rec.messages ?? []).map((msg: string, i: number) => (
<p key={i} className="text-xs text-slate-300 leading-relaxed">{msg}</p>
))}
</div>
</div>
)
}
// ── Scenario concentration ("same bet, different ticker") ───────────────────
function ScenarioExposureCard() {
const { data, isLoading } = usePortfolioScenarioExposure()
const exp: any = data
if (isLoading) return <div className="card animate-pulse h-40 bg-dark-700" />
if (!exp || !exp.positions) return null
const concentration: any[] = exp.concentration ?? []
const scenarios: any[] = exp.scenarios ?? []
return (
<div className="card">
<div className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Layers className="w-4 h-4 text-purple-400" /> Concentration par scénario macro
</div>
<div className="text-[10px] text-slate-500 mb-3">
Repricing Black-Scholes réel (pricing Saxo-first) de chaque position sous 5 scénarios
révèle quand plusieurs positions différentes sont en réalité le même pari répété.
</div>
{exp.warning && (
<div className="mb-3 flex items-start gap-2 text-xs px-3 py-2 rounded border bg-amber-900/20 border-amber-700/30 text-amber-300">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
{exp.warning}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Concentration bars */}
<div>
<div className="text-xs font-semibold text-slate-400 mb-2">
% du book dont c'est le scénario le plus favorable
</div>
<div className="space-y-2.5">
{concentration.map((c: any) => (
<div key={c.key}>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-300">{c.label}</span>
<span className={clsx('font-mono font-bold', c.pct_of_portfolio >= 60 ? 'text-amber-400' : 'text-slate-300')}>
{c.pct_of_portfolio}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-purple-500/70" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%` }} />
</div>
</div>
))}
</div>
</div>
{/* Sensitivity matrix */}
<div>
<div className="text-xs font-semibold text-slate-400 mb-2">P&L estimé du portefeuille par scénario</div>
<table className="w-full text-xs">
<tbody>
{scenarios.map((s: any) => (
<tr key={s.key} className="border-b border-slate-800/40">
<td className="py-1.5 pr-3 text-slate-300">{s.label}</td>
<td className={clsx('py-1.5 text-right font-mono font-bold whitespace-nowrap',
s.portfolio_pnl_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{s.portfolio_pnl_pct >= 0 ? '+' : ''}{s.portfolio_pnl_pct}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{exp.unpriced?.length > 0 && (
<div className="text-[10px] text-slate-600 mt-3 pt-2 border-t border-slate-700/30">
{exp.unpriced.length} position(s) non pricée(s) (pas de legs/données) : {exp.unpriced.map((u: any) => u.title).join(', ')}
</div>
)}
</div>
)
}
// ── Main page ────────────────────────────────────────────────────────────────
function SimRiskPanel() {
const { data, isLoading, refetch } = useSimPortfolioRisk()
const risk = data as any
if (isLoading) return (
<div className="space-y-3">
{[1, 2, 3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}
</div>
)
if (!risk || risk.open_count === 0) return (
<div className="card text-center py-10 text-slate-500">
<PieChart className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div className="text-sm">No open positions in the simulated portfolio</div>
<div className="text-xs mt-1">Logged trades will appear here after the next AI cycle</div>
</div>
)
const concentration: Record<string, any> = risk.concentration ?? {}
const alerts: any[] = risk.alerts ?? []
const conflicts: any[] = risk.conflicts ?? []
const aiMonitor: any = risk.ai_monitor
const aiTs: string = risk.ai_monitor_ts
const dangers = alerts.filter((a: any) => a.level === 'danger')
const warnings = alerts.filter((a: any) => a.level === 'warning')
return (
<div className="space-y-4">
{/* KPIs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="card text-center">
<div className="text-2xl font-bold text-white font-mono">{risk.open_count}</div>
<div className="text-xs text-slate-500 mt-1">Simulated positions</div>
</div>
<div className="card text-center">
<div className={clsx('text-2xl font-bold font-mono', dangers.length > 0 ? 'text-red-400' : 'text-emerald-400')}>
{dangers.length}
</div>
<div className="text-xs text-slate-500 mt-1">Directional conflicts</div>
</div>
<div className="card text-center">
<div className={clsx('text-2xl font-bold font-mono', warnings.length > 0 ? 'text-amber-400' : 'text-emerald-400')}>
{warnings.length}
</div>
<div className="text-xs text-slate-500 mt-1">Concentration alerts</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold font-mono text-slate-300">
{Object.keys(concentration).filter(ac => ac !== 'unknown').length}
</div>
<div className="text-xs text-slate-500 mt-1">Asset classes</div>
</div>
</div>
{/* Concentration + Conflicts side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Asset class bars */}
<div className="card">
<div className="text-sm font-semibold text-white mb-3 flex items-center justify-between">
<span className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-blue-400" /> Allocation by asset class
</span>
<button onClick={() => refetch()} className="text-slate-600 hover:text-slate-400">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-2.5">
{Object.entries(concentration)
.sort(([, a]: any, [, b]: any) => b.pct - a.pct)
.map(([ac, data]: [string, any]) => {
const color = ASSET_CLASS_COLORS[ac] ?? ASSET_CLASS_COLORS.unknown
const exp = risk.direction_exposure?.[ac] ?? {}
const isOver = data.pct >= 35
return (
<div key={ac}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold w-20 capitalize" style={{ color }}>{ac}</span>
<span className="text-[10px] text-slate-500 max-w-[120px] truncate">
{data.tickers?.slice(0, 3).join(', ')}{data.tickers?.length > 3 ? '' : ''}
</span>
</div>
<div className="flex items-center gap-2 text-[10px]">
{exp.bullish > 0 && <span className="text-emerald-400">▲{exp.bullish}</span>}
{exp.bearish > 0 && <span className="text-red-400">▼{exp.bearish}</span>}
<span className={clsx('font-mono font-bold', isOver ? 'text-red-400' : 'text-slate-300')}>
{data.pct}%
</span>
<span className="text-slate-600">({data.count})</span>
</div>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full"
style={{ width: `${data.pct}%`, background: isOver ? '#ef4444cc' : `${color}cc` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Conflicts & alerts */}
<div className="space-y-3">
{conflicts.length === 0 && warnings.length === 0 ? (
<div className="card border-emerald-700/30 bg-emerald-900/10 flex items-center gap-3 py-4">
<CheckCircle className="w-5 h-5 text-emerald-400 shrink-0" />
<div className="text-xs text-emerald-300">Balanced portfolio — no conflicts or over-concentration detected</div>
</div>
) : null}
{conflicts.map((c: any, i: number) => (
<div key={i} className="card border-red-700/40 bg-red-900/10">
<div className="text-xs font-semibold text-red-300 mb-2 flex items-center gap-1">
<ShieldAlert className="w-3.5 h-3.5" /> Directional conflict — {c.underlying}
</div>
<div className="space-y-1">
{c.trades.map((t: any) => (
<div key={t.id} className="flex items-center gap-2 text-[11px]">
<span className={clsx('font-bold font-mono w-6',
t.direction === 'bullish' ? 'text-emerald-400' : 'text-red-400')}>
{t.direction === 'bullish' ? '' : ''}#{t.id}
</span>
<span className="text-slate-400">{t.strategy}</span>
<span className="text-slate-600">{t.entry_date}</span>
<span className="text-slate-600 truncate max-w-[140px]">{t.pattern_name}</span>
</div>
))}
</div>
</div>
))}
{warnings.length > 0 && (
<div className="card border-amber-700/30 bg-amber-900/10">
<div className="text-xs font-semibold text-amber-300 mb-2 flex items-center gap-1">
<AlertTriangle className="w-3.5 h-3.5" /> Concentration alerts
</div>
<div className="space-y-1">
{warnings.map((a: any, i: number) => (
<div key={i} className="text-[11px] text-amber-200/80 flex items-start gap-1.5">
<span className="text-amber-500 shrink-0">•</span>{a.message}
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* AI recommendations */}
{aiMonitor && (
<div className="card border-blue-700/30 bg-blue-900/10">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5" /> AI Recommendations — Simulated portfolio monitor
</div>
{aiTs && <span className="text-[10px] text-slate-600">{aiTs.slice(0, 16).replace('T', ' ')}</span>}
</div>
{aiMonitor.assessment && <p className="text-xs text-slate-300 mb-2">{aiMonitor.assessment}</p>}
{aiMonitor.actions?.length > 0 && (
<div className="space-y-1 mb-2">
{aiMonitor.actions.map((action: any, i: number) => (
<div key={i} className={clsx(
'flex items-start gap-2 text-[11px] rounded px-2 py-1',
action.priority === 'high'
? 'bg-red-900/20 border border-red-700/20 text-red-300'
: 'bg-slate-800/40 border border-slate-700/20 text-slate-300'
)}>
<span className="shrink-0">
{action.type === 'close_trade' ? '🔒' : action.type === 'rebalance' ? '' : '👁'}
</span>
<div>
{action.underlying && <span className="font-mono font-bold mr-1">{action.underlying}</span>}
{action.trade_id && <span className="text-slate-500 mr-1">#{action.trade_id}</span>}
{action.reason}
</div>
</div>
))}
</div>
)}
{aiMonitor.rebalance_suggestion && (
<div className="text-[11px] text-blue-300/70 border-t border-blue-700/20 pt-2">
⚖️ {aiMonitor.rebalance_suggestion}
</div>
)}
</div>
)}
</div>
)
}
export default function RiskDashboard() {
const { data: dashboard, isLoading } = useRiskDashboard()
const { data: corrData } = usePatternCorrelations()
const [tlDays, setTlDays] = useState(90)
const { data: tlData } = usePnlTimeline(tlDays)
const [mode, setMode] = useState<'real' | 'sim'>('real')
const { data: simRisk } = useSimPortfolioRisk()
const simConflicts = (simRisk as any)?.alerts?.filter((a: any) => a.level === 'danger').length ?? 0
const d: any = dashboard ?? {}
const pairs: any[] = corrData?.pairs ?? []
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<ShieldAlert className="w-5 h-5 text-red-400" /> Risk Dashboard
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{mode === 'real'
? 'Concentration · Clusters · Correlations · Position Sizing IBKR Portfolio'
: 'Directional conflicts · Concentration · AI Recommendations Logged trades'}
</p>
</div>
{/* Mode toggle */}
<div className="flex gap-1 bg-dark-700 p-1 rounded">
<button onClick={() => setMode('real')}
className={clsx('px-3 py-1.5 rounded text-sm font-semibold transition-colors', {
'bg-blue-600 text-white': mode === 'real',
'text-slate-400 hover:text-slate-200': mode !== 'real',
})}>
Real Portfolio
</button>
<button onClick={() => setMode('sim')}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-semibold transition-colors', {
'bg-blue-600 text-white': mode === 'sim',
'text-slate-400 hover:text-slate-200': mode !== 'sim',
})}>
Simulated (logged)
{simConflicts > 0 && (
<span className="text-[10px] font-bold bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center">
{simConflicts}
</span>
)}
</button>
</div>
</div>
{mode === 'sim' ? <SimRiskPanel /> : isLoading ? (
<div className="grid grid-cols-4 gap-3">
{[1, 2, 3, 4].map(i => <div key={i} className="card h-20 animate-pulse bg-dark-700" />)}
</div>
) : (
<>
{/* KPI row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="card text-center">
<div className="text-2xl font-bold text-white font-mono">{d.open_trades ?? 0}</div>
<div className="text-xs text-slate-500 mt-1">Open positions</div>
</div>
<div className="card text-center">
<div className={clsx('text-2xl font-bold font-mono',
d.diversification_score >= 60 ? 'text-emerald-400'
: d.diversification_score >= 35 ? 'text-amber-400' : 'text-red-400'
)}>{d.diversification_score ?? ''}%</div>
<div className="text-xs text-slate-500 mt-1">Diversification score</div>
<div className="text-[10px] text-slate-600">N effectif = {d.effective_n_positions ?? ''}</div>
</div>
<div className="card text-center">
<div className={clsx('text-2xl font-bold font-mono',
(d.expected_drawdown_pct ?? 0) < 15 ? 'text-emerald-400'
: (d.expected_drawdown_pct ?? 0) < 25 ? 'text-amber-400' : 'text-red-400'
)}>{d.expected_drawdown_pct ?? ''}%</div>
<div className="text-xs text-slate-500 mt-1">Expected drawdown</div>
</div>
<div className="card text-center">
<div className={clsx('text-2xl font-bold font-mono',
!d.saturated_factors?.length ? 'text-emerald-400' : 'text-red-400'
)}>
{d.saturated_factors?.length ?? 0}
</div>
<div className="text-xs text-slate-500 mt-1">Saturated factors</div>
{d.saturated_factors?.length > 0 && (
<div className="text-[10px] text-red-400">{d.saturated_factors.join(', ')}</div>
)}
</div>
</div>
{/* Recommendation */}
<RecommendationCard rec={d.recommendation} />
{/* Scenario concentration — same bet, different ticker */}
<ScenarioExposureCard />
{/* Alerts */}
{(d.concentration_alerts ?? []).length > 0 && (
<div className="space-y-2">
{d.concentration_alerts.map((alert: any, i: number) => (
<div key={i} className={clsx('flex items-start 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',
})}>
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
{alert.message}
</div>
))}
</div>
)}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Exposure by class */}
<div className="card">
<div className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<GitBranch className="w-4 h-4 text-blue-400" /> Exposure by asset class
</div>
{Object.keys(d.exposure_by_class ?? {}).length === 0 ? (
<div className="text-xs text-slate-600 text-center py-4">No open positions</div>
) : (
<div className="space-y-3">
{Object.entries(d.exposure_by_class ?? {})
.sort(([, a]: any, [, b]: any) => b.pct_of_portfolio - a.pct_of_portfolio)
.map(([cls, info]: any) => (
<ConcentrationGauge key={cls} label={cls} pct={info.pct_of_portfolio} threshold={40} />
))}
</div>
)}
</div>
{/* Risk factors */}
<div className="card">
<div className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Activity className="w-4 h-4 text-orange-400" /> Exposure by risk factor
</div>
{(d.risk_clusters ?? []).length === 0 ? (
<div className="text-xs text-slate-600 text-center py-4">No open positions</div>
) : (
<div className="space-y-3">
{(d.risk_clusters ?? []).map((c: any) => (
<ConcentrationGauge key={c.factor} label={c.factor} pct={c.pct_of_portfolio} threshold={50} />
))}
</div>
)}
</div>
</div>
{/* Equity curve */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-white flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" /> Cumulative P&L curve (mature trades)
</div>
<select value={tlDays} onChange={e => setTlDays(Number(e.target.value))}
className="bg-dark-700 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300">
<option value={30}>30d</option>
<option value={90}>90d</option>
<option value={180}>180d</option>
<option value={365}>1 yr</option>
</select>
</div>
<EquityCurve timeline={tlData?.timeline ?? []} />
</div>
{/* Correlation pairs */}
<div className="card">
<div className="text-sm font-semibold text-white mb-3">
Pattern correlations (historical P&L)
</div>
{pairs.length === 0 ? (
<div className="text-xs text-slate-600 text-center py-4">
Requires 3 mature trades per pattern to calculate correlations
</div>
) : (
<div className="space-y-1.5">
{pairs.slice(0, 10).map((pair: any, i: number) => (
<CorrelationPairRow key={i} pair={pair} />
))}
{pairs.length > 10 && (
<div className="text-xs text-slate-600 text-center pt-1">{pairs.length - 10} more pairs</div>
)}
</div>
)}
{pairs.length > 0 && (
<div className="text-[10px] text-slate-600 mt-2">
Correlation &gt; 0.7 = concentrated risk these patterns share the same underlying factor
</div>
)}
</div>
</>
)}
</div>
)
}