Complete French→English translation across all frontend pages and backend services — every label, button, header, empty state, toast, and nav item is now in English. Build verified clean (tsc + vite). No i18n library added; direct string replacement throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
367 lines
17 KiB
TypeScript
367 lines
17 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: 'Global Liquidity', emoji: '💧', description: 'Market fuel — DXY↓ = loose global conditions; yield curve = recession/recovery signal' },
|
||
credit: { label: 'Credit & Stress', emoji: '⚡', description: 'Systemic thermometer — HYG (HY, bonds lead equities), LQD (IG), VIX' },
|
||
energie: { label: 'Energy', emoji: '⛽', description: 'Primary inflation driver — Brent↑↑ = stagflation/shock; Brent↓ = disinflationary' },
|
||
metaux: { label: 'Strategic Metals', emoji: '🥇', description: 'Copper = "Dr Copper" (global growth); Gold = haven/real rates; Gold/Cu ratio = fear vs expansion bias' },
|
||
croissance: { label: 'Global Growth', emoji: '📊', description: 'Real cycle — S&P, Russell 2000 (breadth risk-on), XLI (ISM mfg proxy), Baltic Dry (secondary)' },
|
||
derive: { label: 'Derived Indicators', emoji: '🔗', description: 'Slope 10Y–3M (recession) · Gold/Cu (fear vs expansion) · S&P vs 200d · 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+': '★★ Very Favorable', 'bullish': '★ Favorable', 'neutral': '→ Neutral',
|
||
'bearish': '✗ Unfavorable', 'bearish+': '✗✗ Against', 'defensive': '⚠ Defensive',
|
||
}
|
||
const ASSET_CLASS_LABELS: Record<string, string> = {
|
||
energy: 'Energy ⛽', metals: 'Metals 🥇', agriculture: 'Agriculture 🌾',
|
||
indices: 'Indices 📊', equities: 'Equities 📈', 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: 'Error during generation — please check your OpenAI key.', 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" /> Macro Regime
|
||
</h1>
|
||
<p className="text-xs text-slate-500 mt-0.5">
|
||
30 institutional gauges · Regime detection · Scenario compatibility
|
||
</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')} />
|
||
Refresh
|
||
</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" /> Loading macro gauges...
|
||
</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">Dominant Scenario</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">regime score</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}d
|
||
</div>
|
||
)}
|
||
{historyDays > 0 && (
|
||
<div className="text-[10px] text-slate-700 mt-0.5 text-center">
|
||
{historyDays}d history
|
||
</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">
|
||
Gauges by bloc ({Object.values(gauges).length} indicators)
|
||
</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">
|
||
Scenario Ranking
|
||
</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">
|
||
Asset Classes · {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" /> AI Analysis
|
||
</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 can interpret the current regime, identify inconsistencies between gauges and suggest trading biases.
|
||
</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" /> Analysis in progress...</>
|
||
) : (
|
||
<><Brain className="w-3 h-3" /> {savedNarration ? 'Re-analyze' : 'Ask AI'}</>
|
||
)}
|
||
</button>
|
||
) : (
|
||
<div className="text-xs text-slate-700 text-center">OpenAI not configured</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Reading order reminder */}
|
||
<div className="card bg-dark-700/30">
|
||
<div className="text-xs font-semibold text-slate-600 mb-2">Reading order (5 min)</div>
|
||
<div className="space-y-0.5 text-[10px] text-slate-600">
|
||
<div>1. <span className="text-slate-500">Liquidity</span> → Fed balance sheet, DXY, real rates</div>
|
||
<div>2. <span className="text-slate-500">Credit</span> → HY spreads, VIX, MOVE</div>
|
||
<div>3. <span className="text-slate-500">Energy</span> → Brent, natural gas</div>
|
||
<div>4. <span className="text-slate-500">Metals</span> → Gold/Copper ratio</div>
|
||
<div>5. <span className="text-slate-500">Synthesis</span> → Dominant scenario</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|