Files
OpenFin/frontend/src/pages/MacroRegime.tsx
OpenSquared 44ada3f8f1 feat: stable macro regime detection — blended signals + Bayesian smoothing
Replace 1-day change_pct with weighted blend (20% 1d / 50% 5d / 30% 10d)
for all 20 scored signals. Add Bayesian prior from rolling 25-day history
(weight 15%→45%) and a 10-point persistence threshold before regime switch.

Bootstrap on first load: replays last 20 trading days via yf.download batch
(45d) to pre-populate _regime_history, so stability is visible immediately.

Frontend: adds 'stable Xj' badge and history depth indicator on regime banner.
Doc: updates v4.0→v4.1, rewrites Étape 1 Régime Macro and glossary entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:40:38 +02:00

367 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useMacroRegime, useAiStatus } from '../hooks/useApi'
import { RefreshCw, Activity, ChevronDown, ChevronUp, Brain } from 'lucide-react'
import clsx from 'clsx'
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
interface MacroGauge {
id: string; label: string; ticker?: string | null
value: number | null; change_pct: number | null
unit: string; bloc: string; note?: string | null
}
const SCENARIO_ORDER = [
'goldilocks', 'desinflation', 'soft_landing', 'reflation',
'stagflation', 'inflation_shock', 'recession', 'crise_liquidite',
] as const
const BLOC_INFO: Record<string, { label: string; emoji: string; description: string }> = {
liquidite: { label: 'Liquidité mondiale', emoji: '💧', description: 'Carburant des marchés — DXY↓ = conditions mondiales détendues ; pente = signal récession/reprise' },
credit: { label: 'Crédit & Stress', emoji: '⚡', description: 'Thermomètre systémique — HYG (HY, obligataires détectent avant les actions), LQD (IG), VIX' },
energie: { label: 'Énergie', emoji: '⛽', description: 'Premier moteur d\'inflation — Brent↑↑ = stagflation/choc ; Brent↓ = désinflationniste' },
metaux: { label: 'Métaux stratégiques', emoji: '🥇', description: 'Cuivre = "Dr Copper" (croissance mondiale) ; Or = refuge/taux réels ; ratio Or/Cu = biais craint vs expansion' },
croissance: { label: 'Croissance mondiale', emoji: '📊', description: 'Cycle réel — S&P, Russell 2000 (breadth risk-on), XLI (proxy ISM mfg), Baltic Dry (secondaire)' },
derive: { label: 'Indicateurs dérivés', emoji: '🔗', description: 'Pente 10Y3M (récession) · Or/Cu (peur vs expansion) · S&P vs 200j · Russell vs S&P (breadth)' },
}
const ASSET_BIAS_COLORS: Record<string, string> = {
'bullish+': '#10b981', 'bullish': '#34d399', 'neutral': '#64748b',
'bearish': '#f97316', 'bearish+': '#ef4444', 'defensive': '#f59e0b',
}
const ASSET_BIAS_LABELS: Record<string, string> = {
'bullish+': '★★ Très favorable', 'bullish': '★ Favorable', 'neutral': '→ Neutre',
'bearish': '✗ Défavorable', 'bearish+': '✗✗ Contre', 'defensive': '⚠ Défensif',
}
const ASSET_CLASS_LABELS: Record<string, string> = {
energy: 'Énergie ⛽', metals: 'Métaux 🥇', agriculture: 'Agriculture 🌾',
indices: 'Indices 📊', equities: 'Actions 📈', forex: 'Forex 💱',
}
function GaugeRow({ g }: { g: MacroGauge }) {
const val = g.value
const chg = g.change_pct
const up = chg != null && chg > 0
const dn = chg != null && chg < 0
const fmt = (v: number) => {
if (v > 10000) return `${(v / 1000).toFixed(1)}k`
if (v > 1000) return v.toFixed(0)
if (v > 100) return v.toFixed(2)
return v.toFixed(3)
}
return (
<div className="flex items-center justify-between py-1 border-b border-slate-800/50 last:border-0 group">
<div className="flex-1 min-w-0 mr-3">
<span className="text-xs text-slate-300 group-hover:text-white transition-colors">{g.label}</span>
{g.ticker && <span className="text-[10px] text-slate-700 ml-1.5">{g.ticker}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
{val != null ? (
<span className="text-sm font-mono font-semibold text-white">
{fmt(val)}<span className="text-slate-600 text-xs ml-0.5">{g.unit}</span>
</span>
) : (
<span className="text-xs text-slate-700">N/A</span>
)}
{chg != null && (
<span className={clsx('text-xs font-mono w-14 text-right', up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-600')}>
{up ? '↑' : dn ? '↓' : '→'} {Math.abs(chg).toFixed(2)}%
</span>
)}
{g.note && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-dark-600 text-slate-500 w-28 text-center truncate">
{g.note}
</span>
)}
</div>
</div>
)
}
function BlocCard({ id, gauges }: { id: string; gauges: MacroGauge[] }) {
const [open, setOpen] = useState(true)
const info = BLOC_INFO[id] ?? { label: id, emoji: '📌', description: '' }
if (!gauges.length) return null
return (
<div className="card p-0 overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-dark-700/50 transition-colors"
>
<div>
<div className="text-sm font-semibold text-white text-left">
{info.emoji} {info.label}
</div>
<div className="text-[10px] text-slate-600 text-left mt-0.5">{info.description}</div>
</div>
{open ? <ChevronUp className="w-3.5 h-3.5 text-slate-600 shrink-0" /> : <ChevronDown className="w-3.5 h-3.5 text-slate-600 shrink-0" />}
</button>
{open && (
<div className="px-3 pb-2 border-t border-slate-800/50">
{gauges.map(g => <GaugeRow key={g.id} g={g} />)}
</div>
)}
</div>
)
}
export default function MacroRegime() {
const { data, isLoading, refetch, isFetching, forceRefetch } = useMacroRegime()
const { data: aiStatus } = useAiStatus()
const [savedNarration, setSavedNarration] = useState<{ text: string; at: string } | null>(null)
const [loadingNarration, setLoadingNarration] = useState(false)
useEffect(() => {
try {
const raw = localStorage.getItem('geo-options:macro-narration')
if (raw) setSavedNarration(JSON.parse(raw))
} catch {}
}, [])
const gauges: Record<string, MacroGauge> = data?.gauges ?? {}
const scenarios = data?.scenarios ?? {}
const dominant: string = scenarios.dominant ?? 'incertain'
const scores: Record<string, number> = scenarios.scores ?? {}
const meta: Record<string, { label: string; color: string; emoji: string }> = scenarios.meta ?? {}
const ranked: [string, number][] = scenarios.ranked ?? []
const reasons: Record<string, string[]> = scenarios.reasons ?? {}
const assetBias: Record<string, Record<string, string>> = scenarios.asset_bias ?? {}
const historyDays: number = scenarios.history_days ?? 0
const regimeStability: number = scenarios.regime_stability ?? 0
const dominantMeta = meta[dominant] ?? { label: dominant, color: '#94a3b8', emoji: '?' }
const dominantBias: Record<string, string> = assetBias[dominant] ?? {}
// Group gauges by bloc
const byBloc: Record<string, MacroGauge[]> = {}
for (const g of Object.values(gauges)) {
if (!byBloc[g.bloc]) byBloc[g.bloc] = []
byBloc[g.bloc].push(g)
}
// Add derived gauges into their own bloc
const derivedGauges = ([gauges.slope_10y3m, gauges.gold_copper_ratio, gauges.spx_vs_200d] as (MacroGauge | undefined)[])
.filter((g): g is MacroGauge => !!g)
if (derivedGauges.length) byBloc['derive'] = derivedGauges
const handleAiNarration = async () => {
setLoadingNarration(true)
try {
const freshData = await forceRefetch()
const res = await api.post('/ai/macro-narration', { macro_regime: freshData })
const text = res.data.narration ?? res.data.text ?? JSON.stringify(res.data)
const entry = { text, at: new Date().toISOString() }
setSavedNarration(entry)
localStorage.setItem('geo-options:macro-narration', JSON.stringify(entry))
} catch {
const entry = { text: 'Erreur lors de la génération — vérifier la clé OpenAI.', at: new Date().toISOString() }
setSavedNarration(entry)
} finally {
setLoadingNarration(false)
}
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-400" /> Régime Macro
</h1>
<p className="text-xs text-slate-500 mt-0.5">
30 compteurs institutionnels · Détection de régime · Compatibilité scénarios
</p>
</div>
<div className="flex items-center gap-3">
{data?.fetched_at && (
<span className="text-xs text-slate-600">
{data.cached ? `cache ${data.cache_age_sec}s` : 'live'} · {data.fetched_at.slice(11, 16)} UTC
</span>
)}
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white border border-slate-700 rounded px-2.5 py-1.5 transition-colors hover:border-slate-500 disabled:opacity-40"
>
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
Actualiser
</button>
</div>
</div>
{isLoading ? (
<div className="card flex items-center justify-center py-12 text-slate-600 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Chargement des compteurs macro...
</div>
) : (
<>
{/* Dominant scenario banner */}
{dominant !== 'incertain' && (
<div className="rounded-xl border p-4 flex items-center gap-4"
style={{ borderColor: `${dominantMeta.color}44`, background: `${dominantMeta.color}0d` }}>
<div className="text-4xl">{dominantMeta.emoji}</div>
<div className="flex-1">
<div className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">Scénario dominant</div>
<div className="text-xl font-bold" style={{ color: dominantMeta.color }}>{dominantMeta.label}</div>
<div className="text-xs text-slate-500 mt-1 flex flex-wrap gap-1">
{(reasons[dominant] ?? []).map((r, i) => (
<span key={i} className="px-1.5 py-0.5 rounded text-[10px]"
style={{ background: `${dominantMeta.color}22`, color: dominantMeta.color }}>
{r}
</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-3xl font-bold font-mono" style={{ color: dominantMeta.color }}>
{scores[dominant]}%
</div>
<div className="text-xs text-slate-600">score de régime</div>
{regimeStability > 0 && (
<div className="text-[10px] mt-1 px-1.5 py-0.5 rounded-full text-center"
style={{ background: `${dominantMeta.color}22`, color: dominantMeta.color }}>
stable {regimeStability}j
</div>
)}
{historyDays > 0 && (
<div className="text-[10px] text-slate-700 mt-0.5 text-center">
{historyDays}j d'historique
</div>
)}
</div>
</div>
)}
<div className="grid grid-cols-3 gap-5">
{/* Left: Gauge blocs */}
<div className="col-span-2 space-y-3">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
Compteurs par bloc ({Object.values(gauges).length} indicateurs)
</div>
{['liquidite', 'credit', 'energie', 'metaux', 'croissance', 'derive'].map(bloc => (
<BlocCard key={bloc} id={bloc} gauges={byBloc[bloc] ?? []} />
))}
</div>
{/* Right: Scenarios + asset compatibility */}
<div className="space-y-4">
{/* Scenario ranking */}
<div className="card">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">
Classement des scénarios
</div>
<div className="space-y-3">
{ranked.map(([key, score]) => {
const m = meta[key] ?? { label: key, color: '#94a3b8', emoji: '?' }
const isDom = key === dominant
const rl = reasons[key] ?? []
return (
<div key={key}>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm w-5 text-center">{m.emoji}</span>
<span className="text-xs flex-1 font-medium"
style={{ color: isDom ? m.color : '#94a3b8', fontWeight: isDom ? 700 : 400 }}>
{m.label}
</span>
<span className="text-xs font-mono font-bold w-8 text-right"
style={{ color: isDom ? m.color : '#475569' }}>
{score}%
</span>
</div>
<div className="bg-slate-800 rounded-full h-1.5 overflow-hidden">
<div className="h-full rounded-full transition-all duration-700"
style={{ width: `${score}%`, background: m.color, opacity: isDom ? 1 : 0.45 }} />
</div>
{isDom && rl.length > 0 && (
<div className="mt-1 space-y-0.5">
{rl.slice(0, 3).map((r, i) => (
<div key={i} className="text-[10px] text-slate-600 flex items-center gap-1">
<span style={{ color: m.color }}></span> {r}
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
{/* Asset class compatibility for dominant scenario */}
{dominant !== 'incertain' && Object.keys(dominantBias).length > 0 && (
<div className="card">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">
Classes d'actifs · {dominantMeta.emoji} {dominantMeta.label}
</div>
<div className="space-y-1.5">
{Object.entries(dominantBias).map(([cls, bias]) => {
const col = ASSET_BIAS_COLORS[bias] ?? '#64748b'
const lbl = ASSET_BIAS_LABELS[bias] ?? bias
return (
<div key={cls} className="flex items-center justify-between">
<span className="text-xs text-slate-400">{ASSET_CLASS_LABELS[cls] ?? cls}</span>
<span className="text-xs font-semibold" style={{ color: col }}>{lbl}</span>
</div>
)
})}
</div>
</div>
)}
{/* AI narration block */}
<div className="card">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5 text-blue-400" /> Analyse IA
</div>
{savedNarration?.at && (
<span className="text-[10px] text-slate-700">
{new Date(savedNarration.at).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
})}
</span>
)}
</div>
{savedNarration ? (
<p className="text-xs text-slate-300 leading-relaxed mb-3">{savedNarration.text}</p>
) : (
<p className="text-xs text-slate-600 leading-relaxed mb-3">
GPT-4o peut interpréter le régime actuel, identifier les incohérences entre compteurs et proposer des biais de trading.
</p>
)}
{aiStatus?.enabled ? (
<button
onClick={handleAiNarration}
disabled={loadingNarration}
className="w-full text-xs bg-blue-600/20 hover:bg-blue-600/40 border border-blue-500/30 hover:border-blue-500/60 text-blue-300 rounded py-1.5 transition-all disabled:opacity-40 flex items-center justify-center gap-1.5"
>
{loadingNarration ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> Analyse en cours...</>
) : (
<><Brain className="w-3 h-3" /> {savedNarration ? 'Ré-analyser' : 'Demander à l\'IA'}</>
)}
</button>
) : (
<div className="text-xs text-slate-700 text-center">OpenAI non configuré</div>
)}
</div>
{/* Reading order reminder */}
<div className="card bg-dark-700/30">
<div className="text-xs font-semibold text-slate-600 mb-2">Ordre de lecture (5 min)</div>
<div className="space-y-0.5 text-[10px] text-slate-600">
<div>1. <span className="text-slate-500">Liquidité</span> Bilan Fed, DXY, taux réels</div>
<div>2. <span className="text-slate-500">Crédit</span> HY spreads, VIX, MOVE</div>
<div>3. <span className="text-slate-500">Énergie</span> Brent, gaz naturel</div>
<div>4. <span className="text-slate-500">Métaux</span> Or/Cuivre ratio</div>
<div>5. <span className="text-slate-500">Synthèse</span> Scénario dominant</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}