feat: wavelets

This commit is contained in:
OpenSquared
2026-07-19 11:36:17 +02:00
parent e10e8c07b8
commit 4578257ea6

View File

@@ -1349,6 +1349,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const [waveletData, setWaveletData] = useState<any | null>(null) const [waveletData, setWaveletData] = useState<any | null>(null)
const [loadingWavelet, setLoadingWavelet] = useState(false) const [loadingWavelet, setLoadingWavelet] = useState(false)
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set()) const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
const [templates, setTemplates] = useState<CausalTemplate[]>([]) const [templates, setTemplates] = useState<CausalTemplate[]>([])
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100 const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null) const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
@@ -1591,6 +1592,65 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
return { correlation, meanAbsError: sumAbs / n, maxAbsError: maxAbs } return { correlation, meanAbsError: sumAbs / n, maxAbsError: maxAbs }
}, [waveletData, waveletChartData]) }, [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 ( return (
<div className="p-6 max-w-screen-xl mx-auto space-y-4"> <div className="p-6 max-w-screen-xl mx-auto space-y-4">
@@ -1917,7 +1977,12 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
))} ))}
</div> </div>
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={280}>
<LineChart data={waveletChartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}> <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" /> <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }} <XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }}
interval={Math.max(0, Math.floor(waveletChartData.length / 8))} /> interval={Math.max(0, Math.floor(waveletChartData.length / 8))} />
@@ -1944,6 +2009,64 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
{waveletCausal && <span className="ml-auto text-blue-400/70">Mode causal (walk-forward) {waveletData.recomputations} recalculs</span>} {waveletCausal && <span className="ml-auto text-blue-400/70">Mode causal (walk-forward) {waveletData.recomputations} recalculs</span>}
</div> </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">% énergie</th>
<th className="py-1 pr-3 font-normal text-right">Δ énergie ({waveletEnergy.L}j)</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={b.label} className="border-t border-slate-800/60">
<td className="py-1 pr-3">
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
{isSlow && <span className="ml-1 text-slate-600">(lente)</span>}
</td>
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{rms.toFixed(4)}</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>
</tr>
)
})}
</tbody>
</table>
<p className="text-[10px] text-slate-600 mt-1">
Trend Ratio = (somme des bandes rapides) / (bande lente) survolez le graphique pour voir ces valeurs évoluer dans le temps.
</p>
</div>
)}
</> </>
) : ( ) : (
<div className="text-xs text-slate-500 text-center py-10"> <div className="text-xs text-slate-500 text-center py-10">