Files
OpenFin/frontend/src/pages/InstrumentDashboard.tsx
2026-07-19 16:03:09 +02:00

2454 lines
124 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<svg width={width} height={height} className="inline-block align-middle opacity-80">
<polyline points={points} fill="none" stroke={color} strokeWidth={1.2} />
</svg>
)
}
// ── 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<string, number>
ranked: string[]
asset_bias: Record<string, any>
}
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<string, number>
gauges: Record<string, GaugeValue>
}
interface Snapshot {
instrument: InstrumentConfig
price_data: PriceCandle[]
indicators: Record<string, LinePoint[]>
regime: { current: string; confidence: number; scores: Record<string, number>; 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<string, string> = {
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 <TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
if (v < -0.3) return <TrendingDown className="w-3.5 h-3.5 text-red-400" />
return <Minus className="w-3.5 h-3.5 text-slate-500" />
}
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<string, string[]> = {
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<string, string[]> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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 (
<div className={clsx('rounded-xl border p-4 space-y-3', borderMap[col])}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
</div>
<span className="text-xs text-slate-600">{dateLabel}</span>
</div>
{macroRegime && macroRegime.dominant !== 'incertain' && (
<div className="rounded-lg border border-slate-700/30 bg-dark-700/40 px-3 py-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 uppercase tracking-wide">Cycle macro global</span>
<span className="text-xs text-slate-600">Fed cycle</span>
</div>
<div className="flex items-center gap-2">
<span className="text-base">{macroRegime.emoji || '🌍'}</span>
<span className={clsx('text-sm font-bold', `text-${macroCol}-300`)}>{macroRegime.label || macroRegime.dominant}</span>
</div>
{top3Macro.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{top3Macro.map((key, i) => {
const score = macroRegime.scores?.[key]
const c = macroRegimeColor(key)
return (
<span key={key} className={clsx(
'text-xs px-1.5 py-0.5 rounded border',
i === 0 ? `text-${c}-300 bg-${c}-900/30 border-${c}-700/40` : 'text-slate-500 bg-dark-800/40 border-slate-700/30'
)}>
{key} {score !== undefined ? Math.round(score) + '%' : ''}
</span>
)
})}
</div>
)}
</div>
)}
<div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
<div className="text-xs text-slate-500 mb-0.5">Régime technique</div>
<div className={clsx('text-sm font-bold leading-tight', `text-${col}-300`)}>{regime.current}</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', `bg-${col}-500`)}
style={{ width: `${Math.round(regime.confidence * 100)}%` }} />
</div>
<span className="text-xs text-slate-500">{Math.round(regime.confidence * 100)}%</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{metrics.map(m => (
<div key={m.label} className="rounded-lg bg-dark-700/50 px-2.5 py-2">
<div className="text-xs text-slate-600 mb-0.5 truncate">{m.label}</div>
<div className={clsx('text-sm font-bold', m.color)}>{m.value}</div>
{m.sub && <div className="text-xs text-slate-600 mt-0.5">{m.sub}</div>}
</div>
))}
</div>
</div>
)
}
// ── 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 (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Indicateurs de Tendance</span>
</div>
<span className="text-xs text-slate-600">{dateLabel}</span>
</div>
<div className="flex items-center justify-between bg-dark-700/40 rounded-lg px-3 py-1.5">
<span className="text-xs text-slate-500">Prix</span>
<span className="text-sm font-bold text-white">
{trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
</span>
</div>
{items.map(group => (
<div key={group.group}>
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1.5">{group.group}</div>
{group.rows.map(row => (
<div key={row.label} className="flex items-center justify-between text-xs py-0.5">
<span className="text-slate-500">{row.label}</span>
<span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
<Arrow v={row.arrow ?? 0} />{row.value}
</span>
</div>
))}
</div>
))}
<div className="border-t border-slate-700/30 pt-3">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-slate-500">RSI(14)</span>
<span className={clsx('font-semibold', rsiColor)}>{rsi.toFixed(0)} {rsiZone}</span>
</div>
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
<div className="absolute left-0 top-0 h-full w-[30%] bg-cyan-900/50" />
<div className="absolute right-0 top-0 h-full w-[30%] bg-orange-900/50" />
<div className="absolute top-0 h-full w-2 bg-white rounded-full transition-all"
style={{ left: `calc(${Math.min(98, rsi)}% - 4px)` }} />
</div>
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
<span>0 Survendu</span><span>50</span><span>Suracheté 100</span>
</div>
</div>
{pct52w !== null && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-slate-500">Range 52 semaines</span>
<span className="text-slate-400">{Math.round(pct52w)}e percentile</span>
</div>
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
<div className="absolute top-0 h-full bg-blue-500/60 rounded-full" style={{ width: `${pct52w}%` }} />
</div>
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
<span>{trend.low_52w?.toFixed(2)}</span><span>{trend.high_52w?.toFixed(2)}</span>
</div>
</div>
)}
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">ATR(14) vs moy. 3M</span>
<span className={clsx((trend.atr_vs_3m_avg_pct ?? 100) > 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)}%
</span>
</div>
</div>
)
}
// ── EventsCard ────────────────────────────────────────────────────────────────
function EventsCard({ events, selectedDate }: { events: SnapshotEvent[]; selectedDate: string | null }) {
const navigate = useNavigate()
const LEVEL_C: Record<string, string> = {
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 (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-amber-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Événements Macro</span>
</div>
{events.length === 0 ? (
<div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div>
) : (
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
{events.map((ev, i) => {
const active = isActiveAt(ev)
return (
<div key={i}
className={clsx(
'rounded-lg border p-2 cursor-pointer transition-colors',
active
? 'border-amber-600/50 bg-amber-900/20 hover:bg-amber-900/30'
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
)}
onClick={() => navigate(`/timeline?date=${ev.date}`)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{active && <span className="shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400 mt-0.5" />}
<span className="text-xs font-medium text-slate-300 leading-tight truncate">{ev.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{ev.sub_type && (
<span className="text-xs px-1 py-0 rounded bg-slate-700/50 text-slate-400 border border-slate-600/30"
style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
{ev.sub_type}
</span>
)}
<span className={clsx('text-xs px-1.5 py-0.5 rounded border', LEVEL_C[ev.level] ?? 'text-slate-400 bg-slate-800 border-slate-700/30')}>
{ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
</span>
</div>
</div>
{/* Eco calendar fields */}
{(ev.expected_value || ev.actual_value) && (
<div className="flex items-center gap-3 mt-1.5 text-xs">
{ev.expected_value && (
<span className="text-slate-500">Att. <span className="text-slate-300">{ev.expected_value}</span></span>
)}
{ev.actual_value && (
<span className="text-slate-500">Réel <span className="font-semibold text-white">{ev.actual_value}</span></span>
)}
{ev.surprise_pct !== null && ev.surprise_pct !== undefined && (
<span className={clsx('font-semibold', surpriseColor(ev.surprise_pct))}>
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(0)}%
{ev.surprise_pct > 5 ? ' ↑' : ev.surprise_pct < -5 ? ' ↓' : ''}
</span>
)}
{ev.unit && <span className="text-slate-600">{ev.unit}</span>}
</div>
)}
{ev.absorption_pct !== null && ev.absorption_pct !== undefined && (
<div className="mt-1.5">
<div className="flex items-center justify-between text-xs text-slate-600 mb-0.5">
<span>Absorption marché</span><span>{ev.absorption_pct}%</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full bg-amber-600/60 rounded-full" style={{ width: `${ev.absorption_pct}%` }} />
</div>
</div>
)}
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
{fmtDateFR(ev.date)}{ev.end_date ? `${fmtDateFR(ev.end_date)}` : ''}
{ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
</div>
{ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
</div>
)
})}
</div>
)}
</div>
)
}
// ── NarrativeCard ─────────────────────────────────────────────────────────────
function NarrativeCard({ narrative, loading, onLoad, instrument }: {
narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig
}) {
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-violet-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Narration IA {instrument.name}</span>
</div>
<button onClick={onLoad} disabled={loading}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-800/30 hover:bg-violet-800/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
>
{loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
{loading ? 'Génération...' : narrative ? 'Actualiser' : 'Générer'}
</button>
</div>
{narrative ? (
<p className="text-sm text-slate-300 leading-relaxed">{narrative}</p>
) : loading ? (
<div className="space-y-2">
{[95, 88, 70].map(w => <div key={w} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${w}%` }} />)}
</div>
) : (
<div className="text-sm text-slate-600 italic">
Cliquez "Générer" pour une analyse IA pour {instrument.name}.
</div>
)}
</div>
)
}
// ── Macro gauge helpers ───────────────────────────────────────────────────────
const SCENARIO_META: Record<string, { label: string; emoji: string; color: string }> = {
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<string, string> = {
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<string, GaugeValue[]> = {}
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 (
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-base">{meta.emoji}</span>
<span className="text-sm font-semibold text-white">{meta.label}</span>
<span className="text-xs text-slate-500"> contexte macro au {dateLabel}</span>
</div>
<span className="text-xs text-slate-600">{snap.snapshot_date}</span>
</div>
{/* Regime scores mini bar */}
<div className="flex gap-1 h-1.5">
{Object.entries(snap.regime_scores)
.sort(([,a],[,b]) => b - a)
.slice(0, 6)
.map(([k, v]) => (
<div key={k} title={`${SCENARIO_META[k]?.label ?? k}: ${(v*100).toFixed(0)}%`}
style={{ width: `${v*100}%`, background: SCENARIO_META[k]?.color ?? '#64748b' }}
className="rounded-full transition-all"
/>
))}
</div>
{/* Gauges by bloc */}
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
{Object.entries(byBloc).map(([bloc, gauges]) => (
<div key={bloc}>
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1">{BLOC_LABELS[bloc] ?? bloc}</div>
<div className="space-y-0.5">
{gauges.map(g => (
<div key={g.id} className="flex items-center justify-between text-xs">
<span className="text-slate-400 truncate max-w-[120px]" title={g.label}>{g.label}</span>
<div className="flex items-center gap-2">
{g.value !== null && (
<span className="text-white font-mono">
{g.unit === '%' ? g.value.toFixed(2) + '%'
: g.unit === 'pts' ? g.value.toFixed(1)
: g.unit === 'ratio' ? g.value.toFixed(3)
: g.value.toFixed(2)}
</span>
)}
{g.change_pct !== null && g.change_pct !== undefined && (
<span className={clsx('font-mono', g.change_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{g.change_pct >= 0 ? '+' : ''}{g.change_pct.toFixed(1)}%
</span>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
// ── 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<string, number> = JSON.parse(ev.prediction_json)
const actuals: Record<string, number> = 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<HTMLDivElement>(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<number | null | 'loading'>('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<string, number> = analysis.prediction_json || {}
const actuals: Record<string, number> = 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<typeof setTimeout>
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 (
<div ref={containerRef} className="h-12 flex items-center justify-center text-xs text-slate-600 italic">
Aucun graphe causal lié pour cet instrument
</div>
)
}
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 (
<div
ref={containerRef}
className="relative w-full select-none"
style={{ height: containerH }}
onClick={() => setActiveChip(null)}
>
{/* Month grid lines */}
{visTicks.map((t, i) => (
<div key={i} className="absolute top-0 pointer-events-none"
style={{ left: t.x, bottom: FRISE_AXIS_H, width: 1, background: 'rgba(100,116,139,0.12)' }} />
))}
{/* Crosshair */}
{crossX !== null && (
<div className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: crossX, width: 1, background: 'rgba(96,165,250,0.3)' }} />
)}
{/* 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 (
<div
key={`${tmpl.id}-${ev.id ?? ev.date}`}
className={clsx(
'absolute rounded border cursor-pointer transition-all flex items-center gap-1 overflow-hidden px-1.5',
'text-[9px] font-medium leading-none',
active
? catTw + (isOpen ? ' ring-1 ring-current/40 brightness-125' : ' hover:brightness-110')
: clsx('text-slate-500 border-slate-700/40 bg-slate-800/25',
'hover:text-slate-300 hover:border-slate-600/60 hover:bg-slate-800/50'),
)}
style={{ left: x1, top: chipY, width: w, height: chipH }}
onMouseDown={e => 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`}`}
>
<span className={clsx('w-1.5 h-1.5 rounded-full bg-current shrink-0', !active && 'opacity-40')} />
{label && <span className="truncate">{label}</span>}
</div>
)
})}
{/* 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 (
<div
className="absolute z-30 bg-dark-800 border border-slate-600/40 rounded-lg shadow-2xl p-3"
style={{ left: popLeft, top: popTop, width: FRISE_POPUP_W }}
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<button
onClick={() => setActiveChip(null)}
className="absolute top-1.5 right-1.5 w-4 h-4 flex items-center justify-center text-slate-600 hover:text-slate-300 text-xs leading-none"
>×</button>
<div className="flex items-start gap-2 mb-1.5 pr-4">
<span className="flex-1 text-xs font-semibold text-slate-200 leading-tight line-clamp-2">{tmpl.name}</span>
<span className={clsx('text-[9px] px-1 py-0.5 rounded border shrink-0',
EV_CAT_TW[ev.category] ?? 'text-slate-500 border-slate-700/30'
)}>{ev.category}</span>
</div>
<div className="text-[10px] text-slate-400 truncate mb-1.5">{ev.title}</div>
<div className="flex items-center gap-3 mb-2 text-[9px] text-slate-600">
<span className="flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{fmtDateFR(ev.date)} {ev.end_date ? fmtDateFR(ev.end_date) : `+${templateAbsorptionDays(tmpl)}j`}
</span>
{ev.impact_score != null && (
<span className="text-amber-600"> {ev.impact_score.toFixed(1)}</span>
)}
{ev.surprise_pct != null && (
<span className={ev.surprise_pct > 0 ? 'text-emerald-600' : 'text-red-600'}>
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(1)}%
</span>
)}
</div>
{/* Précision — fetch live depuis causal_event_analyses au moment du clic */}
{popupScore === 'loading' ? (
<div className="mb-2 text-[9px] text-slate-600 italic">Chargement score</div>
) : 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 (
<div className="mb-2">
<div className="flex items-center justify-between text-[9px] text-slate-600 mb-0.5">
<span>Précision prédiction</span>
<span className={txtCls}>{popupScore}%</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', barCls)} style={{ width: `${popupScore}%` }} />
</div>
</div>
)
})() : null}
<div className="flex gap-1.5">
<button
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-slate-400 hover:text-slate-200 bg-slate-700/40 hover:bg-slate-700/60 rounded px-2 py-1 transition-colors"
>Événement </button>
<button
onClick={() => { navigate(`/causal-lab?template=${tmpl.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 rounded px-2 py-1 transition-colors"
>CausalLab </button>
</div>
</div>
)
})()}
{/* X-axis */}
<div className="absolute left-0 right-0 border-t border-slate-700/30" style={{ bottom: FRISE_AXIS_H }}>
{visTicks.map((t, i) => (
<span key={i} className="absolute text-[9px] text-slate-600 -translate-x-1/2 pt-0.5" style={{ left: t.x }}>
{t.label}
</span>
))}
</div>
</div>
)
}
// ── ExplanationScore ──────────────────────────────────────────────────────────
function computeMagnitudeScore(analysis: { prediction_json: Record<string, number>; actual_json: Record<string, number>; 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<Record<number, number | null>>({})
// 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 (
<div className="space-y-2">
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-slate-700/30 bg-dark-800/50">
<span className="text-xs text-slate-500 whitespace-nowrap">Note globale</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all', barCls)} style={{ width: `${globalScore ?? 0}%` }} />
</div>
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
{globalScore != null ? `${globalScore}% — ` : ''}{verdict}
</span>
<span className="text-[10px] text-slate-600 whitespace-nowrap">
{scored.length}/{totalLinked.length} graphes
</span>
{(needsRefresh || scored.length === 0) && !refreshDone && totalLinked.length > 0 && (
<button
onClick={handleRefresh}
disabled={refreshing}
className="text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 border border-violet-800/40 rounded px-2 py-1 whitespace-nowrap disabled:opacity-50 transition-colors"
>
{refreshing ? '…' : 'Recalculer'}
</button>
)}
</div>
{/* Refresh result panel */}
{debugInfo && (
<div className="rounded-lg border border-slate-700/30 bg-dark-900/60 p-3 text-[10px] font-mono text-slate-600">
{debugInfo.error
? <span className="text-red-500">Erreur: {debugInfo.error}</span>
: <span>Refresh: {debugInfo.refreshed} ok / {debugInfo.failed} err / {debugInfo.total} total</span>
}
</div>
)}
</div>
)
}
// ── 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<FactorState | null>(null)
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState<string | null>(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 (
<div className="h-16 flex items-center justify-center text-xs text-slate-600 italic">
Calcul pression en cours
</div>
)
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 (
<div className="space-y-2">
{/* NET */}
<div className="flex items-center gap-3 px-1">
<span className="text-xs text-slate-500 uppercase tracking-wide">Pression nette</span>
<span className={clsx('font-mono font-bold text-base', netCls)}>
{net_pips >= 0 ? '+' : ''}{net_pips} pips
</span>
<span className={clsx('text-[10px] font-semibold px-1.5 py-0.5 rounded border', netCls,
direction === 'bullish' ? 'border-emerald-700/40 bg-emerald-900/20'
: direction === 'bearish' ? 'border-red-700/40 bg-red-900/20'
: 'border-slate-700/40 bg-slate-800/20')}>
{netLabel}
</span>
<span className="ml-auto text-[10px] text-slate-600">{state.n_events} event{state.n_events > 1 ? 's' : ''} actifs</span>
</div>
{/* Barres par catégorie */}
{categories.length === 0 ? (
<p className="text-xs text-slate-600 italic px-1">Aucun event actif avec prédiction pour cet instrument.</p>
) : (
<div className="space-y-1.5">
{categories.map(cat => {
const barW = Math.round(Math.abs(cat.pips) / maxAbs * 100)
const isPos = cat.pips >= 0
const isOpen = expanded === cat.label
return (
<div key={cat.label}>
<button
className="w-full flex items-center gap-2 text-xs hover:bg-slate-800/30 rounded px-1 py-0.5 transition-colors"
onClick={() => setExpanded(isOpen ? null : cat.label)}
>
<span className="w-32 text-left text-slate-400 shrink-0 truncate">{cat.label}</span>
{/* Barre */}
<div className="flex-1 h-3 bg-slate-800/60 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', isPos ? 'bg-emerald-600/70' : 'bg-red-600/70')}
style={{ width: `${barW}%` }}
/>
</div>
<span className={clsx('font-mono font-semibold w-16 text-right shrink-0', isPos ? 'text-emerald-400' : 'text-red-400')}>
{isPos ? '+' : ''}{cat.pips}
</span>
<span className="text-slate-600 text-[10px] shrink-0">{isOpen ? '▲' : '▼'}</span>
</button>
{/* Détail events */}
{isOpen && (
<div className="ml-2 mt-1 space-y-1 border-l border-slate-700/40 pl-3">
{cat.contributions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-[10px] text-slate-500">
<span className="flex-1 truncate">{c.event_name}</span>
<span className="text-slate-600 shrink-0">{c.event_date} · {c.decay_pct}% actif ({c.days_elapsed}j)</span>
<span className={clsx('font-mono shrink-0 w-12 text-right', c.pips_current >= 0 ? 'text-emerald-500' : 'text-red-500')}>
{c.pips_current >= 0 ? '+' : ''}{c.pips_current}
</span>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
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<InstrumentConfig[]>([])
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false)
const [refreshDebug, setRefreshDebug] = useState<any>(null)
const [loadingNarr, setLoadingNarr] = useState(false)
const [selectorOpen, setSelectorOpen] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(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<any | null>(null)
const [loadingWavelet, setLoadingWavelet] = useState(false)
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
const [loadingReliability, setLoadingReliability] = useState(false)
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
const [templates, setTemplates] = useState<CausalTemplate[]>([])
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
const [theoryCurve, setTheoryCurve] = useState<TheoPoint[] | null>(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<number>(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<string, PriceCandle>, indMap: {} as Record<string, Record<string, number>>, sortedDates: [] as string[], dateIndex: {} as Record<string, number> }
const priceMap: Record<string, PriceCandle> = {}
const indMap: Record<string, Record<string, number>> = {}
const sortedDates: string[] = []
const dateIndex: Record<string, number> = {}
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 (
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
{/* ── Header ── */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<button onClick={() => setSelectorOpen(s => !s)}
className="flex items-center gap-2 px-4 py-2 bg-dark-800 border border-slate-700/40 rounded-xl text-white hover:bg-dark-700 transition-colors"
>
<BarChart2 className="w-4 h-4 text-blue-400" />
<span className="font-semibold">{selected?.name ?? instrumentId}</span>
<span className="text-xs text-slate-500">{selected?.id}</span>
<ChevronDown className="w-3.5 h-3.5 text-slate-500" />
</button>
{selectorOpen && (
<div className="absolute top-full left-0 mt-1 z-50 bg-dark-800 border border-slate-700/40 rounded-xl shadow-2xl p-3 w-[480px] max-h-[60vh] overflow-y-auto">
{grouped.map(g => (
<div key={g.cat} className="mb-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">{g.label}</div>
<div className="grid grid-cols-3 gap-1">
{g.items.map(inst => (
<button key={inst.id}
onClick={() => { navigate(`/instruments/${inst.id}`); setSelectorOpen(false) }}
className={clsx('text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors',
inst.id === instrumentId
? 'bg-blue-800/40 text-blue-300 border border-blue-700/40'
: 'text-slate-400 hover:bg-dark-700 hover:text-white'
)}
>
<div className="font-semibold">{inst.id}</div>
<div className="text-slate-500 truncate">{inst.name}</div>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
{selected && (
<span className="text-xs px-2 py-1 bg-dark-700 border border-slate-700/30 rounded-lg text-slate-400">
{CATEGORY_LABELS[selected.category] ?? selected.category}
</span>
)}
{displayPrice !== undefined && (
<div className="flex items-center gap-3">
<span className="text-xl font-bold text-white">
{displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
</span>
{isLastDate && snapshot && (
<span className={clsx('text-sm font-semibold', (snapshot.change_pct ?? 0) >= 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)}%)
</span>
)}
</div>
)}
<div className="ml-auto flex items-center gap-2">
{/* Chart style toggle */}
<div className="flex items-center bg-dark-800 border border-slate-700/40 rounded-xl p-1">
<button
onClick={() => setChartStyle('candles')}
title="Chandeliers"
className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'candles' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
><BarChart2 className="w-3.5 h-3.5" /></button>
<button
onClick={() => setChartStyle('line')}
title="Courbe lisse"
className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'line' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
><TrendingUp className="w-3.5 h-3.5" /></button>
</div>
{/* Period selector */}
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
{PERIODS.map(p => (
<button key={p.key} onClick={() => setPeriod(p.key)}
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
period === p.key ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
)}
>
{p.label}
</button>
))}
</div>
</div>
{selected && <p className="w-full text-xs text-slate-500">{selected.description}</p>}
</div>
{/* ── Loading ── */}
{loading && (
<div className="space-y-4">
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse" style={{ height: 420 }} />
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-20" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => <div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-72" />)}
</div>
</div>
)}
{/* ── Content ── */}
{!loading && snapshot && (
<>
<InstrumentChart
priceData={snapshot.price_data}
indicators={snapshot.indicators}
events={NO_CHART_EVENTS}
height={420}
chartType={chartStyle}
onDateHover={handleDateHover}
theoryCurve={showTheory && theoryCurve ? theoryCurve : undefined}
onChartReady={handleChartReady}
/>
{/* Date badge */}
<div className="flex items-center gap-2">
<div className={clsx(
'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border',
isLastDate
? 'bg-slate-800 border-slate-700/40 text-slate-400'
: 'bg-blue-900/40 border-blue-700/50 text-blue-300'
)}>
<Calendar className="w-3 h-3" />
Snapshot au {dateLabel}
{!isLastDate && <span className="text-blue-500 ml-1"> survol du graphe</span>}
</div>
</div>
{/* ── Tabs sous la courbe ── */}
<div className="border-b border-slate-700/40 flex gap-1">
{([
{ key: 'counters', label: 'Compteurs' },
{ key: 'analyse', label: 'Analyse de la courbe' },
{ key: 'wavelets', label: 'Ondelettes' },
] as const).map(t => (
<button
key={t.key}
onClick={() => setTabUnder(t.key)}
className={clsx(
'px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
tabUnder === t.key
? 'text-blue-300 border-blue-500'
: 'text-slate-500 border-transparent hover:text-slate-300 hover:border-slate-600'
)}
>
{t.label}
</button>
))}
</div>
{tabUnder === 'counters' && (
<>
<div className="grid grid-cols-3 gap-4">
<RegimeCard
regime={snapshot.regime}
macroRegime={macroAtDate ? snapToMacroRegime(macroAtDate) : (snapshot.macro_regime ?? null)}
signalsAt={dateSignals}
dateLabel={dateLabel}
/>
<TrendCard
trend={dateTrend ?? snapshot.trend}
dateLabel={dateLabel}
/>
<EventsCard
events={snapshot.events}
selectedDate={effectiveDate}
/>
</div>
{macroAtDate && <MacroGaugePanel snap={macroAtDate} dateLabel={dateLabel} />}
</>
)}
{tabUnder === 'analyse' && (() => {
const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? []
const theoPt = effectiveDate && theoryCurve
? theoryCurve.find(p => p.date === effectiveDate) ?? null
: null
return (
<div className="space-y-3">
{/* Pression nette actuelle */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Pression Nette</span>
</div>
<PressureCockpit instrumentId={instrumentId} refreshKey={chartReady} />
</div>
{/* Frise des graphes causaux (inclut l'event) */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart2 className="w-4 h-4 text-violet-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des graphes</span>
<button
onClick={toggleTheory}
disabled={loadingTheory}
className={`ml-auto px-2.5 py-1 rounded text-xs font-medium border transition-colors ${
showTheory
? 'bg-violet-700/60 border-violet-600/60 text-violet-200'
: 'bg-dark-900/40 border-slate-600/40 text-slate-400 hover:border-violet-600/40 hover:text-violet-300'
}`}
>
{loadingTheory ? '↻ Chargement…' : showTheory ? '⟁ Théorie ON' : '⟁ Courbe théorique'}
</button>
</div>
<CausalFrise
events={snapshot.events}
templates={templates}
priceData={snapshot.price_data}
selectedDate={effectiveDate}
causalInsts={causalInsts}
chartDateToX={chartDateToXRef.current ?? undefined}
chartCanvasLeft={chartCanvasLeftRef.current}
chartReady={chartReady}
/>
</div>
{/* Décomposition théorique au curseur */}
{showTheory && theoPt && (
<div className="rounded-xl border border-violet-800/40 bg-violet-950/20 p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-violet-400 text-xs"></span>
<span className="text-xs font-semibold text-violet-300 uppercase tracking-wide">
Contributions théoriques {dateLabel}
</span>
<span className={`ml-auto text-sm font-mono font-bold ${theoPt.cumulative_pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{theoPt.cumulative_pips >= 0 ? '+' : ''}{theoPt.cumulative_pips} pips
</span>
</div>
{theoPt.contributions.length === 0 ? (
<p className="text-xs text-slate-500 italic">Aucun événement actif à cette date.</p>
) : (
<div className="space-y-2">
{theoPt.contributions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div className="flex-1 min-w-0">
<span className="text-slate-300 font-medium truncate block">{c.event_name}</span>
<span className="text-slate-500">{c.template_name} · depuis {c.event_date} · {Math.round(c.decay_factor * 100)}% actif</span>
</div>
<span className={`font-mono font-semibold shrink-0 ${c.pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{c.pips >= 0 ? '+' : ''}{c.pips} pip
</span>
</div>
))}
</div>
)}
</div>
)}
{showTheory && !theoPt && theoryCurve && (
<div className="rounded-xl border border-violet-800/30 bg-violet-950/10 p-3 text-xs text-slate-500 text-center">
Aucune contribution théorique pour cette date
</div>
)}
{/* Note globale */}
<ExplanationScore
events={snapshot.events}
templates={templates}
causalInsts={causalInsts}
onRefreshDone={fetchSnapshotSilent}
onDebugResult={setRefreshDebug}
debugInfo={refreshDebug}
/>
</div>
)
})()}
{tabUnder === 'wavelets' && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Décomposition Ondelette</span>
<div className="ml-auto flex items-center gap-2 flex-wrap text-xs">
<label className="flex items-center gap-1 text-slate-400">
Niveaux
<input type="number" min={2} max={6} value={waveletLevels}
onChange={e => setWaveletLevels(Math.max(2, Math.min(6, Number(e.target.value) || 4)))}
className="w-12 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
</label>
<select value={waveletFamily} onChange={e => setWaveletFamily(e.target.value as any)}
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
<option value="gmw">gmw</option>
<option value="morlet">morlet</option>
<option value="bump">bump</option>
</select>
<select value={waveletMethod} onChange={e => setWaveletMethod(e.target.value as any)}
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
<option value="cwt">CWT</option>
<option value="ssq">SSQ</option>
</select>
<label className="flex items-center gap-1 text-slate-400">
<input type="checkbox" checked={waveletCausal} onChange={e => setWaveletCausal(e.target.checked)} />
Mode causal
</label>
{waveletCausal && (
<label className="flex items-center gap-1 text-slate-400">
Lookback
<input type="number" min={32} value={waveletLookback}
onChange={e => setWaveletLookback(Math.max(32, Number(e.target.value) || 260))}
className="w-14 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
</label>
)}
<button onClick={runWaveletAnalysis} disabled={loadingWavelet}
className="px-2.5 py-1 rounded text-xs font-medium border border-blue-600/40 bg-blue-700/30 text-blue-200 hover:bg-blue-700/50 transition-colors disabled:opacity-50">
{loadingWavelet ? '↻ Calcul…' : "▶ Lancer l'analyse"}
</button>
<button onClick={runWaveletReliability} disabled={loadingReliability}
title="Pour chaque retournement détecté en mode causal (walk-forward, sans anticipation), vérifie si un recalcul 10 jours plus tard confirme le même retournement — un indice de fiabilité par bande."
className="px-2.5 py-1 rounded text-xs font-medium border border-purple-600/40 bg-purple-700/30 text-purple-200 hover:bg-purple-700/50 transition-colors disabled:opacity-50">
{loadingReliability ? '↻ Calcul…' : '◈ Tester la fiabilité'}
</button>
</div>
</div>
{waveletData ? (
<>
<div className="flex flex-wrap gap-3 mb-2 text-[10px]">
{waveletData.bands.map((b: any, i: number) => (
<label key={b.label} className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={!hiddenBands.has(b.label)}
onChange={() => setHiddenBands(prev => {
const next = new Set(prev)
if (next.has(b.label)) next.delete(b.label); else next.add(b.label)
return next
})}
/>
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
{b.reconstruction_failed && (
<span className="text-amber-400" title="Reconstruction indisponible pour cette bande (pas assez d'échelles, ou échec interne de la librairie ondelette) — la série est à zéro, ce n'est pas 'pas d'énergie'."></span>
)}
</label>
))}
</div>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={waveletChartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}
onMouseMove={(state: any) => {
if (state?.activeTooltipIndex != null) setHoveredWaveletIdx(Number(state.activeTooltipIndex))
}}
onMouseLeave={() => setHoveredWaveletIdx(null)}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }}
interval={Math.max(0, Math.floor(waveletChartData.length / 8))} />
{/* 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. */}
<YAxis yAxisId="price" tick={{ fontSize: 9, fill: '#64748b' }} domain={['auto', 'auto']} />
<YAxis yAxisId="band" orientation="right" tick={{ fontSize: 9, fill: '#64748b' }} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10 }} />
<Line yAxisId="price" type="monotone" dataKey="original" stroke="#94a3b8" strokeWidth={1.5} dot={false} name="Prix" isAnimationActive={false} />
<Line yAxisId="price" type="monotone" dataKey="reconstruction" stroke="#22c55e" strokeWidth={1} strokeDasharray="4 2" dot={false} name="Reconstruction" isAnimationActive={false} />
{waveletData.bands.map((b: any, i: number) => !hiddenBands.has(b.label) && (
<Line key={b.label} yAxisId="band" type="monotone" dataKey={b.label}
stroke={WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length]}
strokeWidth={1} dot={false} name={b.label} isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
{waveletStats && (
<div className="mt-2 flex items-center gap-4 flex-wrap text-[10px] text-slate-500">
<span>Corrélation reconstruction: <span className="text-slate-300 font-mono">{waveletStats.correlation != null ? waveletStats.correlation.toFixed(3) : '—'}</span></span>
<span>Erreur moy.: <span className="text-slate-300 font-mono">{waveletStats.meanAbsError.toFixed(3)}</span></span>
<span>Erreur max: <span className="text-slate-300 font-mono">{waveletStats.maxAbsError.toFixed(3)}</span></span>
{waveletCausal && <span className="ml-auto text-blue-400/70">Mode causal (walk-forward) {waveletData.recomputations} recalculs</span>}
</div>
)}
{waveletEnergy && (
<div className="mt-3 pt-3 border-t border-slate-700/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
Énergie par bande {hoveredWaveletIdx != null && waveletChartData[waveletEnergyIdx] && (
<span className="normal-case font-normal text-slate-500"> au {waveletChartData[waveletEnergyIdx].date}</span>
)}
</span>
{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 (
<span className="text-xs">
Trend Ratio <span className={clsx('font-mono font-bold', color)}>{ratio.toFixed(2)}</span>
<span className="text-slate-500"> {verdict}</span>
</span>
)
})()}
</div>
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-500 text-left">
<th className="py-1 pr-3 font-normal">Bande</th>
<th className="py-1 pr-3 font-normal text-right">RMS</th>
<th className="py-1 pr-3 font-normal text-right" title="Pente de l'énergie sur les derniers jours — accélère (▲) ou ralentit (▼), en % de l'énergie actuelle par jour">dE/dt</th>
<th className="py-1 pr-3 font-normal text-right">% énergie</th>
<th className="py-1 pr-3 font-normal text-right">Δ énergie ({waveletEnergy.L}j)</th>
<th className="py-1 pr-3 font-normal text-right" title="|moyenne| / RMS sur la fenêtre — 1 = mouvement net dans un seul sens (directionnel), 0 = oscillation symétrique sans direction nette">Cohérence</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={b.label} className="border-t border-slate-800/60">
<td className="py-1 pr-3">
<div className="flex items-center gap-2">
<span style={{ color }}>{b.label}</span>
{isSlow && <span className="text-slate-600">(lente)</span>}
{b.reconstruction_failed && (
<span className="text-amber-400" title="Reconstruction indisponible pour cette bande — série à zéro, les chiffres ci-contre ne reflètent pas une vraie absence d'énergie."></span>
)}
<Sparkline values={sparkValues} color={color} />
</div>
</td>
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{rms.toFixed(4)}</td>
<td className={clsx('py-1 pr-3 text-right font-mono', slopePct == null ? 'text-slate-600' : slopePct > 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`}
</td>
<td className="py-1 pr-3 text-right text-slate-200 font-mono">{rel.toFixed(0)}%</td>
<td className={clsx('py-1 pr-3 text-right font-mono', delta == null ? 'text-slate-600' : delta >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta.toFixed(0)}%`}
</td>
<td className={clsx('py-1 pr-3 text-right font-mono', coherence >= 0.6 ? 'text-emerald-400' : coherence >= 0.3 ? 'text-amber-400' : 'text-slate-500')}>
{coherence.toFixed(2)}
</td>
</tr>
)
})}
{waveletRegimeView && (
<tr className="border-t border-slate-700/40 bg-slate-800/20">
<td className="py-1 pr-3 text-slate-300 font-semibold">Volatilité</td>
<td className="py-1 pr-3 text-right text-slate-300 font-mono">
{waveletRegimeView.volRaw != null ? `${waveletRegimeView.volRaw.toFixed(1)}%` : '—'}
</td>
<td className={clsx('py-1 pr-3 text-right font-mono',
waveletRegimeView.volDelta == null ? 'text-slate-600' : waveletRegimeView.volDelta > 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`}
</td>
<td className="py-1 pr-3 text-right text-slate-200 font-mono">
{waveletRegimeView.volPercentile != null ? `${waveletRegimeView.volPercentile.toFixed(0)}e pct` : '—'}
</td>
<td className={clsx('py-1 pr-3 text-right', bucketVolPercentile(waveletRegimeView.volPercentile).color)}>
{bucketVolPercentile(waveletRegimeView.volPercentile).label}
</td>
<td className="py-1 pr-3" />
</tr>
)}
</tbody>
</table>
<p className="text-[10px] text-slate-600 mt-1">
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.
</p>
{waveletRegimeView && (
<div className="mt-3 pt-3 border-t border-slate-700/30">
<div className="flex items-center gap-3 mb-2">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
<span className={clsx('text-sm font-bold', waveletRegimeView.regime.color)}>{waveletRegimeView.regime.name}</span>
</div>
<p className="text-xs text-slate-400 mb-3">{waveletRegimeView.regime.reading}</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px]">
<div className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500">Structure (Trend Ratio)</div>
<div className={clsx('font-mono font-semibold', bucketTrendRatio(waveletRegimeView.trendRatio).color)}>
{waveletRegimeView.trendRatio != null ? waveletRegimeView.trendRatio.toFixed(2) : '—'} · {bucketTrendRatio(waveletRegimeView.trendRatio).label}
</div>
</div>
<div className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500">Direction lente</div>
<div className="font-mono font-semibold text-slate-200">{waveletRegimeView.direction} · {waveletRegimeView.energieSlowPct.toFixed(0)}% énergie</div>
</div>
<div className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500">Dynamique (dE lente/dt)</div>
<div className={clsx('font-mono font-semibold', bucketDynamic(waveletRegimeView.slowSlopePct).color)}>
{bucketDynamic(waveletRegimeView.slowSlopePct).label}
</div>
</div>
<div className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500">Qualité (Cohérence lente)</div>
<div className={clsx('font-mono font-semibold', bucketCoherence(waveletRegimeView.coherenceSlow).color)}>
{waveletRegimeView.coherenceSlow != null ? waveletRegimeView.coherenceSlow.toFixed(2) : '—'} · {bucketCoherence(waveletRegimeView.coherenceSlow).label}
</div>
</div>
<div className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500">Risque (Vol percentile)</div>
<div className={clsx('font-mono font-semibold', bucketVolPercentile(waveletRegimeView.volPercentile).color)}>
{bucketVolPercentile(waveletRegimeView.volPercentile).label}
</div>
</div>
<div className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500">Dynamique du risque (ΔVol)</div>
<div className={clsx('font-mono font-semibold',
waveletRegimeView.volDelta == null ? 'text-slate-500' : waveletRegimeView.volDelta > 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'}
</div>
</div>
</div>
</div>
)}
</div>
)}
</>
) : (
<div className="text-xs text-slate-500 text-center py-10">
{loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"}
</div>
)}
{waveletReliability && (
<div className="mt-3 pt-3 border-t border-slate-700/30">
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">
Fiabilité des retournements (horizon de recalcul = cycle propre à chaque bande)
</div>
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-500 text-left">
<th className="py-1 pr-3 font-normal">Bande</th>
<th className="py-1 pr-3 font-normal text-right" title="Écart moyen entre deux pics (ou deux creux) consécutifs, mesuré sur l'historique — pas la période théorique de la bande.">Cycle moyen</th>
<th className="py-1 pr-3 font-normal text-right" title="= cycle moyen × 1.10 — le retournement peut être confirmé n'importe où entre la date d'origine et date+horizon, pas juste pile à cette date">Horizon max</th>
<th className="py-1 pr-3 font-normal text-right" title="Délai réel moyen (parmi les retournements confirmés) entre la date d'origine et l'apparition du retournement confirmant, en hindsight">Délai réel moyen</th>
<th className="py-1 pr-3 font-normal text-right" title="Écart absolu moyen entre le cycle moyen (l'horizon supposé) et le délai réel observé — petit = l'horizon est bien calibré, grand = il est trop long ou trop court par rapport à la réalité">Écart calibration</th>
<th className="py-1 pr-3 font-normal text-right">Retournements testés</th>
<th className="py-1 pr-3 font-normal text-right">Confirmés</th>
<th className="py-1 pr-3 font-normal text-right">Indice de confiance</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={b.label} className="border-t border-slate-800/60">
<td className="py-1 pr-3" style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</td>
<td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.avg_cycle_days != null ? `${b.avg_cycle_days}j` : '—'}</td>
<td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.confirm_horizon != null ? `+${b.confirm_horizon}j` : '—'}</td>
<td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.avg_actual_delay_days != null ? `${b.avg_actual_delay_days}j` : '—'}</td>
<td className={clsx('py-1 pr-3 text-right font-mono', gapColor)}>{gap != null ? `${gap}j` : '—'}</td>
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_tested}</td>
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_confirmed}</td>
<td className={clsx('py-1 pr-3 text-right font-mono font-semibold', color)}>
{pct != null ? `${pct.toFixed(0)}%` : 'n/a (pas assez de retournements testables)'}
</td>
</tr>
)
})}
</tbody>
</table>
<p className="text-[10px] text-slate-600 mt-1">
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
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.
</p>
</div>
)}
</div>
)}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}
onLoad={loadNarrative}
instrument={snapshot.instrument}
/>
</>
)}
{!loading && !snapshot && (
<div className="text-center py-20 text-slate-500">
<BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Aucune donnée disponible pour {instrumentId}</p>
</div>
)}
{selectorOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />}
</div>
)
}