feat: wavelets

This commit is contained in:
OpenSquared
2026-07-14 16:23:18 +02:00
parent ce948f6b65
commit b693aca2dc
17 changed files with 2144 additions and 10 deletions

View File

@@ -6,10 +6,13 @@ import {
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
const api = axios.create({ baseURL: '/api' })
const WAVELET_BAND_COLORS = ['#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6', '#84cc16']
// Stable empty array — prevents InstrumentChart useEffect from re-running on every render
const NO_CHART_EVENTS: never[] = []
@@ -1337,7 +1340,15 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const [loadingNarr, setLoadingNarr] = useState(false)
const [selectorOpen, setSelectorOpen] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets'>('counters')
const [waveletLevels, setWaveletLevels] = useState(4)
const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw')
const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt')
const [waveletCausal, setWaveletCausal] = useState(false)
const [waveletLookback, setWaveletLookback] = useState(130)
const [waveletData, setWaveletData] = useState<any | null>(null)
const [loadingWavelet, setLoadingWavelet] = useState(false)
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
const [templates, setTemplates] = useState<CausalTemplate[]>([])
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
@@ -1532,6 +1543,54 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
const runWaveletAnalysis = async () => {
if (!selected) return
setLoadingWavelet(true)
try {
const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze'
const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod }
if (waveletCausal) params.lookback = waveletLookback
const { data } = await api.get(path, { params })
setWaveletData(data)
setHiddenBands(new Set())
} catch (e) {
console.error('Wavelet analysis failed', e)
setWaveletData(null)
} finally {
setLoadingWavelet(false)
}
}
const waveletChartData = useMemo(() => {
if (!waveletData) return []
const { dates, original, bands, mean } = waveletData
return dates.map((d: string, i: number) => {
const row: any = { date: d.slice(0, 10), original: original[i] }
let recon = mean ?? 0
for (const b of bands) { row[b.label] = b.series[i]; recon += b.series[i] }
row.reconstruction = recon
return row
})
}, [waveletData])
const waveletStats = useMemo(() => {
if (!waveletData || waveletChartData.length < 2) return null
const orig: number[] = waveletData.original
const recon = waveletChartData.map((r: any) => r.reconstruction)
const n = orig.length
const meanO = orig.reduce((a: number, b: number) => a + b, 0) / n
const meanR = recon.reduce((a: number, b: number) => a + b, 0) / n
let cov = 0, varO = 0, varR = 0, sumAbs = 0, maxAbs = 0
for (let i = 0; i < n; i++) {
const dO = orig[i] - meanO, dR = recon[i] - meanR
cov += dO * dR; varO += dO * dO; varR += dR * dR
const err = Math.abs(orig[i] - recon[i])
sumAbs += err; maxAbs = Math.max(maxAbs, err)
}
const correlation = varO > 0 && varR > 0 ? cov / Math.sqrt(varO * varR) : null
return { correlation, meanAbsError: sumAbs / n, maxAbsError: maxAbs }
}, [waveletData, waveletChartData])
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
@@ -1666,6 +1725,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
{([
{ key: 'counters', label: 'Compteurs' },
{ key: 'analyse', label: 'Analyse de la courbe' },
{ key: 'wavelets', label: 'Ondelettes' },
] as const).map(t => (
<button
key={t.key}
@@ -1799,6 +1859,96 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
)
})()}
{tabUnder === 'wavelets' && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Décomposition Ondelette</span>
<div className="ml-auto flex items-center gap-2 flex-wrap text-xs">
<label className="flex items-center gap-1 text-slate-400">
Niveaux
<input type="number" min={2} max={6} value={waveletLevels}
onChange={e => setWaveletLevels(Math.max(2, Math.min(6, Number(e.target.value) || 4)))}
className="w-12 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
</label>
<select value={waveletFamily} onChange={e => setWaveletFamily(e.target.value as any)}
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
<option value="gmw">gmw</option>
<option value="morlet">morlet</option>
<option value="bump">bump</option>
</select>
<select value={waveletMethod} onChange={e => setWaveletMethod(e.target.value as any)}
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
<option value="cwt">CWT</option>
<option value="ssq">SSQ</option>
</select>
<label className="flex items-center gap-1 text-slate-400">
<input type="checkbox" checked={waveletCausal} onChange={e => setWaveletCausal(e.target.checked)} />
Mode causal
</label>
{waveletCausal && (
<label className="flex items-center gap-1 text-slate-400">
Lookback
<input type="number" min={32} value={waveletLookback}
onChange={e => setWaveletLookback(Math.max(32, Number(e.target.value) || 130))}
className="w-14 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
</label>
)}
<button onClick={runWaveletAnalysis} disabled={loadingWavelet}
className="px-2.5 py-1 rounded text-xs font-medium border border-blue-600/40 bg-blue-700/30 text-blue-200 hover:bg-blue-700/50 transition-colors disabled:opacity-50">
{loadingWavelet ? '↻ Calcul…' : "▶ Lancer l'analyse"}
</button>
</div>
</div>
{waveletData ? (
<>
<div className="flex flex-wrap gap-3 mb-2 text-[10px]">
{waveletData.bands.map((b: any, i: number) => (
<label key={b.label} className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={!hiddenBands.has(b.label)}
onChange={() => setHiddenBands(prev => {
const next = new Set(prev)
if (next.has(b.label)) next.delete(b.label); else next.add(b.label)
return next
})}
/>
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
</label>
))}
</div>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={waveletChartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }}
interval={Math.max(0, Math.floor(waveletChartData.length / 8))} />
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10 }} />
<Line type="monotone" dataKey="original" stroke="#94a3b8" strokeWidth={1.5} dot={false} name="Prix" isAnimationActive={false} />
<Line type="monotone" dataKey="reconstruction" stroke="#22c55e" strokeWidth={1} strokeDasharray="4 2" dot={false} name="Reconstruction" isAnimationActive={false} />
{waveletData.bands.map((b: any, i: number) => !hiddenBands.has(b.label) && (
<Line key={b.label} type="monotone" dataKey={b.label}
stroke={WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length]}
strokeWidth={1} dot={false} name={b.label} isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
{waveletStats && (
<div className="mt-2 flex items-center gap-4 flex-wrap text-[10px] text-slate-500">
<span>Corrélation reconstruction: <span className="text-slate-300 font-mono">{waveletStats.correlation != null ? waveletStats.correlation.toFixed(3) : '—'}</span></span>
<span>Erreur moy.: <span className="text-slate-300 font-mono">{waveletStats.meanAbsError.toFixed(3)}</span></span>
<span>Erreur max: <span className="text-slate-300 font-mono">{waveletStats.maxAbsError.toFixed(3)}</span></span>
{waveletCausal && <span className="ml-auto text-blue-400/70">Mode causal (walk-forward) {waveletData.recomputations} recalculs</span>}
</div>
)}
</>
) : (
<div className="text-xs text-slate-500 text-center py-10">
{loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"}
</div>
)}
</div>
)}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}