feat: wavelets

This commit is contained in:
OpenSquared
2026-07-19 11:52:08 +02:00
parent 4578257ea6
commit ca18ac7acb
2 changed files with 81 additions and 11 deletions

View File

@@ -245,7 +245,11 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
visible: visibleRef.current.volatility, visible: visibleRef.current.volatility,
borderColor: 'rgba(236,72,153,0.25)', borderColor: 'rgba(236,72,153,0.25)',
textColor: '#ec4899', 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({ const volatilitySeries = chart.addLineSeries({
color: '#ec4899', color: '#ec4899',

View File

@@ -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 // Stable empty array — prevents InstrumentChart useEffect from re-running on every render
const NO_CHART_EVENTS: never[] = [] 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 (
<svg width={width} height={height} className="inline-block align-middle opacity-80">
<polyline points={points} fill="none" stroke={color} strokeWidth={1.2} />
</svg>
)
}
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
interface CausalTemplate { 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 // 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 // slow trend dominates (breakouts tend to hold); low → high-frequency noise dominates
// (range, false breakouts). // (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_ROLLING_WINDOW = 20
const WAVELET_SLOPE_LAG = 5
const waveletEnergy = useMemo(() => { const waveletEnergy = useMemo(() => {
const bands: { label: string; series: number[] }[] = waveletData?.bands ?? [] 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 if (!bands.length || n === 0) return null
const L = WAVELET_ROLLING_WINDOW 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 s = b.series
const e = new Array(n).fill(0) 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 sumSq = 0
let sum = 0
for (let t = 0; t < n; t++) { for (let t = 0; t < n; t++) {
sumSq += s[t] * s[t] sumSq += s[t] * s[t]
if (t >= L) sumSq -= s[t - L] * s[t - L] sum += s[t]
e[t] = sumSq / Math.min(t + 1, L) 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 // 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 // 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) 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]) }, [waveletData])
const waveletEnergyIdx = waveletEnergy const waveletEnergyIdx = waveletEnergy
@@ -2035,35 +2083,53 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
<tr className="text-slate-500 text-left"> <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">Bande</th>
<th className="py-1 pr-3 font-normal text-right">RMS</th> <th className="py-1 pr-3 font-normal text-right">RMS</th>
<th className="py-1 pr-3 font-normal text-right" title="Pente de l'énergie sur les derniers jours — accélère (▲) ou ralentit (▼), en % de l'énergie actuelle par jour">dE/dt</th>
<th className="py-1 pr-3 font-normal text-right">% énergie</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> <th className="py-1 pr-3 font-normal text-right">Δ énergie ({waveletEnergy.L}j)</th>
<th className="py-1 pr-3 font-normal text-right" title="|moyenne| / RMS sur la fenêtre — 1 = mouvement net dans un seul sens (directionnel), 0 = oscillation symétrique sans direction nette">Cohérence</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{waveletEnergy.bands.map((b, i) => { {waveletEnergy.bands.map((b, i) => {
const rms = Math.sqrt(waveletEnergy.energyByBand[i][waveletEnergyIdx]) const color = WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length]
const e = waveletEnergy.energyByBand[i][waveletEnergyIdx]
const rms = Math.sqrt(e)
const rel = waveletEnergy.relByDate[waveletEnergyIdx][i] const rel = waveletEnergy.relByDate[waveletEnergyIdx][i]
const prevIdx = waveletEnergyIdx - waveletEnergy.L const prevIdx = waveletEnergyIdx - waveletEnergy.L
const delta = prevIdx >= 0 ? rel - waveletEnergy.relByDate[prevIdx][i] : null const delta = prevIdx >= 0 ? rel - waveletEnergy.relByDate[prevIdx][i] : null
const isSlow = i === waveletEnergy.slowIdx const isSlow = i === waveletEnergy.slowIdx
const slope = waveletEnergy.slopeByBand[i][waveletEnergyIdx]
const slopePct = slope != null && e > 1e-12 ? (slope / e) * 100 : null
const coherence = waveletEnergy.coherenceByBand[i][waveletEnergyIdx]
const sparkStart = Math.max(0, waveletEnergyIdx - 59)
const sparkValues = waveletEnergy.energyByBand[i].slice(sparkStart, waveletEnergyIdx + 1).map(Math.sqrt)
return ( return (
<tr key={b.label} className="border-t border-slate-800/60"> <tr key={b.label} className="border-t border-slate-800/60">
<td className="py-1 pr-3"> <td className="py-1 pr-3">
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span> <div className="flex items-center gap-2">
{isSlow && <span className="ml-1 text-slate-600">(lente)</span>} <span style={{ color }}>{b.label}</span>
{isSlow && <span className="text-slate-600">(lente)</span>}
<Sparkline values={sparkValues} color={color} />
</div>
</td> </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-300 font-mono">{rms.toFixed(4)}</td>
<td className={clsx('py-1 pr-3 text-right font-mono', slopePct == null ? 'text-slate-600' : slopePct > 0.5 ? 'text-emerald-400' : slopePct < -0.5 ? 'text-red-400' : 'text-slate-400')}>
{slopePct == null ? '—' : `${slopePct > 0.5 ? '▲' : slopePct < -0.5 ? '▼' : '→'} ${slopePct >= 0 ? '+' : ''}${slopePct.toFixed(1)}%/j`}
</td>
<td className="py-1 pr-3 text-right text-slate-200 font-mono">{rel.toFixed(0)}%</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')}> <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)}%`} {delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta.toFixed(0)}%`}
</td> </td>
<td className={clsx('py-1 pr-3 text-right font-mono', coherence >= 0.6 ? 'text-emerald-400' : coherence >= 0.3 ? 'text-amber-400' : 'text-slate-500')}>
{coherence.toFixed(2)}
</td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
</table> </table>
<p className="text-[10px] text-slate-600 mt-1"> <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. 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.
</p> </p>
</div> </div>
)} )}