Files
OpenFin/frontend/src/pages/RiskDashboard.tsx
2026-07-14 11:21:43 +02:00

517 lines
24 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 } from '../hooks/useApi'
import { ASSET_CLASS_COLORS } from '../constants/assetColors'
import clsx from 'clsx'
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart } 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>
)
}
// ── 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} />
{/* 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>
)
}