"use client"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { TrendingUp, TrendingDown, Minus, Loader2, Database, BarChart2, Activity, Target, Zap, Eye, Layers, ChevronRight, ArrowUpRight, ArrowDownRight, AlertTriangle, Info, Settings, X, ExternalLink, } from "lucide-react"; import { AreaChart, Area, LineChart, Line, BarChart, Bar, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis, ReferenceLine, } 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, MacroSection } from "@/lib/types"; import type { CBRatePath, ILWeeklyDelta } from "@/lib/rateprobability"; import type { SentimentEntry, CotEntry } from "@/lib/types"; import type { CalendarEvent } from "@/app/api/calendar/route"; 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 MoneySupplyM3 { value: number; unit: string; period: string; isProxy: boolean; proxyLabel?: string; source: string; } interface MacroData { currency: string; indicators: Record; forecasts?: MacroForecasts | null; moneySupplyM3?: MoneySupplyM3 | 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; calEvents?: CalendarEvent[]; macroSection?: MacroSection; syncMacroSlide?: "mon" | "infl" | "cro" | "empl"; onMacroSlideChange?: (id: "mon" | "infl" | "cro" | "empl") => void; syncCardTab?: "overview" | "mispricing" | "focus"; onCardTabChange?: (id: "overview" | "mispricing" | "focus") => void; syncSignauxSlide?: "ois" | "cot" | "sent"; onSignauxSlideChange?: (id: "ois" | "cot" | "sent") => void; syncOisChartTab?: "curve" | "probas" | "meetings"; onOisChartTabChange?: (id: "curve" | "probas" | "meetings") => void; isLoading?: boolean; } type Tab = "overview" | "mispricing" | "focus"; type SignalDir = "bullish" | "bearish" | "neutral" | "warning"; type SliderBlock = "news" | "ois" | "cot" | "signal"; // ─── 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"; } // ─── Phase depuis OIS (source primaire) ────────────────────────────────────── // Ordre de priorité : // 1. Resserrement actif : proba de hausse >60% ET bps annuels >+15 // 2. Assouplissement actif: proba de coupe >60% OU bps annuels <−40 // 3. Pause dovish : bps annuels ≤ −15 (coupes attendues, pas imminentes) // 4. Pause hawkish : bps annuels ≥ +20 OU biais sans coupe modérément pricé // 5. Transition : anticipations neutres (fin de cycle, CB en attente) function computePhaseFromOIS(ratePath: CBRatePath | null): BiasPhase | null { if (!ratePath?.peakMeeting) return null; const yearEndBps = ratePath.yearEndImplied !== null ? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100) : 0; const { probMovePct, probIsCut } = ratePath.peakMeeting; if (!probIsCut && probMovePct > 60 && yearEndBps >= 15) return "tightening"; if (probIsCut && (probMovePct > 60 || yearEndBps <= -40)) return "easing"; if (yearEndBps <= -15) return "dovish_pause"; if (yearEndBps >= 20 || (!probIsCut && probMovePct > 40 && yearEndBps > 5)) return "hawkish_pause"; return "transition"; } // ─── Description contextuelle de la phase (avec vrais chiffres OIS) ────────── function phaseDescription(phase: BiasPhase, ratePath: CBRatePath | null): string { const yearEndBps = (ratePath?.yearEndImplied != null && ratePath?.currentRate != null) ? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100) : null; const peak = ratePath?.peakMeeting; const bpsStr = yearEndBps !== null ? `${yearEndBps > 0 ? "+" : ""}${yearEndBps}bps fin d'an` : ""; const probStr = peak ? `${peak.probMovePct.toFixed(0)}% de ${peak.probIsCut ? "coupe" : "hausse"} (${peak.label})` : ""; switch (phase) { case "tightening": return `Cycle de resserrement actif — ${probStr}${bpsStr ? ` · ${bpsStr}` : ""}. Surveiller : inflation, emploi solide, PMI > 50.`; case "easing": return `Cycle d'assouplissement — ${probStr}${bpsStr ? ` · ${bpsStr}` : ""}. Surveiller : désinflation, ralentissement emploi, PMI < 50.`; case "dovish_pause": return `Pause dovish${bpsStr ? ` — ${bpsStr} anticipés` : ""}. Prochaine réunion stable mais coupes attendues plus tard. Surveiller : timing désinflation.`; case "hawkish_pause": return `Pause hawkish${bpsStr ? ` — ${bpsStr} anticipés` : ""}. CB en attente. Surveiller : regain d'inflation ou fort ralentissement.`; case "transition": return `Anticipations neutres${bpsStr ? ` (${bpsStr})` : ""}. CB données-dépendantes — tous les indicateurs comptent.`; } } 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 computeESI(inds: Record | undefined): number | null { if (!inds) return null; const invertedKeys = new Set(["unemployment"]); const checkKeys = ["cpiYoY", "cpiCore", "pmiMfg", "pmiServices", "gdp", "retailSales", "cpiMoM", "ppiMoM", "employment"]; const signs: number[] = []; for (const key of checkKeys) { const s = inds[key]?.surprise; if (s === null || s === undefined) continue; const sign = s > 0 ? 1 : s < 0 ? -1 : 0; signs.push(invertedKeys.has(key) ? -sign : sign); } if (signs.length === 0) return null; return Math.round(signs.reduce((a, v) => a + v, 0) / signs.length * 100); } function SurpriseIndexBadge({ value }: { value: number }) { const cls = value > 20 ? "bg-emerald-500/15 text-emerald-400 border-emerald-500/30" : value < -20 ? "bg-red-500/15 text-red-400 border-red-500/30" : "bg-slate-700/40 text-slate-400 border-slate-600/30"; return ( +20 (majorité de beats), Rouge < −20 (majorité de misses)."} > ESI {value > 0 ? "+" : ""}{value} ); } function FocusRow({ importance, children }: { importance: "critical" | "high" | "medium"; children: React.ReactNode }) { const dot = importance === "critical" ? "bg-red-500" : importance === "high" ? "bg-amber-500" : "bg-slate-500"; return (
{children}
); } // ─── Profils géopolitiques statiques ───────────────────────────────────────── const ENERGY_PROFILE: Record = { USD: { type: "export", desc: "1er producteur mondial pétrole + gaz (EIA). Exportateur net depuis 2019.", products: ["Pétrole", "GNL", "Blé", "Soja", "Maïs"] }, EUR: { type: "import", desc: "Import ~75% énergie (MENA, Russie réduit). Très sensible aux chocs pétrole.", products: ["Blé (FR, DE)", "Machines industrielles"] }, GBP: { type: "neutre", desc: "Mer du Nord en déclin. Production ≈ consommation (~neutre).", products: ["Services financiers"] }, JPY: { type: "import", desc: "Import ~90% énergie. 3ème importateur GNL mondial. Très sensible au Détroit d'Hormuz.", products: ["Électronique", "Voitures"] }, CHF: { type: "import", desc: "Import ~75% énergie (gaz naturel Europe, pétrole OPEP).", products: [] }, CAD: { type: "export", desc: "Pétrole sables bitumineux (Alberta). Export ~4 Mb/j.", products: ["Pétrole", "Gaz naturel", "Blé", "Potasse", "Bois d'œuvre"] }, AUD: { type: "export", desc: "2ème exportateur GNL mondial. Export charbon thermique + métallurgique.", products: ["Minerai de fer", "GNL", "Charbon", "Or", "Blé", "Cuivre"] }, NZD: { type: "import", desc: "Import pétrole. Renouvelables ~85% élec (hydro). Indépendant localement.", products: ["Lait / Produits laitiers", "Viande bovine", "Bois", "Laine"] }, }; // ─── Money Supply M3 — formatage compact (niveau, devise locale) ───────────── function formatM3(m3: MoneySupplyM3): string { const mult = m3.unit.includes("Bn") ? 1e9 : m3.unit.includes("Mn") ? 1e6 : 1; const raw = m3.value * mult; const ccy = m3.unit.split(" ")[0]; const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format(raw); return `${compact} ${ccy}`; } function trendDir(t: "up"|"down"|"flat"|null): SignalDir { if (t === "up") return "bullish"; if (t === "down") return "bearish"; return "neutral"; } // ─── RpArrow : flèche de tendance pour probabilités de taux (delta IL hebdo) ── // delta = valeur_actuelle - valeur_précédent_article // isBearishIfUp = true si une hausse du delta est baissière pour la devise function RpArrow({ delta, isBearishIfPositive, suffix, strongT, modT, }: { delta: number; isBearishIfPositive: boolean; suffix: string; strongT: number; modT: number; }) { const abs = Math.abs(delta); if (abs < modT) return null; const strong = abs >= strongT; const up = delta > 0; const bearish = isBearishIfPositive ? up : !up; const color = bearish ? "text-sky-400" : "text-amber-400"; const arrow = strong ? (up ? "↑↑" : "↓↓") : (up ? "↑" : "↓"); return ( {arrow}{up ? "+" : ""}{Math.abs(Math.round(abs * 10) / 10)}{suffix} ); } // ─── Sous-composants ───────────────────────────────────────────────────────── function MacroBlock({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { return (
{title}
{action}
{children}
); } function IRow({ label, ind, unit = "", consensus, surpriseVsCons, tooltip, info, invertSurprise = false, isNew = false, }: { label: string; ind: Ind | null; unit?: string; consensus?: number | null; surpriseVsCons?: number | null; tooltip?: string | null; info?: string | null; invertSurprise?: boolean; isNew?: boolean; }) { const value = ind?.value ?? null; const prev = ind?.prev ?? null; const fmt = (v: number | null) => v !== null ? `${v.toFixed(2)}${unit}` : "—"; const s = ind?.surprise ?? null; const effS = invertSurprise && s !== null ? -s : s; const valColor = effS === null ? "text-slate-200" : effS > 0 ? "text-emerald-400" : effS < 0 ? "text-red-400" : "text-slate-500"; const effSurpr = invertSurprise && surpriseVsCons !== null ? -(surpriseVsCons ?? 0) : surpriseVsCons; const surpriseCls = effSurpr == null ? "" : effSurpr > 0 ? "text-emerald-500" : effSurpr < 0 ? "text-red-500" : "text-slate-500"; const surpriseArr = effSurpr == null ? "" : effSurpr > 0 ? "▲" : effSurpr < 0 ? "▼" : "▬"; return (
{label} {isNew && ( NEW )} {info && ( i {info} )}
{tooltip ? ( {fmt(value)} {tooltip} ) : ( {fmt(value)} )}
Préc. {fmt(prev)} {surpriseVsCons !== null && surpriseVsCons !== undefined ? ( Surpr. {surpriseArr}{(effSurpr ?? 0) > 0 ? "+" : ""}{(effSurpr ?? 0).toFixed(2)}{unit} ) : consensus !== null && consensus !== undefined ? ( Cons. {fmt(consensus)} ) : null}
); } function SignalBar({ strength, direction }: { strength: number; direction: SignalDir }) { return (
); } // ─── Sources popup ──────────────────────────────────────────────────────────── const TE_COUNTRY: Record = { USD:"united-states", EUR:"euro-area", GBP:"united-kingdom", JPY:"japan", CHF:"switzerland", CAD:"canada", AUD:"australia", NZD:"new-zealand", }; const IC_SLUG: Record = { USD:"fed-rate-monitor", EUR:"ecb-rate-monitor", GBP:"boe-rate-monitor", JPY:"boj-rate-monitor", CAD:"boc-rate-monitor", AUD:"rba-rate-monitor", NZD:"rbnz-rate-monitor", CHF:"snb-rate-monitor", }; const CB_NAME: Record = { USD:"Fed (FOMC)", EUR:"BCE", GBP:"BoE MPC", JPY:"BoJ", CHF:"SNB", CAD:"BoC", AUD:"RBA", NZD:"RBNZ", }; function SourcesPopup({ currency, onClose }: { currency: string; onClose: () => void }) { const country = TE_COUNTRY[currency] ?? currency.toLowerCase(); const icSlug = IC_SLUG[currency]; const cbName = CB_NAME[currency] ?? currency; const sections: Array<{ title: string; icon: string; sources: Array<{ label: string; url: string; note?: string }> }> = [ { title: "OIS · Futures (onglet Signaux)", icon: "📡", sources: [ ...(currency === "USD" ? [{ label: "CME FedWatch — contrats SOFR", url: "https://www.cmegroup.com/markets/interest-rates/cme-fedwatch-tool.html", note: "Probabilités Fed par réunion · source primaire USD" }] : []), ...(icSlug ? [{ label: `Investing.com — ${cbName} Rate Monitor`, url: `https://www.investing.com/central-banks/${icSlug}`, note: "OIS implicites par réunion" }] : []), { label: "InvestingLive — Giuseppe Dellamotta", url: "https://investinglive.com", note: "Articles hebdo : variation bps fin d'an (fallback)" }, ], }, { title: "COT CFTC (onglet Signaux)", icon: "📊", sources: [ { label: "CFTC — TFF Report (Traders in Financial Futures)", url: "https://www.cftc.gov/MarketReports/CommitmentsofTraders/index.htm", note: "Leveraged Money (HF/CTAs) + Asset Managers · publié chaque vendredi" }, ], }, { title: "Sentiment retail (onglet DXM)", icon: "🔄", sources: [ { label: "Myfxbook — Community Outlook", url: "https://www.myfxbook.com/community/outlook", note: "Positions longues/courtes retail en temps réel" }, ], }, { title: "Politique monétaire (Aperçu → Mon.)", icon: "🏦", sources: [ { label: `Trading Economics — ${country} interest rate`, url: `https://tradingeconomics.com/${country}/interest-rate`, note: `Taux directeur ${cbName} actuel` }, { label: `Trading Economics — ${country} money supply M3`, url: `https://tradingeconomics.com/${country}/money-supply-m3`, note: currency === "USD" ? "M2 utilisé en proxy (M3 non publié par la Fed depuis 2006)" : "Masse monétaire M3" }, { label: "FRED — Federal Reserve Economic Data", url: "https://fred.stlouisfed.org", note: "Spreads crédit HY/IG (BAMLH0A0HYM2, BAMLC0A0CM)" }, ], }, { title: "Inflation (Aperçu → Infl.)", icon: "📈", sources: [ { label: `Trading Economics — ${country} inflation`, url: `https://tradingeconomics.com/${country}/inflation-cpi`, note: "CPI, core CPI, PPI · scraping HTML" }, ], }, { title: "Croissance & PMI (Aperçu → Cro.)", icon: "📉", sources: [ { label: `Trading Economics — ${country} GDP`, url: `https://tradingeconomics.com/${country}/gdp-growth`, note: "PIB, PMI manufacturier & services" }, ], }, { title: "Emploi (Aperçu → Empl.)", icon: "👷", sources: [ { label: `Trading Economics — ${country} employment`, url: `https://tradingeconomics.com/${country}/employment-change`, note: "Variation emploi, chômage, NFP (USD), JOLTS, ADP" }, ], }, { title: "Rendements obligataires (Aperçu → Mon.)", icon: "🔢", sources: [ { label: `Trading Economics — ${country} government bond`, url: `https://tradingeconomics.com/${country}/government-bond-yield`, note: "Taux 10Y souverain · mise à jour horaire" }, ], }, ]; return (
e.stopPropagation()} > {/* Header */}
Sources des données · {currency}
Toutes les données utilisées dans cette carte
{/* Sections */}
{sections.map(sec => (
{sec.icon} {sec.title}
{sec.sources.map(src => (
{src.label} {src.note && (

{src.note}

)}
))}
))}
); } // ─── OIS Enhanced Block ─────────────────────────────────────────────────────── // Bloc OIS enrichi : summary (Current Rate → Expected, Next Meeting, Most Likely/Alt) // + 3 onglets graphiques : Rate Curve / Implied Points / Scénarios const STIR_INSTRUMENT: Partial> = { USD: { instrument: "SOFR 1M Futures", exchange: "CME", convention: "mensuel" }, EUR: { instrument: "€STR OIS Swaps", exchange: "ECB / OTC", convention: "meeting-date", note: "Euribor 3M comme proxy liquide" }, GBP: { instrument: "SONIA 1M Futures", exchange: "ICE", convention: "mensuel" }, JPY: { instrument: "TONA OIS Swaps", exchange: "OTC", convention: "meeting-date", note: "Faible liquidité — proxy OSE TIBOR" }, CHF: { instrument: "SARON Futures", exchange: "Eurex", convention: "4×/an (trim.)" }, CAD: { instrument: "CORRA 1M Futures", exchange: "MX", convention: "mensuel" }, AUD: { instrument: "ASX 30-Day IB Futures", exchange: "ASX", convention: "mensuel" }, NZD: { instrument: "OIS NZD (swaps)", exchange: "ASX OTC / Bloomberg NDOIS1M", convention: "maturité exacte / réunion", note: "Pas de futures standardisés. RBNZ publie les probas dans ses MPS." }, }; function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: { ratePath: CBRatePath; syncChartTab?: "curve" | "probas" | "meetings"; onChartTabChange?: (id: "curve" | "probas" | "meetings") => void; }) { const [localChartTab, setLocalChartTab] = useState<"curve" | "probas" | "meetings">("curve"); const chartTab = syncChartTab ?? localChartTab; const setChartTab = (id: "curve" | "probas" | "meetings") => { setLocalChartTab(id); onChartTabChange?.(id); }; const { currentRate, meetings, yearEndImplied, ilDelta, ilCurrent, prevMeetings, prevWeekDate } = ratePath; if (!meetings.length) return null; const m0 = meetings[0]; const bpsYE = yearEndImplied !== null ? Math.round((yearEndImplied - currentRate) * 100) : null; const bpsCls = bpsYE === null ? "text-slate-400" : bpsYE < 0 ? "text-sky-400" : bpsYE > 0 ? "text-red-400" : "text-slate-400"; // Expected move at next meeting const isMoveExpected = m0.probMovePct >= 50; const moveLabel = isMoveExpected ? (m0.probIsCut ? "Cut" : "Hike") : "Hold"; const moveCls = isMoveExpected ? (m0.probIsCut ? "text-sky-400" : "text-red-400") : "text-slate-400"; const moveIcon = isMoveExpected ? (m0.probIsCut ? "↓" : "↑") : "="; // Probability-weighted expected bps at next meeting const expectedBps = Math.round((m0.probMovePct / 100) * (m0.impliedRate - currentRate) * 10000) / 100; // Most Likely / Alternative scenarios at next meeting const mlIsMove = m0.probMovePct >= 50; const mlRate = mlIsMove ? m0.impliedRate : currentRate; const mlProb = mlIsMove ? m0.probMovePct : 100 - m0.probMovePct; const altRate = mlIsMove ? currentRate : m0.impliedRate; const altProb = mlIsMove ? 100 - m0.probMovePct : m0.probMovePct; const altIsMove = !mlIsMove; // moveLabel vaut toujours "Hold" quand le scénario principal est un statu quo (isMoveExpected // false) — le réutiliser pour l'alternative affichait donc "X% Hold" au lieu de "X% Hike/Cut" // quand l'alternative EST le mouvement. La direction de l'alternative-mouvement est toujours // celle de m0.probIsCut, indépendamment du scénario principal. const altLabel = altIsMove ? (m0.probIsCut ? "Cut" : "Hike") : "Hold"; // Chart data (limit to 10 meetings) const chartMeetings = meetings.slice(0, 10); // prevMeetings (snapshot RP) = per-meeting exact; ilDelta (IL article) = décalage uniforme approximatif const rateCurveData = chartMeetings.map(m => { const prevM = prevMeetings?.find(p => p.dateIso === m.dateIso); const weekAgo = prevM ? +prevM.impliedRate.toFixed(3) : ilDelta ? +(m.impliedRate + ilDelta.bpsDelta / 100).toFixed(3) : undefined; return { label: m.label, current: +m.impliedRate.toFixed(3), ...(weekAgo !== undefined ? { weekAgo } : {}) }; }); const hasPrevCurve = !!(prevMeetings?.length || ilDelta); const prevCurveLabel = prevWeekDate ?? ilDelta?.prevDate ?? null; const impliedPtsData = chartMeetings.map(m => ({ label: m.label, bps: +m.changeBps.toFixed(1), })); const scenariosData = meetings.slice(0, 8).map(m => ({ label: m.label, dateIso: m.dateIso, rate: +m.impliedRate.toFixed(3), prob: m.probMovePct, isCut: m.probIsCut, changeBps: m.changeBps, cumulBps: Math.round((m.impliedRate - currentRate) * 100), })); // Y-axis domain for Rate Curve const allRates = rateCurveData.flatMap(d => [d.current, d.weekAgo].filter((v): v is number => v !== undefined)); allRates.push(currentRate); const minR = Math.min(...allRates); const maxR = Math.max(...allRates); const yMargin = Math.max(0.05, (maxR - minR) * 0.25); // Shared for Réunions tab const maxR2 = Math.max(...scenariosData.map(d => d.rate), currentRate); const minR2 = Math.min(...scenariosData.map(d => d.rate), currentRate) - 0.05; const range = maxR2 - minR2 || 0.25; // Probas tab const probValues = chartMeetings.map(m => m.probMovePct); const pMin = Math.max(0, Math.min(...probValues) - 12); const pMax = Math.min(100, Math.max(...probValues) + 12); const peakIsCut = !!ratePath.peakMeeting?.probIsCut; const probGradId = `oisProbGrad_${ratePath.currency}`; const probGradColor = peakIsCut ? "#38bdf8" : "#f59e0b"; const probaData = chartMeetings.map(m => ({ label: m.label.slice(0, 6), prob: m.probMovePct, isCut: m.probIsCut, impliedRate: m.impliedRate, bps: m.changeBps })); return (
{/* ── Summary — ligne unique ────────────────────────────────────────── */}
{moveIcon} {moveLabel} {Math.round(mlProb)}% {isMoveExpected && ( ({m0.changeBps > 0 ? "+" : ""}{Math.round(m0.changeBps)}bps) )} {/* Alt seulement si scénario différent (évite "Hold 70% · Hold 30%") */} {altProb >= 15 && altIsMove !== mlIsMove && ( <> · {Math.round(altProb)}% {altLabel} )} au {ratePath.asOf}
{/* ── Chart section ─────────────────────────────────────────────────── */}
{/* Tab buttons */}
{([ { id: "curve" as const, label: "Courbe" }, { id: "probas" as const, label: "Probabilités" }, { id: "meetings" as const, label: "Réunions" }, ]).map(t => ( ))}
{/* Chart 1 — Implied Rate Path (step line) */} {chartTab === "curve" && (
{/* Légende */}
Actuel {hasPrevCurve && ( {prevCurveLabel ? `Sem. préc. (${prevCurveLabel})` : "Sem. préc."} )} {!hasPrevCurve && ( Sem. préc. indispo )} {/* dashed reference = current rate floor */} {currentRate.toFixed(2)}% Taux actuel
`${v.toFixed(2)}%`} /> { if (!payload?.length) return null; const cur = (payload as {dataKey:string;value:number}[]).find(p => p.dataKey === "current"); const prev = (payload as {dataKey:string;value:number}[]).find(p => p.dataKey === "weekAgo"); const delta = cur && prev ? cur.value - prev.value : null; return (

{label}

{cur && (
Actuel {cur.value.toFixed(3)}%
)} {prev && (
Sem. préc. {prev.value.toFixed(3)}%
)} {delta !== null && ( <>
Δ sem. 0.0001 ? "#f87171" : delta < -0.0001 ? "#38bdf8" : "#64748b", fontSize: 9, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}> {delta > 0 ? "+" : ""}{(delta * 100).toFixed(1)} bps
)}
); }} /> {hasPrevCurve && ( )}
)} {/* Probas — AreaChart probabilité de move par réunion */} {chartTab === "probas" && (
`${v}%`} /> { if (!payload?.length) return null; const v = payload[0]?.value as number; const m = probaData.find(d => d.label === label); const bpsRaw = m ? Math.round(m.bps) : 0; const deltaStr = bpsRaw !== 0 ? `${bpsRaw > 0 ? "+" : ""}${bpsRaw} bps` : null; return (

{label}

{v.toFixed(0)}%{" "} {m?.isCut ? "Baisse" : "Hausse"}

Taux cible : {m?.impliedRate.toFixed(2)}%

{deltaStr &&

Variation : {deltaStr}

}
); }} /> {ratePath.peakMeeting && (
Pic : {ratePath.peakMeeting.label} {ratePath.peakMeeting.probMovePct.toFixed(0)}% {peakIsCut ? "Baisse" : "Hausse"}
)}
)} {/* Réunions — tableau taux implicites par meeting */} {chartTab === "meetings" && (
Réunions proba · Δbps · taux
{scenariosData.map(d => { const isDown = d.rate < currentRate - 0.001; const isUp = d.rate > currentRate + 0.001; const isFlat = !isDown && !isUp; const probPct = Math.round(Math.min(100, Math.abs(d.prob))); const bpsRaw = Math.round(d.cumulBps); const bpsStr = isFlat ? "Hold" : `${bpsRaw > 0 ? "+" : ""}${bpsRaw}bps`; const bpsColor = isDown ? "text-sky-400" : isUp ? "text-red-400" : "text-slate-600"; const barColor = isDown ? "bg-sky-500/55" : isUp ? "bg-red-500/55" : "bg-slate-700/40"; const isPeak = ratePath.peakMeeting?.dateIso === d.dateIso; return (
{d.label}
{probPct}% {bpsStr} {d.rate.toFixed(2)}%
); })}
)}
{/* ── IL footer (analyste InvestingLive) + source STIR ───────────────── */}
{ilCurrent && (
Analyste · {ilCurrent.articleDate} ·{" "} 0 ? "text-red-400" : "text-slate-400"}> {ilCurrent.bpsYearEnd === 0 ? "Hold" : `${ilCurrent.bpsYearEnd > 0 ? "+" : ""}${ilCurrent.bpsYearEnd}bps fin an`} {ratePath.ilDelta && (Math.abs(ratePath.ilDelta.probDelta) >= 3 || Math.abs(ratePath.ilDelta.bpsDelta) >= 5) && ( Δsem: )}
)} {(() => { const stir = STIR_INSTRUMENT[ratePath.currency]; if (!stir) return null; return (
{stir.instrument} · {stir.exchange} · {stir.convention} {stir.note && — {stir.note}}
); })()}
); } // ─── Composant principal ────────────────────────────────────────────────────── export default function CurrencyCard({ currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate, calEvents, macroSection, syncMacroSlide, onMacroSlideChange, syncCardTab, onCardTabChange, syncSignauxSlide, onSignauxSlideChange, syncOisChartTab, onOisChartTabChange, isLoading = false, }: Props) { const meta = CURRENCY_META[currency]; // ── State ──────────────────────────────────────────────────────────────────── const [data, setData] = useState(null); // Phase dérivée des probabilités OIS (source primaire) ou du trend FRED (fallback) const phase = useMemo(() => { const oisPhase = computePhaseFromOIS(ratePath); if (oisPhase) return oisPhase; const rateInd = data?.indicators?.policyRate; if (rateInd?.trend === "up") return "tightening"; if (rateInd?.trend === "down") return "easing"; return "hawkish_pause"; }, [ratePath, data]); const [loading, setLoading] = useState(true); const [rateExp, setRateExp] = useState(null); const [fromCache, setFromCache] = useState(false); const [cacheAge, setCacheAge] = useState(null); const [activeTab, setActiveTab] = useState("overview"); const [inflFilter, setInflFilter] = useState<"all" | "mom" | "yoy">("mom"); const [expandedSig, setExpandedSig] = useState(null); const [divergenceOpen, setDivergenceOpen] = useState(false); const [showCotInfo, setShowCotInfo] = useState(false); const [showAllMeetings, setShowAllMeetings] = useState(false); const [showYield10Y, setShowYield10Y] = useState(false); const [showSources, setShowSources] = useState(false); const [showRecentNews, setShowRecentNews] = useState(false); const [showUpcoming, setShowUpcoming] = useState(false); const [sliderBlock, setSliderBlock] = useState("ois"); const [sliderDir, setSliderDir] = useState<1|-1>(1); type MacroSlide = "mon" | "infl" | "cro" | "empl"; type SignauxSlide = "ois" | "cot" | "sent"; const [macroSlide, setMacroSlide] = useState("mon"); const [macroSlideDir, setMacroSlideDir] = useState<1|-1>(1); const prevSyncMacroRef = useRef(syncMacroSlide ?? "mon"); const [signauxSlide, setSignauxSlide] = useState("ois"); const [signauxSlideDir, setSignauxSlideDir] = useState<1|-1>(1); const prevSyncCardTabRef = useRef(syncCardTab ?? "overview"); const prevSyncSignauxSlideRef = useRef(syncSignauxSlide ?? "ois"); // ── Data fetch ─────────────────────────────────────────────────────────────── const load = useCallback(async () => { const cacheKey = `macro_${currency}`; // Affiche le cache immédiatement — pas de skeleton si données dispos const cached = loadCache(cacheKey); if (cached) { setData(cached.data); setFromCache(true); setCacheAge(formatCacheDate(cached.savedAt)); setLoading(false); } else { setLoading(true); } // Refetch en arrière-plan (10s timeout) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const res = await fetch(`/api/macro?currency=${currency}`, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: MacroData = await res.json(); if ("error" in json) throw new Error(String((json as Record).error)); const merged: MacroData = { ...json, indicators: { ...json.indicators, pmiMfg: json.indicators.pmiMfg ?? cached?.data.indicators.pmiMfg ?? null, pmiServices: json.indicators.pmiServices ?? cached?.data.indicators.pmiServices ?? null, }, }; setData(merged); setFromCache(false); setCacheAge(null); saveCache(cacheKey, merged); } catch { // Echec fetch : le cache déjà affiché reste visible } finally { clearTimeout(timeoutId); 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 m3 = data?.moneySupplyM3 ?? 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; const esi = computeESI(inds); const policyRateValue = inds?.policyRate?.value ?? null; const curveSpread = yield10Y !== null && policyRateValue !== null ? Math.round((yield10Y - policyRateValue) * 100) : null; const curveInverted = curveSpread !== null && curveSpread < 0; const curveSig: SignalDir = curveSpread === null ? "neutral" : curveSpread < -50 ? "warning" : curveInverted ? "neutral" : "bullish"; 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 CFTC — Analyse flux AM (hedging) vs HF (spéculation) + momentum hebdomadaire // Logique : qui domine le flux ET dans quelle direction évolue chaque groupe if (cot) { const hfIsShort = cot.net < 0; const amDominates = Math.abs(cot.amNet) > Math.abs(cot.net); const hfLongsGrowing = cot.longsDelta !== null && cot.longsDelta > 0; const hfShortsReducing= cot.shortsDelta !== null && cot.shortsDelta < 0; const hfShortsGrowing = cot.shortsDelta !== null && cot.shortsDelta > 0; const hfLongsReducing = cot.longsDelta !== null && cot.longsDelta < 0; // Direction du signal = momentum, pas position absolue let cotDir: SignalDir; if (amDominates) { cotDir = cot.amNet > 0 ? "bullish" : "bearish"; } else if (hfIsShort) { cotDir = (hfLongsGrowing || hfShortsReducing) ? "neutral" : "bearish"; } else { cotDir = (hfShortsGrowing || hfLongsReducing) ? "neutral" : "bullish"; } // Tendance HF en mots let hfTrend = ""; if (hfIsShort) { if (hfLongsGrowing && hfShortsReducing) hfTrend = "retournement en cours (L↑ S↓)"; else if (hfLongsGrowing) hfTrend = "accumulation de longs (L↑)"; else if (hfShortsReducing) hfTrend = "couverture de shorts (S↓)"; else hfTrend = "shorts stables"; } else { if (hfShortsGrowing && hfLongsReducing) hfTrend = "retournement baissier (S↑ L↓)"; else if (hfShortsGrowing) hfTrend = "ajout de shorts (S↑)"; else if (hfLongsReducing) hfTrend = "réduction de longs (L↓)"; else hfTrend = "longs stables"; } const hfImbalance = Math.abs(cot.longPct - cot.shortPct); const momentumBoost = cot.netDelta !== null ? Math.min(30, Math.abs(cot.netDelta) / 500) : 0; mispricingSignals.push({ id: "cot", label: "COT CFTC — Flux & Momentum", direction: cotDir, value: amDominates ? `AM ${cot.amNet > 0 ? "+" : ""}${(cot.amNet/1000).toFixed(0)}k domine` : `HF ${cot.net > 0 ? "+" : ""}${(cot.net/1000).toFixed(0)}k domine`, detail: `AM (hedging) : net ${cot.amNet > 0 ? "+" : ""}${(cot.amNet/1000).toFixed(0)}k${cot.amNetDelta !== null ? ` Δ${cot.amNetDelta > 0 ? "+" : ""}${(cot.amNetDelta/1000).toFixed(0)}k` : ""}. HF (spécu) : net ${cot.net > 0 ? "+" : ""}${(cot.net/1000).toFixed(0)}k — ${hfTrend}. Flux dominant : ${amDominates ? "AM (institutionnel)" : "HF (spéculatif)"}.`, strength: Math.min(100, hfImbalance * 1.5 + momentumBoost), icon: , }); } // OIS / rate probability — avec drift bps fin d'an if (ratePath && ratePath.meetings.length > 0) { const peak = ratePath.peakMeeting; const yearEndBps = ratePath.yearEndImplied !== null ? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100) : null; if (peak) { const oisDir: SignalDir = peak.probIsCut ? "bearish" : "bullish"; const bpsLabel = yearEndBps !== null ? `${yearEndBps > 0 ? "+" : ""}${yearEndBps}bps fin an` : ""; mispricingSignals.push({ id: "ois", label: "OIS — Drift taux fin d'an", direction: oisDir, value: bpsLabel ? `${peak.probMovePct.toFixed(0)}% ${peak.probIsCut ? "Cut" : "Hike"} · ${bpsLabel}` : `${peak.probMovePct.toFixed(0)}% ${peak.probIsCut ? "Cut" : "Hike"}`, detail: `Pic OIS : ${peak.probMovePct.toFixed(0)}% de ${peak.probIsCut ? "baisse" : "hausse"} à la réunion ${peak.label}. Taux implicite fin d'an : ${ratePath.yearEndImplied?.toFixed(2) ?? "—"}% (${yearEndBps !== null ? (yearEndBps > 0 ? "+" : "") + yearEndBps + "bps vs taux actuel" : "—"}).`, strength: peak.probMovePct, icon: , }); } } // Yield curve / spread if (spread10Y !== null && currency !== "USD") { const curveDir: SignalDir = spread10Y > 0 ? "bullish" : spread10Y < -150 ? "bearish" : "neutral"; mispricingSignals.push({ id: "yield", label: "Spread 10Y vs USD", direction: curveDir, value: `${spread10Y > 0 ? "+" : ""}${spread10Y}bps`, detail: `Différentiel de taux 10 ans vs US : ${spread10Y > 0 ? "+" : ""}${spread10Y}bps. ${Math.abs(spread10Y) > 100 ? "Écart important — potentiel de compression/expansion non pricé." : "Différentiel modéré."}`, strength: Math.min(100, Math.abs(spread10Y) / 2), icon: , }); } // Taux réel = Taux directeur − CPI YoY headline (= Inflation Rate YoY TE) // Pas de fallback sur Core CPI : Core < Headline → gonflerait artificiellement le taux réel const cpiHeadlineForReal = inds?.cpiYoY?.value ?? null; const policyRate = inds?.policyRate?.value ?? null; if (cpiHeadlineForReal !== null && policyRate !== null) { const realRate = policyRate - cpiHeadlineForReal; const inflDir: SignalDir = realRate < 0 ? "bearish" : realRate > 1.5 ? "bullish" : "neutral"; mispricingSignals.push({ id: "inflation", label: "Taux Réel (CT − CPI YoY)", direction: inflDir, value: `${realRate > 0 ? "+" : ""}${realRate.toFixed(2)}%`, detail: `Taux directeur ${policyRate.toFixed(2)}% − CPI YoY ${cpiHeadlineForReal.toFixed(2)}% = Taux réel ${realRate.toFixed(2)}%. ${realRate < 0 ? "Taux réel négatif → politique encore accommodante → bearish devise." : realRate > 1.5 ? "Taux réel élevé → politique très restrictive → bullish devise." : "Taux réel faiblement positif → neutre."}`, strength: Math.min(100, Math.abs(realRate) * 25), icon: , }); } // Sentiment retail — contrarian (majorité short = bullish, majorité long = bearish) if (sentiment) { const sentDir: SignalDir = sentiment.longPct < 30 ? "bullish" : sentiment.longPct > 70 ? "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 équilibré — pas de signal contrarian fort"}.`, strength: Math.abs(sentiment.longPct - 50) * 2, icon: , }); } const bullCount = mispricingSignals.filter(s => s.direction === "bullish").length; const bearCount = mispricingSignals.filter(s => s.direction === "bearish").length; const avgStr = mispricingSignals.length > 0 ? Math.round(mispricingSignals.reduce((a, s) => a + s.strength, 0) / mispricingSignals.length) : 0; const mispricDir: SignalDir = bullCount > bearCount ? "bullish" : bearCount > bullCount ? "bearish" : "neutral"; // Convergence : les deux s'alignent → fort signal // Divergence : macro et signaux se contredisent → alerte // Macro seul : signaux neutres mais macro directionnelle → signaler quand même // Signaux seuls: macro neutre mais signaux directionnels → signaler quand même const divergenceSignal: SignalDir = mispricDir !== "neutral" && dir !== "neutral" && mispricDir === dir ? mispricDir : mispricDir !== "neutral" && dir !== "neutral" && mispricDir !== dir ? "warning" : dir !== "neutral" ? dir : // macro seule directionnelle (signaux neutres) mispricDir !== "neutral" ? mispricDir : // signaux seuls (macro neutre) "neutral"; const macroOnly = dir !== "neutral" && mispricDir === "neutral"; const signalsOnly = mispricDir !== "neutral" && dir === "neutral"; const bothAligned = mispricDir !== "neutral" && dir !== "neutral" && mispricDir === dir; const bothOpposed = mispricDir !== "neutral" && dir !== "neutral" && mispricDir !== dir; const divergenceType = bothAligned && divergenceSignal === "bullish" ? "Convergence haussière multi-signal" : bothAligned && divergenceSignal === "bearish" ? "Convergence baissière multi-signal" : bothOpposed ? `Divergence: macro ${dir === "bullish" ? "↑" : "↓"} vs signaux ${mispricDir === "bullish" ? "↑" : "↓"}` : macroOnly && dir === "bullish" ? `Macro haussière (score +${macroScore}), signaux neutres` : macroOnly && dir === "bearish" ? `Macro baissière (score ${macroScore}), signaux neutres` : signalsOnly && mispricDir === "bullish" ? `Signaux haussiers, macro neutre` : signalsOnly && mispricDir === "bearish" ? `Signaux baissiers, macro neutre` : "Pas de signal convergent"; // Détail des contributeurs macro (même logique que calcMacroScore) const macroContributors: { label: string; value: string; sig: number }[] = [ { label: "Taux directeur", value: inds?.policyRate?.value != null ? `${inds.policyRate.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.policyRate?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "Core CPI", value: inds?.cpiCore?.value != null ? `${inds.cpiCore.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.cpiCore?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "PMI Mfg", value: inds?.pmiMfg?.value != null ? `${inds.pmiMfg.value.toFixed(1)}` : "", sig: inds?.pmiMfg?.value != null ? (inds.pmiMfg.value > 50 ? 1 : -1) : 0 }, { label: "PMI Services", value: inds?.pmiServices?.value != null ? `${inds.pmiServices.value.toFixed(1)}` : "", sig: inds?.pmiServices?.value != null ? (inds.pmiServices.value > 50 ? 1 : -1) : 0 }, { label: "PIB QoQ", value: inds?.gdp?.value != null ? `${inds.gdp.value > 0 ? "+" : ""}${inds.gdp.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.gdp?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "Retail Sales MoM", value: inds?.retailSales?.value != null ? `${inds.retailSales.value > 0 ? "+" : ""}${inds.retailSales.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.retailSales?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() }, { label: "Chômage", value: inds?.unemployment?.value != null ? `${inds.unemployment.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.unemployment?.surprise; return s == null ? 0 : s < -0.3 ? 1 : s > 0.3 ? -1 : 0; })() }, { label: "Emploi", value: inds?.employment?.value != null ? `${inds.employment.value > 0 ? "+" : ""}${inds.employment.value.toFixed(1)}k` : "", sig: (() => { const s = inds?.employment?.surprise; return s == null ? 0 : s > 10 ? 1 : s < -10 ? -1 : 0; })() }, ].filter(c => c.value !== ""); const goToSlide = useCallback((id: SliderBlock) => { const order: SliderBlock[] = ["news", "ois", "cot", "signal"]; setSliderBlock(prev => { setSliderDir(order.indexOf(id) >= order.indexOf(prev) ? 1 : -1); return id; }); }, []); const goToMacroSlide = useCallback((id: "mon"|"infl"|"cro"|"empl") => { const order = ["mon", "infl", "cro", "empl"] as const; setMacroSlide(prev => { setMacroSlideDir(order.indexOf(id) >= order.indexOf(prev) ? 1 : -1); return id; }); onMacroSlideChange?.(id); onCardTabChange?.("overview"); }, [onMacroSlideChange, onCardTabChange]); // Sync depuis une autre carte : calculer la direction par rapport à la valeur précédente useEffect(() => { if (syncMacroSlide === undefined) return; const order = ["mon", "infl", "cro", "empl"] as const; const prev = prevSyncMacroRef.current; if (syncMacroSlide !== prev) { setMacroSlideDir(order.indexOf(syncMacroSlide) >= order.indexOf(prev) ? 1 : -1); setMacroSlide(syncMacroSlide); prevSyncMacroRef.current = syncMacroSlide; } }, [syncMacroSlide]); // Sync tab principal depuis une autre carte useEffect(() => { if (syncCardTab === undefined) return; const prev = prevSyncCardTabRef.current; if (syncCardTab !== prev) { setActiveTab(syncCardTab); prevSyncCardTabRef.current = syncCardTab; } }, [syncCardTab]); // Sync sous-slide Signaux depuis une autre carte useEffect(() => { if (syncSignauxSlide === undefined) return; const order: SignauxSlide[] = ["ois", "cot", "sent"]; const prev = prevSyncSignauxSlideRef.current; if (syncSignauxSlide !== prev) { setSignauxSlideDir(order.indexOf(syncSignauxSlide) >= order.indexOf(prev) ? 1 : -1); setSignauxSlide(syncSignauxSlide); prevSyncSignauxSlideRef.current = syncSignauxSlide; } }, [syncSignauxSlide]); const goToSignauxSlide = useCallback((id: SignauxSlide) => { const order: SignauxSlide[] = ["ois", "cot", "sent"]; setSignauxSlide(prev => { setSignauxSlideDir(order.indexOf(id) >= order.indexOf(prev) ? 1 : -1); return id; }); onSignauxSlideChange?.(id); onCardTabChange?.("mispricing"); }, [onSignauxSlideChange, onCardTabChange]); // ── Recent published events for this currency (last 72h, non-low impact) ───── const recentEvents = useMemo(() => { if (!calEvents?.length) return []; const cutoff = Date.now() - 72 * 3600 * 1000; return calEvents .filter(e => e.currency === currency && e.isPublished && e.actual !== null && e.impact !== "low" && new Date(e.date).getTime() >= cutoff ) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) .slice(0, 3); }, [calEvents, currency]); // ── Upcoming events for this currency (next 3 days, not yet published) ──────── const upcomingEvents = useMemo(() => { if (!calEvents?.length) return []; const now = Date.now(); const horizon = now + 3 * 24 * 3600 * 1000; return calEvents .filter(e => e.currency === currency && !e.isPublished && e.impact !== "low" && new Date(e.date).getTime() >= now && new Date(e.date).getTime() <= horizon ) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); }, [calEvents, currency]); // ── Categories with a real publication in the last 7 days for this currency // Uses the calendar event date (not lastUpdated scraping timestamp). const recentCalCategories = useMemo(() => { if (!calEvents?.length) return new Set(); const cutoff = Date.now() - 7 * 24 * 3600 * 1000; const cats = new Set(); for (const e of calEvents) { if ( e.currency === currency && e.isPublished && e.actual !== null && e.actual !== "" && new Date(e.date).getTime() >= cutoff ) { cats.add(e.category); } } return cats; }, [calEvents, currency]); // Maps indicator key → EventCategory (matches calendar route types) const IND_CAT: Record = { cpiYoY: "inflation", cpiCore: "inflation", cpiMoM: "inflation", cpiCoreMoM: "inflation", ppiMoM: "inflation", pmiMfg: "pmi", pmiServices: "pmi", pmiComposite: "pmi", unemployment: "employment", employment: "employment", gdp: "gdp", retailSales: "retail_sales", policyRate: "policy_rate", }; const indIsNew = (key: string) => recentCalCategories.has(IND_CAT[key] ?? "__none__"); // ── 5-dimension signal summary (header dots) ────────────────────────────────── const oisSignalDir: SignalDir = (() => { if (!ratePath?.peakMeeting) return "neutral"; return ratePath.peakMeeting.probIsCut ? "bearish" : "bullish"; })(); const cotSignalDir = (mispricingSignals.find(s => s.id === "cot")?.direction ?? "neutral") as SignalDir; const sentSignalDir = (mispricingSignals.find(s => s.id === "sentiment")?.direction ?? "neutral") as SignalDir; const realRateDir: SignalDir = (() => { const cpiH = inds?.cpiYoY?.value ?? null; if (policyRateValue === null || cpiH === null) return "neutral"; const rr = policyRateValue - cpiH; return rr < 0 ? "bearish" : rr > 1.5 ? "bullish" : "neutral"; })(); const signalSummary: { key: string; abbr: string; dir: SignalDir }[] = [ { key: "ois", abbr: "OIS", dir: oisSignalDir }, { key: "mac", abbr: "Fonda.", dir }, { key: "cot", abbr: "COT", dir: cotSignalDir }, { key: "sent", abbr: "Sent", dir: sentSignalDir }, { key: "real", abbr: "Tx.R", dir: realRateDir }, ]; // Auto-select first available slider block when data changes useEffect(() => { const available: SliderBlock[] = []; if (recentEvents.length > 0) available.push("news"); if (ratePath && ratePath.meetings.length > 0) available.push("ois"); if (cot) available.push("cot"); if (divergenceSignal !== "neutral") available.push("signal"); if (!available.length) return; setSliderBlock(prev => available.includes(prev) ? prev : available[0]); }, [recentEvents.length, ratePath, cot, divergenceSignal]); // ── macroSection filtering helpers ─────────────────────────────────────────── const ms = macroSection ?? "all"; const showInflation = ms === "all" || ms === "inflation"; const showPmi = ms === "all" || ms === "pmi"; const showEmployment = ms === "all" || ms === "employment"; const showGdp = ms === "all" || ms === "gdp" || ms === "pmi"; // ── Tabs config ────────────────────────────────────────────────────────────── const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: "overview", label: "Aperçu", icon: }, { id: "mispricing", label: "Signaux", icon: }, { id: "focus", label: "Focus", icon: }, ]; // ── Render ──────────────────────────────────────────────────────────────────── return (
{/* ── Header ──────────────────────────────────────────────────────────── */}
{meta.flag}
{currency} {!loading && ( {biasText} )}
{meta.cbShort} · {meta.name}
{loading && }
{/* Phase pill + mispricing + ESI + cache */}
{phaseLabel(phase)} {esi !== null && } {fromCache && cacheAge && ( )}
{/* 5-dimension signal summary */} {!loading && (
{signalSummary.map(s => (
+1.5% → restrictif (bullish)\n< 0% → accommodant (bearish)` : `${s.abbr} : ${s.dir}`}>
{s.abbr}
))}
{recentEvents.length > 0 && ( )} {upcomingEvents.length > 0 && ( )}
)}
{/* ── News flash panel ────────────────────────────────────────────────── */} {showRecentNews && recentEvents.length > 0 && (
Données publiées récemment
{recentEvents.map(e => { const aNum = e.actual ? parseFloat(e.actual) : null; const fNum = e.forecast ? parseFloat(e.forecast) : null; const surp = aNum !== null && fNum !== null && !isNaN(aNum) && !isNaN(fNum) ? aNum - fNum : null; const isBeat = surp !== null && surp > 0; const isMiss = surp !== null && surp < 0; return (
{e.title} {e.actual && A: {e.actual}} {e.forecast && F: {e.forecast}} {isBeat && } {isMiss && }
); })}
)}
{/* ── Upcoming events panel ───────────────────────────────────────────── */} {showUpcoming && upcomingEvents.length > 0 && (
À venir — 3 prochains jours
{upcomingEvents.map(e => { const fNum = e.forecast ? parseFloat(e.forecast) : null; const pNum = e.previous ? parseFloat(e.previous) : null; const diff = fNum !== null && pNum !== null && !isNaN(fNum) && !isNaN(pNum) ? fNum - pNum : null; const isUp = diff !== null && diff > 0; const isDown = diff !== null && diff < 0; const eventDate = new Date(e.date); const dayLabel = eventDate.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" }); const timeLabel = eventDate.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }); return (
{e.title}
{dayLabel} {timeLabel} {e.forecast && ( F: {e.forecast} )} {e.previous && ( Préc: {e.previous} )} {diff !== null && ( {isUp ? "↑" : isDown ? "↓" : "="}{" "} {diff > 0 ? "+" : ""}{diff.toFixed(2).replace(/\.?0+$/, "")} )}
); })}
)}
{/* ── Tab bar ─────────────────────────────────────────────────────────── */}
{TABS.map((t) => ( ))}
{/* Sources popup */} {showSources && setShowSources(false)} />} {/* ── Tab content ─────────────────────────────────────────────────────── */}
{/* ════ APERÇU ════════════════════════════════════════════════════ */} {activeTab === "overview" && ( <> {/* ── Convergence / Divergence macro ↔ marché ─────────────── */} {(() => { const isNeutral = dir === "neutral" && mispricDir === "neutral"; const verdictText = bothOpposed ? "⚠ Divergence" : bothAligned ? (divergenceSignal === "bullish" ? "✓ Convergence ↑" : "✓ Convergence ↓") : dir !== "neutral" ? "Macro seule" : mispricDir !== "neutral" ? "Signaux seuls" : "Neutre"; const containerCls = bothOpposed ? "bg-amber-500/10 border-amber-500/30" : bothAligned ? sigBg(divergenceSignal) : isNeutral ? "bg-slate-700/40 border-slate-500/50" : "bg-slate-800/50 border-slate-600/30"; const verdictCls = bothOpposed ? "text-amber-400" : isNeutral ? "text-slate-300" : sigColor(divergenceSignal); const arrowCls = (d: SignalDir) => d === "neutral" ? "text-slate-400" : sigColor(d); return (
Fond. {dir === "bullish" ? "↑" : dir === "bearish" ? "↓" : "—"} {verdictText} {mispricDir === "bullish" ? "↑" : mispricDir === "bearish" ? "↓" : "—"} Marché
); })()} {/* ── Slider macro : Mon. / Infl. / Cro. / Empl. ───────────── */} {(() => { const macroTabs: { id: "mon"|"infl"|"cro"|"empl"; label: string }[] = [ { id: "mon", label: "Mon." }, { id: "infl", label: "Infl." }, { id: "cro", label: "Cro." }, { id: "empl", label: "Empl." }, ]; return (
{/* Micro-buttons */}
{macroTabs.map(t => ( ))}
{/* Fixed-height sliding container */}
({ x: d > 0 ? "100%" : "-100%", opacity: 0 }), center: { x: "0%", opacity: 1 }, exit: (d: number) => ({ x: d > 0 ? "-100%" : "100%", opacity: 0 }), }} initial="enter" animate="center" exit="exit" transition={{ type: "tween", duration: 0.22, ease: "easeInOut" }} className="absolute inset-0 overflow-hidden" > {/* Skeleton chargement — affiché quand pas encore de données */} {loading && !data && (
{[68, 82, 55, 76, 60].map((w, i) => (
))}
)} {/* MON. — Politique Monétaire */} {!loading && macroSlide === "mon" && ( {(() => { const cpiH = inds?.cpiYoY?.value ?? null; if (policyRateValue === null || cpiH === null) return null; const rr = parseFloat((policyRateValue - cpiH).toFixed(2)); const rDir: SignalDir = rr < 0 ? "bearish" : rr > 1.5 ? "bullish" : "neutral"; return (
Taux réel (CT−CPI)
{rr > 0 ? "+" : ""}{rr}%
); })()} {curveSpread !== null && (
Courbe (10Y−CT)
{curveSpread > 0 ? "+" : ""}{curveSpread}bps{curveInverted ? " ⚠" : ""}
)} {yield10Y !== null && (
10Y Yield
{yield10Y.toFixed(2)}%
)} {m3 && (
Money Supply M3{m3.isProxy ? " (M2)" : ""} {m3.isProxy && ( i {m3.proxyLabel} )}
{formatM3(m3)}
)}
)} {/* INFL. — Inflation */} {!loading && macroSlide === "infl" && ( )} {/* CRO. — Croissance */} {!loading && macroSlide === "cro" && ( )} {/* EMPL. — Emploi */} {!loading && macroSlide === "empl" && ( )}
); })()} )} {/* ════ SIGNAUX / MISPRICING ════════════════════════════════════ */} {activeTab === "mispricing" && ( <> {/* ── Slider : OIS / COT / Sentiment ──────────────────────── */} {(() => { // OIS est toujours présent (même si données indisponibles) const sigTabs: { id: SignauxSlide; label: string }[] = [ { id: "ois", label: "Taux" }, ]; if (cot) sigTabs.push({ id: "cot", label: "COT" }); if (sentiment) sigTabs.push({ id: "sent", label: "DXM" }); return (
{sigTabs.length > 1 && (
{sigTabs.map(t => ( ))}
)}
({ x: d > 0 ? "100%" : "-100%", opacity: 0 }), center: { x: "0%", opacity: 1 }, exit: (d: number) => ({ x: d > 0 ? "-100%" : "100%", opacity: 0 }), }} initial="enter" animate="center" exit="exit" transition={{ type: "tween", duration: 0.22, ease: "easeInOut" }} className="absolute inset-0 overflow-hidden" > {/* OIS — nouveau bloc enrichi */} {signauxSlide === "ois" && ratePath && ratePath.meetings.length > 0 && ( )} {/* OIS — état indisponible */} {signauxSlide === "ois" && (!ratePath || !ratePath.meetings.length) && ( isLoading ? (
{/* Header skeleton */}
{/* Tab buttons skeleton */}
{[48, 40, 44].map(w => (
))}
{/* Rate curve rows */} {[70, 85, 60, 78, 65, 72].map((w, i) => (
))}
) : (

Données OIS indisponibles

Cache actualisé par GitHub Actions (24–48h)

) )} {/* COT */} {signauxSlide === "cot" && cot && (() => { // ── helpers ─────────────────────────────────────────────── const netFmt = (v: number) => `${v > 0 ? "+" : ""}${(v / 1000).toFixed(1)}k`; function evolText(isLong: boolean, dL: number | null, dS: number | null): { text: string; cls: string } { if (dL == null && dS == null) return { text: "pas de données semaine précédente", cls: "text-slate-600" }; const ld = dL ?? 0, sd = dS ?? 0; if (isLong) { if (ld > 0 && sd < 0) return { text: "↑↑ renforcement haussier — accumule longs + couvre shorts", cls: "text-emerald-400" }; if (ld > 0 && sd > 0) return { text: "↑ accumule des longs (+ aussi des shorts)", cls: "text-emerald-400/70" }; if (ld > 0) return { text: "↑ prend des positions inverses — rachète des longs", cls: "text-emerald-400/80" }; if (sd > 0 && ld < 0) return { text: "↓↓ retournement baissier en cours — réduit longs + ajoute shorts", cls: "text-red-400" }; if (sd > 0) return { text: "↓ ajoute des shorts — signal de distribution", cls: "text-red-400/80" }; if (ld < 0) return { text: "↓ réduit les longs", cls: "text-red-400/70" }; } else { if (sd > 0 && ld < 0) return { text: "↓↓ renforcement baissier — accumule shorts + réduit longs", cls: "text-red-400" }; if (sd > 0 && ld > 0) return { text: "↓ renforce les shorts (+ rachète des longs)", cls: "text-red-400/70" }; if (sd > 0) return { text: "↓ renforce les shorts", cls: "text-red-400/80" }; if (ld > 0 && sd < 0) return { text: "↑↑ retournement haussier en cours — rachète longs + couvre shorts", cls: "text-emerald-400" }; if (ld > 0) return { text: "↑ prend des positions inverses — rachète des longs", cls: "text-emerald-400/80" }; if (sd < 0) return { text: "↑ couvre des shorts", cls: "text-emerald-400/70" }; } return { text: "stable — pas de mouvement significatif", cls: "text-slate-500" }; } // ── groupes ─────────────────────────────────────────────── const groups = [ { id: "hf", label: "HF", desc: "Hedge Funds · CTAs (Leveraged Money CFTC)", accentT: "text-amber-400", accentB: "border-amber-500/25", accentBg: "bg-amber-950/30", net: cot.net, longs: cot.hfLongs, shorts: cot.hfShorts, dL: cot.longsDelta, dS: cot.shortsDelta, }, { id: "am", label: "AM", desc: "Asset Managers · institutionnels (TFF CFTC)", accentT: "text-indigo-400", accentB: "border-indigo-500/25", accentBg: "bg-indigo-950/50", net: cot.amNet, longs: cot.amLongs, shorts: cot.amShorts, dL: cot.amLongsDelta, dS: cot.amShortsDelta, }, { id: "nc", label: "NC", desc: "Non-Commercial · grands spéculateurs (rapport Legacy CFTC)", accentT: "text-purple-400", accentB: "border-purple-500/25", accentBg: "bg-purple-950/30", net: cot.ncNet, longs: cot.ncLongs, shorts: cot.ncShorts, dL: cot.ncLongsDelta, dS: cot.ncShortsDelta, }, ]; // ── verdict ─────────────────────────────────────────────── const hfIsShort = cot.net < 0; const amDominates = Math.abs(cot.amNet) > Math.abs(cot.net); const hfLongsGrowing = cot.longsDelta !== null && cot.longsDelta > 0; const hfShortsReducing= cot.shortsDelta !== null && cot.shortsDelta < 0; const hfShortsGrowing = cot.shortsDelta !== null && cot.shortsDelta > 0; const hfLongsReducing = cot.longsDelta !== null && cot.longsDelta < 0; const totalAbs = Math.abs(cot.amNet) + Math.abs(cot.net); const amPct = totalAbs > 0 ? Math.round(Math.abs(cot.amNet) / totalAbs * 100) : 50; const hfPct = 100 - amPct; const amBull = cot.amNet > 0; const amPilote = amDominates; let verdict = "", sub = "", vCls = ""; if (!hfIsShort && amBull) { verdict = "▲▲ Convergence haussière — AM + HF long alignés"; sub = "AM et HF achètent tous les deux → signal fort"; vCls = "text-emerald-400 border-emerald-500/25 bg-emerald-500/8"; } else if (hfIsShort && !amBull) { verdict = "▼▼ Convergence baissière — AM + HF short alignés"; sub = "AM et HF vendent tous les deux → signal fort"; vCls = "text-red-400 border-red-500/25 bg-red-500/8"; } else if (amPilote && amBull && hfIsShort) { verdict = (hfLongsGrowing || hfShortsReducing) ? `▲ AM long pilote (${amPct}%) — HF couvre ses shorts` : `▲ AM long pilote (${amPct}%) — HF short minoritaire`; sub = (hfLongsGrowing || hfShortsReducing) ? "AM dominant et les HF commencent à se retourner → momentum haussier" : "AM achète massivement · HF vend mais reste minoritaire par volume"; vCls = "text-emerald-400/80 border-emerald-500/20 bg-emerald-500/5"; } else if (amPilote && !amBull && !hfIsShort) { verdict = (hfShortsGrowing || hfLongsReducing) ? `▼ AM short pilote (${amPct}%) — HF commence à vendre` : `▼ AM short pilote (${amPct}%) — HF long minoritaire`; sub = (hfShortsGrowing || hfLongsReducing) ? "AM dominant et HF rejoint → signal baissier" : "AM vend massivement · HF long mais minoritaire par volume"; vCls = "text-red-400/80 border-red-500/20 bg-red-500/5"; } else if (!amPilote && hfIsShort && hfLongsGrowing && hfShortsReducing) { verdict = "↻ HF short pilote — retournement haussier en cours"; sub = "HF couvre ses shorts ET rachète des longs → signal de retournement"; vCls = "text-emerald-400 border-emerald-500/25 bg-emerald-500/8"; } else if (!amPilote && hfIsShort && hfLongsGrowing) { verdict = "▲ HF short — mais prend des positions inverses (longs)"; sub = "Signal d'accumulation → surveiller si les shorts baissent aussi"; vCls = "text-emerald-400/80 border-emerald-500/20 bg-emerald-500/5"; } else if (!amPilote && hfIsShort && hfShortsReducing) { verdict = "▲ HF short — couvre ses positions"; sub = "Pression baissière qui s'allège → retournement possible"; vCls = "text-emerald-400/70 border-emerald-500/15 bg-emerald-500/5"; } else if (!amPilote && !hfIsShort && hfShortsGrowing && hfLongsReducing) { verdict = "↻ HF long pilote — retournement baissier en cours"; sub = "HF réduit ses longs ET ajoute des shorts → signal de retournement"; vCls = "text-red-400 border-red-500/25 bg-red-500/8"; } else if (!amPilote && !hfIsShort && hfShortsGrowing) { verdict = "▼ HF long — prend des positions inverses (shorts)"; sub = "Signal de distribution → surveiller si les longs baissent aussi"; vCls = "text-red-400/80 border-red-500/20 bg-red-500/5"; } else { verdict = "→ Flux mixtes"; sub = `AM ${amBull ? "long" : "short"} (${amPct}%) · HF ${hfIsShort ? "short" : "long"} (${hfPct}%) · pas de signal directionnel clair`; vCls = "text-slate-400 border-slate-700/30 bg-slate-800/20"; } return (
{/* Header */}
COT CFTC · {cot.weekDate}
{cot.prevWeekDate ? `vs ${cot.prevWeekDate}` : ""}
{/* Tooltip explicatif */} {showCotInfo && (

HF — Hedge Funds / CTAs. Spéculation directionnelle, court terme. Réactifs aux catalyseurs macro.

AM — Asset Managers (fonds pension, souverains). Flux de couverture institutionnel. Pilote la devise à moyen terme.

NC — Non-Commercial (rapport Legacy CFTC). Tous les grands spéculateurs non commerciaux, catégorie historique.

Volume : k = milliers de contrats futures standardisés CFTC.
Ex : 1 contrat EUR/USD = 125 000 € · GBP = 62 500 £ · JPY = 12 500 000 ¥ · USD Index = 1 000 × indice.

)} {/* Chart COT — barres bidirectionnelles */} {(() => { const maxAbs = Math.max(...groups.map(g => Math.abs(g.net)), 1); const dk = (v: number | null, invert = false): React.ReactNode => v == null ? : 0) !== invert ? "text-emerald-400/90" : "text-rose-400/90"}>{v >= 0 ? "+" : ""}{(v / 1000).toFixed(1)}k; return (
{/* Axe */}
Short
Long
{groups.map(g => { const isLong = g.net >= 0; const total = g.longs + g.shorts; const longPct = total > 0 ? Math.round(g.longs / total * 100) : 50; const barPct = Math.abs(g.net) / maxAbs * 100; const posClr = isLong ? "text-emerald-400" : "text-rose-400"; const evol = evolText(isLong, g.dL, g.dS); const gradLong = "linear-gradient(to right, rgba(52,211,153,0.80) 0%, rgba(52,211,153,0.15) 100%)"; const gradShort = "linear-gradient(to left, rgba(248,113,113,0.80) 0%, rgba(248,113,113,0.15) 100%)"; const grad = isLong ? gradLong : gradShort; return (
{/* Barre + valeur */}
{g.label} {/* Barre bidirectionnelle */}
{/* Track short */}
{!isLong && (
)}
{/* Séparateur zéro */}
{/* Track long */}
{isLong && (
)}
{/* Valeur */}
{netFmt(g.net)}
{longPct}% long
{/* Ligne L/S split sous la barre */}
{/* Deltas */} {(g.dL != null || g.dS != null) && (
ΔL {dk(g.dL)} · ΔS {dk(g.dS, true)} · {evol.text}
)}
); })}
); })()} {/* Verdict */}
{verdict}
{sub}
); })()} {/* Sentiment */} {signauxSlide === "sent" && sentiment && (() => { const sentDir = sentiment.longPct < 30 ? "bullish" : sentiment.longPct > 70 ? "bearish" : "neutral"; const sentCls = sentDir === "bullish" ? "text-emerald-400" : sentDir === "bearish" ? "text-rose-400" : "text-slate-400"; // Pour chaque paire : "long paire" = long devise BASE // Si longIsBaseLong=false (ccy est cotation), le "long devise" = short paire const pairsToShow = (sentiment.pairs ?? []).slice(0, 6); return (
{/* Header */}
Sentiment Retail · Myfxbook
{sentiment.pair}
{/* Résumé agrégé */}
Long {currency} (agrégé)
{sentiment.longPct.toFixed(0)}%
{sentiment.longPct.toFixed(0)}% long {sentiment.shortPct.toFixed(0)}% short
{sentDir === "bullish" ? "Signal ▲" : sentDir === "bearish" ? "Signal ▼" : "Neutre"}
{/* Données brutes par paire (source directe Myfxbook) */} {pairsToShow.length > 0 && (
Paire % Long paire % Short paire
{pairsToShow.map(p => (
{p.name} {/* Barre */}
{p.longPct.toFixed(0)}% {p.shortPct.toFixed(0)}%
))}
)}

Signal contrarian : majorité retail long → bearish · majorité retail short → bullish

); })()}
); })()} )} {/* ════ FOCUS DONNÉES ══════════════════════════════════════════════ */} {activeTab === "focus" && ( <> {/* Context phase — source : OIS / probabilités de taux (fallback : trend FRED) */}
Phase {phaseLabel(phase)} {ratePath ? "Source : OIS / probabilités" : "Source : trend taux FRED"}

{phaseDescription(phase, ratePath)}

{/* Données clés selon phase */} {(phase === "easing" || phase === "dovish_pause") ? <> : <> } {/* Taux directeur */} {yield10Y !== null && (
10Y Yield
{yield10Y.toFixed(2)}%
)}
)}
); }