feat: wavelets
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<svg width={width} height={height} className="inline-block align-middle opacity-80">
|
||||
<polyline points={points} fill="none" stroke={color} strokeWidth={1.2} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
<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" 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 ({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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 prevIdx = waveletEnergyIdx - waveletEnergy.L
|
||||
const delta = prevIdx >= 0 ? rel - waveletEnergy.relByDate[prevIdx][i] : null
|
||||
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 (
|
||||
<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>}
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color }}>{b.label}</span>
|
||||
{isSlow && <span className="text-slate-600">(lente)</span>}
|
||||
<Sparkline values={sparkValues} color={color} />
|
||||
</div>
|
||||
</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={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>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</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.
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user