fix: sim portfolio asset_class fallback + consolidate risk into Risk Dashboard

- portfolio_risk.py: add _infer_asset_class() with ticker→asset_class map
  covering energy/metals/agri/indices/forex/rates futures, ETFs, forex pairs,
  exchange prefixes (NSE:). Fallback applied when JOIN finds no match (orphaned
  pattern_id after re-seed). Fixes "unknown 100%" shown in screenshot.

- RiskDashboard.tsx: add Portefeuille Réel / Simulé toggle at top.
  New SimRiskPanel component with KPI row + concentration bars + conflict cards
  + AI recommendations — all visible inline in Risk Dashboard.
  Red badge on Simulé tab when danger alerts exist.

- JournalDeBord.tsx: remove standalone Risque Sim. tab (moved to Risk Dashboard).
  Replace with a red banner in summary cards when conflicts are detected,
  pointing user to Risk Dashboard → Simulé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-19 18:26:10 +02:00
parent 58c3767a9d
commit 8f15dff721
3 changed files with 296 additions and 14 deletions

View File

@@ -1441,14 +1441,13 @@ function SimPortfolioRiskPanel() {
const TABS = [
{ key: 'cycles', label: 'Cycles IA', icon: Zap },
{ key: 'macro', label: 'Régimes Macro', icon: Activity },
{ key: 'risk', label: 'Risque Sim.', icon: ShieldAlert },
{ key: 'mtm', label: 'Ouverts', icon: TrendingUp },
{ key: 'closed', label: 'Fermés', icon: Lock },
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
] as const
export default function JournalDeBord() {
const [tab, setTab] = useState<'cycles' | 'macro' | 'risk' | 'mtm' | 'closed' | 'geo'>('cycles')
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'closed' | 'geo'>('cycles')
const [days, setDays] = useState(15)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
@@ -1572,6 +1571,17 @@ export default function JournalDeBord() {
<div className="text-2xl font-bold text-white">{s.trade_entries_logged ?? 0}</div>
<div className="text-xs text-slate-600 mt-0.5">trades logués</div>
</div>
{riskAlertCount > 0 && (
<div className="card text-center border-red-700/40 bg-red-900/10 col-span-full">
<div className="flex items-center justify-center gap-2">
<ShieldAlert className="w-4 h-4 text-red-400" />
<span className="text-sm font-semibold text-red-300">
{riskAlertCount} conflit{riskAlertCount > 1 ? 's' : ''} directionnel{riskAlertCount > 1 ? 's' : ''} détecté{riskAlertCount > 1 ? 's' : ''}
</span>
<span className="text-xs text-slate-500"> voir Risk Dashboard Simulé</span>
</div>
</div>
)}
</div>
)}
@@ -1585,11 +1595,6 @@ export default function JournalDeBord() {
})}>
<Icon className="w-3.5 h-3.5" />
{label}
{key === 'risk' && riskAlertCount > 0 && (
<span className="ml-0.5 text-[10px] font-bold bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center shrink-0">
{riskAlertCount}
</span>
)}
</button>
))}
</div>
@@ -1597,7 +1602,6 @@ export default function JournalDeBord() {
{/* Content */}
{tab === 'cycles' && <CyclesSection />}
{tab === 'macro' && <MacroHistorySection days={days} />}
{tab === 'risk' && <SimPortfolioRiskPanel />}
{tab === 'mtm' && <TradeMtmSection days={days} />}
{tab === 'closed' && <ClosedTradesSection days={days} />}
{tab === 'geo' && <GeoHistorySection days={days} />}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure } from '../hooks/useApi'
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk } from '../hooks/useApi'
import clsx from 'clsx'
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity } from 'lucide-react'
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 }) {
@@ -131,11 +131,210 @@ function RecommendationCard({ rec }: { rec: any }) {
}
// ── Main page ────────────────────────────────────────────────────────────────
const ASSET_CLASS_COLORS: Record<string, string> = {
energy: '#f97316', metals: '#eab308', agriculture: '#84cc16',
equities: '#22c55e', indices: '#3b82f6', forex: '#8b5cf6', rates: '#06b6d4',
unknown: '#64748b',
}
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">Aucune position ouverte dans le portefeuille simulé</div>
<div className="text-xs mt-1">Les trades logués apparaîtront ici après le prochain cycle IA</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">Positions simulées</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">Conflits directionnels</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">Alertes concentration</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">Classes d'actifs</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" /> Répartition par classe d'actif
</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">Portefeuille équilibré aucun conflit ni sur-concentration détectés</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" /> Conflit directionnel {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" /> Alertes de concentration
</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" /> Recommandations IA Moniteur de portefeuille simulé
</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 ?? []
@@ -149,12 +348,36 @@ export default function RiskDashboard() {
<ShieldAlert className="w-5 h-5 text-red-400" /> Risk Dashboard
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Concentration · Clusters · Corrélations · Position Sizing
{mode === 'real'
? 'Concentration · Clusters · Corrélations · Position Sizing — Portefeuille IBKR'
: 'Conflits directionnels · Concentration · Recommandations IA — Trades loggés'}
</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',
})}>
Portefeuille Réel
</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',
})}>
Simulé (loggés)
{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>
{isLoading ? (
{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>