"use client"; import { useEffect, useState, useCallback } from "react"; import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp, Loader2, Database } from "lucide-react"; import { CURRENCY_META, COUNTRY_PROFILES } from "@/lib/constants"; import { biasLabel, biasColor, 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"; interface Ind { value: number | null; prev: number | null; surprise: number | null; trend: "up"|"down"|"flat"|null; lastUpdated: string | 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; } const PHASES: Record = { tightening: { label: "πŸ”΄ Resserrement", color: "text-red-600" }, hawkish_pause: { label: "🟑 Pause Hawkish", color: "text-amber-600" }, easing: { label: "🟒 Assouplissement", color: "text-green-600" }, dovish_pause: { label: "πŸ”΅ Pause Dovish", color: "text-blue-600" }, transition: { label: "🟠 Transition", color: "text-orange-500" }, }; function SectionHeader({ label }: { label: string }) { return (
{label}
); } function TrendIcon({ trend }: { trend: "up"|"down"|"flat"|null }) { if (trend === "up") return ; if (trend === "down") return ; return ; } function Row({ label, ind, unit = "", invertSurprise = false, warn = false, consensus = null, surpriseVsCons = null, tooltip = null, info = null }: { label: string; ind: Ind | null; unit?: string; invertSurprise?: boolean; warn?: boolean; consensus?: number | null; surpriseVsCons?: number | null; tooltip?: string | null; info?: string | null; // petit "i" avec tooltip sur le label }) { const value = ind?.value ?? null; const prev = ind?.prev ?? null; // Colorer la valeur actuelle selon la direction du mouvement vs pΓ©riode prΓ©cΓ©dente const s = ind?.surprise ?? null; const effectiveS = invertSurprise && s !== null ? -s : s; const valCls = effectiveS === null ? "text-gray-800" : effectiveS > 0 ? "text-green-700" : effectiveS < 0 ? "text-red-700" : "text-gray-500"; const fmt = (v: number | null) => v !== null ? `${v.toFixed(2)}${unit}` : "β€”"; // Coloration de la surprise vs consensus (inversion pour chΓ΄mage/unemployment) const effSurprise = invertSurprise && surpriseVsCons !== null ? -surpriseVsCons : surpriseVsCons; const surpriseCls = effSurprise === null ? "" : effSurprise > 0 ? "text-green-600" : effSurprise < 0 ? "text-red-600" : "text-gray-500"; const surpriseArrow = effSurprise === null ? "" : effSurprise > 0 ? "β–²" : effSurprise < 0 ? "β–Ό" : "β–¬"; return (
{/* Ligne 1 : label + valeur actuelle publiΓ©e */}
{label} {warn && ⚠} {info && ( i {info} )}
{tooltip ? ( {fmt(value)} {tooltip} ) : ( {fmt(value)} )}
{/* Ligne 2 : prΓ©cΓ©dent + consensus Γ  venir OU surprise post-publication */}
PrΓ©c. {fmt(prev)} {surpriseVsCons !== null ? ( // Surprise vs consensus (≀5 jours post-release) Surpr.  {surpriseArrow}{surpriseVsCons > 0 ? "+" : ""}{surpriseVsCons.toFixed(2)}{unit} ) : ( // Consensus Γ  venir (upcoming) Cons.  {consensus !== null ? {fmt(consensus)} : β€”} )}
); } export default function CurrencyCard({ currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate }: Props) { const meta = CURRENCY_META[currency]; const [data, setData] = useState(null); const [phase, setPhase] = useState("hawkish_pause"); const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState(false); const [rateExp, setRateExp] = useState(null); const [fromCache, setFromCache] = useState(false); const [cacheAge, setCacheAge] = useState(null); const [inflFilter, setInflFilter] = useState<"all" | "mom" | "yoy">("mom"); // Single fetch β€” server batches all FRED calls 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)); // PMI est scraped mensuellement β†’ si le serveur renvoie null cette semaine // (PMI pas encore publiΓ©), on conserve la valeur prΓ©cΓ©dente du cache local. 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); // Infer phase from policy rate trend const rateInd = merged.indicators.policyRate; if (rateInd?.trend === "up") setPhase("tightening"); else if (rateInd?.trend === "down") setPhase("easing"); else setPhase("hawkish_pause"); } catch { // API indisponible β†’ on utilise le cache localStorage si disponible 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]); // Match rate expectation for this currency 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]); const inds = data?.indicators; const fc = data?.forecasts ?? null; // ForexFactory forecasts // Build a minimal indicator object for scoring 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 biasCls = biasColor(macroScore); const phaseInfo = PHASES[phase]; const yield10Y = yields?.yields[currency] ?? null; const spread10Y = yields?.spreads[currency] ?? null; const borderCls = macroScore >= 4 ? "border-green-200" : macroScore <= -4 ? "border-red-200" : "border-gray-200"; // Consensus = taux attendu Γ  la prochaine rΓ©union CB // PrioritΓ© : ratePath (OIS temps rΓ©el) > rateExp (snapshot statique) const rateConsensus = (() => { const rate = inds?.policyRate?.value ?? null; if (rate === null) return null; // OIS live 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)); } // Fallback snapshot statique (CHF / si rateprobability indispo) 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)); })(); return (
{/* Header */}
{meta.flag}
{currency}
{meta.cbShort} Β· {meta.name}
{loading ? : <>
{biasText}
Score : {macroScore > 0 ? "+" : ""}{macroScore}
}
{phaseInfo.label}
{fromCache && cacheAge && (
cache {cacheAge}
)}
{/* ── ProbabilitΓ©s OIS (rateprobability.com) ─────────────────────────── */} {ratePath && ratePath.meetings.length > 0 ? (
{/* Header */}
OIS Β· marchΓ©s au {ratePath.asOf}
{/* Pic + taux fin d'annΓ©e */} {ratePath.peakMeeting && (
Pic {ratePath.peakMeeting.label} {ratePath.peakMeeting.probMovePct.toFixed(0)}% {ratePath.peakMeeting.probIsCut ? " β–Ό" : " β–²"} {ratePath.peakMeeting.changeBps > 0.5 && +{ratePath.peakMeeting.changeBps.toFixed(0)}bps } {ratePath.yearEndImplied !== null && ( fin d'an {ratePath.yearEndImplied.toFixed(2)}% )}
)} {/* Timeline rΓ©union par rΓ©union */}
{ratePath.meetings.slice(0, 6).map(m => (
{m.label}
= 50 ? "font-bold text-gray-800" : "text-gray-400"}`}> {m.probMovePct.toFixed(0)}%
))}
) : rateExp ? ( /* Fallback snapshot statique (ex: CHF sans donnΓ©es OIS) */
{rateExp.direction === "hike" ? "β–²" : "β–Ό"} {rateExp.bps} bps Β· {rateExp.prob_pct}% prob. {rateExp.prob_desc}
) : null} {/* ── Indicateurs macro β€” organisation "prisme" ─────────────────────── */}
{/* ── POLITIQUE MONΓ‰TAIRE ─────────────────────────────────────────── */}
10Y Yield
{yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "β€”"} {spread10Y !== null && ( 0 ? "text-green-600" : "text-red-600"}`}> ({spread10Y > 0 ? "+" : ""}{spread10Y}bps vs US) )}
{/* ── INFLATION ───────────────────────────────────────────────────── */}
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} tooltip={(() => { const raw = (inds?.cpiCoreMoM as (Ind & { _raw?: { last: number; prev: number; refMonth: string } }) | null)?._raw; const base = "=Core Inflation Rate MoM"; return raw ? `${base} β€” Index: last=${raw.last} prev=${raw.prev} ref=${raw.refMonth}` : base; })()} /> )} {(inflFilter === "all" || inflFilter === "yoy") && ( <> { const fd = (inds?.cpiCore as (Ind & { _finalForecast?: number; _finalDelta?: number }) | null); if (fd?._finalForecast === undefined) return null; const d = fd._finalDelta ?? 0; const arrow = d > 0 ? "↑" : d < 0 ? "↓" : "="; return `Prel./Flash β€” Final prΓ©vu : ${fd._finalForecast?.toFixed(1)}% (${arrow}${d > 0 ? "+" : ""}${d.toFixed(2)})`; })()} info={(() => { const fd = (inds?.cpiCore as (Ind & { _finalDelta?: number }) | null); if (fd?._finalDelta === undefined) return null; return fd._finalDelta > 0 ? "↑F" : fd._finalDelta < 0 ? "↓F" : "=F"; })()} /> { const fd = (inds?.cpiYoY as (Ind & { _finalForecast?: number; _finalDelta?: number }) | null); if (fd?._finalForecast === undefined) return null; const d = fd._finalDelta ?? 0; const arrow = d > 0 ? "↑" : d < 0 ? "↓" : "="; return `Prel./Flash β€” Final prΓ©vu : ${fd._finalForecast?.toFixed(1)}% (${arrow}${d > 0 ? "+" : ""}${d.toFixed(2)})`; })()} info={(() => { const fd = (inds?.cpiYoY as (Ind & { _finalDelta?: number }) | null); if (fd?._finalDelta === undefined) return null; return fd._finalDelta > 0 ? "↑F" : fd._finalDelta < 0 ? "↓F" : "=F"; })()} /> {inds?.commodityPricesYoY && ( )} )} {/* ── CROISSANCE ──────────────────────────────────────────────────── */} {/* ── EMPLOI ──────────────────────────────────────────────────────── */} {/* Variation emploi = NFP/Employment Change en milliers β€” ex: +115k = 115 000 emplois créés */} {/* ── DonnΓ©es supplΓ©mentaires (expanded) ──────────────────────────── */} {expanded && ( <> {/* PMI dΓ©tail */} {/* GΓ©opolitique */} {inds?.tradeBalance ? (
Balance comm. = 0 ? "text-green-600" : "text-red-500"}`}> {(inds.tradeBalance.value ?? 0) >= 0 ? "+" : ""}{inds.tradeBalance.value?.toFixed(1)}B {(inds.tradeBalance.value ?? 0) >= 0 ? " surplus" : " dΓ©ficit"}
) : null} {/* Profil Γ©nergie + matiΓ¨res premiΓ¨res */} {(() => { const profile = COUNTRY_PROFILES[currency]; if (!profile) return null; const energyColor = profile.energy === "exporter" ? "text-green-700 bg-green-50" : profile.energy === "importer" ? "text-red-700 bg-red-50" : "text-gray-600 bg-gray-100"; const energyLabel = profile.energy === "exporter" ? "πŸ›’ Export. Γ©nergie" : profile.energy === "importer" ? "⚑ Import. Γ©nergie" : "βš– Γ‰nergie ~neutre"; return (
{energyLabel} {profile.energyNote}
{profile.commodities.length > 0 && (
{profile.commodities.map(c => ( {c} ))}
)}
); })()} {/* ── Sentiment & Positionnement ──────────────────────────────── */} {sentiment ? (
{sentiment.pair} Β· Myfxbook {sentiment.longPct}% L / {sentiment.shortPct}% S
{/* Barre visuelle long/short */}
{/* Signal contrarien */} {(sentiment.longPct >= 70 || sentiment.shortPct >= 70) && (

= 70 ? "text-red-500" : "text-green-600"}`}> {sentiment.longPct >= 70 ? "⚠ Retail trΓ¨s long β€” signal contrarien baissier" : "⚠ Retail trΓ¨s short β€” signal contrarien haussier"}

)}
) : (
β€” (Myfxbook indisponible)
)} {/* ── COT CFTC ────────────────────────────────────────────────── */}
COT β€” Hedge Funds
{cot ? (
Lev. Money Β· {cot.weekDate} {cot.longPct}% L / {cot.shortPct}% S ({cot.net > 0 ? "+" : ""}{cot.net.toLocaleString("fr-FR")})
{/* Divergence COT vs Sentiment */} {sentiment && Math.abs(cot.longPct - sentiment.longPct) >= 20 && (

⚑ Divergence COT/Retail : {Math.abs(cot.longPct - sentiment.longPct)}pts

)}
) : (
β€” (CFTC indisponible)
)} )}
{/* Footer */}
); }