feat: curve regime

This commit is contained in:
OpenSquared
2026-07-25 08:53:07 +02:00
parent 27678e6b8d
commit 3addea6850
3 changed files with 548 additions and 2 deletions

View File

@@ -1449,7 +1449,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const [searchParams] = useSearchParams()
// ?tab=wavelets lets other pages (e.g. Dashboard's Wavelets Signal card) deep-link
// straight into the Wavelets panel instead of always landing on Counters.
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets'>(() => (searchParams.get('tab') === 'wavelets' ? 'wavelets' : 'counters'))
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets' | 'regime'>(() => {
const t = searchParams.get('tab')
return t === 'counters' || t === 'analyse' || t === 'regime' ? t : 'wavelets'
})
const [waveletLevels, setWaveletLevels] = useState(4)
const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw')
const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt')
@@ -1466,6 +1469,8 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
const [loadingReliability, setLoadingReliability] = useState(false)
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
const [curveRegime, setCurveRegime] = useState<any | null>(null)
const [loadingCurveRegime, setLoadingCurveRegime] = useState(false)
const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
const [templates, setTemplates] = useState<CausalTemplate[]>([])
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
@@ -1716,6 +1721,22 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabUnder, instrumentId, selected])
// Curve Regime tab — auto-loads once per instrument, same pattern as the Wavelet tab
// above (reuses the period selector's current snapshot, no separate live trigger).
const curveRegimeAutoFetchKey = useRef<string | null>(null)
useEffect(() => {
if (tabUnder !== 'regime' || !selected) return
if (curveRegimeAutoFetchKey.current === `${instrumentId}|${period}`) return
curveRegimeAutoFetchKey.current = `${instrumentId}|${period}`
setCurveRegime(null)
setLoadingCurveRegime(true)
api.get(`/instruments/${encodeURIComponent(instrumentId)}/curve-regime`, { params: { period } })
.then(({ data }) => setCurveRegime(data))
.catch((e) => console.error(`[CurveRegime] fetch failed for ${instrumentId}:`, e))
.finally(() => setLoadingCurveRegime(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabUnder, instrumentId, selected, period])
const runWaveletReliability = async () => {
if (!selected) return
setLoadingReliability(true)
@@ -2120,9 +2141,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
{/* ── Tabs sous la courbe ── */}
<div className="border-b border-slate-700/40 flex gap-1">
{([
{ key: 'wavelets', label: 'Wavelet' },
{ key: 'regime', label: 'Curve Regime' },
{ key: 'counters', label: 'Compteurs' },
{ key: 'analyse', label: 'Analyse de la courbe' },
{ key: 'wavelets', label: 'Wavelet' },
] as const).map(t => (
<button
key={t.key}
@@ -2577,6 +2599,110 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
</div>
)}
{tabUnder === 'regime' && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Curve Regime état de marché de l'instrument</span>
<span className="text-[10px] text-slate-600" title="Distinct du régime macro global (Inflation Shock, Goldilocks...) affiché ailleurs dans le Cockpit — celui-ci ne concerne que cet instrument.">
≠ régime macro global
</span>
</div>
{loadingCurveRegime ? (
<div className="text-xs text-slate-500 text-center py-10">Calcul en cours…</div>
) : !curveRegime ? (
<div className="text-xs text-slate-500 text-center py-10">Aucune donnée — réessayez ou vérifiez que l'instrument a un historique de prix.</div>
) : (() => {
const top = curveRegime.top_match
const profile = curveRegime.profile
const vol = profile?.volatilite ?? {}
const scoreColorFor = (s: number) => s >= 0.75 ? 'text-emerald-400' : s >= 0.5 ? 'text-amber-400' : 'text-slate-400'
const AXIS_LABELS: Record<string, string> = { direction: 'Direction', dynamique: 'Dynamique', volatilite: 'Volatilité', structure_iv: 'Structure IV' }
return (
<>
{/* Top match */}
<div className="flex items-center gap-4 flex-wrap">
<div>
<div className={clsx('text-2xl font-bold', top ? scoreColorFor(top.score) : 'text-slate-500')}>
{top ? top.label : 'Indéterminé'}
</div>
<div className="text-[10px] text-slate-600">
Confiance {top ? `${(top.score * 100).toFixed(0)}%` : '—'} · basé sur {top ? Object.keys(top.axis_hits).length : 0} axes calculables
</div>
</div>
{curveRegime.facteur_dominant_bias && (
<div className="text-[10px] px-2 py-1 rounded bg-dark-900/60 border border-slate-700/30 text-slate-400">
Biais macro ({curveRegime.macro_regime_label}) sur cette classe d'actif : <span className="font-semibold text-slate-300">{curveRegime.facteur_dominant_bias}</span>
</div>
)}
</div>
{!curveRegime.has_options_data && (
<div className="text-[10px] text-amber-400/80 bg-amber-900/10 border border-amber-700/20 rounded px-2 py-1.5">
Structure IV / Liquidité indisponibles — lie l'option chain de cet instrument (Config Instruments Watchlist "Option") pour les inclure dans le classement.
</div>
)}
{/* Axis breakdown */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{[
{ key: 'direction', value: profile?.direction },
{ key: 'dynamique', value: profile?.dynamique },
{ key: 'volatilite', value: vol.level ? `${vol.level}${vol.trend && vol.trend !== 'stable' ? ` (${vol.trend})` : ''}` : null },
{ key: 'structure_iv', value: profile?.structure_iv },
].map(({ key, value }) => {
const hit = top?.axis_hits?.[key]
return (
<div key={key} className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500 text-[10px]">{AXIS_LABELS[key]}</div>
<div className={clsx('font-mono font-semibold text-xs',
value == null ? 'text-slate-600' : hit === true ? 'text-emerald-400' : hit === false ? 'text-slate-300' : 'text-slate-300'
)}>
{value ?? 'n/a'}{hit === true && ' ✓'}
</div>
</div>
)
})}
<div className="bg-dark-900/50 rounded px-2 py-1.5">
<div className="text-slate-500 text-[10px]">Liquidité</div>
<div className="font-mono font-semibold text-xs text-slate-300">{profile?.liquidite ?? 'n/a'}</div>
</div>
<div className="bg-dark-900/50 rounded px-2 py-1.5" title="Aucune source de données de corrélation cross-actifs par instrument n'existe encore dans le Cockpit — axe non noté.">
<div className="text-slate-500 text-[10px]">Corrélation cross-actifs</div>
<div className="font-mono font-semibold text-xs text-slate-600">n/a</div>
</div>
</div>
{/* Reference info for the matched regime */}
{top && (
<div className="text-xs space-y-1.5 border-t border-slate-700/30 pt-3">
<div><span className="text-slate-500">Facteur dominant type : </span><span className="text-slate-300">{top.facteur_dominant_ref}</span></div>
<div><span className="text-slate-500">Déclencheurs typiques : </span><span className="text-slate-300">{top.declencheurs}</span></div>
<div><span className="text-slate-500">Stratégies privilégiées : </span><span className="text-slate-300">{top.strategies}</span></div>
<div><span className="text-slate-500">Exemple : </span><span className="text-slate-300">{top.exemple}</span></div>
</div>
)}
{/* Runner-up regimes — visible when the top match isn't a clean, unambiguous winner */}
{curveRegime.ranked?.length > 1 && (
<div className="border-t border-slate-700/30 pt-3">
<div className="text-[10px] text-slate-500 uppercase tracking-wide mb-1.5">Autres régimes candidats</div>
<div className="space-y-1">
{curveRegime.ranked.slice(1, 4).map((r: any) => (
<div key={r.key} className="flex items-center justify-between text-[10px]">
<span className="text-slate-400">{r.label}</span>
<span className={clsx('font-mono', scoreColorFor(r.score))}>{(r.score * 100).toFixed(0)}%</span>
</div>
))}
</div>
</div>
)}
</>
)
})()}
</div>
)}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}