import { useState, useEffect, useCallback, useRef } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' import { Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink, ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X, SlidersHorizontal, BarChart2, Star, Users } from 'lucide-react' import clsx from 'clsx' // ── Types ───────────────────────────────────────────────────────────────────── interface SourceRef { title: string source: string url: string date: string original_score: number } interface InstrumentImpact { id: number instrument_id: string impact_score: number direction: 'bullish' | 'bearish' | 'neutral' | 'depends_on_outcome' rationale: string confidence: number ai_generated: number manually_adjusted: number adjusted_score: number | null adjusted_direction: string | null override_rationale: string } interface MarketEvent { id: number name: string start_date: string end_date: string | null level: 'long' | 'medium' | 'short' category: string sub_type: string description: string market_impact: string affected_assets: string[] impact_score: number absorption_pct: number | null source_refs: SourceRef[] origin?: string created_at?: string impacts?: InstrumentImpact[] evaluated?: boolean } interface CausalAnalysis { id: number market_event_id: number template_id: number template_name: string instrument: string activation_score: number | null analyzed_at: string inputs_json: Record prediction_json: Record actual_json: Record drift_json?: Record graph_json?: { nodes: GraphNode[]; edges: GraphEdge[] } } interface TemplateRef { id: number name: string category: string instruments: string[] } interface GraphNode { id: string; label: string; type: string; x: number; y: number; unit?: string; instrument?: string } interface GraphEdge { from: string; to: string; style?: string; sign?: string } interface GraphData { nodes: GraphNode[]; edges: GraphEdge[] } // ── Constants ───────────────────────────────────────────────────────────────── const LEVEL_COLORS: Record = { long: 'text-purple-400', medium: 'text-amber-400', short: 'text-emerald-400', } const ORIGIN_META: Record = { bootstrap_macro: { label: 'Bootstrap macro', icon: '📚', color: 'text-slate-400', description: 'Données historiques macro/géopolitiques chargées au démarrage' }, bootstrap_eco: { label: 'Bootstrap éco', icon: '📅', color: 'text-amber-400', description: 'Calendrier économique FRED historique (FOMC, CPI, NFP…)' }, bootstrap_ma: { label: 'Bootstrap MA', icon: '📈', color: 'text-cyan-400', description: 'Signal technique MA détecté sur données historiques (yfinance)' }, bootstrap_legacy: { label: 'Bootstrap legacy', icon: '🗄️', color: 'text-slate-500', description: 'Données initiales (bootstrap, origine exacte inconnue)' }, detector_news: { label: 'News RSS', icon: '📰', color: 'text-red-400', description: 'Généré depuis un flux RSS — validé par IA (GPT-4o-mini)' }, detector_eco: { label: 'Surprise FRED', icon: '📊', color: 'text-amber-400', description: 'Surprise économique FRED détectée par le cycle (z-score > seuil)' }, detector_technical: { label: 'Signal technique', icon: '📐', color: 'text-cyan-400', description: 'Croisement MA50/MA100/MA200 détecté automatiquement (yfinance)' }, detector_report: { label: 'Rapport instit.', icon: '📋', color: 'text-blue-400', description: 'Rapport institutionnel haute importance (COT, EIA, Fed…)' }, manual: { label: 'Manuel', icon: '✏️', color: 'text-emerald-400', description: 'Créé manuellement dans l\'interface' }, unknown: { label: 'Inconnu', icon: '❓', color: 'text-slate-600', description: 'Origine non tracée (données antérieures au versioning)' }, } const DIR_COLORS: Record = { bullish: 'text-emerald-400', bearish: 'text-red-400', neutral: 'text-slate-400', depends_on_outcome: 'text-amber-400', } const DIR_ICONS: Record = { bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆', } const LEVELS = ['long', 'medium', 'short'] const ALL_INSTRUMENTS = [ 'SPY','QQQ','IWM','EEM','EFA','GLD','SLV','USO','UNG','TLT', 'HYG','EURUSD=X','USDJPY=X','GBPUSD=X','VXX','AAPL','NVDA','GS','XOM','BTC-USD', ] // ── Helpers ─────────────────────────────────────────────────────────────────── function ScoreBar({ score, max = 1 }: { score: number; max?: number }) { const pct = Math.round((score / max) * 100) const color = score >= 0.7 ? 'bg-red-500' : score >= 0.5 ? 'bg-amber-500' : 'bg-emerald-500' return (
{score.toFixed(2)}
) } // ── Instrument impact row (inline editable) ─────────────────────────────────── function ImpactRow({ imp, eventId, onUpdated, onDeleted, }: { imp: InstrumentImpact eventId: number onUpdated: () => void onDeleted: () => void }) { const [editing, setEditing] = useState(false) const [score, setScore] = useState(String(imp.adjusted_score ?? imp.impact_score)) const [dir, setDir] = useState(imp.adjusted_direction || imp.direction) const [note, setNote] = useState(imp.override_rationale || '') const [saving, setSaving] = useState(false) const effectiveScore = imp.adjusted_score ?? imp.impact_score const effectiveDir = imp.adjusted_direction || imp.direction const save = async () => { setSaving(true) await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adjusted_score: +score, adjusted_direction: dir, override_rationale: note }), }) setSaving(false) setEditing(false) onUpdated() } const del = async () => { if (!confirm(`Supprimer l'impact sur ${imp.instrument_id} ?`)) return await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { method: 'DELETE' }) onDeleted() } return (
{/* Ticker */} {imp.instrument_id} {/* Direction */} {DIR_ICONS[effectiveDir] ?? '●'} {/* Score bar */}
{/* Confidence */} {Math.round(imp.confidence * 100)}% {/* Rationale */} {imp.rationale} {/* AI badge */} {imp.ai_generated ? ( AI ) : ( manual )} {imp.manually_adjusted ? adj. : null} {/* Actions */}
{editing && (
)}
) } // ── Add instrument panel ────────────────────────────────────────────────────── function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: () => void }) { const [open, setOpen] = useState(false) const [ticker, setTicker] = useState('') const [score, setScore] = useState(0.5) const [dir, setDir] = useState('neutral') const [rationale, setRat] = useState('') const [saving, setSaving] = useState(false) const submit = async () => { if (!ticker) return setSaving(true) await fetch(`/api/market-events/${eventId}/impacts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument_id: ticker, impact_score: score, direction: dir, rationale, confidence: 0.8 }), }) setSaving(false) setOpen(false) setTicker('') setRat('') onAdded() } if (!open) { return ( ) } return (
setScore(+e.target.value)} className="accent-emerald-400" /> {score.toFixed(2)}
setRat(e.target.value)} placeholder="Rationale..." className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
) } // ── CausalGraphMini — SVG inline du graphe instancié ───────────────────────── function CausalGraphMini({ nodes, edges, nodeValues }: { nodes: GraphNode[] edges: GraphEdge[] nodeValues: Record }) { if (!nodes.length) return null const xs = nodes.map(n => n.x) const ys = nodes.map(n => n.y) const minX = Math.min(...xs) - 70 const maxX = Math.max(...xs) + 70 const minY = Math.min(...ys) - 30 const maxY = Math.max(...ys) + 30 const vw = Math.max(maxX - minX, 200) const vh = Math.max(maxY - minY, 100) const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])) const BOX_W = 90, BOX_H = 36, SHORTEN = 21 return ( {(['positive', 'negative', 'neutral'] as const).map(s => ( ))} {edges.map((e, i) => { const from = nodeMap[e.from], to = nodeMap[e.to] if (!from || !to) return null const dx = to.x - from.x, dy = to.y - from.y const len = Math.sqrt(dx * dx + dy * dy) || 1 const sign = e.sign || 'neutral' const color = sign === 'positive' ? '#14b8a6' : sign === 'negative' ? '#f97316' : '#64748b' return ( ) })} {nodes.map(n => { const val = nodeValues[n.id] const isOut = n.type === 'market_asset' || n.type === 'output' const isIn = n.type === 'input' const fill = isOut ? (val > 0 ? '#052e16' : val < 0 ? '#450a0a' : '#1e293b') : '#0f172a' const stroke = isIn ? '#3b82f6' : isOut ? (val > 0 ? '#16a34a' : val < 0 ? '#dc2626' : '#64748b') : '#475569' const valColor = val > 0 ? '#4ade80' : val < 0 ? '#f87171' : '#94a3b8' const lbl = n.label.length > 14 ? n.label.slice(0, 13) + '…' : n.label const valStr = val !== undefined ? `${val > 0 ? '+' : ''}${val.toFixed(n.unit === 'pips' ? 0 : 1)}${n.unit === 'pips' ? 'p' : n.unit === '%' ? '%' : ''}` : '—' return ( {lbl} {valStr} ) })} ) } // ── Event detail panel (right side) ────────────────────────────────────────── function EventDetail({ event, onClose, onUpdated, onDeleted, }: { event: MarketEvent onClose: () => void onUpdated: (ev: MarketEvent) => void onDeleted: (id: number) => void }) { const navigate = useNavigate() const [detail, setDetail] = useState(event) const [editing, setEditing] = useState(false) const [editData, setEditData] = useState>({}) const [saving, setSaving] = useState(false) const [analyses, setAnalyses] = useState([]) // Inline analysis const [templates, setTemplates] = useState([]) const [selTmpl, setSelTmpl] = useState(0) const [loadingRec, setLoadingRec] = useState(false) const [loadingInst, setLoadingInst] = useState(false) const [loadingAn, setLoadingAn] = useState(false) const [loadingCreate, setLoadingCreate] = useState(false) const [recMsg, setRecMsg] = useState(null) const [noTemplateFound, setNoTemplateFound] = useState(false) const [instInputs, setInstInputs] = useState>({}) const [instMsg, setInstMsg] = useState(null) const [anResult, setAnResult] = useState<{ score: number | null preds: Record actuals: Record drift?: Record pricesMode?: string lagMin?: number lagDays?: number lagDebug?: { output_ids: string[]; edges_to_output: number; edges_with_lag_days: number; used_all_edges: boolean } } | null>(null) const [graphData, setGraphData] = useState(null) const loadDetail = useCallback(async () => { const r = await fetch(`/api/market-events/${event.id}`) if (r.ok) { const d = await r.json() setDetail(d) } }, [event.id]) const loadAnalyses = useCallback(async () => { const r = await fetch(`/api/causal-lab/analyses?market_event_id=${event.id}&limit=20`) if (r.ok) { const data: CausalAnalysis[] = await r.json() setAnalyses(data) if (data.length > 0) { const latest = data[0] if (latest.template_id) setSelTmpl(latest.template_id) if (Object.keys(latest.inputs_json || {}).length > 0) setInstInputs(latest.inputs_json) const driftRaw = latest.drift_json || {} // drift_json peut être { EURUSD: {...}, XAUUSD: {...} } (nouveau) ou { post_pips: ..., ... } (ancien) const isDriftByInst = driftRaw && !('post_pips' in driftRaw) && Object.keys(driftRaw).length > 0 const driftByInst = isDriftByInst ? driftRaw : latest.instrument && Object.keys(driftRaw).length > 0 ? { [latest.instrument]: driftRaw } : undefined setAnResult({ score: latest.activation_score, preds: latest.prediction_json || {}, actuals: latest.actual_json || {}, drift: driftByInst, pricesMode: undefined, lagMin: undefined, lagDays: undefined, lagDebug: undefined, }) if (latest.graph_json?.nodes?.length) { setGraphData(latest.graph_json as GraphData) } } } }, [event.id]) useEffect(() => { loadDetail() loadAnalyses() fetch('/api/causal-lab/templates').then(r => r.json()).then(d => setTemplates(Array.isArray(d) ? d : [])).catch(() => {}) }, [loadDetail, loadAnalyses]) const saveEdit = async () => { setSaving(true) const payload = { ...detail, ...editData, source_refs: detail.source_refs ?? [] } const r = await fetch(`/api/market-events/${event.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) setSaving(false) if (r.ok) { setEditing(false) await loadDetail() onUpdated({ ...detail, ...editData } as MarketEvent) } } const del = async () => { if (!confirm(`Supprimer "${detail.name}" ?`)) return await fetch(`/api/market-events/${event.id}`, { method: 'DELETE' }) onDeleted(event.id) onClose() } const instantiate = async (tmplId: number) => { if (!tmplId) return setLoadingInst(true); setInstMsg(null); setInstInputs({}) try { const r = await fetch('/api/causal-lab/instantiate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ market_event_id: detail.id, template_id: tmplId }), }) const d = await r.json() if (!r.ok) throw new Error(d.detail || r.statusText) setInstInputs(d.inputs || {}) setInstMsg(d.rationale || null) } catch (e: any) { setInstMsg(`✗ ${(e as any).message}`) } finally { setLoadingInst(false) } } const recommendTemplate = async () => { setLoadingRec(true); setRecMsg(null); setAnResult(null); setGraphData(null) setInstInputs({}); setInstMsg(null); setNoTemplateFound(false) try { const r = await fetch('/api/causal-lab/recommend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ market_event_id: detail.id }), }) const d = await r.json() if (!r.ok) throw new Error(d.detail || r.statusText) const rec = d.recommendation if (rec?.template_id) { setSelTmpl(rec.template_id) setRecMsg(`✓ ${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`) await instantiate(rec.template_id) } else { setRecMsg('Aucun template adapté trouvé') setNoTemplateFound(true) } } catch (e: any) { setRecMsg(`✗ ${(e as any).message}`) } finally { setLoadingRec(false) } } const createTemplateFromEvent = async () => { setLoadingCreate(true); setRecMsg(null); setNoTemplateFound(false) try { const r = await fetch('/api/causal-lab/create-from-event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ market_event_id: detail.id }), }) const d = await r.json() if (!r.ok) throw new Error(d.detail || r.statusText) const tmplRes = await fetch('/api/causal-lab/templates') const tmplData = await tmplRes.json() setTemplates(Array.isArray(tmplData) ? tmplData : []) if (d.id) { setSelTmpl(d.id) setRecMsg(`✓ Template créé : ${d.name}`) await instantiate(d.id) } } catch (e: any) { setRecMsg(`✗ ${(e as any).message}`) } finally { setLoadingCreate(false) } } const deleteAnalysis = async (analysisId: number) => { if (!confirm('Supprimer cette instanciation ?')) return await fetch(`/api/causal-lab/analyses/${analysisId}`, { method: 'DELETE' }) setAnResult(null); setGraphData(null); setInstInputs({}) await loadAnalyses() } const runAnalysis = async () => { if (!selTmpl) return setLoadingAn(true); setAnResult(null); setGraphData(null) try { const r = await fetch('/api/causal-lab/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, inputs: instInputs, coef_overrides: {} }), }) const d = await r.json() if (!r.ok) throw new Error(d.detail || r.statusText) const freshResult = { score: d.activation?.score ?? null, preds: d.node_values ?? {}, actuals: d.actual_moves ?? {}, drift: d.drift_by_inst ?? (d.drift ? { [d.instrument]: d.drift } : undefined), pricesMode: d.prices_mode, lagMin: d.effective_lag_min, lagDays: d.effective_lag_days, lagDebug: d.lag_debug, } setAnResult(freshResult) if (d.graph_json?.nodes?.length) setGraphData(d.graph_json) await loadAnalyses() setAnResult(freshResult) // loadAnalyses() écrase anResult — on restaure le résultat frais } catch (e: any) { setAnResult({ score: null, preds: {}, actuals: {} }) } finally { setLoadingAn(false) } } const evalScore = detail.impact_score return (
{/* Header */}
{editing ? ( setEditData(d => ({ ...d, name: e.target.value }))} className="bg-dark-900 border border-amber-500/50 rounded px-3 py-1.5 text-slate-100 font-bold text-sm w-full" /> ) : (

{detail.name}

)}
{detail.level} {detail.start_date} {detail.end_date && → {detail.end_date}}
{/* Scrollable body */}
{/* Impact score + absorption */}
Score impact
{detail.absorption_pct != null && (
Absorption marché
{detail.absorption_pct}%
)}
{/* Description */} {editing ? (