diff --git a/frontend/src/components/InstrumentChart.tsx b/frontend/src/components/InstrumentChart.tsx index 78bbdbe..082c5e2 100644 --- a/frontend/src/components/InstrumentChart.tsx +++ b/frontend/src/components/InstrumentChart.tsx @@ -245,7 +245,11 @@ export default function InstrumentChart({ priceData, indicators, events = [], he visible: visibleRef.current.volatility, borderColor: 'rgba(236,72,153,0.25)', textColor: '#ec4899', - scaleMargins: { top: 0.78, bottom: 0.02 }, + // bottom: 0.08, not 0.02 — lightweight-charts renders its mandatory attribution + // logo fixed in the bottom-left corner (layout.attributionLogo, required by the + // library's free-tier license unless attributed elsewhere), and a tight margin + // put this axis's own labels right on top of it. + scaleMargins: { top: 0.72, bottom: 0.08 }, }) const volatilitySeries = chart.addLineSeries({ color: '#ec4899', diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 036c547..38407fa 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -16,6 +16,22 @@ const WAVELET_BAND_COLORS = ['#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a // 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 ( + + ) +} + // ── Types ───────────────────────────────────────────────────────────────────── interface CausalTemplate { @@ -1598,7 +1614,19 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i // 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[] }[] = waveletData?.bands ?? [] @@ -1606,17 +1634,37 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i if (!bands.length || n === 0) return null const L = WAVELET_ROLLING_WINDOW - const energyByBand: number[][] = bands.map(b => { + 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] - if (t >= L) sumSq -= s[t - L] * s[t - L] - e[t] = sumSq / Math.min(t + 1, L) + 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 } - return e - }) + 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 @@ -1644,7 +1692,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i trendRatioByDate.push(fastSum > 1e-12 ? slowE / fastSum : null) } - return { bands, energyByBand, relByDate, trendRatioByDate, slowIdx, n, L } + return { bands, energyByBand, meanByBand, coherenceByBand, slopeByBand, relByDate, trendRatioByDate, slowIdx, n, L } }, [waveletData]) const waveletEnergyIdx = waveletEnergy @@ -2035,35 +2083,53 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
- Trend Ratio = (somme des bandes rapides) / (bande lente) — survolez le graphique pour voir ces valeurs évoluer dans le temps. + 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. Survolez le graphique pour voir ces valeurs évoluer dans le temps.
)}