Files
OpenFin/frontend/src/pages/MacroRegime.tsx
OpenSquared dcbc9f19fc feat: translate all UI strings to English for international release
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>
2026-06-22 09:06:37 +02:00

367 lines
17 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: '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 10Y3M (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>
)
}