feat: wavelets
This commit is contained in:
@@ -1349,6 +1349,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
||||
const [waveletData, setWaveletData] = useState<any | null>(null)
|
||||
const [loadingWavelet, setLoadingWavelet] = 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)
|
||||
@@ -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 (
|
||||
<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>
|
||||
<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" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }}
|
||||
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>}
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user