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>
367 lines
18 KiB
TypeScript
367 lines
18 KiB
TypeScript
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 10Y–3M (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>
|
||
)
|
||
}
|