"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 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); // ── 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; 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 signal — champs disponibles : net, longPct, shortPct, totalLev if (cot) { const cotDir: SignalDir = cot.longPct > 60 ? "bearish" // majorité long = contrarian bearish : cot.shortPct > 60 ? "bullish" // majorité short = contrarian bullish : cot.net > 0 ? "bullish" : cot.net < 0 ? "bearish" : "neutral"; const imbalance = Math.abs(cot.longPct - cot.shortPct); mispricingSignals.push({ id: "cot", label: "Sentiment Retail (COT)", direction: cotDir, value: `${cot.longPct.toFixed(0)}% L / ${cot.shortPct.toFixed(0)}% S`, detail: `Retail ${cot.longPct.toFixed(0)}% long vs ${cot.shortPct.toFixed(0)}% short (imbalance ${imbalance.toFixed(0)}%). ${cotDir === "bullish" ? "Majorité short → signal contrarian haussier." : cotDir === "bearish" ? "Majorité long → signal contrarian baissier." : "Sentiment équilibré."}`, strength: Math.min(100, imbalance * 2), icon: , }); } // OIS / rate probability if (ratePath && ratePath.meetings.length > 0) { const peak = ratePath.peakMeeting; if (peak) { const oisDir: SignalDir = peak.probIsCut ? "bearish" : "bullish"; mispricingSignals.push({ id: "ois", label: "OIS Probabilité de Move", direction: oisDir, value: `${peak.probMovePct.toFixed(0)}% ${peak.probIsCut ? "Cut" : "Hike"}`, detail: `Pic de probabilité OIS : ${peak.probMovePct.toFixed(0)}% ${peak.probIsCut ? "de baisse" : "de hausse"} lors de la réunion ${peak.label}. Taux fin d'année implicite : ${ratePath.yearEndImplied?.toFixed(2) ?? "—"}%.`, 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: , }); } // Inflation pressure const cpiYoY = inds?.cpiYoY?.value ?? inds?.cpiCore?.value ?? null; const policyRate = inds?.policyRate?.value ?? null; if (cpiYoY !== null && policyRate !== null) { const realRate = policyRate - cpiYoY; const inflDir: SignalDir = realRate < 0 ? "bearish" : realRate > 1.5 ? "bullish" : "neutral"; mispricingSignals.push({ id: "inflation", label: "Taux Réel (Taux − Inflation)", direction: inflDir, value: `${realRate > 0 ? "+" : ""}${realRate.toFixed(2)}%`, detail: `Taux directeur ${policyRate.toFixed(2)}% − Inflation YoY ${cpiYoY.toFixed(2)}% = Taux réel ${realRate.toFixed(2)}%. ${realRate < 0 ? "Taux réel négatif → politique encore accommodante → bearish devise." : "Taux réel positif → politique restrictive → bullish devise."}`, strength: Math.min(100, Math.abs(realRate) * 25), icon: , }); } // Sentiment if (sentiment) { const sentDir: SignalDir = sentiment.signal === "contrarian_bullish" ? "bullish" : sentiment.signal === "contrarian_bearish" ? "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 neutre"}.`, 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"; // ── 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 + cache */}
{phaseLabel(phase)} {mispricingSignals.length > 0 && ( {mispricDir === "bullish" ? "▲" : mispricDir === "bearish" ? "▼" : "—"} {avgStr} signaux )} {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 && ( fin an: {ratePath.yearEndImplied.toFixed(2)}% )}
)}
)} {/* 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) )}
{/* 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}
{/* COT mini chart */} {cot && cot.history.length > 1 && (
Positions COT Nettes (historique)
0 ? "text-emerald-400" : "text-red-400"}`}> {cot.deltaWoW > 0 ? "+" : ""}{cot.deltaWoW.toFixed(0)}k WoW
({ date: h.weekEnding.slice(5), net: h.net }))} margin={{ top: 2, right: 0, left: 0, bottom: 0 }} > 0 ? "#10b981" : "#ef4444"} stopOpacity={0.3} /> 0 ? "#10b981" : "#ef4444"} stopOpacity={0} /> 0 ? "#10b981" : "#ef4444"} fill={`url(#cot-${currency})`} strokeWidth={1.5} dot={false} /> 0 ? "#10b981" : "#ef4444" }} formatter={(v: number) => [`${v.toFixed(0)}k`, "Nette"]} />
)} {/* 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)}%
)}
)}
); }