"use client"; import { useEffect, useState, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { TrendingUp, TrendingDown, Minus, Loader2, Database, BarChart2, Activity, Target, Zap, Eye, Layers, ChevronRight, ArrowUpRight, ArrowDownRight, AlertTriangle, } from "lucide-react"; import { AreaChart, Area, LineChart, Line, ResponsiveContainer, Tooltip, XAxis, } from "recharts"; import { CURRENCY_META, COUNTRY_PROFILES } from "@/lib/constants"; import { biasLabel, calcMacroScore } from "@/lib/scoring"; import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache"; import type { Currency, BiasPhase, RateExpectation } from "@/lib/types"; import type { CBRatePath } from "@/lib/rateprobability"; import type { SentimentEntry, CotEntry } from "@/lib/types"; import NarrativeButton from "./NarrativeButton"; // ─── Types internes ─────────────────────────────────────────────────────────── interface Ind { value: number | null; prev: number | null; surprise: number | null; trend: "up"|"down"|"flat"|null; lastUpdated: string | null; consensus?: number | null; } interface MacroForecasts { cpi: number | null; cpiSurprise: number | null; cpiCore: number | null; cpiMoM: number | null; cpiCoreMoM: number | null; ppiMoM: number | null; unemployment: number | null; unemploymentSurprise: number | null; pmiMfg: number | null; pmiMfgSurprise: number | null; pmiSvc: number | null; pmiSvcSurprise: number | null; pmiComposite: number | null; pmiCompositeSurprise: number | null; retailSales: number | null; retailSalesSurprise: number | null; gdp: number | null; gdpSurprise: number | null; employment: number | null; employmentSurprise: number | null; } interface MacroData { currency: string; indicators: Record; forecasts?: MacroForecasts | null; fetchedAt: string; } interface Props { currency: Currency; expectations: Record | null; yields: { yields: Record; spreads: Record } | null; sentiment: SentimentEntry | null; cot: CotEntry | null; ratePath: CBRatePath | null; onDivergenceUpdate: (currency: Currency, score: number) => void; } type Tab = "overview" | "mispricing" | "focus"; type SignalDir = "bullish" | "bearish" | "neutral" | "warning"; // ─── Helpers de style ───────────────────────────────────────────────────────── function phaseStyle(p: BiasPhase) { if (p === "tightening") return "bg-red-500/15 text-red-400 border-red-500/30"; if (p === "easing") return "bg-sky-500/15 text-sky-400 border-sky-500/30"; if (p === "hawkish_pause") return "bg-amber-500/15 text-amber-400 border-amber-500/30"; if (p === "dovish_pause") return "bg-blue-500/15 text-blue-400 border-blue-500/30"; return "bg-slate-500/15 text-slate-400 border-slate-500/30"; } function phaseLabel(p: BiasPhase) { if (p === "tightening") return "Resserrement"; if (p === "easing") return "Assouplissement"; if (p === "hawkish_pause") return "Pause Hawkish"; if (p === "dovish_pause") return "Pause Dovish"; return "Transition"; } function scoreDir(score: number): SignalDir { if (score >= 3) return "bullish"; if (score <= -3) return "bearish"; return "neutral"; } function sigColor(d: SignalDir) { if (d === "bullish") return "text-emerald-400"; if (d === "bearish") return "text-red-400"; if (d === "warning") return "text-amber-400"; return "text-slate-400"; } function sigBg(d: SignalDir) { if (d === "bullish") return "bg-emerald-500/10 border-emerald-500/20"; if (d === "bearish") return "bg-red-500/10 border-red-500/20"; if (d === "warning") return "bg-amber-500/10 border-amber-500/20"; return "bg-slate-500/10 border-slate-500/20"; } function sigBar(d: SignalDir) { if (d === "bullish") return "bg-emerald-500"; if (d === "bearish") return "bg-red-500"; if (d === "warning") return "bg-amber-500"; return "bg-slate-500"; } function computeESI(inds: Record | undefined): number | null { if (!inds) return null; const invertedKeys = new Set(["unemployment"]); const checkKeys = ["cpiYoY", "cpiCore", "pmiMfg", "pmiServices", "gdp", "retailSales", "cpiMoM", "ppiMoM", "employment"]; const signs: number[] = []; for (const key of checkKeys) { const s = inds[key]?.surprise; if (s === null || s === undefined) continue; const sign = s > 0 ? 1 : s < 0 ? -1 : 0; signs.push(invertedKeys.has(key) ? -sign : sign); } if (signs.length === 0) return null; return Math.round(signs.reduce((a, v) => a + v, 0) / signs.length * 100); } function SurpriseIndexBadge({ value }: { value: number }) { const color = value > 20 ? "text-emerald-400" : value < -20 ? "text-red-400" : "text-slate-400"; const bgBar = value > 20 ? "bg-emerald-500" : value < -20 ? "bg-red-500" : "bg-slate-500"; return (
ESI
{value > 0 ? "+" : ""}{value}
); } function FocusRow({ importance, children }: { importance: "critical" | "high" | "medium"; children: React.ReactNode }) { const dot = importance === "critical" ? "bg-red-500" : importance === "high" ? "bg-amber-500" : "bg-slate-500"; return (
{children}
); } // ─── Profils géopolitiques statiques ───────────────────────────────────────── const ENERGY_PROFILE: Record = { USD: { type: "export", desc: "1er producteur mondial pétrole + gaz (EIA). Exportateur net depuis 2019.", products: ["Pétrole", "GNL", "Blé", "Soja", "Maïs"] }, EUR: { type: "import", desc: "Import ~75% énergie (MENA, Russie réduit). Très sensible aux chocs pétrole.", products: ["Blé (FR, DE)", "Machines industrielles"] }, GBP: { type: "neutre", desc: "Mer du Nord en déclin. Production ≈ consommation (~neutre).", products: ["Services financiers"] }, JPY: { type: "import", desc: "Import ~90% énergie. 3ème importateur GNL mondial. Très sensible au Détroit d'Hormuz.", products: ["Électronique", "Voitures"] }, CHF: { type: "import", desc: "Import ~75% énergie (gaz naturel Europe, pétrole OPEP).", products: [] }, CAD: { type: "export", desc: "Pétrole sables bitumineux (Alberta). Export ~4 Mb/j.", products: ["Pétrole", "Gaz naturel", "Blé", "Potasse", "Bois d'œuvre"] }, AUD: { type: "export", desc: "2ème exportateur GNL mondial. Export charbon thermique + métallurgique.", products: ["Minerai de fer", "GNL", "Charbon", "Or", "Blé", "Cuivre"] }, NZD: { type: "import", desc: "Import pétrole. Renouvelables ~85% élec (hydro). Indépendant localement.", products: ["Lait / Produits laitiers", "Viande bovine", "Bois", "Laine"] }, }; function trendDir(t: "up"|"down"|"flat"|null): SignalDir { if (t === "up") return "bullish"; if (t === "down") return "bearish"; return "neutral"; } // ─── Sous-composants ───────────────────────────────────────────────────────── function MacroBlock({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
); } function IRow({ label, ind, unit = "", consensus, surpriseVsCons, tooltip, info, invertSurprise = false, }: { label: string; ind: Ind | null; unit?: string; consensus?: number | null; surpriseVsCons?: number | null; tooltip?: string | null; info?: string | null; invertSurprise?: boolean; }) { const value = ind?.value ?? null; const prev = ind?.prev ?? null; const fmt = (v: number | null) => v !== null ? `${v.toFixed(2)}${unit}` : "—"; const s = ind?.surprise ?? null; const effS = invertSurprise && s !== null ? -s : s; const valColor = effS === null ? "text-slate-200" : effS > 0 ? "text-emerald-400" : effS < 0 ? "text-red-400" : "text-slate-500"; const effSurpr = invertSurprise && surpriseVsCons !== null ? -(surpriseVsCons ?? 0) : surpriseVsCons; const surpriseCls = effSurpr == null ? "" : effSurpr > 0 ? "text-emerald-500" : effSurpr < 0 ? "text-red-500" : "text-slate-500"; const surpriseArr = effSurpr == null ? "" : effSurpr > 0 ? "▲" : effSurpr < 0 ? "▼" : "▬"; return (
{label} {info && ( i {info} )}
{tooltip ? ( {fmt(value)} {tooltip} ) : ( {fmt(value)} )}
Préc. {fmt(prev)} {surpriseVsCons !== null && surpriseVsCons !== undefined ? ( Surpr. {surpriseArr}{(effSurpr ?? 0) > 0 ? "+" : ""}{(effSurpr ?? 0).toFixed(2)}{unit} ) : consensus !== null && consensus !== undefined ? ( Cons. {fmt(consensus)} ) : null}
); } function SignalBar({ strength, direction }: { strength: number; direction: SignalDir }) { return (
); } // ─── Composant principal ────────────────────────────────────────────────────── export default function CurrencyCard({ currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate }: Props) { const meta = CURRENCY_META[currency]; // ── State ──────────────────────────────────────────────────────────────────── const [data, setData] = useState(null); const [phase, setPhase] = useState("hawkish_pause"); const [loading, setLoading] = useState(true); const [rateExp, setRateExp] = useState(null); const [fromCache, setFromCache] = useState(false); const [cacheAge, setCacheAge] = useState(null); const [activeTab, setActiveTab] = useState("overview"); const [inflFilter, setInflFilter] = useState<"all" | "mom" | "yoy">("mom"); const [expandedSig, setExpandedSig] = useState(null); const [divergenceOpen, setDivergenceOpen] = useState(false); // ── Data fetch ─────────────────────────────────────────────────────────────── const load = useCallback(async () => { setLoading(true); const cacheKey = `macro_${currency}`; try { const res = await fetch(`/api/macro?currency=${currency}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: MacroData = await res.json(); if ("error" in json) throw new Error(String((json as Record).error)); const prevCache = loadCache(cacheKey); const merged: MacroData = { ...json, indicators: { ...json.indicators, pmiMfg: json.indicators.pmiMfg ?? prevCache?.data.indicators.pmiMfg ?? null, pmiServices: json.indicators.pmiServices ?? prevCache?.data.indicators.pmiServices ?? null, }, }; setData(merged); setFromCache(false); setCacheAge(null); saveCache(cacheKey, merged); const rateInd = merged.indicators.policyRate; if (rateInd?.trend === "up") setPhase("tightening"); else if (rateInd?.trend === "down") setPhase("easing"); else setPhase("hawkish_pause"); } catch { const cached = loadCache(cacheKey); if (cached) { setData(cached.data); setFromCache(true); setCacheAge(formatCacheDate(cached.savedAt)); const rateInd = cached.data.indicators.policyRate; if (rateInd?.trend === "up") setPhase("tightening"); else if (rateInd?.trend === "down") setPhase("easing"); else setPhase("hawkish_pause"); } } finally { setLoading(false); } }, [currency]); useEffect(() => { load(); }, [load]); useEffect(() => { if (!expectations) return; const all = [ ...((expectations.rate_hikes ?? []) as RateExpectation[]), ...((expectations.rate_cuts ?? []) as RateExpectation[]), ]; const cbShort = meta.cbShort.toLowerCase(); setRateExp(all.find((e) => e.cb.toLowerCase().includes(cbShort) || e.cb.toLowerCase().includes(currency.toLowerCase()) ) ?? null); }, [expectations, currency, meta.cbShort]); // ── Computed values ────────────────────────────────────────────────────────── const inds = data?.indicators; const fc = data?.forecasts ?? null; const forScoring = { policyRate: { value: inds?.policyRate?.value ?? null, prev: inds?.policyRate?.prev ?? null, consensus: null, surprise: inds?.policyRate?.surprise ?? null, trend: inds?.policyRate?.trend ?? null, lastUpdated: "" }, cpiCore: { value: inds?.cpiCore?.value ?? null, prev: inds?.cpiCore?.prev ?? null, consensus: null, surprise: inds?.cpiCore?.surprise ?? null, trend: inds?.cpiCore?.trend ?? null, lastUpdated: "" }, pmiMfg: { value: inds?.pmiMfg?.value ?? null, prev: inds?.pmiMfg?.prev ?? null, consensus: null, surprise: inds?.pmiMfg?.surprise ?? null, trend: inds?.pmiMfg?.trend ?? null, lastUpdated: "" }, pmiServices: { value: inds?.pmiServices?.value ?? null, prev: inds?.pmiServices?.prev ?? null, consensus: null, surprise: inds?.pmiServices?.surprise ?? null, trend: inds?.pmiServices?.trend ?? null, lastUpdated: "" }, gdp: { value: inds?.gdp?.value ?? null, prev: inds?.gdp?.prev ?? null, consensus: null, surprise: inds?.gdp?.surprise ?? null, trend: inds?.gdp?.trend ?? null, lastUpdated: "" }, retailSales: { value: inds?.retailSales?.value ?? null, prev: inds?.retailSales?.prev ?? null, consensus: null, surprise: inds?.retailSales?.surprise ?? null, trend: inds?.retailSales?.trend ?? null, lastUpdated: "" }, unemployment: { value: inds?.unemployment?.value ?? null, prev: inds?.unemployment?.prev ?? null, consensus: null, surprise: inds?.unemployment?.surprise ?? null, trend: inds?.unemployment?.trend ?? null, lastUpdated: "" }, employment: { value: inds?.employment?.value ?? null, prev: inds?.employment?.prev ?? null, consensus: null, surprise: inds?.employment?.surprise ?? null, trend: inds?.employment?.trend ?? null, lastUpdated: "" }, }; const macroScore = calcMacroScore(forScoring, phase); const biasText = biasLabel(macroScore); const dir = scoreDir(macroScore); const yield10Y = yields?.yields[currency] ?? null; const spread10Y = yields?.spreads[currency] ?? null; const esi = computeESI(inds); const policyRateValue = inds?.policyRate?.value ?? null; const curveSpread = yield10Y !== null && policyRateValue !== null ? Math.round((yield10Y - policyRateValue) * 100) : null; const curveInverted = curveSpread !== null && curveSpread < 0; const curveSig: SignalDir = curveSpread === null ? "neutral" : curveSpread < -50 ? "warning" : curveInverted ? "neutral" : "bullish"; useEffect(() => { onDivergenceUpdate(currency, macroScore); }, [macroScore, currency, onDivergenceUpdate]); const rateConsensus = (() => { const rate = inds?.policyRate?.value ?? null; if (rate === null) return null; if (ratePath && ratePath.meetings.length > 0) { const next = ratePath.meetings[0]; if (next.probMovePct > 50) return next.probIsCut ? parseFloat((rate - 0.25).toFixed(2)) : parseFloat((rate + 0.25).toFixed(2)); return parseFloat(rate.toFixed(2)); } if (!rateExp) return null; const desc = rateExp.prob_desc.toLowerCase(); if (desc.includes("no change")) return parseFloat(rate.toFixed(2)); if (rateExp.direction === "cut" && rateExp.prob_pct > 50) return parseFloat((rate - 0.25).toFixed(2)); if (rateExp.direction === "hike" && rateExp.prob_pct > 50) return parseFloat((rate + 0.25).toFixed(2)); return parseFloat(rate.toFixed(2)); })(); // ── Mispricing signals (computed from live data) ────────────────────────────── const mispricingSignals: { id: string; label: string; value: string; detail: string; direction: SignalDir; strength: number; icon: React.ReactNode; }[] = []; // COT CFTC — Leveraged Money (hedge funds), source : CFTC FinFutWk.txt // Logique : "follow smart money" — majorité long HF = bullish, majorité short = bearish // ≠ Retail Myfxbook (contrarian) qui est géré séparément dans le signal "sentiment" if (cot) { const cotDir: SignalDir = cot.longPct > 60 ? "bullish" // HF majoritairement long = bullish : cot.shortPct > 60 ? "bearish" // HF majoritairement short = bearish : cot.net > 0 ? "bullish" : cot.net < 0 ? "bearish" : "neutral"; const imbalance = Math.abs(cot.longPct - cot.shortPct); mispricingSignals.push({ id: "cot", label: "COT Hedge Funds (CFTC)", direction: cotDir, value: `${cot.longPct.toFixed(0)}% L / ${cot.shortPct.toFixed(0)}% S`, detail: `Leveraged Money CFTC : ${cot.longPct.toFixed(0)}% long vs ${cot.shortPct.toFixed(0)}% short (imbalance ${imbalance.toFixed(0)}%, net ${cot.net > 0 ? "+" : ""}${(cot.net / 1000).toFixed(0)}k). ${cotDir === "bullish" ? "Hedge funds majoritairement longs → signal institutionnel haussier." : cotDir === "bearish" ? "Hedge funds majoritairement shorts → signal institutionnel baissier." : "Positioning institutionnel équilibré."}`, strength: Math.min(100, imbalance * 2), icon: , }); } // OIS / rate probability — avec drift bps fin d'an if (ratePath && ratePath.meetings.length > 0) { const peak = ratePath.peakMeeting; const yearEndBps = ratePath.yearEndImplied !== null ? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100) : null; if (peak) { const oisDir: SignalDir = peak.probIsCut ? "bearish" : "bullish"; const bpsLabel = yearEndBps !== null ? `${yearEndBps > 0 ? "+" : ""}${yearEndBps}bps fin an` : ""; mispricingSignals.push({ id: "ois", label: "OIS — Drift taux fin d'an", direction: oisDir, value: bpsLabel ? `${peak.probMovePct.toFixed(0)}% ${peak.probIsCut ? "Cut" : "Hike"} · ${bpsLabel}` : `${peak.probMovePct.toFixed(0)}% ${peak.probIsCut ? "Cut" : "Hike"}`, detail: `Pic OIS : ${peak.probMovePct.toFixed(0)}% de ${peak.probIsCut ? "baisse" : "hausse"} à la réunion ${peak.label}. Taux implicite fin d'an : ${ratePath.yearEndImplied?.toFixed(2) ?? "—"}% (${yearEndBps !== null ? (yearEndBps > 0 ? "+" : "") + yearEndBps + "bps vs taux actuel" : "—"}).`, strength: peak.probMovePct, icon: , }); } } // Yield curve / spread if (spread10Y !== null && currency !== "USD") { const curveDir: SignalDir = spread10Y > 0 ? "bullish" : spread10Y < -150 ? "bearish" : "neutral"; mispricingSignals.push({ id: "yield", label: "Spread 10Y vs USD", direction: curveDir, value: `${spread10Y > 0 ? "+" : ""}${spread10Y}bps`, detail: `Différentiel de taux 10 ans vs US : ${spread10Y > 0 ? "+" : ""}${spread10Y}bps. ${Math.abs(spread10Y) > 100 ? "Écart important — potentiel de compression/expansion non pricé." : "Différentiel modéré."}`, strength: Math.min(100, Math.abs(spread10Y) / 2), icon: , }); } // Taux réel = Taux directeur − CPI YoY headline (= Inflation Rate YoY TE) // Pas de fallback sur Core CPI : Core < Headline → gonflerait artificiellement le taux réel const cpiHeadlineForReal = inds?.cpiYoY?.value ?? null; const policyRate = inds?.policyRate?.value ?? null; if (cpiHeadlineForReal !== null && policyRate !== null) { const realRate = policyRate - cpiHeadlineForReal; const inflDir: SignalDir = realRate < 0 ? "bearish" : realRate > 1.5 ? "bullish" : "neutral"; mispricingSignals.push({ id: "inflation", label: "Taux Réel (CT − CPI YoY)", direction: inflDir, value: `${realRate > 0 ? "+" : ""}${realRate.toFixed(2)}%`, detail: `Taux directeur ${policyRate.toFixed(2)}% − CPI YoY ${cpiHeadlineForReal.toFixed(2)}% = Taux réel ${realRate.toFixed(2)}%. ${realRate < 0 ? "Taux réel négatif → politique encore accommodante → bearish devise." : realRate > 1.5 ? "Taux réel élevé → politique très restrictive → bullish devise." : "Taux réel faiblement positif → neutre."}`, strength: Math.min(100, Math.abs(realRate) * 25), icon: , }); } // Sentiment retail — contrarian (majorité short = bullish, majorité long = bearish) if (sentiment) { const sentDir: SignalDir = sentiment.longPct < 30 ? "bullish" : sentiment.longPct > 70 ? "bearish" : "neutral"; mispricingSignals.push({ id: "sentiment", label: "Sentiment Retail (Contrarian)", direction: sentDir, value: `${sentiment.longPct.toFixed(0)}% Long`, detail: `Retail ${sentiment.longPct.toFixed(0)}% long / ${sentiment.shortPct.toFixed(0)}% short. Signal contrarian : ${sentDir === "bullish" ? "majorité short → opportunité haussière" : sentDir === "bearish" ? "majorité long → opportunité baissière" : "sentiment équilibré — pas de signal contrarian fort"}.`, strength: Math.abs(sentiment.longPct - 50) * 2, icon: , }); } const bullCount = mispricingSignals.filter(s => s.direction === "bullish").length; const bearCount = mispricingSignals.filter(s => s.direction === "bearish").length; const avgStr = mispricingSignals.length > 0 ? Math.round(mispricingSignals.reduce((a, s) => a + s.strength, 0) / mispricingSignals.length) : 0; const mispricDir: SignalDir = bullCount > bearCount ? "bullish" : bearCount > bullCount ? "bearish" : "neutral"; // Convergence : les deux s'alignent → fort signal // Divergence : macro et signaux se contredisent → alerte // Macro seul : signaux neutres mais macro directionnelle → signaler quand même // Signaux seuls: macro neutre mais signaux directionnels → signaler quand même const divergenceSignal: SignalDir = mispricDir !== "neutral" && dir !== "neutral" && mispricDir === dir ? mispricDir : mispricDir !== "neutral" && dir !== "neutral" && mispricDir !== dir ? "warning" : dir !== "neutral" ? dir : // macro seule directionnelle (signaux neutres) mispricDir !== "neutral" ? mispricDir : // signaux seuls (macro neutre) "neutral"; const macroOnly = dir !== "neutral" && mispricDir === "neutral"; const signalsOnly = mispricDir !== "neutral" && dir === "neutral"; const bothAligned = mispricDir !== "neutral" && dir !== "neutral" && mispricDir === dir; const bothOpposed = mispricDir !== "neutral" && dir !== "neutral" && mispricDir !== dir; const divergenceType = bothAligned && divergenceSignal === "bullish" ? "Convergence haussière multi-signal" : bothAligned && divergenceSignal === "bearish" ? "Convergence baissière multi-signal" : bothOpposed ? `Divergence: macro ${dir === "bullish" ? "↑" : "↓"} vs signaux ${mispricDir === "bullish" ? "↑" : "↓"}` : macroOnly && dir === "bullish" ? `Macro haussière (score +${macroScore}), signaux neutres` : macroOnly && dir === "bearish" ? `Macro baissière (score ${macroScore}), signaux neutres` : signalsOnly && mispricDir === "bullish" ? `Signaux haussiers, macro neutre` : signalsOnly && mispricDir === "bearish" ? `Signaux baissiers, macro neutre` : "Pas de signal convergent"; // Détail des contributeurs macro (même logique que calcMacroScore) const macroContributors: { label: string; value: string; sig: number }[] = [ { label: "Taux directeur", value: inds?.policyRate?.value != null ? `${inds.policyRate.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.policyRate?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "Core CPI", value: inds?.cpiCore?.value != null ? `${inds.cpiCore.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.cpiCore?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "PMI Mfg", value: inds?.pmiMfg?.value != null ? `${inds.pmiMfg.value.toFixed(1)}` : "", sig: inds?.pmiMfg?.value != null ? (inds.pmiMfg.value > 50 ? 1 : -1) : 0 }, { label: "PMI Services", value: inds?.pmiServices?.value != null ? `${inds.pmiServices.value.toFixed(1)}` : "", sig: inds?.pmiServices?.value != null ? (inds.pmiServices.value > 50 ? 1 : -1) : 0 }, { label: "PIB QoQ", value: inds?.gdp?.value != null ? `${inds.gdp.value > 0 ? "+" : ""}${inds.gdp.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.gdp?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "Retail Sales", value: inds?.retailSales?.value != null ? `${inds.retailSales.value > 0 ? "+" : ""}${inds.retailSales.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.retailSales?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "Chômage", value: inds?.unemployment?.value != null ? `${inds.unemployment.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.unemployment?.surprise; return s == null ? 0 : s < -0.3 ? 1 : s > 0.3 ? -1 : 0; })() }, { label: "Emploi", value: inds?.employment?.value != null ? `${inds.employment.value > 0 ? "+" : ""}${inds.employment.value.toFixed(1)}k` : "", sig: (() => { const s = inds?.employment?.surprise; return s == null ? 0 : s > 10 ? 1 : s < -10 ? -1 : 0; })() }, ].filter(c => c.value !== ""); // ── Tabs config ────────────────────────────────────────────────────────────── const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: "overview", label: "Aperçu", icon: }, { id: "mispricing", label: "Signaux", icon: }, { id: "focus", label: "Focus", icon: }, ]; // ── Render ──────────────────────────────────────────────────────────────────── return (
{/* ── Header ──────────────────────────────────────────────────────────── */}
{meta.flag}
{currency} {!loading && ( {biasText} )}
{meta.cbShort} · {meta.name}
{loading ? : <>
{macroScore > 0 ? "+" : ""}{macroScore}
score
}
{/* Phase pill + mispricing + ESI + cache */}
{phaseLabel(phase)} {mispricingSignals.length > 0 && ( {mispricDir === "bullish" ? "▲" : mispricDir === "bearish" ? "▼" : "—"} {avgStr} signaux )} {esi !== null && } {fromCache && cacheAge && ( cache {cacheAge} )}
{/* ── Tab bar ─────────────────────────────────────────────────────────── */}
{TABS.map((t) => ( ))}
{/* ── Tab content ─────────────────────────────────────────────────────── */}
{/* ════ APERÇU ════════════════════════════════════════════════════ */} {activeTab === "overview" && ( <> {/* OIS mini-chart */} {ratePath && ratePath.meetings.length > 0 && (
OIS · Probabilités de move au {ratePath.asOf}
({ label: m.label, prob: m.probMovePct, }))} margin={{ top: 2, right: 0, left: 0, bottom: 0 }} > [`${v.toFixed(0)}%`, "Probabilité"]} /> {ratePath.peakMeeting && (
Pic : {ratePath.peakMeeting.label} {ratePath.peakMeeting.probMovePct.toFixed(0)}% {ratePath.peakMeeting.probIsCut ? "Cut" : "Hike"} {ratePath.yearEndImplied !== null && (() => { const bps = Math.round((ratePath.yearEndImplied! - ratePath.currentRate) * 100); const cls = bps < 0 ? "text-sky-400 font-bold" : bps > 0 ? "text-red-400 font-bold" : "text-slate-500"; return ( {bps > 0 ? "+" : ""}{bps}bps fin an ); })()}
)}
)} {/* Divergence / convergence signal — expandable */} {divergenceSignal !== "neutral" && (
{divergenceOpen && (
{/* Macro indicators */}
Fondamentaux macro score {macroScore > 0 ? "+" : ""}{macroScore}
{macroContributors.map(c => (
0 ? "text-emerald-400" : c.sig < 0 ? "text-red-400" : "text-slate-600"}> {c.sig > 0 ? "↑" : c.sig < 0 ? "↓" : "→"} {c.label}
0 ? "text-emerald-400" : c.sig < 0 ? "text-red-400" : "text-slate-500"}`}> {c.value}
))}
{/* Market signals */} {mispricingSignals.length > 0 && (
Signaux marché {bullCount}↑ {bearCount}↓
{mispricingSignals.map(s => (
{s.direction === "bullish" ? "↑" : s.direction === "bearish" ? "↓" : "→"} {s.label}
{s.value}
))}
)}
)}
)} {/* Politique Monétaire */}
10Y Yield
{yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"} {spread10Y !== null && ( 0 ? "text-emerald-500" : "text-red-500"}`}> ({spread10Y > 0 ? "+" : ""}{spread10Y}bps vs US) )}
{curveSpread !== null && (
Courbe (10Y−CT)
{curveSpread > 0 ? "+" : ""}{curveSpread}bps{curveInverted ? " ⚠" : ""}
)} {(() => { // Taux réel = Taux directeur − CPI YoY headline (= Inflation Rate YoY) // On n'utilise PAS Core CPI comme fallback : Core < Headline → gonflerait le taux réel const cpiHeadline = inds?.cpiYoY?.value ?? null; if (policyRateValue === null || cpiHeadline === null) return null; const realRate = parseFloat((policyRateValue - cpiHeadline).toFixed(2)); const rDir: SignalDir = realRate < 0 ? "bearish" : realRate > 1.5 ? "bullish" : "neutral"; return (
Taux réel (CT−CPI)
{realRate > 0 ? "+" : ""}{realRate}%
); })()}
{/* Inflation avec filtre MoM/YoY */}
Inflation
{(["all", "mom", "yoy"] as const).map((v) => ( ))}
{(inflFilter === "all" || inflFilter === "mom") && ( <> { const isQoQ = (inds?.cpiCoreMoM as (Ind & { isQoQ?: boolean }) | null)?.isQoQ; return isQoQ ? "Core CPI QoQ" : "Core CPI MoM"; })()} ind={inds?.cpiCoreMoM ?? null} unit="%" consensus={fc?.cpiCoreMoM ?? null} info="=Core Inflation Rate MoM" tooltip={(() => { const raw = (inds?.cpiCoreMoM as (Ind & { _raw?: { last: number; prev: number; refMonth: string } }) | null)?._raw; return raw ? `Index: last=${raw.last} prev=${raw.prev} ref=${raw.refMonth}` : null; })()} /> )} {(inflFilter === "all" || inflFilter === "yoy") && ( <> { const fd = (inds?.cpiCore as (Ind & { _finalForecast?: number; _finalDelta?: number }) | null); const base = "=Core Inflation Rate YoY"; if (fd?._finalForecast === undefined) return base; const d = fd._finalDelta ?? 0; return `${base} • Final prévu : ${fd._finalForecast?.toFixed(1)}% (${d > 0 ? "↑" : d < 0 ? "↓" : "="})`; })()} /> { const fd = (inds?.cpiYoY as (Ind & { _finalForecast?: number; _finalDelta?: number }) | null); const base = "=Inflation Rate YoY"; if (fd?._finalForecast === undefined) return base; const d = fd._finalDelta ?? 0; return `${base} • Final prévu : ${fd._finalForecast?.toFixed(1)}% (${d > 0 ? "↑" : d < 0 ? "↓" : "="})`; })()} /> {inds?.commodityPricesYoY && ( )} )}
{/* Croissance */} {/* Emploi */} {/* PMI détail */} )} {/* ════ SIGNAUX / MISPRICING ════════════════════════════════════ */} {activeTab === "mispricing" && ( <> {/* Biais global */}
Biais Signaux
{mispricDir === "bullish" ? `${currency} Haussier` : mispricDir === "bearish" ? `${currency} Baissier` : "Signal Mixte"}
Force moy.
{avgStr}
{/* Signal list */}
{mispricingSignals.length === 0 && (
Données insuffisantes pour calculer les signaux
)} {mispricingSignals.map((sig) => (
{expandedSig === sig.id && (

{sig.detail}

)}
))}
)} {/* ════ FOCUS DONNÉES ══════════════════════════════════════════════ */} {activeTab === "focus" && ( <> {/* Context phase */}
Phase {phaseLabel(phase)}

{phase === "tightening" && "Surveiller les données soutenant une poursuite du resserrement : inflation, emploi solide, PMI expansionniste."} {phase === "easing" && "Surveiller les données justifiant des baisses : désinflation, ralentissement du marché de l'emploi, PMI en contraction."} {phase === "hawkish_pause" && "Surveiller les données qui pourraient forcer la main : regain d'inflation ou au contraire fort ralentissement."} {phase === "dovish_pause" && "Surveiller les signes de reprise permettant une normalisation de la politique monétaire."} {phase === "transition" && "Phase de transition — tous les indicateurs sont importants pour déterminer la direction future."}

{/* Données clés selon phase */} {(phase === "easing" || phase === "dovish_pause") ? <> : <> } {/* Autres indicateurs (référence) */} {(phase === "easing" || phase === "dovish_pause") ? <> : <> } {/* Taux directeur */} {yield10Y !== null && (
10Y Yield
{yield10Y.toFixed(2)}%
)}
)}
); }