diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index c23595a..036c547 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1349,6 +1349,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const [waveletData, setWaveletData] = useState(null) const [loadingWavelet, setLoadingWavelet] = useState(false) const [hiddenBands, setHiddenBands] = useState>(new Set()) + const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState(null) const [templates, setTemplates] = useState([]) const [causalScores, setCausalScores] = useState>({}) // eventId → activation_score*100 const [macroAtDate, setMacroAtDate] = useState(null) @@ -1591,6 +1592,65 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i 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). + const WAVELET_ROLLING_WINDOW = 20 + + const waveletEnergy = useMemo(() => { + const bands: { label: string; series: number[] }[] = waveletData?.bands ?? [] + const n = bands[0]?.series?.length ?? 0 + if (!bands.length || n === 0) return null + + const L = WAVELET_ROLLING_WINDOW + const energyByBand: number[][] = bands.map(b => { + const s = b.series + const e = new Array(n).fill(0) + let sumSq = 0 + for (let t = 0; t < n; t++) { + sumSq += s[t] * s[t] + if (t >= L) sumSq -= s[t - L] * s[t - L] + e[t] = sumSq / Math.min(t + 1, L) + } + return e + }) + + // 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, relByDate, trendRatioByDate, slowIdx, n, L } + }, [waveletData]) + + const waveletEnergyIdx = waveletEnergy + ? Math.min(hoveredWaveletIdx ?? waveletEnergy.n - 1, waveletEnergy.n - 1) + : 0 + return (
@@ -1917,7 +1977,12 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i ))}
- + { + if (state?.activeTooltipIndex != null) setHoveredWaveletIdx(Number(state.activeTooltipIndex)) + }} + onMouseLeave={() => setHoveredWaveletIdx(null)} + > @@ -1944,6 +2009,64 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {waveletCausal && Mode causal (walk-forward) — {waveletData.recomputations} recalculs} )} + + {waveletEnergy && ( +
+
+ + Énergie par bande {hoveredWaveletIdx != null && waveletChartData[waveletEnergyIdx] && ( + — au {waveletChartData[waveletEnergyIdx].date} + )} + + {waveletEnergy.trendRatioByDate[waveletEnergyIdx] != null && (() => { + const ratio = waveletEnergy.trendRatioByDate[waveletEnergyIdx]! + const verdict = ratio >= 2 ? 'cycle lent dominant' : ratio >= 0.7 ? 'équilibré' : 'bruit dominant' + const color = ratio >= 2 ? 'text-emerald-400' : ratio >= 0.7 ? 'text-amber-400' : 'text-red-400' + return ( + + Trend Ratio {ratio.toFixed(2)} + — {verdict} + + ) + })()} +
+ + + + + + + + + + + {waveletEnergy.bands.map((b, i) => { + const rms = Math.sqrt(waveletEnergy.energyByBand[i][waveletEnergyIdx]) + 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 + return ( + + + + + + + ) + })} + +
BandeRMS% énergieΔ énergie ({waveletEnergy.L}j)
+ {b.label} + {isSlow && (lente)} + {rms.toFixed(4)}{rel.toFixed(0)}%= 0 ? 'text-emerald-400' : 'text-red-400')}> + {delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta.toFixed(0)}%`} +
+

+ Trend Ratio = (somme des bandes rapides) / (bande lente) — survolez le graphique pour voir ces valeurs évoluer dans le temps. +

+
+ )} ) : (