feat: Phase 3 — Portfolio Risk Engine (Exposition, Clusters, Kelly, Risk Dashboard)

Sprint 3.1 — Vue Portefeuille Consolidée
- database.py: get_portfolio_exposure() — exposition par classe d'actif + facteur de risque
- database.py: get_pnl_timeline() — courbe P&L cumulé pour equity curve
- Alertes concentration automatiques (>40% par classe, >50% par facteur)
- _RISK_FACTOR_MAP: classification géopolitique/inflation/récession/liquidité/dollar

Sprint 3.2 — Risk Cluster Engine
- database.py: get_risk_clusters() — saturation par facteur + risk_prompt_context
- database.py: get_pattern_correlations() — matrice Pearson sur trades matures
- auto_cycle.py: injection du contexte risque dans le prompt de scoring (Step 3.5)
- ai_analyzer.py: paramètre risk_context dans score_patterns_with_context()
- Pénalisation automatique des patterns sur facteurs saturés dans le scoring GPT

Sprint 3.3 — Position Sizing Kelly Fractionnel
- database.py: compute_kelly_sizing() — f* = (p×G - (1-p))/G, Kelly ×33% par défaut
- Ajustement cluster: sizing ÷2 si facteur saturé
- Ajustement fiabilité: sizing ÷2 si win_rate historique <40% (≥5 trades)
- JournalDeBord.tsx: colonne "Kelly" avec KellyCell (% + €, ajustements signalés)
- routers/risk.py: GET /api/risk/kelly/{pattern_id}

Sprint 3.4 — Tableau de Bord Risque Global
- database.py: get_risk_dashboard() — HHI, score diversification, drawdown attendu, recommandation
- database.py: _build_risk_recommendation() — alerte Risk Committee automatique
- RiskDashboard.tsx: nouvelle page — jauges concentration, courbe P&L, corrélations, recommandation
- Dashboard.tsx: banner d'alerte concentration sur le Cockpit avec lien vers /risk
- routers/risk.py: GET /api/risk/exposure|timeline|clusters|correlations|dashboard
- App.tsx + Sidebar.tsx: route /risk + entrée menu Risk Dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-17 17:18:36 +02:00
parent f09c5b8ee7
commit e44c8799b9
11 changed files with 962 additions and 7 deletions

View File

@@ -14,6 +14,7 @@ import RapportIA from './pages/RapportIA'
import SuperContexte from './pages/SuperContexte'
import Config from './pages/Config'
import Analytics from './pages/Analytics'
import RiskDashboard from './pages/RiskDashboard'
import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() {
@@ -43,6 +44,7 @@ export default function App() {
<Route path="/super-contexte" element={<SuperContexte />} />
<Route path="/config" element={<Config />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/risk" element={<RiskDashboard />} />
</Routes>
</main>
</div>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -18,6 +18,7 @@ const nav = [
{ to: '/rapport', icon: FileBarChart, label: 'Rapport IA' },
{ to: '/super-contexte', icon: Brain, label: 'Super Contexte' },
{ to: '/analytics', icon: FlaskConical, label: 'Analytics' },
{ to: '/risk', icon: ShieldAlert, label: 'Risk Dashboard' },
{ to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendrier' },
{ to: '/config', icon: Settings, label: 'Configuration' },

View File

@@ -629,3 +629,49 @@ export const useCalibration = (days = 365) =>
queryFn: () => api.get('/analytics/calibration', { params: { days } }).then(r => r.data),
staleTime: 10 * 60_000,
})
// ── Risk Engine (Phase 3) ─────────────────────────────────────────────────────
export const useRiskExposure = () =>
useQuery({
queryKey: ['risk-exposure'],
queryFn: () => api.get('/risk/exposure').then(r => r.data),
staleTime: 2 * 60_000,
})
export const usePnlTimeline = (days = 90) =>
useQuery({
queryKey: ['pnl-timeline', days],
queryFn: () => api.get('/risk/timeline', { params: { days } }).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useRiskClusters = () =>
useQuery({
queryKey: ['risk-clusters'],
queryFn: () => api.get('/risk/clusters').then(r => r.data),
staleTime: 2 * 60_000,
})
export const usePatternCorrelations = () =>
useQuery({
queryKey: ['pattern-correlations'],
queryFn: () => api.get('/risk/correlations').then(r => r.data),
staleTime: 10 * 60_000,
})
export const useKellySizing = (patternId: string, capital = 10000) =>
useQuery({
queryKey: ['kelly', patternId, capital],
queryFn: () => api.get(`/risk/kelly/${encodeURIComponent(patternId)}`, { params: { capital } }).then(r => r.data),
enabled: !!patternId,
staleTime: 5 * 60_000,
})
export const useRiskDashboard = () =>
useQuery({
queryKey: ['risk-dashboard'],
queryFn: () => api.get('/risk/dashboard').then(r => r.data),
staleTime: 2 * 60_000,
refetchInterval: 5 * 60_000,
})

View File

@@ -3,9 +3,9 @@ import {
useGeoRiskScore, useAllQuotes,
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
usePortfolioPositions, useTradeMtm, useRiskProfiles,
usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard,
} from '../hooks/useApi'
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2 } from 'lucide-react'
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert } from 'lucide-react'
import clsx from 'clsx'
import type { Quote } from '../types'
import { format } from 'date-fns'
@@ -416,6 +416,7 @@ export default function Dashboard() {
const { data: positions, refetch: refetchPositions } = usePortfolioPositions('open')
const { data: tradeMtmData } = useTradeMtm(30)
const { data: riskProfilesData } = useRiskProfiles()
const { data: riskDashboard } = useRiskDashboard()
const { mutate: scorePatterns, isPending: scoring } = useScorePatterns()
const { mutate: addPos } = useAddPosition()
@@ -602,6 +603,22 @@ export default function Dashboard() {
</div>
</div>
{/* Risk concentration banner */}
{(riskDashboard as any)?.concentration_alerts?.length > 0 && (
<div className="space-y-1.5">
{((riskDashboard as any).concentration_alerts as any[]).slice(0, 2).map((alert: any, i: number) => (
<div key={i} className={clsx('flex items-center 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',
})}>
<ShieldAlert className="w-3 h-3 shrink-0" />
{alert.message}
<a href="/risk" className="ml-auto text-slate-500 hover:text-slate-300 underline">Risk Dashboard </a>
</div>
))}
</div>
)}
{/* Top row */}
<div className="grid grid-cols-4 gap-4">
{/* Geo Risk */}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, api } from '../hooks/useApi'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
@@ -82,6 +82,23 @@ function IvRankCell({ underlying }: { underlying: string }) {
)
}
function KellyCell({ patternId, capital = 10000 }: { patternId: string; capital?: number }) {
const { data, isLoading } = useKellySizing(patternId, capital)
if (!patternId || isLoading) return <span className="text-slate-700 text-[10px]"></span>
if (!data || data.error) return <span className="text-slate-700 text-[10px]"></span>
const pct = data.suggested_capital_pct
const eur = data.suggested_capital_eur
const color = pct <= 0 ? 'text-slate-600' : pct < 5 ? 'text-amber-400' : 'text-emerald-400'
const adjusted = data.cluster_adjustment_reason || data.reliability_adjustment
return (
<div className="flex flex-col items-end gap-0.5">
<span className={clsx('text-[10px] font-bold font-mono', color)}>{pct?.toFixed(1)}%</span>
<span className="text-[8px] text-slate-600">{eur != null ? `${eur}` : ''}</span>
{adjusted && <span className="text-[8px] text-orange-400" title={adjusted}> ajusté</span>}
</div>
)
}
function MacroHistorySection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useMacroHistory(days)
const history: any[] = (data as any)?.history ?? []
@@ -417,6 +434,7 @@ function TradeMtmSection({ days }: { days: number }) {
<th className="text-right px-3 py-2 font-medium">Prix actuel</th>
<th className="text-right px-3 py-2 font-medium">Maturité</th>
<th className="text-right px-3 py-2 font-medium">IV Rank</th>
<th className="text-right px-3 py-2 font-medium">Kelly</th>
<th className="text-right px-3 py-2 font-medium">P&L th.</th>
<th className="px-3 py-2 font-medium w-8"></th>
</tr>
@@ -508,6 +526,9 @@ function TradeMtmSection({ days }: { days: number }) {
<td className="px-3 py-2 text-right">
<IvRankCell underlying={t.underlying} />
</td>
<td className="px-3 py-2 text-right">
<KellyCell patternId={t.pattern_id} />
</td>
<td className="px-3 py-2 text-right">
<PnlBadge pnl={t.pnl_pct} />
</td>

View File

@@ -0,0 +1,297 @@
import { useState } from 'react'
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure } from '../hooks/useApi'
import clsx from 'clsx'
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity } 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"> Saturé (&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">
Pas encore de données de P&L (nécessite des trades matures)
</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" />}
Recommandation Risk Committee
</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 ────────────────────────────────────────────────────────────────
export default function RiskDashboard() {
const { data: dashboard, isLoading } = useRiskDashboard()
const { data: corrData } = usePatternCorrelations()
const [tlDays, setTlDays] = useState(90)
const { data: tlData } = usePnlTimeline(tlDays)
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">
Concentration · Clusters · Corrélations · Position Sizing
</p>
</div>
</div>
{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">Positions ouvertes</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">Score diversification</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">Drawdown attendu</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">Facteurs saturés</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" /> Exposition par classe d'actif
</div>
{Object.keys(d.exposure_by_class ?? {}).length === 0 ? (
<div className="text-xs text-slate-600 text-center py-4">Aucune position ouverte</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" /> Exposition par facteur de risque
</div>
{(d.risk_clusters ?? []).length === 0 ? (
<div className="text-xs text-slate-600 text-center py-4">Aucune position ouverte</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" /> Courbe P&L cumulé (trades matures)
</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}>30j</option>
<option value={90}>90j</option>
<option value={180}>180j</option>
<option value={365}>1 an</option>
</select>
</div>
<EquityCurve timeline={tlData?.timeline ?? []} />
</div>
{/* Correlation pairs */}
<div className="card">
<div className="text-sm font-semibold text-white mb-3">
Corrélations entre patterns (P&L historique)
</div>
{pairs.length === 0 ? (
<div className="text-xs text-slate-600 text-center py-4">
Nécessite 3 trades matures par pattern pour calculer les corrélations
</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} autres paires</div>
)}
</div>
)}
{pairs.length > 0 && (
<div className="text-[10px] text-slate-600 mt-2">
Corrélation &gt; 0.7 = risque concentré ces patterns partagent le même facteur sous-jacent
</div>
)}
</div>
</>
)}
</div>
)
}