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:
@@ -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]:
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user