diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 38407fa..b679a33 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -32,6 +32,75 @@ function Sparkline({ values, color, width = 56, height = 18 }: { values: number[ ) } +// ── 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 { @@ -1699,6 +1768,74 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i ? 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 (
@@ -2126,11 +2263,78 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i ) })} + {waveletRegimeView && ( + + Volatilité + + {waveletRegimeView.volRaw != null ? `${waveletRegimeView.volRaw.toFixed(1)}%` : '—'} + + 3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-400')}> + {waveletRegimeView.volDelta == null ? '—' : + `${waveletRegimeView.volDelta > 3 ? '▲' : waveletRegimeView.volDelta < -3 ? '▼' : '→'} ${waveletRegimeView.volDelta >= 0 ? '+' : ''}${waveletRegimeView.volDelta.toFixed(0)}pt`} + + + {waveletRegimeView.volPercentile != null ? `${waveletRegimeView.volPercentile.toFixed(0)}e pct` : '—'} + + + {bucketVolPercentile(waveletRegimeView.volPercentile).label} + + + + )}

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

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

{waveletRegimeView.regime.reading}

+
+
+
Structure (Trend Ratio)
+
+ {waveletRegimeView.trendRatio != null ? waveletRegimeView.trendRatio.toFixed(2) : '—'} · {bucketTrendRatio(waveletRegimeView.trendRatio).label} +
+
+
+
Direction lente
+
{waveletRegimeView.direction} · {waveletRegimeView.energieSlowPct.toFixed(0)}% énergie
+
+
+
Dynamique (dE lente/dt)
+
+ {bucketDynamic(waveletRegimeView.slowSlopePct).label} +
+
+
+
Qualité (Cohérence lente)
+
+ {waveletRegimeView.coherenceSlow != null ? waveletRegimeView.coherenceSlow.toFixed(2) : '—'} · {bucketCoherence(waveletRegimeView.coherenceSlow).label} +
+
+
+
Risque (Vol percentile)
+
+ {bucketVolPercentile(waveletRegimeView.volPercentile).label} +
+
+
+
Dynamique du risque (ΔVol)
+
3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-300')}> + {waveletRegimeView.volDelta == null ? '—' : waveletRegimeView.volDelta > 3 ? 'Risque ↑' : waveletRegimeView.volDelta < -3 ? 'Risque ↓' : 'Stable'} +
+
+
+
+ )}
)}