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

@@ -5,6 +5,55 @@ from services.database import get_conn
_BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"}
# Ticker → asset_class mapping for common instruments (fallback when pattern not in DB)
_TICKER_AC: Dict[str, str] = {
# Energy
"CL=F": "energy", "BZ=F": "energy", "NG=F": "energy", "RB=F": "energy",
"HO=F": "energy", "USO": "energy", "XLE": "energy", "XOP": "energy", "OIL": "energy",
# Metals
"GC=F": "metals", "SI=F": "metals", "HG=F": "metals", "PL=F": "metals",
"PA=F": "metals", "GLD": "metals", "SLV": "metals", "GDX": "metals", "GDXJ": "metals",
# Agriculture
"ZW=F": "agriculture", "ZC=F": "agriculture", "ZS=F": "agriculture",
"CT=F": "agriculture", "KC=F": "agriculture", "SB=F": "agriculture", "CC=F": "agriculture",
"WEAT": "agriculture", "CORN": "agriculture", "SOYB": "agriculture",
# Equity indices
"^GSPC": "indices", "^DJI": "indices", "^NDX": "indices", "^RUT": "indices",
"^VIX": "indices", "^FTSE": "indices", "^GDAXI": "indices", "^FCHI": "indices",
"^N225": "indices", "^HSI": "indices", "^NSEI": "indices", "^BSESN": "indices",
"^STOXX50E": "indices", "^IBEX": "indices",
"SPY": "indices", "QQQ": "indices", "IWM": "indices", "DIA": "indices",
"VXX": "indices", "UVXY": "indices", "SVXY": "indices",
# Forex
"EURUSD=X": "forex", "GBPUSD=X": "forex", "USDJPY=X": "forex",
"AUDUSD=X": "forex", "USDCAD=X": "forex", "USDCHF=X": "forex",
"NZDUSD=X": "forex", "EURGBP=X": "forex", "EURJPY=X": "forex",
"GBPJPY=X": "forex", "USDCNH=X": "forex",
"FXE": "forex", "UUP": "forex", "FXB": "forex", "FXY": "forex",
# Rates
"ZB=F": "rates", "ZN=F": "rates", "ZF=F": "rates", "ZT=F": "rates",
"TLT": "rates", "IEF": "rates", "SHY": "rates", "HYG": "rates",
"LQD": "rates", "EMB": "rates",
}
def _infer_asset_class(ticker: str) -> str:
"""Derive asset_class from ticker when not stored in DB."""
t = (ticker or "").upper().strip()
if t in _TICKER_AC:
return _TICKER_AC[t]
if ":" in t: # NSE:RELIANCE, BSE:TCS, etc.
return "equities"
if t.endswith("=X") and len(t) >= 7: # forex pairs like EURUSD=X
return "forex"
if t.endswith("=F"): # generic futures
return "energy" # most unknown futures are commodities
if t.startswith("^"): # index
return "indices"
if t.isalpha() and len(t) <= 5: # short alpha = equity
return "equities"
return "unknown"
def _direction(strategy: str) -> str:
s = (strategy or "").lower()
@@ -12,7 +61,7 @@ def _direction(strategy: str) -> str:
def get_open_simulation_trades() -> List[Dict[str, Any]]:
"""Open trades enriched with asset_class from joined pattern table."""
"""Open trades enriched with asset_class — via stored column, JOIN fallback, then ticker inference."""
conn = get_conn()
rows = conn.execute("""
SELECT tep.*,
@@ -23,7 +72,13 @@ def get_open_simulation_trades() -> List[Dict[str, Any]]:
ORDER BY tep.entry_date DESC
""").fetchall()
conn.close()
return [dict(r) for r in rows]
result = []
for r in rows:
d = dict(r)
if not d.get("asset_class"):
d["asset_class"] = _infer_asset_class(d.get("underlying", ""))
result.append(d)
return result
def analyze_simulation_portfolio() -> Dict[str, Any]:

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>