Files
forex-dashboard/components/CurrencyCard.tsx
T

848 lines
46 KiB
TypeScript
Raw Normal View History

2026-05-27 13:18:45 +02:00
"use client";
import { useEffect, useState, useCallback } from "react";
2026-06-02 20:51:20 +02:00
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";
2026-06-01 23:24:32 +02:00
import { CURRENCY_META, COUNTRY_PROFILES } from "@/lib/constants";
2026-06-02 20:51:20 +02:00
import { biasLabel, calcMacroScore } from "@/lib/scoring";
import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
2026-05-27 13:18:45 +02:00
import type { Currency, BiasPhase, RateExpectation } from "@/lib/types";
2026-06-01 23:24:32 +02:00
import type { CBRatePath } from "@/lib/rateprobability";
import type { SentimentEntry, CotEntry } from "@/lib/types";
2026-05-27 13:18:45 +02:00
import NarrativeButton from "./NarrativeButton";
2026-06-02 20:51:20 +02:00
// ─── Types internes ───────────────────────────────────────────────────────────
interface Ind {
value: number | null; prev: number | null; surprise: number | null;
trend: "up"|"down"|"flat"|null; lastUpdated: string | null;
consensus?: number | null;
}
2026-06-01 23:24:32 +02:00
interface MacroForecasts {
cpi: number | null; cpiSurprise: number | null;
2026-06-02 20:51:20 +02:00
cpiCore: number | null; cpiMoM: number | null;
cpiCoreMoM: number | null; ppiMoM: number | null;
2026-06-01 23:24:32 +02:00
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;
}
2026-06-02 20:51:20 +02:00
interface MacroData {
currency: string;
indicators: Record<string, Ind | null>;
forecasts?: MacroForecasts | null;
fetchedAt: string;
}
2026-05-27 13:18:45 +02:00
interface Props {
currency: Currency;
expectations: Record<string, unknown> | null;
yields: { yields: Record<string, number | null>; spreads: Record<string, number | null> } | null;
2026-06-01 23:24:32 +02:00
sentiment: SentimentEntry | null;
cot: CotEntry | null;
ratePath: CBRatePath | null;
2026-05-27 13:18:45 +02:00
onDivergenceUpdate: (currency: Currency, score: number) => void;
}
2026-06-02 20:51:20 +02:00
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";
}
2026-05-27 13:18:45 +02:00
2026-06-02 20:51:20 +02:00
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 }) {
2026-06-01 23:24:32 +02:00
return (
2026-06-02 20:51:20 +02:00
<div className="bg-slate-800/40 rounded-xl border border-slate-700/30 p-3 space-y-1.5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold mb-2">{title}</div>
{children}
2026-06-01 23:24:32 +02:00
</div>
);
}
2026-06-02 20:51:20 +02:00
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;
2026-05-27 13:18:45 +02:00
}) {
const value = ind?.value ?? null;
const prev = ind?.prev ?? null;
2026-06-02 20:51:20 +02:00
const fmt = (v: number | null) => v !== null ? `${v.toFixed(2)}${unit}` : "—";
2026-05-27 13:18:45 +02:00
const s = ind?.surprise ?? null;
2026-06-02 20:51:20 +02:00
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 ? "▼" : "▬";
2026-06-01 23:24:32 +02:00
2026-05-27 13:18:45 +02:00
return (
2026-06-02 20:51:20 +02:00
<div className="space-y-0.5">
<div className="flex items-center justify-between text-[12px]">
2026-05-27 13:18:45 +02:00
<div className="flex items-center gap-1.5 min-w-0">
2026-06-02 20:51:20 +02:00
<span className="text-slate-600 shrink-0"></span>
<span className="text-slate-400 truncate">{label}</span>
2026-06-02 17:52:36 +02:00
{info && (
<span className="relative group/info inline-flex shrink-0 cursor-help">
2026-06-02 20:51:20 +02:00
<span className="inline-flex items-center justify-center w-3 h-3 rounded-full border border-slate-700 text-slate-500 text-[7px] font-bold leading-none">i</span>
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 w-52 px-2 py-1.5 rounded-md bg-slate-950 text-slate-300 text-[10px] leading-snug opacity-0 group-hover/info:opacity-100 transition-opacity duration-150 z-50 shadow-lg whitespace-normal border border-slate-700">
2026-06-02 17:52:36 +02:00
{info}
</span>
</span>
)}
2026-05-27 13:18:45 +02:00
</div>
2026-06-02 17:37:34 +02:00
{tooltip ? (
<span className="relative group/val cursor-default shrink-0">
2026-06-02 20:51:20 +02:00
<span className={`font-semibold tabular-nums ${valColor}`}>{fmt(value)}</span>
<span className="pointer-events-none absolute bottom-full right-0 mb-1.5 w-52 px-2 py-1.5 rounded-md bg-slate-950 text-slate-300 text-[10px] leading-snug opacity-0 group-hover/val:opacity-100 transition-opacity duration-150 z-50 shadow-lg whitespace-normal border border-slate-700">
2026-06-02 17:37:34 +02:00
{tooltip}
</span>
</span>
) : (
2026-06-02 20:51:20 +02:00
<span className={`font-semibold tabular-nums shrink-0 ${valColor}`}>{fmt(value)}</span>
2026-06-02 17:37:34 +02:00
)}
2026-05-27 13:18:45 +02:00
</div>
2026-06-02 20:51:20 +02:00
<div className="flex items-center justify-between pl-4 text-[10px] text-slate-600">
<span>Préc. <span className="text-slate-500">{fmt(prev)}</span></span>
{surpriseVsCons !== null && surpriseVsCons !== undefined ? (
<span className={`font-medium ${surpriseCls}`}>
Surpr. {surpriseArr}{(effSurpr ?? 0) > 0 ? "+" : ""}{(effSurpr ?? 0).toFixed(2)}{unit}
2026-06-01 23:24:32 +02:00
</span>
2026-06-02 20:51:20 +02:00
) : consensus !== null && consensus !== undefined ? (
<span>Cons. <span className="text-blue-400 font-medium">{fmt(consensus)}</span></span>
) : null}
2026-05-27 13:18:45 +02:00
</div>
</div>
);
}
2026-06-02 20:51:20 +02:00
function SignalBar({ strength, direction }: { strength: number; direction: SignalDir }) {
return (
<div className="relative w-full h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
<motion.div
className={`absolute left-0 top-0 h-full rounded-full ${sigBar(direction)}`}
initial={{ width: 0 }}
animate={{ width: `${strength}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
/>
</div>
);
}
// ─── Composant principal ──────────────────────────────────────────────────────
export default function CurrencyCard({
currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate
}: Props) {
const meta = CURRENCY_META[currency];
// ── State ────────────────────────────────────────────────────────────────────
const [data, setData] = useState<MacroData | null>(null);
const [phase, setPhase] = useState<BiasPhase>("hawkish_pause");
const [loading, setLoading] = useState(true);
const [rateExp, setRateExp] = useState<RateExpectation | null>(null);
const [fromCache, setFromCache] = useState(false);
const [cacheAge, setCacheAge] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>("overview");
2026-06-02 15:35:58 +02:00
const [inflFilter, setInflFilter] = useState<"all" | "mom" | "yoy">("mom");
2026-06-02 20:51:20 +02:00
const [expandedSig, setExpandedSig] = useState<string | null>(null);
2026-05-27 13:18:45 +02:00
2026-06-02 20:51:20 +02:00
// ── Data fetch ───────────────────────────────────────────────────────────────
2026-05-27 13:18:45 +02:00
const load = useCallback(async () => {
setLoading(true);
const cacheKey = `macro_${currency}`;
2026-05-27 13:18:45 +02:00
try {
const res = await fetch(`/api/macro?currency=${currency}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
2026-05-27 13:18:45 +02:00
const json: MacroData = await res.json();
if ("error" in json) throw new Error(String((json as Record<string,unknown>).error));
const prevCache = loadCache<MacroData>(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);
2026-05-27 13:18:45 +02:00
const rateInd = merged.indicators.policyRate;
if (rateInd?.trend === "up") setPhase("tightening");
2026-05-27 13:18:45 +02:00
else if (rateInd?.trend === "down") setPhase("easing");
else setPhase("hawkish_pause");
} catch {
const cached = loadCache<MacroData>(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");
}
2026-05-27 13:18:45 +02:00
} 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();
2026-06-02 20:51:20 +02:00
setRateExp(all.find((e) =>
e.cb.toLowerCase().includes(cbShort) || e.cb.toLowerCase().includes(currency.toLowerCase())
) ?? null);
2026-05-27 13:18:45 +02:00
}, [expectations, currency, meta.cbShort]);
2026-06-02 20:51:20 +02:00
// ── Computed values ──────────────────────────────────────────────────────────
const inds = data?.indicators;
const fc = data?.forecasts ?? null;
2026-05-27 13:18:45 +02:00
const forScoring = {
2026-06-02 20:51:20 +02:00
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: "" },
2026-05-27 13:18:45 +02:00
unemployment: { value: inds?.unemployment?.value ?? null, prev: inds?.unemployment?.prev ?? null, consensus: null, surprise: inds?.unemployment?.surprise ?? null, trend: inds?.unemployment?.trend ?? null, lastUpdated: "" },
2026-06-02 20:51:20 +02:00
employment: { value: inds?.employment?.value ?? null, prev: inds?.employment?.prev ?? null, consensus: null, surprise: inds?.employment?.surprise ?? null, trend: inds?.employment?.trend ?? null, lastUpdated: "" },
2026-05-27 13:18:45 +02:00
};
const macroScore = calcMacroScore(forScoring, phase);
const biasText = biasLabel(macroScore);
2026-06-02 20:51:20 +02:00
const dir = scoreDir(macroScore);
const yield10Y = yields?.yields[currency] ?? null;
2026-05-27 13:18:45 +02:00
const spread10Y = yields?.spreads[currency] ?? null;
2026-06-02 20:51:20 +02:00
useEffect(() => { onDivergenceUpdate(currency, macroScore); }, [macroScore, currency, onDivergenceUpdate]);
2026-06-01 23:24:32 +02:00
const rateConsensus = (() => {
const rate = inds?.policyRate?.value ?? null;
if (rate === null) return null;
if (ratePath && ratePath.meetings.length > 0) {
const next = ratePath.meetings[0];
2026-06-02 20:51:20 +02:00
if (next.probMovePct > 50)
2026-06-01 23:24:32 +02:00
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));
})();
2026-06-02 20:51:20 +02:00
// ── 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: <BarChart2 size={13} />,
});
}
// 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: <Target size={13} />,
});
}
}
// 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: <Activity size={13} />,
});
}
// 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: <Zap size={13} />,
});
}
// 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: <Eye size={13} />,
});
}
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: <Layers size={10} /> },
{ id: "mispricing", label: "Signaux", icon: <Eye size={10} /> },
{ id: "focus", label: "Focus", icon: <Target size={10} /> },
];
// ── Render ────────────────────────────────────────────────────────────────────
2026-05-27 13:18:45 +02:00
return (
2026-06-02 20:51:20 +02:00
<div className="bg-slate-900 border border-slate-800 rounded-2xl flex flex-col">
{/* ── Header ──────────────────────────────────────────────────────────── */}
<div className="px-4 pt-4 pb-3 border-b border-slate-800">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2.5">
2026-05-27 13:18:45 +02:00
<span className="text-2xl leading-none">{meta.flag}</span>
<div>
2026-06-02 20:51:20 +02:00
<div className="flex items-center gap-2">
<span className="text-xl font-black text-white tracking-tight">{currency}</span>
{!loading && (
<span className={`text-sm font-bold ${sigColor(dir)}`}>{biasText}</span>
)}
</div>
<div className="text-[11px] text-slate-500">{meta.cbShort} · {meta.name}</div>
2026-05-27 13:18:45 +02:00
</div>
</div>
<div className="text-right">
{loading
2026-06-02 20:51:20 +02:00
? <Loader2 size={16} className="animate-spin text-slate-600 mt-1" />
2026-05-27 13:18:45 +02:00
: <>
2026-06-02 20:51:20 +02:00
<div className={`text-2xl font-black tabular-nums ${sigColor(dir)}`}>
{macroScore > 0 ? "+" : ""}{macroScore}
</div>
<div className="text-[10px] text-slate-600">score</div>
2026-05-27 13:18:45 +02:00
</>
}
</div>
</div>
2026-06-02 20:51:20 +02:00
{/* Phase pill + mispricing + cache */}
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border ${phaseStyle(phase)}`}>
{phaseLabel(phase)}
</span>
{mispricingSignals.length > 0 && (
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border flex items-center gap-1 ${sigBg(mispricDir)}`}>
<span className={sigColor(mispricDir)}>
{mispricDir === "bullish" ? "▲" : mispricDir === "bearish" ? "▼" : "—"} {avgStr}
</span>
<span className="text-slate-600">signaux</span>
</span>
)}
{fromCache && cacheAge && (
2026-06-02 20:51:20 +02:00
<span className="flex items-center gap-0.5 text-[9px] text-amber-500" title="Données depuis le cache local">
<Database size={9} /> cache {cacheAge}
</span>
)}
</div>
2026-05-27 13:18:45 +02:00
</div>
2026-06-02 20:51:20 +02:00
{/* ── Tab bar ─────────────────────────────────────────────────────────── */}
<div className="flex border-b border-slate-800 px-1 pt-1">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-2 text-[11px] font-medium rounded-t-lg transition-all ${
activeTab === t.id
? "text-white bg-slate-800"
: "text-slate-500 hover:text-slate-300"
}`}
>
{t.icon}
{t.label}
{t.id === "mispricing" && mispricingSignals.length > 0 && (
<span className={`text-[9px] font-bold px-1 rounded ${
mispricDir === "bullish" ? "bg-emerald-500/20 text-emerald-400"
: mispricDir === "bearish" ? "bg-red-500/20 text-red-400"
: "bg-slate-700 text-slate-400"
}`}>
{bullCount > bearCount ? `${bullCount}↑` : bearCount > bullCount ? `${bearCount}↓` : "—"}
2026-06-01 23:24:32 +02:00
</span>
)}
2026-06-02 20:51:20 +02:00
</button>
))}
<div className="flex-1" />
<NarrativeButton currency={currency} phase={phase} macroScore={macroScore} />
</div>
2026-06-01 23:24:32 +02:00
2026-06-02 20:51:20 +02:00
{/* ── Tab content ─────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto p-3">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.12 }}
className="space-y-2"
>
{/* ════ APERÇU ════════════════════════════════════════════════════ */}
{activeTab === "overview" && (
<>
{/* OIS mini-chart */}
{ratePath && ratePath.meetings.length > 0 && (
<div className="bg-slate-800/40 rounded-xl border border-slate-700/30 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">OIS · Probabilités de move</span>
<span className="text-[10px] text-slate-600">au {ratePath.asOf}</span>
</div>
<ResponsiveContainer width="100%" height={48}>
<LineChart
data={ratePath.meetings.map(m => ({
label: m.label,
prob: m.probMovePct,
}))}
margin={{ top: 2, right: 0, left: 0, bottom: 0 }}
>
<Line type="monotone" dataKey="prob" stroke="#f59e0b" strokeWidth={1.5} dot={{ r: 2, fill: "#f59e0b" }} />
<XAxis dataKey="label" tick={{ fontSize: 8, fill: "#64748b" }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ background: "#0f172a", border: "1px solid #334155", borderRadius: 6, fontSize: 10 }}
labelStyle={{ color: "#94a3b8" }}
itemStyle={{ color: "#f59e0b" }}
formatter={(v: number) => [`${v.toFixed(0)}%`, "Probabilité"]}
/>
</LineChart>
</ResponsiveContainer>
{ratePath.peakMeeting && (
<div className="flex items-center justify-between mt-1 text-[10px]">
<span className="text-slate-600">Pic : <span className="text-slate-400">{ratePath.peakMeeting.label}</span></span>
<span className={ratePath.peakMeeting.probIsCut ? "text-sky-400 font-bold" : "text-red-400 font-bold"}>
{ratePath.peakMeeting.probMovePct.toFixed(0)}% {ratePath.peakMeeting.probIsCut ? "Cut" : "Hike"}
</span>
{ratePath.yearEndImplied !== null && (
<span className="text-slate-600">fin an: {ratePath.yearEndImplied.toFixed(2)}%</span>
)}
</div>
)}
</div>
)}
2026-06-01 23:24:32 +02:00
2026-06-02 20:51:20 +02:00
{/* Politique Monétaire */}
<MacroBlock title="Politique Monétaire">
<IRow
label="Taux directeur"
ind={inds?.policyRate ?? null}
unit="%" consensus={rateConsensus}
/>
<div className="flex items-center justify-between text-[12px]">
<div className="flex items-center gap-1.5">
<span className="text-slate-600"></span>
<span className="text-slate-400">10Y Yield</span>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-200 tabular-nums">
{yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"}
</span>
{spread10Y !== null && (
<span className={`text-[10px] ${spread10Y > 0 ? "text-emerald-500" : "text-red-500"}`}>
({spread10Y > 0 ? "+" : ""}{spread10Y}bps vs US)
</span>
)}
</div>
2026-06-01 23:24:32 +02:00
</div>
2026-06-02 20:51:20 +02:00
</MacroBlock>
{/* Inflation avec filtre MoM/YoY */}
<div className="bg-slate-800/40 rounded-xl border border-slate-700/30 p-3 space-y-1.5">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Inflation</span>
<div className="flex gap-0.5">
{(["all", "mom", "yoy"] as const).map((v) => (
<button
key={v}
onClick={() => setInflFilter(v)}
className={`text-[8px] px-1.5 py-0.5 rounded font-semibold uppercase tracking-wide transition-colors ${
inflFilter === v
? "bg-slate-600 text-white"
: "text-slate-500 hover:text-slate-300"
}`}
>
{v === "all" ? "Tout" : v.toUpperCase()}
</button>
2026-06-01 23:24:32 +02:00
))}
</div>
2026-06-02 20:51:20 +02:00
</div>
{(inflFilter === "all" || inflFilter === "mom") && (
<>
<IRow label="PPI MoM" ind={inds?.ppiMoM ?? null} unit="%" consensus={fc?.ppiMoM ?? null} />
<IRow label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" consensus={fc?.cpiMoM ?? null} info="Source : Inflation Rate MoM (TradingEconomics)" />
<IRow
label={(() => {
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") && (
<>
<IRow
label="Core CPI YoY"
ind={inds?.cpiCore ?? null}
unit="%" consensus={fc?.cpiCore ?? fc?.cpi ?? null}
surpriseVsCons={fc?.cpiSurprise ?? null}
info={(() => {
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 ? "↓" : "="})`;
})()}
/>
<IRow
label="Inflation Rate YoY"
ind={inds?.cpiYoY ?? null}
unit="%" consensus={fc?.cpi ?? null}
info={(() => {
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 && (
<IRow label="Commodity Prices YoY" ind={inds.commodityPricesYoY} unit="%" info="AUD — RBA Commodity Price Index" />
)}
</>
2026-06-01 23:24:32 +02:00
)}
</div>
2026-06-02 20:51:20 +02:00
{/* Croissance */}
<MacroBlock title="Croissance">
<IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} surpriseVsCons={fc?.gdpSurprise ?? null} />
<IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} surpriseVsCons={fc?.pmiCompositeSurprise ?? null} />
<IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} surpriseVsCons={fc?.retailSalesSurprise ?? null} />
</MacroBlock>
{/* Emploi */}
<MacroBlock title="Emploi">
<IRow label="Variation emploi" ind={inds?.employment ?? null} unit="k" consensus={fc?.employment ?? null} surpriseVsCons={fc?.employmentSurprise ?? null} />
<IRow label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise consensus={fc?.unemployment ?? null} surpriseVsCons={fc?.unemploymentSurprise ?? null} />
</MacroBlock>
{/* PMI détail */}
<MacroBlock title="PMI Détail">
<IRow label="PMI Manufacturing" ind={inds?.pmiMfg ?? null} consensus={fc?.pmiMfg ?? null} surpriseVsCons={fc?.pmiMfgSurprise ?? null} />
<IRow label="PMI Services" ind={inds?.pmiServices ?? null} consensus={fc?.pmiSvc ?? null} surpriseVsCons={fc?.pmiSvcSurprise ?? null} />
</MacroBlock>
</>
2026-06-01 23:24:32 +02:00
)}
2026-06-02 20:51:20 +02:00
{/* ════ SIGNAUX / MISPRICING ════════════════════════════════════ */}
{activeTab === "mispricing" && (
<>
{/* Biais global */}
<div className={`rounded-xl border p-3 flex items-center justify-between ${sigBg(mispricDir)}`}>
<div>
<div className="text-[10px] text-slate-500 uppercase tracking-widest mb-0.5">Biais Signaux</div>
<div className={`text-sm font-bold ${sigColor(mispricDir)}`}>
{mispricDir === "bullish" ? `${currency} Haussier` : mispricDir === "bearish" ? `${currency} Baissier` : "Signal Mixte"}
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-500 mb-0.5">Force moy.</div>
<div className={`text-2xl font-black tabular-nums ${sigColor(mispricDir)}`}>{avgStr}</div>
</div>
2026-06-01 23:24:32 +02:00
</div>
2026-06-02 20:51:20 +02:00
{/* COT mini chart */}
{cot && cot.history.length > 1 && (
<div className="bg-slate-800/40 rounded-xl border border-slate-700/30 p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<BarChart2 size={11} className="text-slate-400" />
<span className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">Positions COT Nettes (historique)</span>
</div>
<span className={`text-xs font-bold ${cot.deltaWoW > 0 ? "text-emerald-400" : "text-red-400"}`}>
{cot.deltaWoW > 0 ? "+" : ""}{cot.deltaWoW.toFixed(0)}k WoW
</span>
</div>
<ResponsiveContainer width="100%" height={52}>
<AreaChart
data={cot.history.slice(-8).map(h => ({ date: h.weekEnding.slice(5), net: h.net }))}
margin={{ top: 2, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id={`cot-${currency}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={cot.deltaWoW > 0 ? "#10b981" : "#ef4444"} stopOpacity={0.3} />
<stop offset="95%" stopColor={cot.deltaWoW > 0 ? "#10b981" : "#ef4444"} stopOpacity={0} />
</linearGradient>
</defs>
<Area type="monotone" dataKey="net"
stroke={cot.deltaWoW > 0 ? "#10b981" : "#ef4444"}
fill={`url(#cot-${currency})`} strokeWidth={1.5} dot={false}
/>
<XAxis dataKey="date" tick={{ fontSize: 8, fill: "#64748b" }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ background: "#0f172a", border: "1px solid #334155", borderRadius: 6, fontSize: 10 }}
labelStyle={{ color: "#94a3b8" }}
itemStyle={{ color: cot.deltaWoW > 0 ? "#10b981" : "#ef4444" }}
formatter={(v: number) => [`${v.toFixed(0)}k`, "Nette"]}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
{/* Signal list */}
<div className="space-y-2">
{mispricingSignals.length === 0 && (
<div className="text-center text-slate-600 text-[11px] py-4">Données insuffisantes pour calculer les signaux</div>
)}
{mispricingSignals.map((sig) => (
<div key={sig.id}
className="rounded-xl border overflow-hidden bg-slate-800/30"
style={{
borderColor: sig.direction === "bullish" ? "rgba(16,185,129,0.15)"
: sig.direction === "bearish" ? "rgba(239,68,68,0.15)"
: "rgba(100,116,139,0.2)"
}}
>
<button
className="w-full px-3 py-2.5 flex items-center gap-3 text-left"
onClick={() => setExpandedSig(expandedSig === sig.id ? null : sig.id)}
>
<span className={`p-1.5 rounded-lg shrink-0 ${
sig.direction === "bullish" ? "bg-emerald-500/15 text-emerald-400"
: sig.direction === "bearish" ? "bg-red-500/15 text-red-400"
: "bg-amber-500/15 text-amber-400"
}`}>
{sig.icon}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-slate-300 font-medium">{sig.label}</span>
<span className={`text-[11px] font-bold tabular-nums ${sigColor(sig.direction)}`}>{sig.value}</span>
</div>
<SignalBar strength={sig.strength} direction={sig.direction} />
</div>
<ChevronRight size={12} className={`text-slate-600 shrink-0 transition-transform ${expandedSig === sig.id ? "rotate-90" : ""}`} />
</button>
<AnimatePresence>
{expandedSig === sig.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3 pb-3 pt-1 border-t border-slate-700/40">
<p className="text-[11px] text-slate-400 leading-relaxed">{sig.detail}</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
2026-06-01 23:24:32 +02:00
</div>
2026-06-02 20:51:20 +02:00
</>
)}
{/* ════ FOCUS DONNÉES ══════════════════════════════════════════════ */}
{activeTab === "focus" && (
<>
{/* Context phase */}
<div className={`rounded-xl border p-3 text-[11px] ${sigBg(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
<div className={`font-semibold mb-1 ${sigColor(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
Phase {phaseLabel(phase)}
</div>
<p className="text-slate-400 leading-relaxed">
{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."}
2026-06-01 23:24:32 +02:00
</p>
2026-06-02 20:51:20 +02:00
</div>
{/* Données clés selon phase */}
<MacroBlock title={phase === "easing" || phase === "dovish_pause" ? "Données Assouplissement" : "Données Resserrement"}>
{(phase === "easing" || phase === "dovish_pause")
? <>
<IRow label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise consensus={fc?.unemployment ?? null} />
<IRow label="Variation emploi" ind={inds?.employment ?? null} unit="k" consensus={fc?.employment ?? null} />
<IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} />
<IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} />
<IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} />
</>
: <>
<IRow label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" consensus={fc?.cpiMoM ?? null} />
<IRow label="Core CPI YoY" ind={inds?.cpiCore ?? null} unit="%" consensus={fc?.cpiCore ?? null} />
<IRow label="Inflation Rate YoY" ind={inds?.cpiYoY ?? null} unit="%" consensus={fc?.cpi ?? null} />
<IRow label="PMI Services" ind={inds?.pmiServices ?? null} consensus={fc?.pmiSvc ?? null} />
<IRow label="Variation emploi" ind={inds?.employment ?? null} unit="k" consensus={fc?.employment ?? null} />
</>
}
</MacroBlock>
{/* Autres indicateurs (référence) */}
<MacroBlock title="Référence (autres données)">
{(phase === "easing" || phase === "dovish_pause")
? <>
<IRow label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" />
<IRow label="Core CPI YoY" ind={inds?.cpiCore ?? null} unit="%" />
<IRow label="PPI MoM" ind={inds?.ppiMoM ?? null} unit="%" />
</>
: <>
<IRow label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise />
<IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" />
<IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" />
</>
}
</MacroBlock>
{/* Taux directeur */}
<MacroBlock title="Politique Monétaire">
<IRow label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" consensus={rateConsensus} />
{yield10Y !== null && (
<div className="flex items-center justify-between text-[12px]">
<div className="flex items-center gap-1.5">
<span className="text-slate-600"></span>
<span className="text-slate-400">10Y Yield</span>
</div>
<span className="font-semibold text-slate-200 tabular-nums">{yield10Y.toFixed(2)}%</span>
</div>
)}
</MacroBlock>
</>
2026-06-01 23:24:32 +02:00
)}
2026-05-27 13:18:45 +02:00
2026-06-02 20:51:20 +02:00
</motion.div>
</AnimatePresence>
2026-05-27 13:18:45 +02:00
</div>
</div>
);
}