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 ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CausalTemplate {
|
interface CausalTemplate {
|
||||||
@@ -1699,6 +1768,74 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
? Math.min(hoveredWaveletIdx ?? waveletEnergy.n - 1, waveletEnergy.n - 1)
|
? Math.min(hoveredWaveletIdx ?? waveletEnergy.n - 1, waveletEnergy.n - 1)
|
||||||
: 0
|
: 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 (
|
return (
|
||||||
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
<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>
|
</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>
|
</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). É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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user