feat: simulation portfolio surveillance + patterns grid/filter UI

Portfolio Monitor (v4.4):
- New portfolio_risk.py service: concentration by asset_class, directional
  conflict detection (same underlying, opposite directions), overweight alerts
- AI agent (Step 7b) runs GPT-4o-mini after each cycle log: assessment +
  prioritized actions + rebalance suggestion, persisted in system_logs
- GET /api/journal/portfolio-risk — full risk breakdown + latest AI monitor reco
- POST /api/journal/trade-check — pre-entry conflict & concentration check
- asset_class column added to trade_entry_prices (auto-migration + populated at INSERT)
- Journal: new "Risque Sim." tab with concentration bars, conflict alerts,
  AI recommendations; red badge on tab when danger alerts exist

PatternEditor:
- Grid view default (2-3 cols responsive), list toggle
- Asset class filter chips (energy/metals/agri/equities/indices/forex/rates)
- Sort: Date (default) / Score IA / Prob.
- Period filter: Tout / 7j / 30j
- Result count badge when filters active

Doc: v4.3 → v4.4, updated Journal/PatternEditor/cycle steps/schema/glossary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-19 15:49:46 +02:00
parent d34b4043fb
commit 58c3767a9d
8 changed files with 749 additions and 38 deletions

View File

@@ -341,6 +341,7 @@ export const CYCLE_REFRESH_KEYS = [
['journal-macro'],
['geo-risk-score'],
['pattern-matches'],
['journal-portfolio-risk'],
]
export const useTriggerCycle = () => {
@@ -458,6 +459,19 @@ export const useSaveExitDefaults = () => {
})
}
export const useSimPortfolioRisk = () =>
useQuery({
queryKey: ['journal-portfolio-risk'],
queryFn: () => api.get('/journal/portfolio-risk').then(r => r.data),
staleTime: 30_000,
})
export const useTradeCheck = () =>
useMutation({
mutationFn: (body: { underlying: string; strategy: string; asset_class: string }) =>
api.post('/journal/trade-check', body).then(r => r.data),
})
// ── Risk Profiles ─────────────────────────────────────────────────────────────
export const useRiskProfiles = () =>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, Fragment } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock } from 'lucide-react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock, ShieldAlert, PieChart } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
@@ -1234,21 +1234,228 @@ function CyclesSection() {
// ── Page principale ────────────────────────────────────────────────────────────
const ASSET_CLASS_COLORS: Record<string, string> = {
energy: '#f97316', metals: '#eab308', agriculture: '#84cc16',
equities: '#22c55e', indices: '#3b82f6', forex: '#8b5cf6', rates: '#06b6d4',
unknown: '#64748b',
}
function SimPortfolioRiskPanel() {
const { data, isLoading, refetch } = useSimPortfolioRisk()
const risk = data as any
if (isLoading) {
return (
<div className="card animate-pulse">
<div className="h-5 bg-dark-700 rounded w-48 mb-4" />
<div className="space-y-2">{[1, 2, 3].map(i => <div key={i} className="h-8 bg-dark-700 rounded" />)}</div>
</div>
)
}
if (!risk || risk.open_count === 0) {
return (
<div className="card text-center py-8 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</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-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PieChart className="w-4 h-4 text-blue-400" />
<span className="text-sm font-semibold text-white">Portefeuille Simulé — Analyse de Risque</span>
<span className="text-xs text-slate-500">{risk.open_count} positions ouvertes</span>
{dangers.length > 0 && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-red-900/30 border border-red-600/40 text-red-400 rounded px-1.5 py-0.5">
<ShieldAlert className="w-3 h-3" /> {dangers.length} CONFLIT{dangers.length > 1 ? 'S' : ''}
</span>
)}
</div>
<button onClick={() => refetch()} className="text-slate-600 hover:text-slate-400 transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{/* Asset class concentration bars */}
<div className="card">
<div className="text-xs text-slate-500 mb-3 font-semibold uppercase tracking-wide">Répartition par classe d'actif</div>
<div className="space-y-2">
{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 exposure = risk.direction_exposure?.[ac] ?? {}
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">{data.tickers?.slice(0, 3).join(', ')}{data.tickers?.length > 3 ? '…' : ''}</span>
</div>
<div className="flex items-center gap-2 text-[10px]">
{exposure.bullish > 0 && (
<span className="text-emerald-400 flex items-center gap-0.5">
<TrendingUp className="w-2.5 h-2.5" /> {exposure.bullish}
</span>
)}
{exposure.bearish > 0 && (
<span className="text-red-400 flex items-center gap-0.5">
<TrendingDown className="w-2.5 h-2.5" /> {exposure.bearish}
</span>
)}
<span className="text-slate-400 font-mono font-bold">{data.pct}%</span>
<span className="text-slate-600">({data.count})</span>
</div>
</div>
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{ width: `${data.pct}%`, background: `${color}cc` }}
/>
</div>
</div>
)
})}
</div>
</div>
{/* Alerts */}
{alerts.length > 0 && (
<div className="space-y-2">
{/* Conflicts */}
{conflicts.map((c: any, i: number) => (
<div key={i} className="card border-red-700/40 bg-red-900/10">
<div className="flex items-start gap-2">
<ShieldAlert className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-red-300 mb-1">
Conflit directionnel {c.underlying}
</div>
<div className="space-y-0.5">
{c.trades.map((t: any) => (
<div key={t.id} className="flex items-center gap-2 text-[11px]">
<span className={clsx('font-bold font-mono',
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>
{t.pattern_name && (
<span className="text-slate-600 truncate max-w-[180px]">{t.pattern_name}</span>
)}
</div>
))}
</div>
</div>
</div>
</div>
))}
{/* Concentration & overweight warnings */}
{warnings.length > 0 && (
<div className="card border-amber-700/30 bg-amber-900/10">
<div className="text-xs font-semibold text-amber-300 mb-1.5 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>
)}
{/* AI Monitor 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
</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 font-bold">
{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 mt-1">
{aiMonitor.rebalance_suggestion}
</div>
)}
</div>
)}
{alerts.length === 0 && (
<div className="card border-emerald-700/30 bg-emerald-900/10 text-center py-3">
<CheckCircle className="w-5 h-5 text-emerald-400 mx-auto mb-1" />
<div className="text-xs text-emerald-300">Portefeuille équilibré aucun conflit ni sur-concentration détectés</div>
</div>
)}
</div>
)
}
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' | 'mtm' | 'closed' | 'geo'>('cycles')
const [tab, setTab] = useState<'cycles' | 'macro' | 'risk' | 'mtm' | 'closed' | 'geo'>('cycles')
const [days, setDays] = useState(15)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
const [resetMsg, setResetMsg] = useState('')
const { data: summary, refetch: refetchSummary } = useJournalSummary()
const { data: riskData } = useSimPortfolioRisk()
const riskAlertCount = (riskData as any)?.alerts?.filter((a: any) => a.level === 'danger').length ?? 0
const qc = useQueryClient()
const s = summary as any
@@ -1371,13 +1578,18 @@ export default function JournalDeBord() {
{/* Tabs */}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{TABS.map(({ key, label, icon: Icon }) => (
<button key={key} onClick={() => setTab(key)}
<button key={key} onClick={() => setTab(key as any)}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
<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>
@@ -1385,6 +1597,7 @@ 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, useMemo } from 'react'
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores, usePatternReliability } from '../hooks/useApi'
import clsx from 'clsx'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert } from 'lucide-react'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert, LayoutGrid, List, SlidersHorizontal } from 'lucide-react'
function jaccard(a: string[], b: string[]): number {
if (!a.length && !b.length) return 0
@@ -15,6 +15,10 @@ function jaccard(a: string[], b: string[]): number {
const TRIGGERS = ['military', 'sanctions', 'elections', 'natural_disaster', 'health_crisis', 'resource_scarcity', 'trade_war', 'energy', 'political_speech', 'financial_crisis']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex', 'rates']
const ASSET_CLASS_LABELS: Record<string, string> = {
energy: '⚡ Énergie', metals: '🥇 Métaux', agriculture: '🌾 Agri',
equities: '📈 Actions', indices: '📊 Indices', forex: '💱 Forex', rates: '📉 Taux',
}
const TRIGGER_LABELS: Record<string, string> = {
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
@@ -635,6 +639,10 @@ export default function PatternEditor() {
const [creating, setCreating] = useState(false)
const [showAiSuggest, setShowAiSuggest] = useState(false)
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [sortBy, setSortBy] = useState<'date' | 'score' | 'prob'>('date')
const [filterAsset, setFilterAsset] = useState<string>('all')
const [filterDays, setFilterDays] = useState<number | null>(null)
// Build AI score map: patternId → best effective score (same logic as cockpit panel)
const aiScoreMap = useMemo(() => {
@@ -672,18 +680,40 @@ export default function PatternEditor() {
return map
}, [simData])
const displayPatterns = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
const displayPatterns = useMemo(() => {
let result = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
if (filterAsset !== 'all') {
result = result.filter((p: any) => p.asset_class === filterAsset)
}
if (filterDays !== null) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - filterDays)
result = result.filter((p: any) => p.created_at && new Date(p.created_at) >= cutoff)
}
switch (sortBy) {
case 'score':
return [...result].sort((a, b) => (aiScoreMap[b.id] ?? 0) - (aiScoreMap[a.id] ?? 0))
case 'prob':
return [...result].sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
default:
return [...result].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
}
}, [patterns, tab, filterAsset, filterDays, sortBy, aiScoreMap])
const handleSave = (form: any) => {
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
}
const gridClass = viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'
: 'space-y-3'
return (
<div className="p-6 space-y-5">
<div className="p-6 space-y-4">
{showAiSuggest && (
<AiSuggestModal
onClose={() => setShowAiSuggest(false)}
@@ -698,7 +728,7 @@ export default function PatternEditor() {
<Zap className="w-5 h-5 text-blue-400" /> Patterns Géopolitiques
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{patterns?.length ?? 0} patterns actifs ·{' '}
{patterns?.length ?? 0} patterns ·{' '}
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} personnalisés
</p>
</div>
@@ -729,26 +759,105 @@ export default function PatternEditor() {
</div>
)}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
{/* Source tabs + controls */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-1 bg-dark-700 p-1 rounded">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Sort */}
<div className="flex gap-1 bg-dark-700 p-1 rounded items-center">
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-500 ml-1 mr-0.5" />
{([
{ key: 'date', label: 'Date' },
{ key: 'score', label: 'Score IA' },
{ key: 'prob', label: 'Prob.' },
] as const).map(({ key, label }) => (
<button key={key} onClick={() => setSortBy(key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': sortBy === key,
'text-slate-400 hover:text-slate-200': sortBy !== key,
})}>
{label}
</button>
))}
</div>
{/* Period filter */}
<div className="flex gap-1 bg-dark-700 p-1 rounded">
{([
{ key: null, label: 'Tout' },
{ key: 7, label: '7j' },
{ key: 30, label: '30j' },
] as const).map(({ key, label }) => (
<button key={String(key)} onClick={() => setFilterDays(key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': filterDays === key,
'text-slate-400 hover:text-slate-200': filterDays !== key,
})}>
{label}
</button>
))}
</div>
{/* View mode */}
<div className="flex gap-1 bg-dark-700 p-1 rounded">
<button onClick={() => setViewMode('grid')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
<button onClick={() => setViewMode('list')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<List className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{/* Asset class filter chips */}
<div className="flex flex-wrap gap-1.5 items-center">
<button onClick={() => setFilterAsset('all')}
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
'bg-slate-600/40 border-slate-500/60 text-white': filterAsset === 'all',
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== 'all',
})}>
Tous types
</button>
{ASSET_CLASSES.map(ac => (
<button key={ac} onClick={() => setFilterAsset(filterAsset === ac ? 'all' : ac)}
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
'bg-blue-600/30 border-blue-500/60 text-blue-300': filterAsset === ac,
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== ac,
})}>
{label}
{ASSET_CLASS_LABELS[ac]}
</button>
))}
{(filterAsset !== 'all' || filterDays !== null) && (
<span className="text-xs text-slate-600 ml-1">
{displayPatterns.length} résultat{displayPatterns.length !== 1 ? 's' : ''}
</span>
)}
</div>
{isLoading ? (
<div className="space-y-3">{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}</div>
<div className={gridClass}>
{[1,2,3,4,5,6].map(i => <div key={i} className="card animate-pulse h-24 bg-dark-700" />)}
</div>
) : (
<div className="space-y-3">
<div className={gridClass}>
{displayPatterns.map((p: any) => (
<PatternCard key={p.id}
p={p}
@@ -761,9 +870,15 @@ export default function PatternEditor() {
/>
))}
{displayPatterns.length === 0 && (
<div className="card text-center py-10 text-slate-500">
<div className="card text-center py-10 text-slate-500 col-span-full">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Aucun pattern personnalisé</div>
<div>
{filterAsset !== 'all'
? `Aucun pattern dans "${ASSET_CLASS_LABELS[filterAsset]}"`
: filterDays !== null
? `Aucun pattern dans les ${filterDays} derniers jours`
: 'Aucun pattern personnalisé'}
</div>
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
</div>
)}