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 from '../components/InstrumentChart' const api = axios.create({ baseURL: '/api' }) // ── 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: any[] } } 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 { 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 } 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 const EVENT_TO_CAUSAL_CAT: Record = { macro_us: 'macro_us', macro_eu: 'macro_eu', geopolitical: 'geopolitical', report: 'report', sentiment: 'sentiment', commodity: 'commodity', fundamental: 'macro_us', event_calendar: 'macro_us', } // 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: [], } const CAUSAL_CAT_TW: Record = { macro_us: 'text-blue-400 border-blue-700/30 bg-blue-900/20', macro_eu: 'text-cyan-400 border-cyan-700/30 bg-cyan-900/20', geopolitical: 'text-red-400 border-red-700/30 bg-red-900/20', report: 'text-amber-400 border-amber-700/30 bg-amber-900/20', sentiment: 'text-violet-400 border-violet-700/30 bg-violet-900/20', commodity: 'text-orange-400 border-orange-700/30 bg-orange-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)}% )}
))}
))}
) } // ── EventTimeline ───────────────────────────────────────────────────────────── function EventTimeline({ events, priceData, selectedDate, onNavigate, }: { events: SnapshotEvent[] priceData: PriceCandle[] selectedDate: string | null onNavigate: (date: string) => void }) { const containerRef = useRef(null) const [contWidth, setContWidth] = useState(800) useEffect(() => { const el = containerRef.current; if (!el) return const obs = new ResizeObserver(entries => setContWidth(entries[0].contentRect.width)) obs.observe(el) return () => obs.disconnect() }, []) if (!priceData.length || !events.length) { return (
Aucun événement sur cette période
) } const minDate = priceData[0].time const maxDate = priceData[priceData.length - 1].time const minTs = new Date(minDate).getTime() const maxTs = new Date(maxDate).getTime() const PAD = 24 const usable = Math.max(contWidth - PAD * 2, 1) function tsToX(d: string): number { return PAD + ((new Date(d).getTime() - minTs) / (maxTs - minTs)) * usable } const visible = events.filter(ev => ev.date >= minDate && ev.date <= maxDate) const HALF_W = 48 const ROW_H = 28 const MAX_ROWS = 5 const rowEnds: number[] = new Array(MAX_ROWS).fill(-Infinity) const placed = visible.map(ev => { const x = tsToX(ev.date) let row = 0 for (let r = 0; r < MAX_ROWS; r++) { if (rowEnds[r] <= x - HALF_W) { row = r; break } row = r } rowEnds[row] = x + HALF_W return { ev, x, row } }) const maxRow = placed.length ? Math.max(...placed.map(p => p.row)) : 0 const containerH = (maxRow + 1) * ROW_H + 28 const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate ? tsToX(selectedDate) : null return (
{crossX !== null && (
)} {placed.map(({ ev, x, row }) => { const active = isActiveAt(ev, selectedDate) return (
onNavigate(ev.date)} title={`${ev.title}\n${fmtDateFR(ev.date)}`} className="absolute flex flex-col items-center cursor-pointer group" style={{ left: x, top: row * ROW_H, transform: 'translateX(-50%)' }} > {ev.title.length > 24 ? ev.title.slice(0, 24) + '…' : ev.title}
) })}
{fmtDateFR(minDate)} {fmtDateFR(maxDate)}
) } // ── EventGraphCards ─────────────────────────────────────────────────────────── function EventGraphCards({ events, templates, selectedDate, instrumentCategory, }: { events: SnapshotEvent[] templates: CausalTemplate[] selectedDate: string | null instrumentCategory: string }) { const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? [] const allCats = new Set( events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean) ) const activeCats = new Set( events.filter(ev => isActiveAt(ev, selectedDate)) .map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean) ) const relevant = templates.filter(t => allCats.has(t.category)) if (!relevant.length) { return (
Aucun graphe causal associé aux événements de cette période
) } return (
{relevant.map(t => { const eventActive = activeCats.has(t.category) const instrumentTouched = causalInsts.some(ci => t.instruments.includes(ci)) return (
{t.name} {t.category}
{t.instruments.map(inst => ( {inst} ))}
{eventActive ? '● actif' : '○ période'}
) })}
) } // ── ExplanationScore ────────────────────────────────────────────────────────── function ExplanationScore({ events, templates, selectedDate, instrumentCategory, }: { events: SnapshotEvent[] templates: CausalTemplate[] selectedDate: string | null instrumentCategory: string }) { const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? [] const allCats = new Set(events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)) const activeCats = new Set( events.filter(ev => isActiveAt(ev, selectedDate)) .map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean) ) const periodTmpl = templates.filter(t => allCats.has(t.category)) const touchedActive = templates.filter(t => activeCats.has(t.category) && causalInsts.some(ci => t.instruments.includes(ci)) ) const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0 const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible' const badgeCls = score >= 60 ? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40' : score >= 30 ? 'text-amber-400 bg-amber-900/30 border-amber-700/40' : 'text-slate-400 bg-slate-800/60 border-slate-700/30' const barCls = score >= 60 ? 'bg-emerald-500' : score >= 30 ? 'bg-amber-500' : 'bg-slate-600' return (
Note globale
{Math.round(score)}% — {verdict} {periodTmpl.length > 0 && ( {touchedActive.length}/{periodTmpl.length} graphes )}
) } // ── Main page ───────────────────────────────────────────────────────────────── const PERIODS = [ { key: '3mo', label: '3M' }, { key: '6mo', label: '6M' }, { key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' }, ] export default function InstrumentDashboard() { const { id = 'SPY' } = useParams<{ id: string }>() const navigate = useNavigate() const [period, setPeriod] = useState('1y') const [instruments, setInstruments] = useState([]) const [snapshot, setSnapshot] = useState(null) const [narrative, setNarrative] = useState('') const [loading, setLoading] = useState(false) 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 [macroAtDate, setMacroAtDate] = useState(null) const instrumentId = id.toUpperCase() useEffect(() => { api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {}) api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {}) }, []) useEffect(() => { setLoading(true) setSnapshot(null) setNarrative('') setSelectedDate(null) setMacroAtDate(null) 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]) 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 { 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)}%) )}
)}
{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' && (
{/* Zone haute — frise des événements */}
Frise des événements survol = détail · clic = timeline
navigate(`/timeline?date=${date}`)} />
{/* Zone basse — graphes causaux liés aux événements */}
Graphes causaux vert = actif + touche l'instrument · ambre = actif · gris = période seulement
{/* Note globale */}
)} )} {!loading && !snapshot && (

Aucune donnée disponible pour {instrumentId}

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