"use client"; import { useEffect, useState, useCallback } from "react"; import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp, Loader2 } from "lucide-react"; import { CURRENCY_META } from "@/lib/constants"; import { biasLabel, biasColor, calcMacroScore } from "@/lib/scoring"; import type { Currency, BiasPhase, RateExpectation } 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 MacroData { currency: string; indicators: Record; fetchedAt: string } interface Props { currency: Currency; expectations: Record | null; yields: { yields: Record; spreads: Record } | 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 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 }: { label: string; ind: Ind | null; unit?: string; invertSurprise?: boolean; warn?: boolean; consensus?: number | null; }) { const value = ind?.value ?? null; const prev = ind?.prev ?? null; // Colorer la valeur actuelle selon la direction du mouvement 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}` : "—"; return (
{/* Ligne 1 : label + valeur actuelle publiée */}
{label}{warn && âš }
{fmt(value)}
{/* Ligne 2 : précédent + consensus marché */}
Préc. {fmt(prev)} Cons.  {consensus !== null ? {fmt(consensus)} : —}
); } export default function CurrencyCard({ currency, expectations, yields, 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); // Single fetch — server batches all FRED calls const load = useCallback(async () => { setLoading(true); try { const res = await fetch(`/api/macro?currency=${currency}`); if (!res.ok) return; const json: MacroData = await res.json(); setData(json); // Infer phase from policy rate trend const rateInd = json.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; // 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: null, prev: null, consensus: null, surprise: null, trend: null, lastUpdated: "" }, pmiServices: { value: null, prev: null, consensus: null, surprise: null, 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"; return (
{/* Header */}
{meta.flag}
{currency}
{meta.cbShort} · {meta.name}
{loading ? : <>
{biasText}
Score : {macroScore > 0 ? "+" : ""}{macroScore}
}
{phaseInfo.label}
{/* Rate expectation pill */} {rateExp && (
{rateExp.direction === "hike" ? "▲" : "▼"} {rateExp.bps} bps i Variation cumulée attendue sur les ~12 prochains mois selon les marchés (OIS / futures de taux). Distinct de la probabilité à la prochaine réunion ci-dessous. · {rateExp.prob_pct}% prob. {rateExp.prob_desc}
)} {/* Core indicators (always visible) */}
{/* Expanded indicators */} {expanded && ( <> {/* 10Y yield */}
10Y Yield {yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"} {spread10Y !== null && ( 0 ? "text-green-600" : "text-red-600"}`}> ({spread10Y > 0 ? "+" : ""}{spread10Y}bps) )}
)}
{/* Footer */}
); }