Files
forex-dashboard/components/CurrencyCard.tsx
T
2026-06-02 20:51:20 +02:00

848 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, Ind | null>;
forecasts?: MacroForecasts | null;
fetchedAt: string;
}
interface Props {
currency: Currency;
expectations: Record<string, unknown> | null;
yields: { yields: Record<string, number | null>; spreads: Record<string, number | null> } | 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 (
<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}
</div>
);
}
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 (
<div className="space-y-0.5">
<div className="flex items-center justify-between text-[12px]">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-slate-600 shrink-0"></span>
<span className="text-slate-400 truncate">{label}</span>
{info && (
<span className="relative group/info inline-flex shrink-0 cursor-help">
<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">
{info}
</span>
</span>
)}
</div>
{tooltip ? (
<span className="relative group/val cursor-default shrink-0">
<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">
{tooltip}
</span>
</span>
) : (
<span className={`font-semibold tabular-nums shrink-0 ${valColor}`}>{fmt(value)}</span>
)}
</div>
<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}
</span>
) : consensus !== null && consensus !== undefined ? (
<span>Cons. <span className="text-blue-400 font-medium">{fmt(consensus)}</span></span>
) : null}
</div>
</div>
);
}
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");
const [inflFilter, setInflFilter] = useState<"all" | "mom" | "yoy">("mom");
const [expandedSig, setExpandedSig] = useState<string | null>(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<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);
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<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");
}
} 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: <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 ────────────────────────────────────────────────────────────────────
return (
<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">
<span className="text-2xl leading-none">{meta.flag}</span>
<div>
<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>
</div>
</div>
<div className="text-right">
{loading
? <Loader2 size={16} className="animate-spin text-slate-600 mt-1" />
: <>
<div className={`text-2xl font-black tabular-nums ${sigColor(dir)}`}>
{macroScore > 0 ? "+" : ""}{macroScore}
</div>
<div className="text-[10px] text-slate-600">score</div>
</>
}
</div>
</div>
{/* 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 && (
<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>
</div>
{/* ── 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}↓` : "—"}
</span>
)}
</button>
))}
<div className="flex-1" />
<NarrativeButton currency={currency} phase={phase} macroScore={macroScore} />
</div>
{/* ── 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>
)}
{/* 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>
</div>
</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>
))}
</div>
</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" />
)}
</>
)}
</div>
{/* 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>
</>
)}
{/* ════ 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>
</div>
{/* 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>
))}
</div>
</>
)}
{/* ════ 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."}
</p>
</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>
</>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}