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 { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import InstrumentChart, { TheoPoint } from '../components/InstrumentChart' const api = axios.create({ baseURL: '/api' }) const WAVELET_BAND_COLORS = ['#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6', '#84cc16'] // Stable empty array — prevents InstrumentChart useEffect from re-running on every render const NO_CHART_EVENTS: never[] = [] // Tiny inline shape indicator for a band's recent RMS trajectory — same energy level can // hide an accelerating vs. a decelerating band; the sparkline shows which. function Sparkline({ values, color, width = 56, height = 18 }: { values: number[]; color: string; width?: number; height?: number }) { if (values.length < 2) return null const min = Math.min(...values), max = Math.max(...values) const range = max - min || 1 const points = values .map((v, i) => `${((i / (values.length - 1)) * width).toFixed(1)},${(height - ((v - min) / range) * height).toFixed(1)}`) .join(' ') return ( ) } // ── Wavelet regime engine ────────────────────────────────────────────────────── // Wavelets answer "how is variance spread across timescales"; volatility answers "how // much variance is there in total" — two prices can carry the same slow-band energy while // one trends smoothly (100→105 in a straight line) and the other only gets there via wild // swings (100→95→105→98→110→103). Same Trend Ratio, wildly different volatility. Combining // both, plus their own trends, into a 6-regime classifier per the spec discussed. type RegimeBucket = { label: string; color: string } function bucketTrendRatio(r: number | null): RegimeBucket { if (r == null) return { label: '—', color: 'text-slate-500' } if (r < 1) return { label: 'Bruit dominant', color: 'text-red-400' } if (r < 3) return { label: 'Mixte', color: 'text-amber-400' } if (r < 10) return { label: 'Tendance', color: 'text-emerald-400' } return { label: 'Cycle dominant', color: 'text-blue-400' } } function bucketCoherence(c: number | null): RegimeBucket { if (c == null) return { label: '—', color: 'text-slate-500' } if (c > 0.8) return { label: 'Direction très propre', color: 'text-emerald-400' } if (c >= 0.4) return { label: 'Direction moyenne', color: 'text-amber-400' } return { label: 'Oscillation', color: 'text-red-400' } } function bucketDynamic(slopePct: number | null): RegimeBucket { if (slopePct == null) return { label: '—', color: 'text-slate-500' } if (slopePct > 0.5) return { label: 'Se renforce', color: 'text-emerald-400' } if (slopePct < -0.5) return { label: "S'affaiblit", color: 'text-red-400' } return { label: 'Stable', color: 'text-slate-400' } } function bucketVolPercentile(p: number | null): RegimeBucket { if (p == null) return { label: '—', color: 'text-slate-500' } if (p < 20) return { label: 'Très calme', color: 'text-blue-400' } if (p < 60) return { label: 'Normal', color: 'text-emerald-400' } if (p < 80) return { label: 'Agité', color: 'text-amber-400' } return { label: 'Extrême', color: 'text-red-400' } } function classifyMarketRegime(input: { trendRatio: number | null; coherenceSlow: number | null; volPercentile: number | null volDelta: number | null; trendRatioSlope: number | null; slowSlopePct: number | null }): { name: string; reading: string; color: string } { const { trendRatio, coherenceSlow, volPercentile, volDelta, trendRatioSlope, slowSlopePct } = input const trHigh = trendRatio != null && trendRatio > 3 const trLow = trendRatio != null && trendRatio < 1 const cohHigh = coherenceSlow != null && coherenceSlow > 0.8 const cohLow = coherenceSlow != null && coherenceSlow < 0.4 const volLow = volPercentile != null && volPercentile < 40 const volHigh = volPercentile != null && volPercentile > 60 const volUp = volDelta != null && volDelta > 3 const volDown = volDelta != null && volDelta < -3 const trUp = trendRatioSlope != null && trendRatioSlope > 0.2 const trDown = trendRatioSlope != null && trendRatioSlope < -0.2 const slowUp = slowSlopePct != null && slowSlopePct > 0.5 const slowDown = slowSlopePct != null && slowSlopePct < -0.5 if (trHigh && cohHigh && volLow && volDown) return { name: 'Drift', reading: 'Marché extrêmement organisé — tendance lente propre, sous faible volatilité qui continue de baisser.', color: 'text-blue-400' } if (trHigh && cohHigh && volHigh && volUp) return { name: 'Tendance explosive', reading: 'Même direction que le Drift, mais en accélération — momentum fort sous volatilité montante.', color: 'text-emerald-400' } if (trLow && volLow) return { name: 'Compression', reading: 'Accumulation — toutes les bandes sont faibles, marché en attente de sortie de range.', color: 'text-slate-400' } if (trLow && volHigh && cohLow) return { name: 'Chaos', reading: 'News / panique — bruit dominant sous forte volatilité, aucune direction nette.', color: 'text-red-400' } if (trDown && slowDown && volUp) return { name: 'Transition', reading: "La structure se casse — la tendance lente s'affaiblit pendant que la volatilité monte.", color: 'text-amber-400' } if (trUp && slowUp && volDown) return { name: 'Construction de tendance', reading: 'Le marché devient de plus en plus propre — tendance lente qui se renforce sous volatilité qui retombe.', color: 'text-emerald-400' } return { name: 'Indéterminé', reading: "Combinaison de signaux ambiguë — aucun des 6 régimes types ne se dégage nettement à cette date.", color: 'text-slate-500' } } // ── 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 }[] } calibration_json: { absorption_days?: number; lag_days?: number; half_life_days?: number; decay_type?: 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 fromCalib = tmpl.calibration_json?.absorption_days if (fromCalib && fromCalib > 0) return fromCalib 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, }: { events: SnapshotEvent[] templates: CausalTemplate[] priceData: PriceCandle[] selectedDate: string | null causalInsts: string[] chartDateToX?: (d: string) => number | null chartCanvasLeft?: number chartReady?: number }) { 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) const [popupScore, setPopupScore] = useState('loading') // Fetch analysis live when popup opens — compute same precision as MarketEvents expanded view useEffect(() => { const evId = activeChip?.ev.id if (!evId) { setPopupScore(null); return } setPopupScore('loading') api.get(`/causal-lab/analyses?market_event_id=${evId}&limit=1`) .then(r => { const analysis = r.data?.[0] console.log('[CausalFrise] analysis for event', evId, analysis) if (!analysis) { setPopupScore(null); return } // Compute magnitude-weighted precision (same formula as MarketEvents expanded panel) const preds: Record = analysis.prediction_json || {} const actuals: Record = analysis.actual_json || {} const graph = analysis.graph_json || {} const outputNodes: { id: string; instrument?: string }[] = (graph.nodes || []) .filter((n: { type?: string }) => n.type === 'market_asset' || n.type === 'output') console.log('[CausalFrise] preds:', preds, 'actuals:', actuals, 'outputNodes:', outputNodes) const nodeScores = outputNodes.map((n: { id: string; instrument?: string }) => { const pred = preds[n.id] const act = actuals[n.instrument || n.id] ?? actuals[n.id] if (pred == null || act == null) return null if (pred === 0 && act === 0) return 100 if (pred === 0) return 40 const sameDir = pred * act > 0 if (!sameDir) return 0 const ratio = Math.min(Math.abs(act), Math.abs(pred)) / Math.max(Math.abs(act), Math.abs(pred)) return Math.round(50 + ratio * 50) }).filter((s): s is number => s != null) console.log('[CausalFrise] nodeScores:', nodeScores) if (nodeScores.length > 0) { setPopupScore(Math.round(nodeScores.reduce((a, b) => a + b, 0) / nodeScores.length)) } else { // Fallback: use stored activation_score if no prediction data const stored = analysis.activation_score setPopupScore(stored != null ? Math.round(stored * 100) : null) } }) .catch(err => { console.error('[CausalFrise] fetch error:', err); setPopupScore(null) }) }, [activeChip?.ev.id]) 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; absorptionDays: number } 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), absorptionDays } }) .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, absorptionDays }) => { const catTw = EV_CAT_TW[ev.category] ?? 'text-slate-400 border-slate-700/30 bg-slate-800/40' // Height scales with absorption: 13px (short) → 20px (30+ days), centred in lane const chipH = Math.max(13, Math.min(20, Math.round(13 + Math.min(absorptionDays, 30) / 30 * 7))) const chipY = lane * FRISE_LANE_H + Math.floor((FRISE_LANE_H - chipH) / 2) const isOpen = activeChip?.ev.id === ev.id && activeChip?.tmpl.id === tmpl.id const charsFit = Math.floor((w - 18) / 5.5) const label = charsFit < 3 ? '' : ev.title.length > charsFit ? ev.title.slice(0, charsFit - 1) + '…' : ev.title return (
e.stopPropagation()} onClick={e => { e.stopPropagation() setActiveChip(isOpen ? null : { tmpl, ev, chipX: x1, chipY }) }} title={`${ev.title} [${tmpl.name}]\n${fmtDateFR(ev.date)} → ${ev.end_date ? fmtDateFR(ev.end_date) : `+${absorptionDays}j`}`} > {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 — fetch live depuis causal_event_analyses au moment du clic */} {popupScore === 'loading' ? (
Chargement score…
) : popupScore != null ? (() => { const barCls = popupScore >= 70 ? 'bg-emerald-500' : popupScore >= 40 ? 'bg-amber-500' : 'bg-red-500' const txtCls = popupScore >= 70 ? 'text-emerald-400' : popupScore >= 40 ? 'text-amber-400' : 'text-red-400' return (
Précision prédiction {popupScore}%
) })() : null}
) })()} {/* X-axis */}
{visTicks.map((t, i) => ( {t.label} ))}
) } // ── ExplanationScore ────────────────────────────────────────────────────────── function computeMagnitudeScore(analysis: { prediction_json: Record; actual_json: Record; graph_json: { nodes?: { id: string; type?: string; instrument?: string }[] } }): number | null { const preds = analysis.prediction_json || {} const actuals = analysis.actual_json || {} const outputNodes = (analysis.graph_json?.nodes || []).filter(n => n.type === 'market_asset' || n.type === 'output') const nodeScores = outputNodes.map(n => { const pred = preds[n.id] const act = actuals[n.instrument || n.id] ?? actuals[n.id] if (pred == null || act == null) return null if (pred === 0 && act === 0) return 100 if (pred === 0) return 40 if (pred * act <= 0) return 0 const ratio = Math.min(Math.abs(act), Math.abs(pred)) / Math.max(Math.abs(act), Math.abs(pred)) return Math.round(50 + ratio * 50) }).filter((s): s is number => s != null) return nodeScores.length > 0 ? Math.round(nodeScores.reduce((a, b) => a + b, 0) / nodeScores.length) : null } function ExplanationScore({ events, templates, causalInsts, onRefreshDone, onDebugResult, debugInfo, }: { events: SnapshotEvent[] templates: CausalTemplate[] causalInsts: string[] 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) const [liveScores, setLiveScores] = useState>({}) // Fetch all linked event analyses dynamically — same formula as MarketEvents expanded panel const linkedIds = events.filter(ev => ev.template_id && ev.id).map(ev => ev.id as number) const linkedKey = linkedIds.join(',') useEffect(() => { if (!linkedIds.length) return Promise.all(linkedIds.map(id => api.get(`/causal-lab/analyses?market_event_id=${id}&limit=1`) .then(r => ({ id, score: computeMagnitudeScore(r.data?.[0]) })) .catch(() => ({ id, score: null })) )).then(results => { setLiveScores(Object.fromEntries(results.map(r => [r.id, r.score]))) }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [linkedKey]) const scored: number[] = [] const totalLinked: number[] = [] for (const ev of events) { if (!ev.template_id) continue const tmpl = templates.find(t => t.id === ev.template_id) if (!tmpl) continue if (!causalInsts.some(ci => tmpl.instruments.includes(ci))) continue totalLinked.push(ev.id ?? 0) const s = ev.id != null ? liveScores[ev.id] : null 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 && ( )}
{/* Refresh result panel */} {debugInfo && (
{debugInfo.error ? Erreur: {debugInfo.error} : Refresh: {debugInfo.refreshed} ok / {debugInfo.failed} err / {debugInfo.total} total }
)}
) } // ── 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' }, ] // ── Types factor-state ──────────────────────────────────────────────────────── interface FactorContrib { event_name: string; event_date: string; template_name: string pips_full: number; days_elapsed: number; absorption_days: number decay_pct: number; pips_current: number } interface FactorCategory { label: string; pips: number; contributions: FactorContrib[] } interface FactorState { instrument: string; at_date: string; net_pips: number direction: 'bullish' | 'bearish' | 'neutral' categories: FactorCategory[]; n_events: number } // ── PressureCockpit ─────────────────────────────────────────────────────────── function PressureCockpit({ instrumentId, refreshKey }: { instrumentId: string; refreshKey: number }) { const [state, setState] = useState(null) const [loading, setLoading] = useState(false) const [expanded, setExpanded] = useState(null) useEffect(() => { if (!instrumentId) return setLoading(true) api.get(`/instruments/${instrumentId}/factor-state`) .then(r => setState(r.data)) .catch(() => setState(null)) .finally(() => setLoading(false)) }, [instrumentId, refreshKey]) if (loading) return (
Calcul pression en cours…
) if (!state) return null const { net_pips, direction, categories } = state const maxAbs = Math.max(...categories.map(c => Math.abs(c.pips)), 1) const netCls = direction === 'bullish' ? 'text-emerald-400' : direction === 'bearish' ? 'text-red-400' : 'text-slate-400' const netLabel = direction === 'bullish' ? '▲ HAUSSIER' : direction === 'bearish' ? '▼ BAISSIER' : '◼ NEUTRE' return (
{/* NET */}
Pression nette {net_pips >= 0 ? '+' : ''}{net_pips} pips {netLabel} {state.n_events} event{state.n_events > 1 ? 's' : ''} actifs
{/* Barres par catégorie */} {categories.length === 0 ? (

Aucun event actif avec prédiction pour cet instrument.

) : (
{categories.map(cat => { const barW = Math.round(Math.abs(cat.pips) / maxAbs * 100) const isPos = cat.pips >= 0 const isOpen = expanded === cat.label return (
{/* Détail events */} {isOpen && (
{cat.contributions.map((c, i) => (
{c.event_name} {c.event_date} · {c.decay_pct}% actif ({c.days_elapsed}j) = 0 ? 'text-emerald-500' : 'text-red-500')}> {c.pips_current >= 0 ? '+' : ''}{c.pips_current}
))}
)}
) })}
)}
) } 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' | 'wavelets'>('counters') const [waveletLevels, setWaveletLevels] = useState(4) const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw') const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt') const [waveletCausal, setWaveletCausal] = useState(false) const [waveletLookback, setWaveletLookback] = useState(260) const [waveletData, setWaveletData] = useState(null) const [loadingWavelet, setLoadingWavelet] = useState(false) const [waveletReliability, setWaveletReliability] = useState(null) const [loadingReliability, setLoadingReliability] = useState(false) const [hiddenBands, setHiddenBands] = useState>(new Set()) const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState(null) 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 const runWaveletAnalysis = async () => { if (!selected) return setLoadingWavelet(true) try { const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze' const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod } if (waveletCausal) params.lookback = waveletLookback const { data } = await api.get(path, { params }) setWaveletData(data) setHiddenBands(new Set()) } catch (e) { console.error('Wavelet analysis failed', e) setWaveletData(null) } finally { setLoadingWavelet(false) } } const runWaveletReliability = async () => { if (!selected) return setLoadingReliability(true) try { const { data } = await api.get('/wavelet/reliability', { params: { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback, }, }) setWaveletReliability(data) } catch (e) { console.error('Wavelet reliability failed', e) setWaveletReliability(null) } finally { setLoadingReliability(false) } } const waveletChartData = useMemo(() => { if (!waveletData) return [] const { dates, original, bands, mean } = waveletData return dates.map((d: string, i: number) => { const row: any = { date: d.slice(0, 10), original: original[i] } let recon = mean ?? 0 for (const b of bands) { row[b.label] = b.series[i]; recon += b.series[i] } row.reconstruction = recon return row }) }, [waveletData]) const waveletStats = useMemo(() => { if (!waveletData || waveletChartData.length < 2) return null const orig: number[] = waveletData.original const recon = waveletChartData.map((r: any) => r.reconstruction) const n = orig.length const meanO = orig.reduce((a: number, b: number) => a + b, 0) / n const meanR = recon.reduce((a: number, b: number) => a + b, 0) / n let cov = 0, varO = 0, varR = 0, sumAbs = 0, maxAbs = 0 for (let i = 0; i < n; i++) { const dO = orig[i] - meanO, dR = recon[i] - meanR cov += dO * dR; varO += dO * dO; varR += dR * dR const err = Math.abs(orig[i] - recon[i]) sumAbs += err; maxAbs = Math.max(maxAbs, err) } const correlation = varO > 0 && varR > 0 ? cov / Math.sqrt(varO * varR) : null return { correlation, meanAbsError: sumAbs / n, maxAbsError: maxAbs } }, [waveletData, waveletChartData]) // Rolling energy per band E_i(t) = mean(w_i(t-k)^2, k=0..L-1) — RMS power of each band // over a trailing window, its share of total energy across bands, and its 20-day change. // Trend Ratio = (sum of all faster bands) / (slowest band) — a single number meant to be // far more stable than watching a band's raw coefficient cross zero: high → a genuine // slow trend dominates (breakouts tend to hold); low → high-frequency noise dominates // (range, false breakouts). // // Two additions on top of raw energy: // - dE/dt (WAVELET_SLOPE_LAG-day slope of the rolling energy): energy alone doesn't say // whether a band is building or fading — two bands can carry the same RMS while one is // accelerating and the other decaying, and only the slope tells them apart. // - Coherence C_i(t) = |rolling mean(w_i)| / RMS(w_i): energy also doesn't capture whether // a band is moving persistently in one direction or oscillating symmetrically around // zero — a clean sinusoid and ragged noise can carry identical energy. Bounded [0,1] by // construction (Cauchy-Schwarz: |mean| ≤ RMS always) — 1 = every value in the window // pushed the same direction (a real directional cycle), 0 = perfectly symmetric, // cancels out (pure oscillation, no net drift). const WAVELET_ROLLING_WINDOW = 20 const WAVELET_SLOPE_LAG = 5 const waveletEnergy = useMemo(() => { const bands: { label: string; series: number[]; reconstruction_failed?: boolean }[] = waveletData?.bands ?? [] const n = bands[0]?.series?.length ?? 0 if (!bands.length || n === 0) return null const L = WAVELET_ROLLING_WINDOW const energyByBand: number[][] = [] const meanByBand: number[][] = [] const coherenceByBand: number[][] = [] for (const b of bands) { const s = b.series const e = new Array(n).fill(0) const m = new Array(n).fill(0) const c = new Array(n).fill(0) let sumSq = 0 let sum = 0 for (let t = 0; t < n; t++) { sumSq += s[t] * s[t] sum += s[t] if (t >= L) { sumSq -= s[t - L] * s[t - L]; sum -= s[t - L] } const count = Math.min(t + 1, L) e[t] = sumSq / count m[t] = sum / count const rms = Math.sqrt(e[t]) c[t] = rms > 1e-12 ? Math.min(1, Math.abs(m[t]) / rms) : 0 } energyByBand.push(e) meanByBand.push(m) coherenceByBand.push(c) } // dE/dt: short-lag slope of the (already-smoothed) rolling energy — a continuous // "is this band's power building or fading right now" series, distinct from the // 20-day snapshot delta already shown in the table. const slopeByBand: (number | null)[][] = energyByBand.map(e => e.map((_, t) => (t >= WAVELET_SLOPE_LAG ? (e[t] - e[t - WAVELET_SLOPE_LAG]) / WAVELET_SLOPE_LAG : null)) ) // The "slowest" band = the one with the largest upper period bound parsed from its own // label (e.g. "25-256j" → 256). Falls back to the last band in the array (the usual // fast→slow ordering convention) if any label doesn't parse. const periodHighs = bands.map(b => { const m = b.label.match(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)/) return m ? parseFloat(m[2]) : null }) const slowIdx = periodHighs.every(v => v != null) ? periodHighs.indexOf(Math.max(...(periodHighs as number[]))) : bands.length - 1 const relByDate: number[][] = [] const trendRatioByDate: (number | null)[] = [] for (let t = 0; t < n; t++) { const values = energyByBand.map(e => e[t]) const total = values.reduce((a, b) => a + b, 0) relByDate.push(values.map(v => (total > 0 ? (v / total) * 100 : 0))) // slow/fast (not the literal fast/slow from the spec — see note where this is used): // a HIGH ratio should mean "the slow trend dominates", matching the worked example // (70% slow / 30% fast called "trend-dominated") — fast/slow gives 0.43 there, which // reads as noise-dominated on the very same 3=trend/0.3=noise scale, contradicting it. const slowE = values[slowIdx] const fastSum = values.reduce((a, v, i) => (i === slowIdx ? a : a + v), 0) trendRatioByDate.push(fastSum > 1e-12 ? slowE / fastSum : null) } return { bands, energyByBand, meanByBand, coherenceByBand, slopeByBand, relByDate, trendRatioByDate, slowIdx, n, L } }, [waveletData]) const waveletEnergyIdx = waveletEnergy ? Math.min(hoveredWaveletIdx ?? waveletEnergy.n - 1, waveletEnergy.n - 1) : 0 // Volatility percentile (vs. this instrument's own history — 8% means something // different on EURUSD than on the NASDAQ, the percentile rank makes it universal) and its // own trend, joined to the wavelet's date axis so the regime panel updates on hover too. const WAVELET_VOL_DELTA_LAG = 10 const waveletRegime = useMemo(() => { if (!waveletEnergy) return null const volPoints: LinePoint[] = snapshot?.indicators?.volatility ?? [] if (!volPoints.length) return null const volByDate = new Map(volPoints.map(p => [p.time, p.value])) const sortedVols = volPoints.map(p => p.value).sort((a, b) => a - b) const percentileOf = (v: number) => { let lo = 0, hi = sortedVols.length while (lo < hi) { const mid = (lo + hi) >> 1; if (sortedVols[mid] <= v) lo = mid + 1; else hi = mid } return (lo / sortedVols.length) * 100 } const n = waveletEnergy.n const volAt: (number | null)[] = new Array(n).fill(null) const volPercentileAt: (number | null)[] = new Array(n).fill(null) for (let t = 0; t < n; t++) { const date = waveletChartData[t]?.date const v = date != null ? volByDate.get(date) : undefined if (v != null) { volAt[t] = v; volPercentileAt[t] = percentileOf(v) } } const trendRatioSlopeAt = (t: number): number | null => { const lag = WAVELET_SLOPE_LAG const cur = waveletEnergy.trendRatioByDate[t] const prev = t >= lag ? waveletEnergy.trendRatioByDate[t - lag] : null return cur != null && prev != null ? cur - prev : null } const volDeltaAt = (t: number): number | null => { const lag = WAVELET_VOL_DELTA_LAG const cur = volPercentileAt[t] const prev = t >= lag ? volPercentileAt[t - lag] : null return cur != null && prev != null ? cur - prev : null } return { volAt, volPercentileAt, trendRatioSlopeAt, volDeltaAt } }, [waveletEnergy, waveletChartData, snapshot]) const waveletRegimeView = useMemo(() => { if (!waveletEnergy) return null const idx = waveletEnergyIdx const slowIdx = waveletEnergy.slowIdx const trendRatio = waveletEnergy.trendRatioByDate[idx] const coherenceSlow = waveletEnergy.coherenceByBand[slowIdx][idx] const meanSlow = waveletEnergy.meanByBand[slowIdx][idx] const direction = meanSlow > 1e-9 ? 'Haussière' : meanSlow < -1e-9 ? 'Baissière' : 'Neutre' const energieSlowPct = waveletEnergy.relByDate[idx][slowIdx] const eSlow = waveletEnergy.energyByBand[slowIdx][idx] const slopeSlow = waveletEnergy.slopeByBand[slowIdx][idx] const slowSlopePct = slopeSlow != null && eSlow > 1e-12 ? (slopeSlow / eSlow) * 100 : null const volPercentile = waveletRegime?.volPercentileAt[idx] ?? null const volRaw = waveletRegime?.volAt[idx] ?? null const volDelta = waveletRegime ? waveletRegime.volDeltaAt(idx) : null const trendRatioSlope = waveletRegime ? waveletRegime.trendRatioSlopeAt(idx) : null const regime = classifyMarketRegime({ trendRatio, coherenceSlow, volPercentile, volDelta, trendRatioSlope, slowSlopePct }) return { trendRatio, coherenceSlow, direction, energieSlowPct, slowSlopePct, volPercentile, volRaw, volDelta, trendRatioSlope, regime, } }, [waveletEnergy, waveletEnergyIdx, waveletRegime]) 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' }, { key: 'wavelets', label: 'Ondelettes' }, ] 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 (
{/* Pression nette actuelle */}
Pression Nette
{/* 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 */}
) })()} {tabUnder === 'wavelets' && (
Décomposition Ondelette
{waveletCausal && ( )}
{waveletData ? ( <>
{waveletData.bands.map((b: any, i: number) => ( ))}
{ if (state?.activeTooltipIndex != null) setHoveredWaveletIdx(Number(state.activeTooltipIndex)) }} onMouseLeave={() => setHoveredWaveletIdx(null)} > {/* Two Y-axes: band oscillations (demeaned, small amplitude) would be crushed flat against the raw price level (e.g. ~1.15 for EURUSD) on a shared axis — same dual-scale idiom as InstrumentChart's theoryCurve overlay. */} {waveletData.bands.map((b: any, i: number) => !hiddenBands.has(b.label) && ( ))} {waveletStats && (
Corrélation reconstruction: {waveletStats.correlation != null ? waveletStats.correlation.toFixed(3) : '—'} Erreur moy.: {waveletStats.meanAbsError.toFixed(3)} Erreur max: {waveletStats.maxAbsError.toFixed(3)} {waveletCausal && Mode causal (walk-forward) — {waveletData.recomputations} recalculs}
)} {waveletEnergy && (
Énergie par bande {hoveredWaveletIdx != null && waveletChartData[waveletEnergyIdx] && ( — au {waveletChartData[waveletEnergyIdx].date} )} {waveletEnergy.trendRatioByDate[waveletEnergyIdx] != null && (() => { const ratio = waveletEnergy.trendRatioByDate[waveletEnergyIdx]! const verdict = ratio >= 2 ? 'cycle lent dominant' : ratio >= 0.7 ? 'équilibré' : 'bruit dominant' const color = ratio >= 2 ? 'text-emerald-400' : ratio >= 0.7 ? 'text-amber-400' : 'text-red-400' return ( Trend Ratio {ratio.toFixed(2)} — {verdict} ) })()}
{waveletEnergy.bands.map((b, i) => { const color = WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] const e = waveletEnergy.energyByBand[i][waveletEnergyIdx] const rms = Math.sqrt(e) const rel = waveletEnergy.relByDate[waveletEnergyIdx][i] const prevIdx = waveletEnergyIdx - waveletEnergy.L const delta = prevIdx >= 0 ? rel - waveletEnergy.relByDate[prevIdx][i] : null const isSlow = i === waveletEnergy.slowIdx const slope = waveletEnergy.slopeByBand[i][waveletEnergyIdx] const slopePct = slope != null && e > 1e-12 ? (slope / e) * 100 : null const coherence = waveletEnergy.coherenceByBand[i][waveletEnergyIdx] const sparkStart = Math.max(0, waveletEnergyIdx - 59) const sparkValues = waveletEnergy.energyByBand[i].slice(sparkStart, waveletEnergyIdx + 1).map(Math.sqrt) return ( ) })} {waveletRegimeView && ( )}
Bande RMS dE/dt % énergie Δ énergie ({waveletEnergy.L}j) Cohérence
{b.label} {isSlow && (lente)} {b.reconstruction_failed && ( )}
{rms.toFixed(4)} 0.5 ? 'text-emerald-400' : slopePct < -0.5 ? 'text-red-400' : 'text-slate-400')}> {slopePct == null ? '—' : `${slopePct > 0.5 ? '▲' : slopePct < -0.5 ? '▼' : '→'} ${slopePct >= 0 ? '+' : ''}${slopePct.toFixed(1)}%/j`} {rel.toFixed(0)}% = 0 ? 'text-emerald-400' : 'text-red-400')}> {delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta.toFixed(0)}%`} = 0.6 ? 'text-emerald-400' : coherence >= 0.3 ? 'text-amber-400' : 'text-slate-500')}> {coherence.toFixed(2)}
Volatilité {waveletRegimeView.volRaw != null ? `${waveletRegimeView.volRaw.toFixed(1)}%` : '—'} 3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-400')}> {waveletRegimeView.volDelta == null ? '—' : `${waveletRegimeView.volDelta > 3 ? '▲' : waveletRegimeView.volDelta < -3 ? '▼' : '→'} ${waveletRegimeView.volDelta >= 0 ? '+' : ''}${waveletRegimeView.volDelta.toFixed(0)}pt`} {waveletRegimeView.volPercentile != null ? `${waveletRegimeView.volPercentile.toFixed(0)}e pct` : '—'} {bucketVolPercentile(waveletRegimeView.volPercentile).label}

Trend Ratio = (somme des bandes rapides) / (bande lente). Énergie haute + cohérence haute = cycle lent puissant et directionnel ; énergie haute + cohérence basse = oscillation ample sans direction nette. Volatilité en percentile de son propre historique (universel : 8% ne veut pas dire la même chose sur EURUSD que sur le Nasdaq). Survolez le graphique pour voir ces valeurs évoluer dans le temps.

{waveletRegimeView && (
Régime {waveletRegimeView.regime.name}

{waveletRegimeView.regime.reading}

Structure (Trend Ratio)
{waveletRegimeView.trendRatio != null ? waveletRegimeView.trendRatio.toFixed(2) : '—'} · {bucketTrendRatio(waveletRegimeView.trendRatio).label}
Direction lente
{waveletRegimeView.direction} · {waveletRegimeView.energieSlowPct.toFixed(0)}% énergie
Dynamique (dE lente/dt)
{bucketDynamic(waveletRegimeView.slowSlopePct).label}
Qualité (Cohérence lente)
{waveletRegimeView.coherenceSlow != null ? waveletRegimeView.coherenceSlow.toFixed(2) : '—'} · {bucketCoherence(waveletRegimeView.coherenceSlow).label}
Risque (Vol percentile)
{bucketVolPercentile(waveletRegimeView.volPercentile).label}
Dynamique du risque (ΔVol)
3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-300')}> {waveletRegimeView.volDelta == null ? '—' : waveletRegimeView.volDelta > 3 ? 'Risque ↑' : waveletRegimeView.volDelta < -3 ? 'Risque ↓' : 'Stable'}
)}
)} ) : (
{loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"}
)} {waveletReliability && (
Fiabilité des retournements (horizon de recalcul = cycle propre à chaque bande)
{waveletReliability.bands.map((b: any, i: number) => { const pct = b.confidence_pct const color = pct == null ? 'text-slate-500' : pct >= 70 ? 'text-emerald-400' : pct >= 40 ? 'text-amber-400' : 'text-red-400' const gap = b.calibration_gap_days const gapColor = gap == null ? 'text-slate-600' : gap <= 2 ? 'text-emerald-400' : gap <= 8 ? 'text-amber-400' : 'text-red-400' return ( ) })}
Bande Cycle moyen Horizon max Délai réel moyen Écart calibration Retournements testés Confirmés Indice de confiance
{b.label} {b.avg_cycle_days != null ? `${b.avg_cycle_days}j` : '—'} {b.confirm_horizon != null ? `+${b.confirm_horizon}j` : '—'} {b.avg_actual_delay_days != null ? `${b.avg_actual_delay_days}j` : '—'} {gap != null ? `${gap}j` : '—'} {b.n_tested} {b.n_confirmed} {pct != null ? `${pct.toFixed(0)}%` : 'n/a (pas assez de retournements testables)'}

Pour chaque retournement détecté en mode causal (pente lissée sur {waveletReliability.smooth_days}j qui change de signe, sans regarder le futur), l'horizon de recalcul est propre à chaque bande — cycle moyen historique (pic-à-pic) × 1.10, pas un nombre de jours fixe identique pour toutes (10 jours ne veut rien dire pour une bande à 2j de période comme pour une à 20j). Le retournement confirme l'original s'il réapparaît n'importe où entre la date d'origine et date+horizon. "Écart calibration" compare l'horizon supposé (cycle moyen) au délai réel observé — un écart élevé signale que l'horizon utilisé n'est pas représentatif de la vitesse réelle de confirmation, indépendamment de l'indice de confiance lui-même. Un indice bas signale des retournements souvent dus à un effet de bord de la décomposition (peu fiable pile au bord de la fenêtre disponible), pas à un vrai signal.

)}
)} )} {!loading && !snapshot && (

Aucune donnée disponible pour {instrumentId}

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