import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown, Minus, BarChart2, Clock, Calendar, AlertCircle, } from 'lucide-react' import axios from 'axios' import clsx from 'clsx' import InstrumentChart, { TheoPoint } from '../components/InstrumentChart' const api = axios.create({ baseURL: '/api' }) // Stable empty array — prevents InstrumentChart useEffect from re-running on every render const NO_CHART_EVENTS: never[] = [] // ── Types ───────────────────────────────────────────────────────────────────── interface CausalTemplate { id: number; name: string; category: string; sub_type: string instruments: string[]; description: string graph_json: { nodes: { id: string; type: string; label: string; instrument?: string }[] edges: { from: string; to: string; lag_days?: number; lag_min?: number; sign?: string }[] } } interface InstrumentConfig { id: string; name: string; yf_ticker: string; category: string; currency: string description: string regime_labels: string[] chart: { ma_periods: number[]; show_volume: boolean } correlation_instruments: string[] } interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number } interface LinePoint { time: string; value: number } interface SnapshotEvent { id?: number; template_id?: number | null analyzed_instruments?: string | null // comma-sep: "EURUSD,SP500" — set when causal analysis exists date: string; end_date: string | null; title: string; level: string category: string; sub_type?: string; description: string; impact_score: number expected_value?: string | null; actual_value?: string | null surprise_pct?: number | null; unit?: string | null; absorption_pct?: number | null prediction_json?: string | null // JSON: {node_id: pips} from causal_event_analyses actual_json?: string | null // JSON: {instrument: pips} from causal_event_analyses activation_score?: number | null // stored directional accuracy from causal_event_analyses } interface RegimeSignals { ma50_above_ma200: boolean | null; ma50_slope_pct: number; ma200_slope_pct: number momentum_20d_pct: number; dist_ma200_pct: number; vol_ratio_pct: number } interface TrendMetrics { ma50_slope_5d: number; ma200_slope_20d: number; rsi14_current: number atr14_current: number; atr_vs_3m_avg_pct: number; momentum_1m_pct: number momentum_3m_pct: number; dist_ma50_pct: number | null; dist_ma200_pct: number | null current_price: number; high_52w: number; low_52w: number } interface MacroRegime { dominant: string label: string color: string emoji: string scores: Record ranked: string[] asset_bias: Record } interface GaugeValue { id: string; label: string; value: number | null; change_pct: number | null unit: string; bloc: string; note?: string } interface MacroGaugeSnap { snapshot_date: string dominant: string regime_scores: Record gauges: Record } interface Snapshot { instrument: InstrumentConfig price_data: PriceCandle[] indicators: Record regime: { current: string; confidence: number; scores: Record; signals: RegimeSignals } macro_regime: MacroRegime trend: TrendMetrics events: SnapshotEvent[] current_price: number; change_pct: number; change_abs: number; period: string } // ── Helpers ─────────────────────────────────────────────────────────────────── const CATEGORY_ORDER = ['equity_index', 'equity_intl', 'metal', 'energy', 'bond', 'credit', 'fx', 'volatility', 'stock', 'crypto'] const CATEGORY_LABELS: Record = { equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux', energy: 'Énergie', bond: 'Obligataire', credit: 'Crédit', fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto', } function regimeColor(label: string): string { const l = label.toLowerCase() if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald' if (/bear|crash|crunch|shock|squeeze|fear|risk.off|selloff|crisis|deficit/i.test(l)) return 'red' if (/volatil|spike|stress/i.test(l)) return 'orange' if (/range|neutral|consolid|compress/i.test(l)) return 'slate' return 'blue' } function pctColor(v: number | null, inverse = false): string { if (v === null || v === undefined) return 'text-slate-400' const pos = inverse ? v < 0 : v > 0 const neg = inverse ? v > 0 : v < 0 return pos ? 'text-emerald-400' : neg ? 'text-red-400' : 'text-slate-400' } function Arrow({ v }: { v: number }) { if (v > 0.3) return if (v < -0.3) return return } function fmt(v: number | null, digits = 2): string { if (v === null || v === undefined) return '—' return (v >= 0 ? '+' : '') + v.toFixed(digits) } function fmtDateFR(s: string | null): string { if (!s) return '—' const [y, m, d] = s.split('-') return `${d}/${m}/${y}` } function pctN(a: number | undefined, b: number | undefined): number { if (a === undefined || b === undefined || b === 0) return 0 return ((a - b) / b) * 100 } function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean { if (!selectedDate) return false if (ev.date > selectedDate) return false if (ev.end_date) return ev.end_date >= selectedDate const diffDays = (new Date(selectedDate).getTime() - new Date(ev.date).getTime()) / 86400000 return diffDays <= 30 } // ── Causal graph config ─────────────────────────────────────────────────────── // Maps snapshot event category → causal template category // market_events.category → causal_graph_templates.category // event_calendar & fundamental cover both US and EU; geopolitical/report/sentiment map 1-to-1 const EVENT_TO_CAUSAL_CAT: Record = { event_calendar: ['macro_us', 'macro_eu'], fundamental: ['macro_us', 'macro_eu'], geopolitical: ['geopolitical'], report: ['report'], sentiment: ['sentiment'], commodity: ['commodity'], technical: [], } // Maps instrument dashboard category → causal lab instrument keys const CAT_TO_CAUSAL_INST: Record = { equity_index: ['SP500'], equity_intl: ['SP500'], metal: ['XAUUSD'], energy: ['BRENT'], fx: ['EURUSD'], bond: ['US10Y', 'EU10Y'], credit: ['US10Y'], volatility: ['SP500'], stock: ['SP500'], crypto: [], } // Keyed by market_event.category — same palette as the ★ stars on the chart const EV_CAT_TW: Record = { event_calendar: 'text-amber-400 border-amber-700/40 bg-amber-900/20', geopolitical: 'text-red-400 border-red-700/40 bg-red-900/20', fundamental: 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20', report: 'text-blue-400 border-blue-700/40 bg-blue-900/20', sentiment: 'text-violet-400 border-violet-700/40 bg-violet-900/20', technical: 'text-cyan-400 border-cyan-700/40 bg-cyan-900/20', } // ── Macro regime colour mapping ─────────────────────────────────────────────── const MACRO_COLOR_MAP: Record = { goldilocks: 'emerald', desinflation: 'cyan', soft_landing: 'blue', reflation: 'amber', stagflation: 'orange', inflation_shock: 'red', recession: 'red', crise_liquidite: 'red', incertain: 'slate', } function macroRegimeColor(dominant: string): string { return MACRO_COLOR_MAP[dominant] ?? 'slate' } // ── RegimeCard ──────────────────────────────────────────────────────────────── function RegimeCard({ regime, macroRegime, signalsAt, dateLabel, }: { regime: Snapshot['regime'] macroRegime: MacroRegime | null signalsAt: RegimeSignals | null dateLabel: string }) { const signals = signalsAt ?? regime.signals const col = regimeColor(regime.current) const borderMap: Record = { emerald: 'border-emerald-700/40 bg-emerald-950/30', red: 'border-red-700/40 bg-red-950/30', orange: 'border-orange-700/40 bg-orange-950/30', slate: 'border-slate-700/40 bg-slate-900/30', blue: 'border-blue-700/40 bg-blue-950/30', cyan: 'border-cyan-700/40 bg-cyan-950/30', amber: 'border-amber-700/40 bg-amber-950/30', } const ma50Color = signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400' const volColor = (signals.vol_ratio_pct ?? 0) > 130 ? 'text-orange-400' : (signals.vol_ratio_pct ?? 0) < 70 ? 'text-cyan-400' : 'text-slate-300' const metrics: { label: string; value: string; sub?: string; color: string }[] = [ { label: 'MA50 / MA200', value: signals.ma50_above_ma200 === true ? 'Au-dessus' : signals.ma50_above_ma200 === false ? 'En-dessous' : '—', sub: signals.ma50_above_ma200 === true ? '↑ Golden cross' : signals.ma50_above_ma200 === false ? '↓ Death cross' : '', color: ma50Color, }, { label: 'Slope MA50 (10j)', value: fmt(signals.ma50_slope_pct) + '%', color: pctColor(signals.ma50_slope_pct) }, { label: 'Slope MA200 (10j)', value: fmt(signals.ma200_slope_pct) + '%', color: pctColor(signals.ma200_slope_pct) }, { label: 'Momentum 20j', value: fmt(signals.momentum_20d_pct) + '%', color: pctColor(signals.momentum_20d_pct) }, { label: 'Distance MA200', value: fmt(signals.dist_ma200_pct) + '%', sub: signals.dist_ma200_pct > 10 ? 'Surextension' : signals.dist_ma200_pct < -10 ? 'Survendu' : 'Neutre', color: pctColor(signals.dist_ma200_pct), }, { label: 'Volatilité ATR', value: (signals.vol_ratio_pct ?? 0).toFixed(0) + '%', sub: (signals.vol_ratio_pct ?? 0) > 130 ? 'Élevée' : (signals.vol_ratio_pct ?? 0) < 70 ? 'Comprimée' : 'Normale', color: volColor, }, ] const macroCol = macroRegime ? macroRegimeColor(macroRegime.dominant) : 'slate' const top3Macro = macroRegime?.ranked?.slice(0, 3) ?? [] return (
Régime
{dateLabel}
{macroRegime && macroRegime.dominant !== 'incertain' && (
Cycle macro global Fed cycle
{macroRegime.emoji || '🌍'} {macroRegime.label || macroRegime.dominant}
{top3Macro.length > 0 && (
{top3Macro.map((key, i) => { const score = macroRegime.scores?.[key] const c = macroRegimeColor(key) return ( {key} {score !== undefined ? Math.round(score) + '%' : ''} ) })}
)}
)}
Régime technique
{regime.current}
{Math.round(regime.confidence * 100)}%
{metrics.map(m => (
{m.label}
{m.value}
{m.sub &&
{m.sub}
}
))}
) } // ── TrendCard ───────────────────────────────────────────────────────────────── function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: string }) { const rsi = trend.rsi14_current ?? 50 const rsiColor = rsi > 70 ? 'text-orange-400' : rsi < 30 ? 'text-cyan-400' : 'text-slate-300' const rsiZone = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre' const items = [ { group: 'Tendance', rows: [ { label: 'Slope MA50 (5j)', value: fmt(trend.ma50_slope_5d) + '%', arrow: trend.ma50_slope_5d }, { label: 'Slope MA200 (20j)', value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d }, { label: 'Distance MA50', value: trend.dist_ma50_pct != null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0 }, { label: 'Distance MA200', value: trend.dist_ma200_pct != null ? fmt(trend.dist_ma200_pct) + '%' : '—', arrow: trend.dist_ma200_pct ?? 0, bold: true }, ]}, { group: 'Momentum', rows: [ { label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct }, { label: 'Momentum 3M', value: fmt(trend.momentum_3m_pct) + '%', arrow: trend.momentum_3m_pct, bold: true }, ]}, ] const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0 ? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100 : null return (
Indicateurs de Tendance
{dateLabel}
Prix {trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
{items.map(group => (
{group.group}
{group.rows.map(row => (
{row.label} {row.value}
))}
))}
RSI(14) {rsi.toFixed(0)} — {rsiZone}
0 Survendu50Suracheté 100
{pct52w !== null && (
Range 52 semaines {Math.round(pct52w)}e percentile
{trend.low_52w?.toFixed(2)}{trend.high_52w?.toFixed(2)}
)}
ATR(14) vs moy. 3M 130 ? 'text-orange-400' : (trend.atr_vs_3m_avg_pct ?? 100) < 70 ? 'text-cyan-400' : 'text-slate-400')}> {(trend.atr_vs_3m_avg_pct ?? 100).toFixed(0)}%
) } // ── EventsCard ──────────────────────────────────────────────────────────────── function EventsCard({ events, selectedDate }: { events: SnapshotEvent[]; selectedDate: string | null }) { const navigate = useNavigate() const LEVEL_C: Record = { long: 'text-violet-400 bg-violet-900/30 border-violet-700/30', medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30', short: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30', } function isActiveAt(ev: SnapshotEvent): boolean { if (!selectedDate) return false const evStart = ev.date const evEnd = ev.end_date if (evStart > selectedDate) return false if (evEnd) return evEnd >= selectedDate // point event: active if within 30 days after const diffDays = (new Date(selectedDate).getTime() - new Date(evStart).getTime()) / 86400000 return diffDays <= 30 } function surpriseColor(v: number | null | undefined): string { if (v === null || v === undefined) return 'text-slate-400' if (v > 5) return 'text-emerald-400' if (v < -5) return 'text-red-400' return 'text-slate-400' } return (
Événements Macro
{events.length === 0 ? (
Aucun événement lié trouvé
) : (
{events.map((ev, i) => { const active = isActiveAt(ev) return (
navigate(`/timeline?date=${ev.date}`)} >
{active && } {ev.title}
{ev.sub_type && ( {ev.sub_type} )} {ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
{/* Eco calendar fields */} {(ev.expected_value || ev.actual_value) && (
{ev.expected_value && ( Att. {ev.expected_value} )} {ev.actual_value && ( Réel {ev.actual_value} )} {ev.surprise_pct !== null && ev.surprise_pct !== undefined && ( {ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(0)}% {ev.surprise_pct > 5 ? ' ↑' : ev.surprise_pct < -5 ? ' ↓' : ''} )} {ev.unit && {ev.unit}}
)} {ev.absorption_pct !== null && ev.absorption_pct !== undefined && (
Absorption marché{ev.absorption_pct}%
)}
{fmtDateFR(ev.date)}{ev.end_date ? ` → ${fmtDateFR(ev.end_date)}` : ''} {ev.impact_score > 0.7 && }
{ev.description &&

{ev.description}

}
) })}
)}
) } // ── NarrativeCard ───────────────────────────────────────────────────────────── function NarrativeCard({ narrative, loading, onLoad, instrument }: { narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig }) { return (
Narration IA — {instrument.name}
{narrative ? (

{narrative}

) : loading ? (
{[95, 88, 70].map(w =>
)}
) : (
Cliquez "Générer" pour une analyse IA pour {instrument.name}.
)}
) } // ── Macro gauge helpers ─────────────────────────────────────────────────────── const SCENARIO_META: Record = { goldilocks: { label: 'Goldilocks', emoji: '🟢', color: '#10b981' }, desinflation: { label: 'Désinflation', emoji: '🔵', color: '#3b82f6' }, soft_landing: { label: 'Soft Landing', emoji: '🔷', color: '#06b6d4' }, reflation: { label: 'Reflation', emoji: '🟠', color: '#f97316' }, stagflation: { label: 'Stagflation', emoji: '🟡', color: '#f59e0b' }, inflation_shock: { label: 'Choc Inflationniste', emoji: '🔥', color: '#dc2626' }, recession: { label: 'Récession', emoji: '🔴', color: '#ef4444' }, crise_liquidite: { label: 'Crise de liquidité', emoji: '🟣', color: '#7c3aed' }, incertain: { label: 'Incertain', emoji: '⬜', color: '#64748b' }, } function snapToMacroRegime(snap: MacroGaugeSnap): MacroRegime { const scores = snap.regime_scores ?? {} const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain const ranked = Object.entries(scores) .sort(([, a], [, b]) => b - a) .map(([k]) => k) return { dominant: snap.dominant, label: meta.label, color: meta.color, emoji: meta.emoji, scores, ranked, asset_bias: {}, } } const BLOC_LABELS: Record = { liquidite: 'Liquidité / Taux', credit: 'Crédit / Vol', energie: 'Énergie', metaux: 'Métaux', croissance: 'Croissance US', secteurs: 'Secteurs', volatilite: 'Volatilité surf.', global: 'Global / EM', forex_ro: 'Forex Risk-Off', derive: 'Dérivés', } function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel: string }) { const byBloc: Record = {} for (const g of Object.values(snap.gauges)) { if (g.value === null && g.change_pct === null) continue const b = g.bloc ?? 'derive' if (!byBloc[b]) byBloc[b] = [] byBloc[b].push(g) } const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain return (
{meta.emoji} {meta.label} — contexte macro au {dateLabel}
{snap.snapshot_date}
{/* Regime scores mini bar */}
{Object.entries(snap.regime_scores) .sort(([,a],[,b]) => b - a) .slice(0, 6) .map(([k, v]) => (
))}
{/* Gauges by bloc */}
{Object.entries(byBloc).map(([bloc, gauges]) => (
{BLOC_LABELS[bloc] ?? bloc}
{gauges.map(g => (
{g.label}
{g.value !== null && ( {g.unit === '%' ? g.value.toFixed(2) + '%' : g.unit === 'pts' ? g.value.toFixed(1) : g.unit === 'ratio' ? g.value.toFixed(3) : g.value.toFixed(2)} )} {g.change_pct !== null && g.change_pct !== undefined && ( = 0 ? 'text-emerald-400' : 'text-red-400')}> {g.change_pct >= 0 ? '+' : ''}{g.change_pct.toFixed(1)}% )}
))}
))}
) } // ── Shared: trading-day-index x-coordinate ──────────────────────────────────── // Uses the same time scale as LightweightCharts (trading days, weekends skipped). // For a date D, snaps to the nearest available trading day index. function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => number { const dates = priceData.map(c => c.time) const N = dates.length if (N < 2) return () => 0 function snap(d: string): number { if (d <= dates[0]) return 0 if (d >= dates[N - 1]) return N - 1 let lo = 0, hi = N - 1 while (lo < hi) { const mid = (lo + hi) >> 1 if (dates[mid] < d) lo = mid + 1 else hi = mid } return lo } return (d: string) => (snap(d) / (N - 1)) * width } // ── Comprehension scoring ───────────────────────────────────────────────────── function templateAbsorptionDays(tmpl: CausalTemplate): number { const maxLag = Math.max(0, ...tmpl.graph_json.edges.map(e => e.lag_days || 0)) return maxLag > 0 ? maxLag : 30 } /** Score 0-100 measuring how well the graph predicted actual pips for an instrument. */ function comprehensionScore(ev: SnapshotEvent, tmpl: CausalTemplate, instrument: string): number | null { // Prefer the stored activation_score from causal_event_analyses (consistent with MarketEvents panel) if (ev.activation_score != null) return Math.round(ev.activation_score * 100) if (!ev.prediction_json || !ev.actual_json) return null try { const preds: Record = JSON.parse(ev.prediction_json) const actuals: Record = JSON.parse(ev.actual_json) const actual = actuals[instrument] ?? actuals[instrument.toUpperCase()] ?? actuals[instrument.toLowerCase()] if (actual == null) return null const outputNode = tmpl.graph_json.nodes.find(n => (n.type === 'market_asset' || n.type === 'output') && n.instrument?.toUpperCase() === instrument.toUpperCase() ) const predicted = outputNode ? (preds[outputNode.id] ?? null) : null if (predicted == null) return null if (predicted === 0 && actual === 0) return 100 if (predicted === 0) return 40 const sameDir = predicted * actual > 0 if (!sameDir) return 0 const ratio = Math.min(Math.abs(actual), Math.abs(predicted)) / Math.max(Math.abs(actual), Math.abs(predicted)) return Math.round(50 + ratio * 50) } catch { return null } } // ── CausalFrise ─────────────────────────────────────────────────────────────── const FRISE_LANE_H = 24 // px par lane const FRISE_CHIP_H = 18 // hauteur d'un chip const FRISE_CHIP_PAD = (FRISE_LANE_H - FRISE_CHIP_H) / 2 const FRISE_AXIS_H = 20 // axe temporel en bas const FRISE_MIN_W = 44 // largeur minimale d'un chip (en px) const FRISE_POPUP_W = 220 // largeur du popup function CausalFrise({ events, templates, priceData, selectedDate, causalInsts, chartDateToX, chartCanvasLeft, chartReady, causalScores, }: { events: SnapshotEvent[] templates: CausalTemplate[] priceData: PriceCandle[] selectedDate: string | null causalInsts: string[] chartDateToX?: (d: string) => number | null chartCanvasLeft?: number chartReady?: number causalScores: Record }) { const navigate = useNavigate() const containerRef = useRef(null) const [contWidth, setContWidth] = useState(400) const [contLeft, setContLeft] = useState(0) const [activeChip, setActiveChip] = useState<{ tmpl: CausalTemplate; ev: SnapshotEvent; chipX: number; chipY: number } | null>(null) useEffect(() => { const el = containerRef.current; if (!el) return const update = () => { const rect = el.getBoundingClientRect() setContWidth(rect.width) setContLeft(rect.left) } update() const obs = new ResizeObserver(update) obs.observe(el) return () => obs.disconnect() }, [chartReady]) // re-measure when chart is ready (layout may have changed) // Close popup on outside click (tiny delay avoids self-close) useEffect(() => { if (!activeChip) return let tid: ReturnType const close = () => setActiveChip(null) tid = setTimeout(() => document.addEventListener('mousedown', close), 60) return () => { clearTimeout(tid); document.removeEventListener('mousedown', close) } }, [activeChip]) const linked = events.filter(ev => { if (!ev.analyzed_instruments || ev.template_id == null) return false const insts = ev.analyzed_instruments.split(',') return causalInsts.some(ci => insts.includes(ci)) }) if (!priceData.length || !linked.length) { return (
Aucun graphe causal lié pour cet instrument
) } const minDate = priceData[0].time const maxDate = priceData[priceData.length - 1].time const usable = Math.max(contWidth, 1) // If chart coordinate bridge is available, use it for pixel-perfect alignment. // chart.timeToCoordinate() gives x relative to chart canvas left edge. // We subtract (chartCanvasLeft - contLeft) to convert to frise container coords. // canvasOffset > 0 means chart canvas starts to the right of the frise container. const canvasOffset = (chartCanvasLeft ?? 0) - contLeft const tdToX: (d: string) => number = chartDateToX ? (d: string) => { const x = chartDateToX(d) if (x !== null) return x - canvasOffset // Fallback for out-of-range dates: clamp to edges return d <= minDate ? -canvasOffset : usable - canvasOffset } : makeTdToX(priceData, usable) // Build chips (one per event × template) type Chip = { ev: SnapshotEvent; tmpl: CausalTemplate x1: number; x2: number; w: number; active: boolean } const chips: Chip[] = linked .filter(ev => ev.date <= maxDate) .map(ev => { const tmpl = templates.find(t => t.id === ev.template_id) if (!tmpl) return null const absorptionDays = templateAbsorptionDays(tmpl) const endDate = ev.end_date ?? (() => { const d = new Date(ev.date); d.setDate(d.getDate() + absorptionDays) return d.toISOString().slice(0, 10) })() const x1 = tdToX(ev.date) const raw = tdToX(endDate) - x1 const w = Math.max(raw, FRISE_MIN_W) return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) } }) .filter((c): c is Chip => c !== null) .sort((a, b) => a.x1 - b.x1) // Greedy lane assignment — no overlap const laneEnds: number[] = [] const placed = chips.map(chip => { const GAP = 3 let lane = laneEnds.findIndex(end => end + GAP <= chip.x1) if (lane === -1) { lane = laneEnds.length; laneEnds.push(0) } laneEnds[lane] = chip.x2 return { ...chip, lane } }) const numLanes = Math.max(laneEnds.length, 1) const containerH = numLanes * FRISE_LANE_H + FRISE_AXIS_H + 4 // Month tick marks — skip some if too crowded const ticks: { label: string; x: number }[] = [] { const start = new Date(minDate) let cur = new Date(start.getFullYear(), start.getMonth() + 1, 1) while (cur.toISOString().slice(0, 10) <= maxDate) { ticks.push({ label: cur.toLocaleDateString('fr-FR', { month: 'short', ...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}), }), x: tdToX(cur.toISOString().slice(0, 10)), }) cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1) } } // Keep 1 label every ~70px to avoid crowding const tickStep = Math.max(1, Math.ceil(ticks.length / (usable / 70))) const visTicks = ticks.filter((_, i) => i % tickStep === 0) const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate ? tdToX(selectedDate) : null return (
setActiveChip(null)} > {/* Month grid lines */} {visTicks.map((t, i) => (
))} {/* Crosshair */} {crossX !== null && (
)} {/* Chips */} {placed.map(({ ev, tmpl, x1, w, lane, active }) => { const catTw = EV_CAT_TW[ev.category] ?? 'text-slate-400 border-slate-700/30 bg-slate-800/40' const chipY = lane * FRISE_LANE_H + FRISE_CHIP_PAD const isOpen = activeChip?.ev.id === ev.id && activeChip?.tmpl.id === tmpl.id const charsFit = Math.floor((w - 18) / 5.5) const label = charsFit < 3 ? '' : tmpl.name.length > charsFit ? tmpl.name.slice(0, charsFit - 1) + '…' : tmpl.name return (
e.stopPropagation()} onClick={e => { e.stopPropagation() setActiveChip(isOpen ? null : { tmpl, ev, chipX: x1, chipY }) }} title={`${tmpl.name} · ${ev.title}\n${fmtDateFR(ev.date)} → ${ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}`} > {label && {label}}
) })} {/* Popup */} {activeChip && (() => { const { tmpl, ev, chipX, chipY } = activeChip const popH = 108 const popTop = chipY - popH - 6 >= 0 ? chipY - popH - 6 : chipY + FRISE_CHIP_H + 4 const popLeft = Math.max(0, Math.min(chipX, contWidth - FRISE_POPUP_W - 4)) return (
e.stopPropagation()} onClick={e => e.stopPropagation()} >
{tmpl.name} {ev.category}
{ev.title}
{fmtDateFR(ev.date)} → {ev.end_date ? fmtDateFR(ev.end_date) : `+${templateAbsorptionDays(tmpl)}j`} {ev.impact_score != null && ( ★ {ev.impact_score.toFixed(1)} )} {ev.surprise_pct != null && ( 0 ? 'text-emerald-600' : 'text-red-600'}> {ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(1)}% )}
{/* Précision — causalScores (API fraîche) avec fallback sur activation_score du snapshot */} {(() => { const snapScore = ev.activation_score != null ? Math.round(ev.activation_score * 100) : null const s = ev.id != null ? (causalScores[ev.id] ?? snapScore) : snapScore if (s == null) return null const barCls = s >= 70 ? 'bg-emerald-500' : s >= 40 ? 'bg-amber-500' : 'bg-red-500' const txtCls = s >= 70 ? 'text-emerald-400' : s >= 40 ? 'text-amber-400' : 'text-red-400' return (
Précision prédiction {s}%
) })()}
) })()} {/* X-axis */}
{visTicks.map((t, i) => ( {t.label} ))}
) } // ── ExplanationScore ────────────────────────────────────────────────────────── function ExplanationScore({ events, templates, causalInsts, onRefreshDone, onDebugResult, debugInfo, causalScores, }: { events: SnapshotEvent[] templates: CausalTemplate[] causalInsts: string[] causalScores: Record onRefreshDone?: () => void // eslint-disable-next-line @typescript-eslint/no-explicit-any onDebugResult?: (data: any) => void // eslint-disable-next-line @typescript-eslint/no-explicit-any debugInfo?: any }) { const [refreshing, setRefreshing] = useState(false) const [refreshDone, setRefreshDone] = useState(false) // For each linked event, compute the comprehension score and collect scored ones const scored: number[] = [] const totalLinked: number[] = [] const debugRows: {id?: number; name: string; pred: string; actual: string; score: number | null; reason: string; surprisePct?: number | null; rawPred?: string}[] = [] for (const ev of events) { if (!ev.template_id) continue const tmpl = templates.find(t => t.id === ev.template_id) if (!tmpl) continue const inst = causalInsts.find(ci => tmpl.instruments.includes(ci)) if (!inst) continue totalLinked.push(ev.id ?? 0) // Detailed debug per event const hasPred = !!(ev.prediction_json && ev.prediction_json !== '{}') const hasActual = !!(ev.actual_json && ev.actual_json !== '{}') let reason = '' if (!hasPred) reason = 'prediction_json vide' else if (!hasActual) reason = 'actual_json vide' else { try { const preds = JSON.parse(ev.prediction_json!) const actuals = JSON.parse(ev.actual_json!) const outputNode = tmpl.graph_json.nodes.find(n => (n.type === 'market_asset' || n.type === 'output') && n.instrument?.toUpperCase() === inst.toUpperCase() ) if (!outputNode) reason = `nœud output "${inst}" introuvable (types: ${[...new Set(tmpl.graph_json.nodes.map(n => n.type))].join(',')})` else if (preds[outputNode.id] == null) reason = `preds["${outputNode.id}"] absent` else if (actuals[inst] == null && actuals[inst.toUpperCase()] == null) reason = `actuals["${inst}"] absent — clés: ${Object.keys(actuals).join(',')}` else reason = 'ok' } catch (e) { reason = `parse error: ${e}` } } debugRows.push({ id: ev.id, name: ev.title?.slice(0, 30) ?? '?', pred: hasPred ? '✓' : '✗', actual: hasActual ? '✓' : '✗', score: null, reason, surprisePct: ev.surprise_pct ?? null, rawPred: (ev.prediction_json ?? 'null').slice(0, 30), }) const snapScore = ev.activation_score != null ? Math.round(ev.activation_score * 100) : null const s = ev.id != null ? (causalScores[ev.id] ?? snapScore) : snapScore if (debugRows.length > 0) debugRows[debugRows.length - 1].score = s if (s != null) scored.push(s) } const globalScore = scored.length > 0 ? Math.round(scored.reduce((a, b) => a + b, 0) / scored.length) : null const verdict = globalScore == null ? 'En attente' : globalScore >= 70 ? 'Bien expliqué' : globalScore >= 40 ? 'Partiellement' : 'Peu lisible' const badgeCls = globalScore == null ? 'text-slate-500 bg-slate-800/60 border-slate-700/30' : globalScore >= 70 ? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40' : globalScore >= 40 ? 'text-amber-400 bg-amber-900/30 border-amber-700/40' : 'text-red-400 bg-red-900/20 border-red-700/30' const barCls = globalScore == null ? 'bg-slate-600' : globalScore >= 70 ? 'bg-emerald-500' : globalScore >= 40 ? 'bg-amber-500' : 'bg-red-500' const handleRefresh = async () => { onDebugResult?.(null) setRefreshing(true) try { const res = await api.post('/causal-lab/auto-analyze/refresh') const data = res.data onDebugResult?.(data) setRefreshDone(true) onRefreshDone?.() setTimeout(() => setRefreshDone(false), 8000) } catch (err: any) { onDebugResult?.({ error: String(err) }) } finally { setRefreshing(false) } } const needsRefresh = totalLinked.length > 0 && scored.length === 0 && !refreshDone return (
Note globale
{globalScore != null ? `${globalScore}% — ` : ''}{verdict} {scored.length}/{totalLinked.length} graphes {(needsRefresh || scored.length === 0) && !refreshDone && totalLinked.length > 0 && ( )}
{/* Debug panel — score diagnostics per event */} {debugRows.length > 0 && scored.length === 0 && (
Diagnostic scores ({debugRows.length} graphes)
{debugRows.map((r, i) => (
{r.pred} {r.actual} {r.name} {r.reason} {r.surprisePct != null ? {r.surprisePct > 0 ? '+' : ''}{r.surprisePct.toFixed(1)}% : surp?} {r.score != null && {r.score}%} {r.rawPred}
))} {debugInfo && (
{debugInfo.error ? Erreur: {debugInfo.error} : Refresh: {debugInfo.refreshed} ok / {debugInfo.failed} err / {debugInfo.total} total } {debugInfo.details?.map((d: any, i: number) => (
{d.result} #{d.event_id} {(d.event_name ?? '').slice(0, 20)} {d.run_error && err:{d.run_error.slice(0, 40)}}
surp:{d.surprise_pct != null ? Number(d.surprise_pct).toFixed(1)+'%' : `null (act=${d.actual_value} exp=${d.expected_value})`} {' | '}inputs:{JSON.stringify(d.inputs ?? {})} {' | '}pred_keys:[{(d.node_keys ?? []).join(',')}] {' | '}actual:{JSON.stringify(d.actual_moves ?? {})}
))} {/* DB state: show rows not in refresh (non-empty pred/actual but still 'vide' in snapshot) */} {debugInfo.db_state?.length > 0 && (
DB state ({debugInfo.db_state.length} analyses)
{debugInfo.db_state.map((r: any, i: number) => (
#{r.event_id} {(r.event_name ?? '').slice(0, 18)} pred:{r.pred_empty ? '∅' : `[${(r.pred_keys ?? []).join(',')}]`} act:{r.actual_empty ? '∅' : JSON.stringify(r.actual_keys)}
))}
)}
)}
)}
) } // ── Main page ───────────────────────────────────────────────────────────────── const PERIODS = [ { key: '5d', label: '5D' }, { key: '1mo', label: '1M' }, { key: '3mo', label: '3M' }, { key: '6mo', label: '6M' }, { key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' }, ] export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { instrumentIdProp?: string; isVisible?: boolean } = {}) { const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>() const navigate = useNavigate() const [period, setPeriod] = useState('1y') const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles') const [instruments, setInstruments] = useState([]) const [snapshot, setSnapshot] = useState(null) const [narrative, setNarrative] = useState('') const [loading, setLoading] = useState(false) const [refreshDebug, setRefreshDebug] = useState(null) const [loadingNarr, setLoadingNarr] = useState(false) const [selectorOpen, setSelectorOpen] = useState(false) const [selectedDate, setSelectedDate] = useState(null) const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters') const [templates, setTemplates] = useState([]) const [causalScores, setCausalScores] = useState>({}) // eventId → activation_score*100 const [macroAtDate, setMacroAtDate] = useState(null) const [theoryCurve, setTheoryCurve] = useState(null) const [loadingTheory, setLoadingTheory] = useState(false) const [showTheory, setShowTheory] = useState(false) // Chart coordinate bridge — lets CausalFrise align with the chart's x-axis const chartDateToXRef = useRef<((d: string) => number | null) | null>(null) const chartCanvasLeftRef = useRef(0) const [chartReady, setChartReady] = useState(0) // bump to trigger frise re-render // instrumentIdProp is set when mounted as a keep-alive tab (so URL params don't bleed across instances) const instrumentId = (instrumentIdProp ?? paramId).toUpperCase() useEffect(() => { api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {}) api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {}) }, []) // Silent refresh when the tab becomes visible again (e.g. after analysis updated from MarketEvents) const mountedRef = useRef(false) useEffect(() => { if (!mountedRef.current) { mountedRef.current = true; return } if (isVisible) fetchSnapshotSilent() // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible]) const fetchSnapshot = useCallback(() => { setLoading(true) api.get(`/instruments/${instrumentId}/snapshot?period=${period}`) .then(r => { setSnapshot(r.data) const pd: PriceCandle[] = r.data.price_data if (pd?.length) setSelectedDate(pd[pd.length - 1].time) }) .catch(() => {}) .finally(() => setLoading(false)) }, [instrumentId, period]) // Silent re-fetch (no loading spinner) — used after Recalculer so ExplanationScore keeps its state const fetchSnapshotSilent = useCallback(() => { api.get(`/instruments/${instrumentId}/snapshot?period=${period}`) .then(r => { setSnapshot(r.data) }) .catch(() => {}) }, [instrumentId, period]) // Fetch causal scores directly from the analyses API (not via snapshot) so the popup // always shows the DB-stored activation_score, independent of snapshot staleness const refreshCausalScores = useCallback((events: SnapshotEvent[]) => { const ids = events.map(e => e.id).filter(Boolean) as number[] if (!ids.length) return Promise.all(ids.map(id => api.get(`/causal-lab/analyses?market_event_id=${id}&limit=1`) .then(r => ({ id, score: r.data[0]?.activation_score ?? null })) .catch(() => ({ id, score: null })) )).then(results => { setCausalScores(Object.fromEntries(results.map(r => [r.id, r.score != null ? Math.round(r.score * 100) : null]))) }) }, []) useEffect(() => { setSnapshot(null) setNarrative('') setSelectedDate(null) setMacroAtDate(null) setTheoryCurve(null) setShowTheory(false) chartDateToXRef.current = null setChartReady(0) fetchSnapshot() }, [instrumentId, period]) // Refresh causal scores whenever the snapshot events change useEffect(() => { if (snapshot?.events?.length) refreshCausalScores(snapshot.events) }, [snapshot?.events, refreshCausalScores]) const loadNarrative = useCallback(() => { setLoadingNarr(true) api.post(`/instruments/${instrumentId}/narrative`) .then(r => setNarrative(r.data.narrative)) .catch(() => {}) .finally(() => setLoadingNarr(false)) }, [instrumentId]) const handleDateHover = useCallback((date: string | null) => { if (date) setSelectedDate(date) }, []) const handleChartReady = useCallback((fn: ((d: string) => number | null) | null, left: number) => { chartDateToXRef.current = fn chartCanvasLeftRef.current = left setChartReady(k => k + 1) }, []) const toggleTheory = useCallback(() => { if (showTheory) { setShowTheory(false) setTheoryCurve(null) return } if (theoryCurve) { setShowTheory(true); return } setLoadingTheory(true) api.get(`/instruments/${instrumentId}/theoretical-curve?period=${period}`) .then(r => { setTheoryCurve(r.data); setShowTheory(true) }) .catch(() => {}) .finally(() => setLoadingTheory(false)) }, [showTheory, theoryCurve, instrumentId, period]) const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => { if (!snapshot) return { priceMap: {} as Record, indMap: {} as Record>, sortedDates: [] as string[], dateIndex: {} as Record } const priceMap: Record = {} const indMap: Record> = {} const sortedDates: string[] = [] const dateIndex: Record = {} for (const c of snapshot.price_data) { priceMap[c.time] = c; sortedDates.push(c.time) } sortedDates.forEach((d, i) => { dateIndex[d] = i }) for (const [key, pts] of Object.entries(snapshot.indicators)) { for (const pt of pts) { if (!indMap[pt.time]) indMap[pt.time] = {}; indMap[pt.time][key] = pt.value } } return { priceMap, indMap, sortedDates, dateIndex } }, [snapshot]) // Fetch macro gauge context when crosshair date changes (after sortedDates is available) useEffect(() => { if (!selectedDate || !sortedDates.length) return const isLast = selectedDate === sortedDates[sortedDates.length - 1] if (isLast) { setMacroAtDate(null); return } api.get(`/market/macro-gauges/at?date=${selectedDate}`) .then(r => { if (r.data?.snapshot_date) setMacroAtDate(r.data) }) .catch(() => {}) }, [selectedDate, sortedDates]) const effectiveDate = useMemo(() => { if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate return sortedDates[sortedDates.length - 1] ?? null }, [selectedDate, sortedDates, dateIndex]) const dateTrend = useMemo((): TrendMetrics | null => { if (!effectiveDate || !snapshot) return null const candle = priceMap[effectiveDate]; if (!candle) return null const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {} const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14 ?? 0 const d5 = idx >= 5 ? sortedDates[idx - 5] : null const d20 = idx >= 20 ? sortedDates[idx - 20] : null const d21 = idx >= 21 ? sortedDates[idx - 21] : null const d63 = idx >= 63 ? sortedDates[idx - 63] : null let atrSum = 0, atrCnt = 0 for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } } let high52 = candle.high, low52 = candle.low for (let i = Math.max(0, idx - 252); i <= idx; i++) { const c = priceMap[sortedDates[i]]; if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low } } return { ma50_slope_5d: pctN(ma50, d5 ? indMap[d5]?.ma50 : undefined), ma200_slope_20d: pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined), rsi14_current: ind.rsi14 ?? 50, atr14_current: atr14, atr_vs_3m_avg_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100, momentum_1m_pct: d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0, momentum_3m_pct: d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0, dist_ma50_pct: ma50 ? pctN(price, ma50) : null, dist_ma200_pct: ma200 ? pctN(price, ma200) : null, current_price: price, high_52w: high52, low_52w: low52, } }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot]) const dateSignals = useMemo((): RegimeSignals | null => { if (!effectiveDate || !snapshot) return null const candle = priceMap[effectiveDate]; if (!candle) return null const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {} const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14 const d10 = idx >= 10 ? sortedDates[idx - 10] : null const d20 = idx >= 20 ? sortedDates[idx - 20] : null let atrSum = 0, atrCnt = 0 for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } } return { ma50_above_ma200: ma50 !== undefined && ma200 !== undefined ? ma50 > ma200 : null, ma50_slope_pct: pctN(ma50, d10 ? indMap[d10]?.ma50 : undefined), ma200_slope_pct: pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined), momentum_20d_pct: d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0, dist_ma200_pct: ma200 ? pctN(price, ma200) : 0, vol_ratio_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 0, } }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot]) const grouped = CATEGORY_ORDER.map(cat => ({ cat, label: CATEGORY_LABELS[cat] ?? cat, items: instruments.filter(i => i.category === cat), })).filter(g => g.items.length > 0) const selected = instruments.find(i => i.id === instrumentId) const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1] const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—' const displayPrice = dateTrend?.current_price ?? snapshot?.current_price return (
{/* ── Header ── */}
{selectorOpen && (
{grouped.map(g => (
{g.label}
{g.items.map(inst => ( ))}
))}
)}
{selected && ( {CATEGORY_LABELS[selected.category] ?? selected.category} )} {displayPrice !== undefined && (
{displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} {isLastDate && snapshot && ( = 0 ? 'text-emerald-400' : 'text-red-400')}> {(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%) )}
)}
{/* Chart style toggle */}
{/* Period selector */}
{PERIODS.map(p => ( ))}
{selected &&

{selected.description}

}
{/* ── Loading ── */} {loading && (
{[1, 2, 3].map(i =>
)}
)} {/* ── Content ── */} {!loading && snapshot && ( <> {/* Date badge */}
Snapshot au {dateLabel} {!isLastDate && ← survol du graphe}
{/* ── Tabs sous la courbe ── */}
{([ { key: 'counters', label: 'Compteurs' }, { key: 'analyse', label: 'Analyse de la courbe' }, ] as const).map(t => ( ))}
{tabUnder === 'counters' && ( <>
{macroAtDate && } )} {tabUnder === 'analyse' && (() => { const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? [] const theoPt = effectiveDate && theoryCurve ? theoryCurve.find(p => p.date === effectiveDate) ?? null : null return (
{/* Frise des graphes causaux (inclut l'event) */}
Frise des graphes
{/* Décomposition théorique au curseur */} {showTheory && theoPt && (
Contributions théoriques — {dateLabel} = 0 ? 'text-emerald-400' : 'text-red-400'}`}> {theoPt.cumulative_pips >= 0 ? '+' : ''}{theoPt.cumulative_pips} pips
{theoPt.contributions.length === 0 ? (

Aucun événement actif à cette date.

) : (
{theoPt.contributions.map((c, i) => (
{c.event_name} {c.template_name} · depuis {c.event_date} · {Math.round(c.decay_factor * 100)}% actif
= 0 ? 'text-emerald-400' : 'text-red-400'}`}> {c.pips >= 0 ? '+' : ''}{c.pips} pip
))}
)}
)} {showTheory && !theoPt && theoryCurve && (
⟁ Aucune contribution théorique pour cette date
)} {/* Note globale */}
) })()} )} {!loading && !snapshot && (

Aucune donnée disponible pour {instrumentId}

)} {selectorOpen &&
setSelectorOpen(false)} />}
) }