feat: wavelets
This commit is contained in:
@@ -32,6 +32,75 @@ function Sparkline({ values, color, width = 56, height = 18 }: { values: number[
|
||||
)
|
||||
}
|
||||
|
||||
// ── Wavelet regime engine ──────────────────────────────────────────────────────
|
||||
// Wavelets answer "how is variance spread across timescales"; volatility answers "how
|
||||
// much variance is there in total" — two prices can carry the same slow-band energy while
|
||||
// one trends smoothly (100→105 in a straight line) and the other only gets there via wild
|
||||
// swings (100→95→105→98→110→103). Same Trend Ratio, wildly different volatility. Combining
|
||||
// both, plus their own trends, into a 6-regime classifier per the spec discussed.
|
||||
|
||||
type RegimeBucket = { label: string; color: string }
|
||||
|
||||
function bucketTrendRatio(r: number | null): RegimeBucket {
|
||||
if (r == null) return { label: '—', color: 'text-slate-500' }
|
||||
if (r < 1) return { label: 'Bruit dominant', color: 'text-red-400' }
|
||||
if (r < 3) return { label: 'Mixte', color: 'text-amber-400' }
|
||||
if (r < 10) return { label: 'Tendance', color: 'text-emerald-400' }
|
||||
return { label: 'Cycle dominant', color: 'text-blue-400' }
|
||||
}
|
||||
function bucketCoherence(c: number | null): RegimeBucket {
|
||||
if (c == null) return { label: '—', color: 'text-slate-500' }
|
||||
if (c > 0.8) return { label: 'Direction très propre', color: 'text-emerald-400' }
|
||||
if (c >= 0.4) return { label: 'Direction moyenne', color: 'text-amber-400' }
|
||||
return { label: 'Oscillation', color: 'text-red-400' }
|
||||
}
|
||||
function bucketDynamic(slopePct: number | null): RegimeBucket {
|
||||
if (slopePct == null) return { label: '—', color: 'text-slate-500' }
|
||||
if (slopePct > 0.5) return { label: 'Se renforce', color: 'text-emerald-400' }
|
||||
if (slopePct < -0.5) return { label: "S'affaiblit", color: 'text-red-400' }
|
||||
return { label: 'Stable', color: 'text-slate-400' }
|
||||
}
|
||||
function bucketVolPercentile(p: number | null): RegimeBucket {
|
||||
if (p == null) return { label: '—', color: 'text-slate-500' }
|
||||
if (p < 20) return { label: 'Très calme', color: 'text-blue-400' }
|
||||
if (p < 60) return { label: 'Normal', color: 'text-emerald-400' }
|
||||
if (p < 80) return { label: 'Agité', color: 'text-amber-400' }
|
||||
return { label: 'Extrême', color: 'text-red-400' }
|
||||
}
|
||||
|
||||
function classifyMarketRegime(input: {
|
||||
trendRatio: number | null; coherenceSlow: number | null; volPercentile: number | null
|
||||
volDelta: number | null; trendRatioSlope: number | null; slowSlopePct: number | null
|
||||
}): { name: string; reading: string; color: string } {
|
||||
const { trendRatio, coherenceSlow, volPercentile, volDelta, trendRatioSlope, slowSlopePct } = input
|
||||
const trHigh = trendRatio != null && trendRatio > 3
|
||||
const trLow = trendRatio != null && trendRatio < 1
|
||||
const cohHigh = coherenceSlow != null && coherenceSlow > 0.8
|
||||
const cohLow = coherenceSlow != null && coherenceSlow < 0.4
|
||||
const volLow = volPercentile != null && volPercentile < 40
|
||||
const volHigh = volPercentile != null && volPercentile > 60
|
||||
const volUp = volDelta != null && volDelta > 3
|
||||
const volDown = volDelta != null && volDelta < -3
|
||||
const trUp = trendRatioSlope != null && trendRatioSlope > 0.2
|
||||
const trDown = trendRatioSlope != null && trendRatioSlope < -0.2
|
||||
const slowUp = slowSlopePct != null && slowSlopePct > 0.5
|
||||
const slowDown = slowSlopePct != null && slowSlopePct < -0.5
|
||||
|
||||
if (trHigh && cohHigh && volLow && volDown)
|
||||
return { name: 'Drift', reading: 'Marché extrêmement organisé — tendance lente propre, sous faible volatilité qui continue de baisser.', color: 'text-blue-400' }
|
||||
if (trHigh && cohHigh && volHigh && volUp)
|
||||
return { name: 'Tendance explosive', reading: 'Même direction que le Drift, mais en accélération — momentum fort sous volatilité montante.', color: 'text-emerald-400' }
|
||||
if (trLow && volLow)
|
||||
return { name: 'Compression', reading: 'Accumulation — toutes les bandes sont faibles, marché en attente de sortie de range.', color: 'text-slate-400' }
|
||||
if (trLow && volHigh && cohLow)
|
||||
return { name: 'Chaos', reading: 'News / panique — bruit dominant sous forte volatilité, aucune direction nette.', color: 'text-red-400' }
|
||||
if (trDown && slowDown && volUp)
|
||||
return { name: 'Transition', reading: "La structure se casse — la tendance lente s'affaiblit pendant que la volatilité monte.", color: 'text-amber-400' }
|
||||
if (trUp && slowUp && volDown)
|
||||
return { name: 'Construction de tendance', reading: 'Le marché devient de plus en plus propre — tendance lente qui se renforce sous volatilité qui retombe.', color: 'text-emerald-400' }
|
||||
return { name: 'Indéterminé', reading: "Combinaison de signaux ambiguë — aucun des 6 régimes types ne se dégage nettement à cette date.", color: 'text-slate-500' }
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CausalTemplate {
|
||||
@@ -1699,6 +1768,74 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
||||
? Math.min(hoveredWaveletIdx ?? waveletEnergy.n - 1, waveletEnergy.n - 1)
|
||||
: 0
|
||||
|
||||
// Volatility percentile (vs. this instrument's own history — 8% means something
|
||||
// different on EURUSD than on the NASDAQ, the percentile rank makes it universal) and its
|
||||
// own trend, joined to the wavelet's date axis so the regime panel updates on hover too.
|
||||
const WAVELET_VOL_DELTA_LAG = 10
|
||||
|
||||
const waveletRegime = useMemo(() => {
|
||||
if (!waveletEnergy) return null
|
||||
const volPoints: LinePoint[] = snapshot?.indicators?.volatility ?? []
|
||||
if (!volPoints.length) return null
|
||||
|
||||
const volByDate = new Map(volPoints.map(p => [p.time, p.value]))
|
||||
const sortedVols = volPoints.map(p => p.value).sort((a, b) => a - b)
|
||||
const percentileOf = (v: number) => {
|
||||
let lo = 0, hi = sortedVols.length
|
||||
while (lo < hi) { const mid = (lo + hi) >> 1; if (sortedVols[mid] <= v) lo = mid + 1; else hi = mid }
|
||||
return (lo / sortedVols.length) * 100
|
||||
}
|
||||
|
||||
const n = waveletEnergy.n
|
||||
const volAt: (number | null)[] = new Array(n).fill(null)
|
||||
const volPercentileAt: (number | null)[] = new Array(n).fill(null)
|
||||
for (let t = 0; t < n; t++) {
|
||||
const date = waveletChartData[t]?.date
|
||||
const v = date != null ? volByDate.get(date) : undefined
|
||||
if (v != null) { volAt[t] = v; volPercentileAt[t] = percentileOf(v) }
|
||||
}
|
||||
|
||||
const trendRatioSlopeAt = (t: number): number | null => {
|
||||
const lag = WAVELET_SLOPE_LAG
|
||||
const cur = waveletEnergy.trendRatioByDate[t]
|
||||
const prev = t >= lag ? waveletEnergy.trendRatioByDate[t - lag] : null
|
||||
return cur != null && prev != null ? cur - prev : null
|
||||
}
|
||||
const volDeltaAt = (t: number): number | null => {
|
||||
const lag = WAVELET_VOL_DELTA_LAG
|
||||
const cur = volPercentileAt[t]
|
||||
const prev = t >= lag ? volPercentileAt[t - lag] : null
|
||||
return cur != null && prev != null ? cur - prev : null
|
||||
}
|
||||
|
||||
return { volAt, volPercentileAt, trendRatioSlopeAt, volDeltaAt }
|
||||
}, [waveletEnergy, waveletChartData, snapshot])
|
||||
|
||||
const waveletRegimeView = useMemo(() => {
|
||||
if (!waveletEnergy) return null
|
||||
const idx = waveletEnergyIdx
|
||||
const slowIdx = waveletEnergy.slowIdx
|
||||
const trendRatio = waveletEnergy.trendRatioByDate[idx]
|
||||
const coherenceSlow = waveletEnergy.coherenceByBand[slowIdx][idx]
|
||||
const meanSlow = waveletEnergy.meanByBand[slowIdx][idx]
|
||||
const direction = meanSlow > 1e-9 ? 'Haussière' : meanSlow < -1e-9 ? 'Baissière' : 'Neutre'
|
||||
const energieSlowPct = waveletEnergy.relByDate[idx][slowIdx]
|
||||
const eSlow = waveletEnergy.energyByBand[slowIdx][idx]
|
||||
const slopeSlow = waveletEnergy.slopeByBand[slowIdx][idx]
|
||||
const slowSlopePct = slopeSlow != null && eSlow > 1e-12 ? (slopeSlow / eSlow) * 100 : null
|
||||
const volPercentile = waveletRegime?.volPercentileAt[idx] ?? null
|
||||
const volRaw = waveletRegime?.volAt[idx] ?? null
|
||||
const volDelta = waveletRegime ? waveletRegime.volDeltaAt(idx) : null
|
||||
const trendRatioSlope = waveletRegime ? waveletRegime.trendRatioSlopeAt(idx) : null
|
||||
|
||||
const regime = classifyMarketRegime({ trendRatio, coherenceSlow, volPercentile, volDelta, trendRatioSlope, slowSlopePct })
|
||||
|
||||
return {
|
||||
trendRatio, coherenceSlow, direction, energieSlowPct, slowSlopePct,
|
||||
volPercentile, volRaw, volDelta, trendRatioSlope, regime,
|
||||
}
|
||||
}, [waveletEnergy, waveletEnergyIdx, waveletRegime])
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
||||
|
||||
@@ -2126,11 +2263,78 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{waveletRegimeView && (
|
||||
<tr className="border-t border-slate-700/40 bg-slate-800/20">
|
||||
<td className="py-1 pr-3 text-slate-300 font-semibold">Volatilité</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-300 font-mono">
|
||||
{waveletRegimeView.volRaw != null ? `${waveletRegimeView.volRaw.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className={clsx('py-1 pr-3 text-right font-mono',
|
||||
waveletRegimeView.volDelta == null ? 'text-slate-600' : waveletRegimeView.volDelta > 3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-400')}>
|
||||
{waveletRegimeView.volDelta == null ? '—' :
|
||||
`${waveletRegimeView.volDelta > 3 ? '▲' : waveletRegimeView.volDelta < -3 ? '▼' : '→'} ${waveletRegimeView.volDelta >= 0 ? '+' : ''}${waveletRegimeView.volDelta.toFixed(0)}pt`}
|
||||
</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-200 font-mono">
|
||||
{waveletRegimeView.volPercentile != null ? `${waveletRegimeView.volPercentile.toFixed(0)}e pct` : '—'}
|
||||
</td>
|
||||
<td className={clsx('py-1 pr-3 text-right', bucketVolPercentile(waveletRegimeView.volPercentile).color)}>
|
||||
{bucketVolPercentile(waveletRegimeView.volPercentile).label}
|
||||
</td>
|
||||
<td className="py-1 pr-3" />
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-[10px] text-slate-600 mt-1">
|
||||
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.
|
||||
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. Volatilité en percentile de son propre historique (universel : 8% ne veut pas dire la même chose sur EURUSD que sur le Nasdaq). Survolez le graphique pour voir ces valeurs évoluer dans le temps.
|
||||
</p>
|
||||
|
||||
{waveletRegimeView && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
|
||||
<span className={clsx('text-sm font-bold', waveletRegimeView.regime.color)}>{waveletRegimeView.regime.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mb-3">{waveletRegimeView.regime.reading}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px]">
|
||||
<div className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||
<div className="text-slate-500">Structure (Trend Ratio)</div>
|
||||
<div className={clsx('font-mono font-semibold', bucketTrendRatio(waveletRegimeView.trendRatio).color)}>
|
||||
{waveletRegimeView.trendRatio != null ? waveletRegimeView.trendRatio.toFixed(2) : '—'} · {bucketTrendRatio(waveletRegimeView.trendRatio).label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||
<div className="text-slate-500">Direction lente</div>
|
||||
<div className="font-mono font-semibold text-slate-200">{waveletRegimeView.direction} · {waveletRegimeView.energieSlowPct.toFixed(0)}% énergie</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||
<div className="text-slate-500">Dynamique (dE lente/dt)</div>
|
||||
<div className={clsx('font-mono font-semibold', bucketDynamic(waveletRegimeView.slowSlopePct).color)}>
|
||||
{bucketDynamic(waveletRegimeView.slowSlopePct).label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||
<div className="text-slate-500">Qualité (Cohérence lente)</div>
|
||||
<div className={clsx('font-mono font-semibold', bucketCoherence(waveletRegimeView.coherenceSlow).color)}>
|
||||
{waveletRegimeView.coherenceSlow != null ? waveletRegimeView.coherenceSlow.toFixed(2) : '—'} · {bucketCoherence(waveletRegimeView.coherenceSlow).label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||
<div className="text-slate-500">Risque (Vol percentile)</div>
|
||||
<div className={clsx('font-mono font-semibold', bucketVolPercentile(waveletRegimeView.volPercentile).color)}>
|
||||
{bucketVolPercentile(waveletRegimeView.volPercentile).label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded px-2 py-1.5">
|
||||
<div className="text-slate-500">Dynamique du risque (ΔVol)</div>
|
||||
<div className={clsx('font-mono font-semibold',
|
||||
waveletRegimeView.volDelta == null ? 'text-slate-500' : waveletRegimeView.volDelta > 3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-300')}>
|
||||
{waveletRegimeView.volDelta == null ? '—' : waveletRegimeView.volDelta > 3 ? 'Risque ↑' : waveletRegimeView.volDelta < -3 ? 'Risque ↓' : 'Stable'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user