feat: wavelets
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user