2026-05-27 13:18:45 +02:00
"use client" ;
2026-06-27 14:12:05 +02:00
import { useEffect , useRef , useState , useCallback , useMemo } from "react" ;
2026-06-02 20:51:20 +02:00
import { motion , AnimatePresence } from "framer-motion" ;
import {
TrendingUp , TrendingDown , Minus , Loader2 , Database ,
BarChart2 , Activity , Target , Zap , Eye , Layers ,
2026-06-27 14:12:05 +02:00
ChevronRight , ArrowUpRight , ArrowDownRight , AlertTriangle , Info ,
2026-06-02 20:51:20 +02:00
} from "lucide-react" ;
import {
2026-06-27 14:12:05 +02:00
AreaChart , Area , LineChart , Line , BarChart , Bar , Cell ,
ResponsiveContainer , Tooltip , XAxis , YAxis , ReferenceLine ,
2026-06-02 20:51:20 +02:00
} from "recharts" ;
2026-06-01 23:24:32 +02:00
import { CURRENCY_META , COUNTRY_PROFILES } from "@/lib/constants" ;
2026-06-02 20:51:20 +02:00
import { biasLabel , calcMacroScore } from "@/lib/scoring" ;
2026-05-28 22:58:27 +02:00
import { saveCache , loadCache , formatCacheDate } from "@/lib/localCache" ;
2026-06-27 14:12:05 +02:00
import type { Currency , BiasPhase , RateExpectation , MacroSection } from "@/lib/types" ;
2026-06-08 19:41:50 +02:00
import type { CBRatePath , ILWeeklyDelta } from "@/lib/rateprobability" ;
2026-06-01 23:24:32 +02:00
import type { SentimentEntry , CotEntry } from "@/lib/types" ;
2026-06-27 14:12:05 +02:00
import type { CalendarEvent } from "@/app/api/calendar/route" ;
2026-05-27 13:18:45 +02:00
import NarrativeButton from "./NarrativeButton" ;
2026-06-02 20:51:20 +02:00
// ─── Types internes ───────────────────────────────────────────────────────────
interface Ind {
value : number | null ; prev : number | null ; surprise : number | null ;
trend : "up" | "down" | "flat" | null ; lastUpdated : string | null ;
consensus? : number | null ;
}
2026-06-01 23:24:32 +02:00
interface MacroForecasts {
cpi : number | null ; cpiSurprise : number | null ;
2026-06-02 20:51:20 +02:00
cpiCore : number | null ; cpiMoM : number | null ;
cpiCoreMoM : number | null ; ppiMoM : number | null ;
2026-06-01 23:24:32 +02:00
unemployment : number | null ; unemploymentSurprise : number | null ;
pmiMfg : number | null ; pmiMfgSurprise : number | null ;
pmiSvc : number | null ; pmiSvcSurprise : number | null ;
pmiComposite : number | null ; pmiCompositeSurprise : number | null ;
retailSales : number | null ; retailSalesSurprise : number | null ;
gdp : number | null ; gdpSurprise : number | null ;
employment : number | null ; employmentSurprise : number | null ;
}
2026-06-02 20:51:20 +02:00
interface MacroData {
currency : string ;
indicators : Record < string , Ind | null >;
forecasts? : MacroForecasts | null ;
fetchedAt : string ;
}
2026-05-27 13:18:45 +02:00
interface Props {
currency : Currency ;
expectations : Record < string , unknown > | null ;
yields : { yields : Record < string , number | null >; spreads : Record < string , number | null > } | null ;
2026-06-01 23:24:32 +02:00
sentiment : SentimentEntry | null ;
cot : CotEntry | null ;
ratePath : CBRatePath | null ;
2026-05-27 13:18:45 +02:00
onDivergenceUpdate : ( currency : Currency , score : number ) => void ;
2026-06-27 14:12:05 +02:00
calEvents? : CalendarEvent [];
macroSection? : MacroSection ;
syncMacroSlide ?: "mon" | "infl" | "cro" | "empl" ;
onMacroSlideChange ?: ( id : "mon" | "infl" | "cro" | "empl" ) => void ;
2026-06-27 16:40:35 +02:00
syncCardTab ?: "overview" | "mispricing" | "focus" ;
onCardTabChange ?: ( id : "overview" | "mispricing" | "focus" ) => void ;
syncSignauxSlide ?: "ois" | "cot" | "sent" ;
onSignauxSlideChange ?: ( id : "ois" | "cot" | "sent" ) => void ;
2026-05-27 13:18:45 +02:00
}
2026-06-02 20:51:20 +02:00
type Tab = "overview" | "mispricing" | "focus" ;
type SignalDir = "bullish" | "bearish" | "neutral" | "warning" ;
2026-06-27 14:12:05 +02:00
type SliderBlock = "news" | "ois" | "cot" | "signal" ;
2026-06-02 20:51:20 +02:00
// ─── 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" ;
}
2026-06-08 19:41:50 +02:00
// ─── 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.` ;
}
}
2026-06-02 20:51:20 +02:00
function scoreDir ( score : number ) : SignalDir {
if ( score >= 3 ) return "bullish" ;
if ( score <= - 3 ) return "bearish" ;
return "neutral" ;
}
function sigColor ( d : SignalDir ) {
if ( d === "bullish" ) return "text-emerald-400" ;
if ( d === "bearish" ) return "text-red-400" ;
if ( d === "warning" ) return "text-amber-400" ;
return "text-slate-400" ;
}
function sigBg ( d : SignalDir ) {
if ( d === "bullish" ) return "bg-emerald-500/10 border-emerald-500/20" ;
if ( d === "bearish" ) return "bg-red-500/10 border-red-500/20" ;
if ( d === "warning" ) return "bg-amber-500/10 border-amber-500/20" ;
return "bg-slate-500/10 border-slate-500/20" ;
}
2026-05-27 13:18:45 +02:00
2026-06-02 20:51:20 +02:00
function sigBar ( d : SignalDir ) {
if ( d === "bullish" ) return "bg-emerald-500" ;
if ( d === "bearish" ) return "bg-red-500" ;
if ( d === "warning" ) return "bg-amber-500" ;
return "bg-slate-500" ;
}
2026-06-02 22:11:39 +02:00
function computeESI ( inds : Record < string , Ind | null > | 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 }) {
2026-06-27 19:09:50 +02:00
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" ;
2026-06-02 22:11:39 +02:00
return (
2026-06-27 19:09:50 +02:00
< span
className = { `text-[9px] font-semibold px-1.5 py-0.5 rounded-full border tabular-nums ${ cls } ` }
title = { "ESI — Economic Surprise Index\n\nBeat/miss de 9 indicateurs (CPI, PMI, PIB, emploi…).\nPlage − 100 → +100. Vert > +20 (majorité de beats), Rouge < − 20 (majorité de misses)." }
>
ESI { value > 0 ? "+" : "" }{ value }
</ span >
2026-06-02 22:11:39 +02:00
);
}
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 (
< div className = "flex items-start gap-2" >
< span className = { `w-1.5 h-1.5 rounded-full mt-[7px] shrink-0 ${ dot } ` } />
< div className = "flex-1 min-w-0" >{ children }</ div >
</ div >
);
}
2026-06-03 00:28:56 +02:00
// ─── Profils géopolitiques statiques ─────────────────────────────────────────
const ENERGY_PROFILE : Record < string , {
type : "export" | "import" | "neutre" ;
desc : string ;
products : string [];
}> = {
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" ] },
};
2026-06-02 20:51:20 +02:00
function trendDir ( t : "up" | "down" | "flat" | null ) : SignalDir {
if ( t === "up" ) return "bullish" ;
if ( t === "down" ) return "bearish" ;
return "neutral" ;
}
2026-06-08 19:41:50 +02:00
// ─── 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 (
< span className = { `font-bold ${ color } ` }>
{ arrow }{ up ? "+" : "" }{ Math . abs ( Math . round ( abs * 10 ) / 10 )}{ suffix }
</ span >
);
}
2026-06-02 20:51:20 +02:00
// ─── Sous-composants ─────────────────────────────────────────────────────────
2026-06-27 14:12:05 +02:00
function MacroBlock ({ title , children , action } : { title : string ; children : React.ReactNode ; action? : React.ReactNode }) {
2026-06-01 23:24:32 +02:00
return (
2026-06-02 20:51:20 +02:00
< div className = "bg-slate-800/40 rounded-xl border border-slate-700/30 p-3 space-y-1.5" >
2026-06-27 14:12:05 +02:00
< div className = "flex items-center justify-between mb-2" >
2026-06-27 18:55:16 +02:00
< div className = "text-[10px] text-slate-300 uppercase tracking-wider font-semibold" >{ title }</ div >
2026-06-27 14:12:05 +02:00
{ action }
</ div >
2026-06-02 20:51:20 +02:00
{ children }
2026-06-01 23:24:32 +02:00
</ div >
);
}
2026-06-02 20:51:20 +02:00
function IRow ({
2026-06-27 14:12:05 +02:00
label , ind , unit = "" , consensus , surpriseVsCons , tooltip , info , invertSurprise = false , isNew = false ,
2026-06-02 20:51:20 +02:00
} : {
label : string ; ind : Ind | null ; unit? : string ;
consensus? : number | null ; surpriseVsCons? : number | null ;
2026-06-27 14:12:05 +02:00
tooltip? : string | null ; info? : string | null ; invertSurprise? : boolean ; isNew? : boolean ;
2026-05-27 13:18:45 +02:00
}) {
const value = ind ? . value ?? null ;
const prev = ind ? . prev ?? null ;
2026-06-02 20:51:20 +02:00
const fmt = ( v : number | null ) => v !== null ? ` ${ v . toFixed ( 2 ) }${ unit } ` : "—" ;
2026-05-27 13:18:45 +02:00
const s = ind ? . surprise ?? null ;
2026-06-02 20:51:20 +02:00
const effS = invertSurprise && s !== null ? - s : s ;
const valColor = effS === null ? "text-slate-200"
: effS > 0 ? "text-emerald-400"
: effS < 0 ? "text-red-400"
: "text-slate-500" ;
const effSurpr = invertSurprise && surpriseVsCons !== null ? - ( surpriseVsCons ?? 0 ) : surpriseVsCons ;
const surpriseCls = effSurpr == null ? "" : effSurpr > 0 ? "text-emerald-500" : effSurpr < 0 ? "text-red-500" : "text-slate-500" ;
const surpriseArr = effSurpr == null ? "" : effSurpr > 0 ? "▲" : effSurpr < 0 ? "▼" : "▬" ;
2026-06-01 23:24:32 +02:00
2026-05-27 13:18:45 +02:00
return (
2026-06-02 20:51:20 +02:00
< div className = "space-y-0.5" >
< div className = "flex items-center justify-between text-[12px]" >
2026-05-27 13:18:45 +02:00
< div className = "flex items-center gap-1.5 min-w-0" >
2026-06-02 20:51:20 +02:00
< span className = "text-slate-600 shrink-0" > → </ span >
< span className = "text-slate-400 truncate" >{ label }</ span >
2026-06-27 14:12:05 +02:00
{ isNew && (
< span className = "text-[7px] font-bold px-1 py-px rounded bg-amber-500/20 text-amber-400 border border-amber-500/30 shrink-0 animate-pulse" > NEW </ span >
)}
2026-06-02 17:52:36 +02:00
{ info && (
< span className = "relative group/info inline-flex shrink-0 cursor-help" >
2026-06-02 20:51:20 +02:00
< span className = "inline-flex items-center justify-center w-3 h-3 rounded-full border border-slate-700 text-slate-500 text-[7px] font-bold leading-none" > i </ span >
< span className = "pointer-events-none absolute bottom-full left-0 mb-1.5 w-52 px-2 py-1.5 rounded-md bg-slate-950 text-slate-300 text-[10px] leading-snug opacity-0 group-hover/info:opacity-100 transition-opacity duration-150 z-50 shadow-lg whitespace-normal border border-slate-700" >
2026-06-02 17:52:36 +02:00
{ info }
</ span >
</ span >
)}
2026-05-27 13:18:45 +02:00
</ div >
2026-06-02 17:37:34 +02:00
{ tooltip ? (
< span className = "relative group/val cursor-default shrink-0" >
2026-06-02 20:51:20 +02:00
< span className = { `font-semibold tabular-nums ${ valColor } ` }>{ fmt ( value )}</ span >
< span className = "pointer-events-none absolute bottom-full right-0 mb-1.5 w-52 px-2 py-1.5 rounded-md bg-slate-950 text-slate-300 text-[10px] leading-snug opacity-0 group-hover/val:opacity-100 transition-opacity duration-150 z-50 shadow-lg whitespace-normal border border-slate-700" >
2026-06-02 17:37:34 +02:00
{ tooltip }
</ span >
</ span >
) : (
2026-06-02 20:51:20 +02:00
< span className = { `font-semibold tabular-nums shrink-0 ${ valColor } ` }>{ fmt ( value )}</ span >
2026-06-02 17:37:34 +02:00
)}
2026-05-27 13:18:45 +02:00
</ div >
2026-06-02 20:51:20 +02:00
< div className = "flex items-center justify-between pl-4 text-[10px] text-slate-600" >
< span > Préc . < span className = "text-slate-500" >{ fmt ( prev )}</ span ></ span >
{ surpriseVsCons !== null && surpriseVsCons !== undefined ? (
< span className = { `font-medium ${ surpriseCls } ` }>
Surpr . { surpriseArr }{( effSurpr ?? 0 ) > 0 ? "+" : "" }{( effSurpr ?? 0 ). toFixed ( 2 )}{ unit }
2026-06-01 23:24:32 +02:00
</ span >
2026-06-02 20:51:20 +02:00
) : consensus !== null && consensus !== undefined ? (
< span > Cons . < span className = "text-blue-400 font-medium" >{ fmt ( consensus )}</ span ></ span >
) : null }
2026-05-27 13:18:45 +02:00
</ div >
</ div >
);
}
2026-06-02 20:51:20 +02:00
function SignalBar ({ strength , direction } : { strength : number ; direction : SignalDir }) {
return (
< div className = "relative w-full h-1.5 bg-slate-700/50 rounded-full overflow-hidden" >
< motion.div
className = { `absolute left-0 top-0 h-full rounded-full ${ sigBar ( direction ) } ` }
initial = {{ width : 0 }}
animate = {{ width : ` ${ strength } %` }}
transition = {{ duration : 0.5 , ease : "easeOut" }}
/>
</ div >
);
}
2026-06-27 14:12:05 +02:00
// ─── OIS Enhanced Block ───────────────────────────────────────────────────────
// Bloc OIS enrichi : summary (Current Rate → Expected, Next Meeting, Most Likely/Alt)
// + 3 onglets graphiques : Rate Curve / Implied Points / Scénarios
function OISEnhancedBlock ({ ratePath } : { ratePath : CBRatePath }) {
const [ chartTab , setChartTab ] = useState < "curve" | "implied" | "scenarios" > ( "curve" );
2026-06-27 19:40:48 +02:00
const { currentRate , meetings , yearEndImplied , ilDelta , ilCurrent , prevMeetings , prevWeekDate } = ratePath ;
2026-06-27 14:12:05 +02:00
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 ;
// Chart data (limit to 10 meetings)
const chartMeetings = meetings . slice ( 0 , 10 );
2026-06-27 19:40:48 +02:00
// 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 ;
2026-06-27 14:12:05 +02:00
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 ,
}));
// 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 );
return (
< div className = "rounded-xl border border-slate-700/30 overflow-hidden" >
{ /* ── Summary header ────────────────────────────────────────────────── */ }
< div className = "bg-slate-800/40 px-3 pt-2.5 pb-2" >
{ /* Title */ }
< div className = "flex items-center justify-between mb-2" >
< div className = "flex items-center gap-2" >
< span className = "text-[10px] text-slate-400 uppercase tracking-wider font-bold" > OIS · Futures </ span >
< span className = "text-[8px] font-bold px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-400 border border-emerald-500/25" > LIVE </ span >
</ div >
< span className = "text-[9px] text-slate-600" > au { ratePath . asOf }</ span >
</ div >
{ /* 3-column rates */ }
< div className = "grid grid-cols-3 gap-1 mb-2" >
< div >
< div className = "text-[8px] text-slate-600" > Current Rate </ div >
< div className = "text-[15px] font-bold text-slate-200 tabular-nums" >{ currentRate . toFixed ( 2 )} % </ div >
</ div >
< div >
< div className = "text-[8px] text-slate-600" > Expected </ div >
< div className = { `text-[15px] font-bold tabular-nums ${ bpsCls } ` }>{ yearEndImplied ? . toFixed ( 2 ) ?? "—" } % </ div >
</ div >
< div className = "text-right" >
< div className = "text-[8px] text-slate-600" > Next Meeting </ div >
< div className = "text-[11px] font-semibold text-slate-300" >{ m0 . label }</ div >
</ div >
</ div >
{ /* Expected Move + Change bps + Δ fin an */ }
< div className = "flex items-center gap-3 mb-2" >
< div >
< div className = "text-[8px] text-slate-600" > Expected Move </ div >
< div className = { `flex items-center gap-0.5 text-[12px] font-bold ${ moveCls } ` }>
< span >{ moveIcon }</ span >< span >{ moveLabel }</ span >
</ div >
</ div >
< div >
< div className = "text-[8px] text-slate-600" > Change ( bps )</ div >
< div className = { `text-[12px] font-bold tabular-nums ${ bpsCls } ` }>
{ expectedBps > 0 ? "+" : "" }{ expectedBps . toFixed ( 2 )}
</ div >
</ div >
{ bpsYE !== null && (
< div className = "ml-auto text-right" >
< div className = "text-[8px] text-slate-600" > Δ fin an </ div >
< div className = { `text-[18px] font-black tabular-nums ${ bpsCls } ` }>
{ bpsYE > 0 ? "+" : "" }{ bpsYE }< span className = "text-[9px] ml-0.5" > bps </ span >
</ div >
</ div >
)}
</ div >
{ /* Most Likely / Alternative probability bars */ }
< div className = "space-y-1" >
< div className = "flex items-center gap-1.5" >
< span className = "text-[8px] text-slate-600 w-14 shrink-0" > Most Likely </ span >
< span className = { `text-[9px] font-bold w-3 text-center shrink-0 ${ mlIsMove ? moveCls : "text-slate-400" } ` }>{ mlIsMove ? moveIcon : "=" }</ span >
< span className = "text-[9px] font-bold text-slate-200 tabular-nums w-10 shrink-0" >{ mlRate . toFixed ( 2 )} % </ span >
< div className = "flex-1 bg-slate-700/40 rounded-full h-1.5 overflow-hidden" >
< div
className = { `h-full rounded-full ${ mlIsMove ? ( m0 . probIsCut ? "bg-sky-500" : "bg-red-500" ) : "bg-amber-500/70" } ` }
style = {{ width : ` ${ mlProb } %` }}
/>
</ div >
< span className = "text-[8px] font-semibold text-slate-400 w-7 text-right shrink-0" >{ Math . round ( mlProb )} % </ span >
</ div >
< div className = "flex items-center gap-1.5" >
< span className = "text-[8px] text-slate-600 w-14 shrink-0" > Alternative </ span >
< span className = { `text-[9px] font-bold w-3 text-center shrink-0 ${ altIsMove ? moveCls : "text-slate-500" } ` }>{ altIsMove ? moveIcon : "=" }</ span >
< span className = "text-[9px] font-bold text-slate-500 tabular-nums w-10 shrink-0" >{ altRate . toFixed ( 2 )} % </ span >
< div className = "flex-1 bg-slate-700/40 rounded-full h-1.5 overflow-hidden" >
< div
className = { `h-full rounded-full ${ altIsMove ? ( m0 . probIsCut ? "bg-sky-400/40" : "bg-red-400/40" ) : "bg-slate-500/40" } ` }
style = {{ width : ` ${ altProb } %` }}
/>
</ div >
< span className = "text-[8px] font-semibold text-slate-600 w-7 text-right shrink-0" >{ Math . round ( altProb )} % </ span >
</ div >
</ div >
</ div >
{ /* ── Chart section ─────────────────────────────────────────────────── */ }
< div className = "border-t border-slate-700/40 px-3 pt-2 pb-2" >
{ /* Tab buttons */ }
< div className = "flex gap-1 mb-2" >
{([ { id : "curve" as const , label : "Rate Curve" }, { id : "implied" as const , label : "Implied Pts" }, { id : "scenarios" as const , label : "Scénarios" } ]). map ( t => (
< button
key = { t . id }
onClick = {() => setChartTab ( t . id )}
className = { `text-[9px] font-semibold px-2 py-0.5 rounded-full border transition-all ${
chartTab === t . id
? "bg-slate-700 border-slate-500 text-white"
2026-06-27 18:55:16 +02:00
: "border-slate-700/60 text-slate-400 hover:text-white"
2026-06-27 14:12:05 +02:00
} ` }
>
{ t . label }
</ button >
))}
</ div >
{ /* Chart 1 — Implied Interest Rate Curve */ }
{ chartTab === "curve" && (
< div >
2026-06-27 18:47:01 +02:00
{ /* Légende visuelle */ }
< div className = "flex items-center gap-3 mb-1.5" >
< span className = "text-[8px] text-slate-500" > Courbe de taux implicite </ span >
< div className = "flex items-center gap-2 ml-auto" >
< span className = "flex items-center gap-1 text-[8px] text-slate-200" >
< span className = "inline-block w-5 h-px bg-slate-200 rounded" />
Actuel
</ span >
2026-06-27 19:40:48 +02:00
< span className = { `flex items-center gap-1 text-[8px] ${ hasPrevCurve ? "text-sky-400" : "text-slate-600" } ` }>
< span className = { `inline-block w-5 border-t border-dashed ${ hasPrevCurve ? "border-sky-400" : "border-slate-700" } ` } />
{ hasPrevCurve && prevCurveLabel ? `Sem. préc. ( ${ prevCurveLabel } )` : hasPrevCurve ? "Sem. préc." : "Sem. préc. (indispo)" }
2026-06-27 18:47:01 +02:00
</ span >
</ div >
2026-06-27 14:12:05 +02:00
</ div >
< ResponsiveContainer width = "100%" height = { 120 }>
< LineChart data = { rateCurveData } margin = {{ top : 5 , right : 5 , left : - 15 , bottom : 0 }}>
< XAxis dataKey = "label" tick = {{ fontSize : 7 , fill : "#64748b" }} axisLine = { false } tickLine = { false } interval = "preserveStartEnd" />
< YAxis tick = {{ fontSize : 7 , fill : "#64748b" }} axisLine = { false } tickLine = { false } width = { 32 }
domain = {[ minR - yMargin , maxR + yMargin ]}
tickFormatter = {( v : number ) => v . toFixed ( 2 )} />
< Tooltip
2026-06-27 19:11:51 +02:00
contentStyle = {{ background : "#0f172a" , border : "1px solid #334155" , borderRadius : 6 , fontSize : 9 }}
labelStyle = {{ color : "#94a3b8" , fontSize : 9 }}
itemStyle = {{ color : "#e2e8f0" , fontSize : 9 }}
2026-06-27 14:12:05 +02:00
formatter = {( v : number , name : string ) => [ ` ${ v . toFixed ( 3 ) } %` , name === "current" ? "Actuel" : "Sem. préc." ]}
/>
< Line type = "monotone" dataKey = "current" stroke = "#e2e8f0" strokeWidth = { 1.5 } dot = {{ r : 2 , fill : "#e2e8f0" }} name = "current" />
2026-06-27 19:40:48 +02:00
{ hasPrevCurve && (
2026-06-27 14:12:05 +02:00
< Line type = "monotone" dataKey = "weekAgo" stroke = "#38bdf8" strokeWidth = { 1.5 } dot = {{ r : 2 , fill : "#38bdf8" }} strokeDasharray = "4 2" name = "weekAgo" />
)}
</ LineChart >
</ ResponsiveContainer >
</ div >
)}
{ /* Chart 2 — Implied Points (bps cumulatifs par réunion) */ }
{ chartTab === "implied" && (
< div >
2026-06-27 18:47:01 +02:00
{ /* Légende couleurs barres */ }
< div className = "flex items-center gap-3 mb-1.5" >
< span className = "text-[8px] text-slate-500" > Implied Points ( bps par réunion )</ span >
< div className = "flex items-center gap-2 ml-auto" >
< span className = "flex items-center gap-1 text-[8px] text-emerald-400" >
< span className = "inline-block w-2 h-2 rounded-sm bg-emerald-400/80" /> Hausse
</ span >
< span className = "flex items-center gap-1 text-[8px] text-pink-400" >
< span className = "inline-block w-2 h-2 rounded-sm bg-pink-400/80" /> Baisse
</ span >
</ div >
</ div >
2026-06-27 14:12:05 +02:00
< ResponsiveContainer width = "100%" height = { 120 }>
< BarChart data = { impliedPtsData } margin = {{ top : 5 , right : 5 , left : - 15 , bottom : 0 }}>
< XAxis dataKey = "label" tick = {{ fontSize : 7 , fill : "#64748b" }} axisLine = { false } tickLine = { false } interval = "preserveStartEnd" />
< YAxis tick = {{ fontSize : 7 , fill : "#64748b" }} axisLine = { false } tickLine = { false } width = { 36 }
tickFormatter = {( v : number ) => ` ${ v } bps` } />
< ReferenceLine y = { 0 } stroke = "#334155" strokeWidth = { 0.5 } />
< Tooltip
2026-06-27 20:12:21 +02:00
content = {({ label , payload }) => {
if ( ! payload ? . length ) return null ;
const v = payload [ 0 ] ? . value as number ;
return (
< div style = {{ background : "#0f172a" , border : "1px solid #334155" , borderRadius : 6 , padding : "4px 8px" }}>
< p style = {{ color : "#94a3b8" , fontSize : 9 , margin : 0 }}>{ label }</ p >
< p style = {{ color : "#e2e8f0" , fontSize : 9 , margin : "2px 0 0" }}>
Implied & nbsp ; : { v > 0 ? "+" : "" }{ typeof v === "number" ? v . toFixed ( 1 ) : "—" } bps
</ p >
</ div >
);
}}
2026-06-27 14:12:05 +02:00
/>
< Bar dataKey = "bps" radius = {[ 2 , 2 , 0 , 0 ]}>
{ impliedPtsData . map (( entry , i ) => (
< Cell key = { i } fill = { entry . bps < 0 ? "#f472b6" : entry . bps > 0 ? "#4ade80" : "#475569" } fillOpacity = { 0.8 } />
))}
</ Bar >
</ BarChart >
</ ResponsiveContainer >
</ div >
)}
{ /* Chart 3 — Scénarios : taux implicite par réunion (barres horizontales) */ }
{ chartTab === "scenarios" && (
< div >
< div className = "text-[8px] text-slate-600 mb-1.5" > Taux implicite par réunion </ div >
< div className = "space-y-1" >
{(() => {
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 ;
return scenariosData . map ( d => {
const barW = Math . max ( 5 , Math . min ( 100 , (( d . rate - minR2 ) / range ) * 100 ));
const isDown = d . rate < currentRate - 0.001 ;
const isUp = d . rate > currentRate + 0.001 ;
const barCl = isDown ? "bg-sky-500/70" : isUp ? "bg-red-500/70" : "bg-amber-500/60" ;
const isPeak = ratePath . peakMeeting ? . dateIso === d . dateIso ;
return (
< div key = { d . label } className = "flex items-center gap-1.5" >
< span className = { `text-[8px] w-9 shrink-0 ${ isPeak ? "text-amber-300 font-bold" : "text-slate-600" } ` }>
{ d . label }{ isPeak ? "●" : "" }
</ span >
< div className = "flex-1 bg-slate-700/30 rounded-full h-2.5 overflow-hidden" >
< div className = { `h-full ${ barCl } rounded-full` } style = {{ width : ` ${ barW } %` }} />
</ div >
< span className = "text-[8px] font-mono text-slate-300 w-10 text-right shrink-0" >{ d . rate . toFixed ( 2 )} % </ span >
< span className = "text-[7px] text-slate-600 w-6 text-right shrink-0" >{ d . prob . toFixed ( 0 )} % </ span >
</ div >
);
});
})()}
</ div >
</ div >
)}
</ div >
{ /* ── IL footer (analyste InvestingLive) ────────────────────────────── */ }
{ ilCurrent && (
< div className = "border-t border-slate-700/40 bg-slate-900/30 px-3 py-1.5 flex items-center justify-between gap-2" >
< span className = "text-[9px] text-slate-600 italic shrink-0" >
Analyste · { ilCurrent . articleDate } · { " " }
< span className = { ilCurrent . isCut ? "text-sky-400" : ilCurrent . isNoChange ? "text-slate-400" : "text-red-400" }>
{ ilCurrent . isNoChange ? "Hold" : ` ${ ilCurrent . isCut ? "" : "+" }${ ilCurrent . bpsYearEnd } bps` }
</ span >
</ span >
{ ratePath . ilDelta && ( Math . abs ( ratePath . ilDelta . probDelta ) >= 3 || Math . abs ( ratePath . ilDelta . bpsDelta ) >= 5 ) && (
< span className = "text-[9px] text-slate-500 flex items-center gap-1 shrink-0" >
Δ sem :
< RpArrow delta = { ratePath . ilDelta . probDelta } isBearishIfPositive = { ratePath . ilDelta . isCut } suffix = "%" strongT = { 10 } modT = { 3 } />
< RpArrow delta = { ratePath . ilDelta . bpsDelta } isBearishIfPositive = { true } suffix = "bps" strongT = { 25 } modT = { 5 } />
</ span >
)}
</ div >
)}
</ div >
);
}
2026-06-02 20:51:20 +02:00
// ─── Composant principal ──────────────────────────────────────────────────────
export default function CurrencyCard ({
2026-06-27 14:12:05 +02:00
currency , expectations , yields , sentiment , cot , ratePath , onDivergenceUpdate ,
calEvents , macroSection , syncMacroSlide , onMacroSlideChange ,
2026-06-27 16:40:35 +02:00
syncCardTab , onCardTabChange , syncSignauxSlide , onSignauxSlideChange ,
2026-06-02 20:51:20 +02:00
} : Props ) {
const meta = CURRENCY_META [ currency ];
// ── State ────────────────────────────────────────────────────────────────────
const [ data , setData ] = useState < MacroData | null >( null );
2026-06-08 19:41:50 +02:00
// Phase dérivée des probabilités OIS (source primaire) ou du trend FRED (fallback)
const phase = useMemo < BiasPhase >(() => {
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 ]);
2026-06-02 20:51:20 +02:00
const [ loading , setLoading ] = useState ( true );
const [ rateExp , setRateExp ] = useState < RateExpectation | null >( null );
const [ fromCache , setFromCache ] = useState ( false );
const [ cacheAge , setCacheAge ] = useState < string | null >( null );
const [ activeTab , setActiveTab ] = useState < Tab >( "overview" );
2026-06-02 15:35:58 +02:00
const [ inflFilter , setInflFilter ] = useState < "all" | "mom" | "yoy" > ( "mom" );
2026-06-02 20:51:20 +02:00
const [ expandedSig , setExpandedSig ] = useState < string | null >( null );
2026-06-02 23:47:03 +02:00
const [ divergenceOpen , setDivergenceOpen ] = useState ( false );
2026-06-27 14:12:05 +02:00
const [ showCotInfo , setShowCotInfo ] = useState ( false );
const [ showAllMeetings , setShowAllMeetings ] = useState ( false );
const [ showYield10Y , setShowYield10Y ] = useState ( false );
const [ showRecentNews , setShowRecentNews ] = useState ( false );
const [ showUpcoming , setShowUpcoming ] = useState ( false );
const [ sliderBlock , setSliderBlock ] = useState < SliderBlock >( "ois" );
const [ sliderDir , setSliderDir ] = useState < 1 |- 1 >( 1 );
type MacroSlide = "mon" | "infl" | "cro" | "empl" ;
type SignauxSlide = "ois" | "cot" | "sent" ;
const [ macroSlide , setMacroSlide ] = useState < MacroSlide >( "mon" );
const [ macroSlideDir , setMacroSlideDir ] = useState < 1 |- 1 >( 1 );
const prevSyncMacroRef = useRef < MacroSlide >( syncMacroSlide ?? "mon" );
const [ signauxSlide , setSignauxSlide ] = useState < SignauxSlide >( "ois" );
const [ signauxSlideDir , setSignauxSlideDir ] = useState < 1 |- 1 >( 1 );
2026-06-27 16:40:35 +02:00
const prevSyncCardTabRef = useRef < Tab >( syncCardTab ?? "overview" );
const prevSyncSignauxSlideRef = useRef < SignauxSlide >( syncSignauxSlide ?? "ois" );
2026-05-27 13:18:45 +02:00
2026-06-02 20:51:20 +02:00
// ── Data fetch ───────────────────────────────────────────────────────────────
2026-05-27 13:18:45 +02:00
const load = useCallback ( async () => {
setLoading ( true );
2026-05-28 22:58:27 +02:00
const cacheKey = `macro_ ${ currency } ` ;
2026-05-27 13:18:45 +02:00
try {
const res = await fetch ( `/api/macro?currency= ${ currency } ` );
2026-05-28 22:58:27 +02:00
if ( ! res . ok ) throw new Error ( `HTTP ${ res . status } ` );
2026-05-27 13:18:45 +02:00
const json : MacroData = await res . json ();
2026-05-28 22:58:27 +02:00
if ( "error" in json ) throw new Error ( String (( json as Record < string , unknown >). error ));
2026-05-28 23:13:40 +02:00
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 );
2026-05-28 22:58:27 +02:00
setFromCache ( false );
setCacheAge ( null );
2026-05-28 23:13:40 +02:00
saveCache ( cacheKey , merged );
2026-05-28 22:58:27 +02:00
} catch {
const cached = loadCache < MacroData >( cacheKey );
if ( cached ) {
setData ( cached . data );
setFromCache ( true );
setCacheAge ( formatCacheDate ( cached . savedAt ));
}
2026-05-27 13:18:45 +02:00
} finally {
setLoading ( false );
}
}, [ currency ]);
useEffect (() => { load (); }, [ load ]);
useEffect (() => {
if ( ! expectations ) return ;
const all = [
...(( expectations . rate_hikes ?? []) as RateExpectation []),
...(( expectations . rate_cuts ?? []) as RateExpectation []),
];
const cbShort = meta . cbShort . toLowerCase ();
2026-06-02 20:51:20 +02:00
setRateExp ( all . find (( e ) =>
e . cb . toLowerCase (). includes ( cbShort ) || e . cb . toLowerCase (). includes ( currency . toLowerCase ())
) ?? null );
2026-05-27 13:18:45 +02:00
}, [ expectations , currency , meta . cbShort ]);
2026-06-02 20:51:20 +02:00
// ── Computed values ──────────────────────────────────────────────────────────
const inds = data ? . indicators ;
const fc = data ? . forecasts ?? null ;
2026-05-27 13:18:45 +02:00
const forScoring = {
2026-06-02 20:51:20 +02:00
policyRate : { value : inds?.policyRate?.value ?? null , prev : inds?.policyRate?.prev ?? null , consensus : null , surprise : inds?.policyRate?.surprise ?? null , trend : inds?.policyRate?.trend ?? null , lastUpdated : "" },
cpiCore : { value : inds?.cpiCore?.value ?? null , prev : inds?.cpiCore?.prev ?? null , consensus : null , surprise : inds?.cpiCore?.surprise ?? null , trend : inds?.cpiCore?.trend ?? null , lastUpdated : "" },
pmiMfg : { value : inds?.pmiMfg?.value ?? null , prev : inds?.pmiMfg?.prev ?? null , consensus : null , surprise : inds?.pmiMfg?.surprise ?? null , trend : inds?.pmiMfg?.trend ?? null , lastUpdated : "" },
pmiServices : { value : inds?.pmiServices?.value ?? null , prev : inds?.pmiServices?.prev ?? null , consensus : null , surprise : inds?.pmiServices?.surprise ?? null , trend : inds?.pmiServices?.trend ?? null , lastUpdated : "" },
gdp : { value : inds?.gdp?.value ?? null , prev : inds?.gdp?.prev ?? null , consensus : null , surprise : inds?.gdp?.surprise ?? null , trend : inds?.gdp?.trend ?? null , lastUpdated : "" },
retailSales : { value : inds?.retailSales?.value ?? null , prev : inds?.retailSales?.prev ?? null , consensus : null , surprise : inds?.retailSales?.surprise ?? null , trend : inds?.retailSales?.trend ?? null , lastUpdated : "" },
2026-05-27 13:18:45 +02:00
unemployment : { value : inds?.unemployment?.value ?? null , prev : inds?.unemployment?.prev ?? null , consensus : null , surprise : inds?.unemployment?.surprise ?? null , trend : inds?.unemployment?.trend ?? null , lastUpdated : "" },
2026-06-02 20:51:20 +02:00
employment : { value : inds?.employment?.value ?? null , prev : inds?.employment?.prev ?? null , consensus : null , surprise : inds?.employment?.surprise ?? null , trend : inds?.employment?.trend ?? null , lastUpdated : "" },
2026-05-27 13:18:45 +02:00
};
const macroScore = calcMacroScore ( forScoring , phase );
const biasText = biasLabel ( macroScore );
2026-06-02 20:51:20 +02:00
const dir = scoreDir ( macroScore );
const yield10Y = yields ? . yields [ currency ] ?? null ;
2026-05-27 13:18:45 +02:00
const spread10Y = yields ? . spreads [ currency ] ?? null ;
2026-06-02 22:11:39 +02:00
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" ;
2026-06-02 20:51:20 +02:00
useEffect (() => { onDivergenceUpdate ( currency , macroScore ); }, [ macroScore , currency , onDivergenceUpdate ]);
2026-06-01 23:24:32 +02:00
const rateConsensus = (() => {
const rate = inds ? . policyRate ? . value ?? null ;
if ( rate === null ) return null ;
if ( ratePath && ratePath . meetings . length > 0 ) {
const next = ratePath . meetings [ 0 ];
2026-06-02 20:51:20 +02:00
if ( next . probMovePct > 50 )
2026-06-01 23:24:32 +02:00
return next . probIsCut
? parseFloat (( rate - 0.25 ). toFixed ( 2 ))
: parseFloat (( rate + 0.25 ). toFixed ( 2 ));
return parseFloat ( rate . toFixed ( 2 ));
}
if ( ! rateExp ) return null ;
const desc = rateExp . prob_desc . toLowerCase ();
if ( desc . includes ( "no change" )) return parseFloat ( rate . toFixed ( 2 ));
if ( rateExp . direction === "cut" && rateExp . prob_pct > 50 ) return parseFloat (( rate - 0.25 ). toFixed ( 2 ));
if ( rateExp . direction === "hike" && rateExp . prob_pct > 50 ) return parseFloat (( rate + 0.25 ). toFixed ( 2 ));
return parseFloat ( rate . toFixed ( 2 ));
})();
2026-06-02 20:51:20 +02:00
// ── Mispricing signals (computed from live data) ──────────────────────────────
const mispricingSignals : {
id : string ; label : string ; value : string ; detail : string ;
direction : SignalDir ; strength : number ; icon : React.ReactNode ;
}[] = [];
2026-06-27 14:12:05 +02:00
// COT CFTC — Analyse flux AM (hedging) vs HF (spéculation) + momentum hebdomadaire
// Logique : qui domine le flux ET dans quelle direction évolue chaque groupe
2026-06-02 20:51:20 +02:00
if ( cot ) {
2026-06-27 14:12:05 +02:00
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 ;
2026-06-02 20:51:20 +02:00
mispricingSignals . push ({
2026-06-27 14:12:05 +02:00
id : "cot" , label : "COT CFTC — Flux & Momentum" ,
2026-06-02 23:42:01 +02:00
direction : cotDir ,
2026-06-27 14:12:05 +02:00
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 ),
2026-06-02 20:51:20 +02:00
icon : < BarChart2 size = { 13 } />,
});
}
2026-06-02 22:11:39 +02:00
// OIS / rate probability — avec drift bps fin d'an
2026-06-02 20:51:20 +02:00
if ( ratePath && ratePath . meetings . length > 0 ) {
const peak = ratePath . peakMeeting ;
2026-06-02 22:11:39 +02:00
const yearEndBps = ratePath . yearEndImplied !== null
? Math . round (( ratePath . yearEndImplied - ratePath . currentRate ) * 100 ) : null ;
2026-06-02 20:51:20 +02:00
if ( peak ) {
const oisDir : SignalDir = peak . probIsCut ? "bearish" : "bullish" ;
2026-06-02 22:11:39 +02:00
const bpsLabel = yearEndBps !== null ? ` ${ yearEndBps > 0 ? "+" : "" }${ yearEndBps } bps fin an` : "" ;
2026-06-02 20:51:20 +02:00
mispricingSignals . push ({
2026-06-02 22:11:39 +02:00
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" : "—" } ).` ,
2026-06-02 20:51:20 +02:00
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 } />,
});
}
2026-06-03 00:44:00 +02:00
// 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 ;
2026-06-02 20:51:20 +02:00
const policyRate = inds ? . policyRate ? . value ?? null ;
2026-06-03 00:44:00 +02:00
if ( cpiHeadlineForReal !== null && policyRate !== null ) {
const realRate = policyRate - cpiHeadlineForReal ;
2026-06-02 20:51:20 +02:00
const inflDir : SignalDir = realRate < 0 ? "bearish" : realRate > 1.5 ? "bullish" : "neutral" ;
mispricingSignals . push ({
2026-06-03 00:44:00 +02:00
id : "inflation" , label : "Taux Réel (CT − CPI YoY)" , direction : inflDir ,
2026-06-02 20:51:20 +02:00
value : ` ${ realRate > 0 ? "+" : "" }${ realRate . toFixed ( 2 ) } %` ,
2026-06-03 00:44:00 +02:00
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." } ` ,
2026-06-02 20:51:20 +02:00
strength : Math.min ( 100 , Math . abs ( realRate ) * 25 ),
icon : < Zap size = { 13 } />,
});
}
2026-06-02 22:11:39 +02:00
// Sentiment retail — contrarian (majorité short = bullish, majorité long = bearish)
2026-06-02 20:51:20 +02:00
if ( sentiment ) {
2026-06-02 22:11:39 +02:00
const sentDir : SignalDir = sentiment . longPct < 30 ? "bullish"
: sentiment . longPct > 70 ? "bearish" : "neutral" ;
2026-06-02 20:51:20 +02:00
mispricingSignals . push ({
id : "sentiment" , label : "Sentiment Retail (Contrarian)" , direction : sentDir ,
value : ` ${ sentiment . longPct . toFixed ( 0 ) } % Long` ,
2026-06-02 22:11:39 +02:00
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" } .` ,
2026-06-02 20:51:20 +02:00
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" ;
2026-06-03 00:33:58 +02:00
// 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
2026-06-02 22:11:39 +02:00
const divergenceSignal : SignalDir =
mispricDir !== "neutral" && dir !== "neutral" && mispricDir === dir ? mispricDir :
mispricDir !== "neutral" && dir !== "neutral" && mispricDir !== dir ? "warning" :
2026-06-03 00:33:58 +02:00
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 ;
2026-06-02 22:11:39 +02:00
const divergenceType =
2026-06-03 00:33:58 +02:00
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` :
2026-06-02 22:11:39 +02:00
"Pas de signal convergent" ;
2026-06-02 23:47:03 +02:00
// 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 ; })() },
2026-06-08 19:41:50 +02:00
{ 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 ; })() },
2026-06-02 23:47:03 +02:00
{ 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 !== "" );
2026-06-27 14:12:05 +02:00
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 );
2026-06-27 18:58:40 +02:00
onCardTabChange ? .( "overview" );
}, [ onMacroSlideChange , onCardTabChange ]);
2026-06-27 14:12:05 +02:00
// 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 ]);
2026-06-27 16:40:35 +02:00
// 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 ]);
2026-06-27 14:12:05 +02:00
const goToSignauxSlide = useCallback (( id : SignauxSlide ) => {
const order : SignauxSlide [] = [ "ois" , "cot" , "sent" ];
setSignauxSlide ( prev => {
setSignauxSlideDir ( order . indexOf ( id ) >= order . indexOf ( prev ) ? 1 : - 1 );
return id ;
});
2026-06-27 16:40:35 +02:00
onSignauxSlideChange ? .( id );
2026-06-27 18:58:40 +02:00
onCardTabChange ? .( "mispricing" );
}, [ onSignauxSlideChange , onCardTabChange ]);
2026-06-27 14:12:05 +02:00
// ── 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 < string >();
const cutoff = Date . now () - 7 * 24 * 3600 * 1000 ;
const cats = new Set < string >();
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 < string , string > = {
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 },
2026-06-27 19:05:23 +02:00
{ key : "real" , abbr : "Tx.R" , dir : realRateDir },
2026-06-27 14:12:05 +02:00
];
// 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" ;
2026-06-02 20:51:20 +02:00
// ── Tabs config ──────────────────────────────────────────────────────────────
const TABS : { id : Tab ; label : string ; icon : React.ReactNode }[] = [
{ id : "overview" , label : "Aperçu" , icon : < Layers size = { 10 } /> },
{ id : "mispricing" , label : "Signaux" , icon : < Eye size = { 10 } /> },
{ id : "focus" , label : "Focus" , icon : < Target size = { 10 } /> },
];
// ── Render ────────────────────────────────────────────────────────────────────
2026-05-27 13:18:45 +02:00
return (
2026-06-27 14:12:05 +02:00
< div className = "bg-slate-900 border border-slate-800 rounded-2xl flex flex-col overflow-hidden" >
2026-06-02 20:51:20 +02:00
{ /* ── Header ──────────────────────────────────────────────────────────── */ }
< div className = "px-4 pt-4 pb-3 border-b border-slate-800" >
< div className = "flex items-start justify-between mb-2" >
< div className = "flex items-center gap-2.5" >
2026-05-27 13:18:45 +02:00
< span className = "text-2xl leading-none" >{ meta . flag }</ span >
< div >
2026-06-02 20:51:20 +02:00
< div className = "flex items-center gap-2" >
< span className = "text-xl font-black text-white tracking-tight" >{ currency }</ span >
{ ! loading && (
< span className = { `text-sm font-bold ${ sigColor ( dir ) } ` }>{ biasText }</ span >
)}
</ div >
< div className = "text-[11px] text-slate-500" >{ meta . cbShort } · { meta . name }</ div >
2026-05-27 13:18:45 +02:00
</ div >
</ div >
2026-06-27 14:12:05 +02:00
{ loading && < Loader2 size = { 16 } className = "animate-spin text-slate-600 mt-1" />}
2026-05-27 13:18:45 +02:00
</ div >
2026-06-02 20:51:20 +02:00
2026-06-02 22:11:39 +02:00
{ /* Phase pill + mispricing + ESI + cache */ }
2026-06-02 20:51:20 +02:00
< 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 >
2026-06-02 22:11:39 +02:00
{ esi !== null && < SurpriseIndexBadge value = { esi } />}
2026-05-28 22:58:27 +02:00
{ fromCache && cacheAge && (
2026-06-27 19:05:23 +02:00
< span className = "text-amber-500/60 cursor-help" title = { `Données depuis le cache local ( ${ cacheAge } )` }>
< Database size = { 9 } />
2026-06-02 20:51:20 +02:00
</ span >
2026-05-28 22:58:27 +02:00
)}
</ div >
2026-06-27 14:12:05 +02:00
{ /* 5-dimension signal summary */ }
{ ! loading && (
< div className = "flex items-center gap-3 mt-2 pt-2 border-t border-slate-800" >
{ signalSummary . map ( s => (
2026-06-27 19:05:23 +02:00
< div key = { s . key } className = "flex flex-col items-center gap-0.5" title = { s . key === "real" ? `Taux Réel = Taux directeur − CPI YoY. \ n> +1.5% → restrictif (bullish) \ n< 0% → accommodant (bearish)` : ` ${ s . abbr } : ${ s . dir } ` }>
2026-06-27 14:12:05 +02:00
< div className = { `w-2 h-2 rounded-full ${
s . dir === "bullish" ? "bg-emerald-500"
: s . dir === "bearish" ? "bg-red-500"
: s . dir === "warning" ? "bg-amber-500"
: "bg-slate-700"
} ` } />
< span className = "text-[7px] text-slate-600 leading-none" >{ s . abbr }</ span >
</ div >
))}
< div className = "ml-auto flex items-center gap-1" >
{ recentEvents . length > 0 && (
< button
onClick = {() => { setShowRecentNews ( v => ! v ); setShowUpcoming ( false ); }}
className = { `text-[8px] font-semibold flex items-center gap-0.5 px-1.5 py-0.5 rounded-full border transition-all ${
showRecentNews
? "bg-amber-500/20 border-amber-500/40 text-amber-300"
: "bg-amber-500/10 border-amber-500/20 text-amber-400 hover:bg-amber-500/20 animate-pulse"
} ` }
>
< span className = "w-1 h-1 rounded-full bg-current" />
{ recentEvents . length } publiée { recentEvents . length > 1 ? "s" : "" }
</ button >
)}
{ upcomingEvents . length > 0 && (
< button
onClick = {() => { setShowUpcoming ( v => ! v ); setShowRecentNews ( false ); }}
className = { `text-[8px] font-semibold flex items-center gap-0.5 px-1.5 py-0.5 rounded-full border transition-all ${
showUpcoming
? "bg-sky-500/20 border-sky-500/40 text-sky-300"
: "bg-sky-500/10 border-sky-500/20 text-sky-400 hover:bg-sky-500/20"
} ` }
>
< span className = "w-1 h-1 rounded-full bg-current" />
{ upcomingEvents . length } à venir
</ button >
)}
</ div >
</ div >
)}
2026-05-27 13:18:45 +02:00
</ div >
2026-06-27 14:12:05 +02:00
{ /* ── News flash panel ────────────────────────────────────────────────── */ }
< AnimatePresence >
{ showRecentNews && recentEvents . length > 0 && (
< motion.div
initial = {{ height : 0 , opacity : 0 }}
animate = {{ height : "auto" , opacity : 1 }}
exit = {{ height : 0 , opacity : 0 }}
transition = {{ type : "spring" , stiffness : 320 , damping : 28 }}
className = "overflow-hidden"
>
< div className = "bg-gradient-to-b from-amber-500/10 to-amber-500/5 border-b border-amber-500/20 px-3 py-2.5" >
< div className = "flex items-center gap-1.5 mb-2" >
< span className = "w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
< span className = "text-[9px] font-bold text-amber-400/80 uppercase tracking-widest" > Données publiées récemment </ span >
< button onClick = {() => setShowRecentNews ( false )} className = "ml-auto text-amber-500/40 hover:text-amber-400 transition-colors text-[11px] leading-none" > ✕ </ button >
</ div >
< div className = "space-y-1" >
{ 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 (
< div key = { e . id } className = "flex items-center gap-2" >
< span className = { `w-1 h-1 rounded-full shrink-0 ${ e . impact === "high" ? "bg-red-500" : "bg-amber-500" } ` } />
< span className = "text-[10px] text-slate-300 flex-1 truncate" >{ e . title }</ span >
< span className = "flex items-center gap-1 shrink-0" >
{ e . actual && < span className = { `text-[10px] font-bold tabular-nums ${ isBeat ? "text-emerald-400" : isMiss ? "text-red-400" : "text-slate-300" } ` }> A : { e . actual }</ span >}
{ e . forecast && < span className = "text-[9px] text-slate-600" > F : { e . forecast }</ span >}
{ isBeat && < span className = "text-[8px] font-black text-emerald-400 bg-emerald-500/15 px-1 rounded" > ↑ </ span >}
{ isMiss && < span className = "text-[8px] font-black text-red-400 bg-red-500/15 px-1 rounded" > ↓ </ span >}
</ span >
</ div >
);
})}
</ div >
</ div >
</ motion.div >
)}
</ AnimatePresence >
{ /* ── Upcoming events panel ───────────────────────────────────────────── */ }
< AnimatePresence >
{ showUpcoming && upcomingEvents . length > 0 && (
< motion.div
initial = {{ height : 0 , opacity : 0 }}
animate = {{ height : "auto" , opacity : 1 }}
exit = {{ height : 0 , opacity : 0 }}
transition = {{ type : "spring" , stiffness : 320 , damping : 28 }}
className = "overflow-hidden"
>
< div className = "bg-gradient-to-b from-sky-500/10 to-sky-500/5 border-b border-sky-500/20 px-3 py-2.5" >
< div className = "flex items-center gap-1.5 mb-2" >
< span className = "w-1.5 h-1.5 rounded-full bg-sky-400 shrink-0" />
< span className = "text-[9px] font-bold text-sky-400/80 uppercase tracking-widest" > À venir — 3 prochains jours </ span >
< button onClick = {() => setShowUpcoming ( false )} className = "ml-auto text-sky-500/40 hover:text-sky-400 transition-colors text-[11px] leading-none" > ✕ </ button >
</ div >
< div className = "space-y-1.5" >
{ 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 (
< div key = { e . id } className = "flex items-start gap-2" >
< span className = { `w-1 h-1 rounded-full shrink-0 mt-[5px] ${ e . impact === "high" ? "bg-red-500" : "bg-amber-500" } ` } />
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-1 flex-wrap" >
< span className = "text-[10px] text-slate-300 truncate" >{ e . title }</ span >
</ div >
< div className = "flex items-center gap-2 mt-0.5 flex-wrap" >
< span className = "text-[9px] text-slate-500" >{ dayLabel } { timeLabel }</ span >
{ e . forecast && (
< span className = "text-[9px] text-sky-400 font-semibold" >
F : { e . forecast }
</ span >
)}
{ e . previous && (
< span className = "text-[9px] text-slate-500" >
Préc : { e . previous }
</ span >
)}
{ diff !== null && (
< span className = { `text-[8px] font-black px-1 rounded ${
isUp ? "text-emerald-400 bg-emerald-500/15"
: isDown ? "text-red-400 bg-red-500/15"
: "text-slate-400 bg-slate-700"
} ` }>
{ isUp ? "↑" : isDown ? "↓" : "=" }{ " " }
{ diff > 0 ? "+" : "" }{ diff . toFixed ( 2 ). replace ( /\.?0+$/ , "" )}
</ span >
)}
</ div >
</ div >
</ div >
);
})}
</ div >
</ div >
</ motion.div >
)}
</ AnimatePresence >
2026-06-02 20:51:20 +02:00
{ /* ── Tab bar ─────────────────────────────────────────────────────────── */ }
< div className = "flex border-b border-slate-800 px-1 pt-1" >
{ TABS . map (( t ) => (
< button
key = { t . id }
2026-06-27 16:40:35 +02:00
onClick = {() => { setActiveTab ( t . id ); onCardTabChange ? .( t . id ); }}
2026-06-02 20:51:20 +02:00
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"
2026-06-27 18:55:16 +02:00
: "text-slate-400 hover:text-white"
2026-06-02 20:51:20 +02:00
} ` }
>
{ t . icon }
{ t . label }
{ t . id === "mispricing" && mispricingSignals . length > 0 && (
< span className = { `text-[9px] font-bold px-1 rounded ${
mispricDir === "bullish" ? "bg-emerald-500/20 text-emerald-400"
: mispricDir === "bearish" ? "bg-red-500/20 text-red-400"
: "bg-slate-700 text-slate-400"
} ` }>
{ bullCount > bearCount ? ` ${ bullCount } ↑` : bearCount > bullCount ? ` ${ bearCount } ↓` : "—" }
2026-06-01 23:24:32 +02:00
</ span >
)}
2026-06-02 20:51:20 +02:00
</ button >
))}
< div className = "flex-1" />
< NarrativeButton currency = { currency } phase = { phase } macroScore = { macroScore } />
</ div >
2026-06-01 23:24:32 +02:00
2026-06-02 20:51:20 +02:00
{ /* ── Tab content ─────────────────────────────────────────────────────── */ }
2026-06-27 14:12:05 +02:00
< div className = "flex-1 overflow-y-auto overflow-x-hidden p-3" >
2026-06-02 20:51:20 +02:00
< 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" && (
<>
2026-06-08 20:12:18 +02:00
2026-06-27 19:09:50 +02:00
{ /* ── Convergence / Divergence macro ↔ marché ─────────────── */ }
2026-06-27 19:27:19 +02:00
{(() => {
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-800/60 border-slate-600/40"
: "bg-slate-800/50 border-slate-600/30" ;
const verdictCls = bothOpposed ? "text-amber-400"
: isNeutral ? "text-slate-400"
: sigColor ( divergenceSignal );
return (
< div className = { `rounded-lg border px-2.5 py-1.5 flex items-center gap-1.5 text-[10px] ${ containerCls } ` }>
< span className = "text-slate-400 shrink-0 text-[9px] uppercase tracking-wider font-semibold" > Fond .</ span >
< span className = { `font-black text-[13px] leading-none ${ dir === "neutral" ? "text-slate-500" : sigColor ( dir ) } ` }>
{ dir === "bullish" ? "↑" : dir === "bearish" ? "↓" : "—" }
</ span >
< span className = { `font-bold flex-1 text-center ${ verdictCls } ` }>{ verdictText }</ span >
< span className = { `font-black text-[13px] leading-none ${ mispricDir === "neutral" ? "text-slate-500" : sigColor ( mispricDir ) } ` }>
{ mispricDir === "bullish" ? "↑" : mispricDir === "bearish" ? "↓" : "—" }
</ span >
< span className = "text-slate-400 shrink-0 text-[9px] uppercase tracking-wider font-semibold" > Marché </ span >
</ div >
);
})()}
2026-06-01 23:24:32 +02:00
2026-06-27 14:12:05 +02:00
{ /* ── 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 (
< div className = "space-y-1.5" >
{ /* Micro-buttons */ }
< div className = "flex gap-1" >
{ macroTabs . map ( t => (
< button
key = { t . id }
onClick = {() => goToMacroSlide ( t . id )}
className = { `text-[9px] font-semibold px-2.5 py-0.5 rounded-full border transition-all ${
macroSlide === t . id
? "bg-slate-700 border-slate-500 text-white"
2026-06-27 18:55:16 +02:00
: "bg-transparent border-slate-700/60 text-slate-400 hover:text-white hover:border-slate-500"
2026-06-27 14:12:05 +02:00
} ` }
>
{ t . label }
</ button >
))}
2026-06-02 23:47:03 +02:00
</ div >
2026-06-27 14:12:05 +02:00
{ /* Fixed-height sliding container */ }
< div className = "relative h-[260px] overflow-hidden rounded-xl" >
< AnimatePresence mode = "wait" custom = { macroSlideDir }>
< motion.div
key = { macroSlide }
custom = { macroSlideDir }
variants = {{
enter : ( d : number ) => ({ 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"
>
{ /* MON. — Politique Monétaire */ }
{ macroSlide === "mon" && (
< MacroBlock title = "Politique Monétaire" >
< IRow label = "Taux directeur" ind = { inds ? . policyRate ?? null } unit = "%" consensus = { rateConsensus } isNew = { indIsNew ( "policyRate" )} />
{(() => {
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 (
< 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" > Taux réel ( CT − CPI )</ span ></ div >
< span className = { `font-semibold tabular-nums ${ sigColor ( rDir ) } ` } title = { ` ${ policyRateValue . toFixed ( 2 ) } % − CPI YoY ${ cpiH . toFixed ( 2 ) } %` }>{ rr > 0 ? "+" : "" }{ rr } % </ span >
2026-06-02 23:47:03 +02:00
</ div >
2026-06-27 14:12:05 +02:00
);
})()}
{ curveSpread !== 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" > Courbe ( 10 Y − CT )</ span ></ div >
< span className = { `font-semibold tabular-nums ${ sigColor ( curveSig ) } ` }>{ curveSpread > 0 ? "+" : "" }{ curveSpread } bps { curveInverted ? " ⚠" : "" }</ span >
2026-06-02 23:47:03 +02:00
</ div >
2026-06-27 14:12:05 +02:00
)}
{ 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" > 10 Y Yield </ span ></ div >
2026-06-27 19:09:50 +02:00
< span className = "font-semibold text-slate-200 tabular-nums" >{ yield10Y . toFixed ( 2 )} % </ span >
2026-06-27 14:12:05 +02:00
</ div >
)}
</ MacroBlock >
2026-06-02 23:47:03 +02:00
)}
2026-06-02 22:11:39 +02:00
2026-06-27 14:12:05 +02:00
{ /* INFL. — Inflation */ }
{ macroSlide === "infl" && (
< MacroBlock title = "Inflation" >
< IRow label = "CPI MoM" ind = { inds ? . cpiMoM ?? null } unit = "%" consensus = { fc ? . cpiMoM ?? null } isNew = { indIsNew ( "cpiMoM" )} />
< IRow label = "PPI MoM" ind = { inds ? . ppiMoM ?? null } unit = "%" consensus = { fc ? . ppiMoM ?? null } isNew = { indIsNew ( "ppiMoM" )} />
< IRow label = "Core CPI YoY" ind = { inds ? . cpiCore ?? null } unit = "%" consensus = { fc ? . cpiCore ?? fc ? . cpi ?? null } surpriseVsCons = { fc ? . cpiSurprise ?? null } isNew = { indIsNew ( "cpiCore" )} />
< IRow label = "Inflation Rate YoY" ind = { inds ? . cpiYoY ?? null } unit = "%" consensus = { fc ? . cpi ?? null } isNew = { indIsNew ( "cpiYoY" )} />
</ MacroBlock >
2026-06-03 01:04:07 +02:00
)}
2026-06-02 20:51:20 +02:00
2026-06-27 14:12:05 +02:00
{ /* CRO. — Croissance */ }
{ macroSlide === "cro" && (
< MacroBlock title = "Croissance" >
< IRow label = "PIB (QoQ%)" ind = { inds ? . gdp ?? null } unit = "%" consensus = { fc ? . gdp ?? null } surpriseVsCons = { fc ? . gdpSurprise ?? null } isNew = { indIsNew ( "gdp" )} />
< IRow label = "PMI Composite" ind = { inds ? . pmiComposite ?? null } consensus = { fc ? . pmiComposite ?? null } surpriseVsCons = { fc ? . pmiCompositeSurprise ?? null } isNew = { indIsNew ( "pmiComposite" )} />
< IRow label = "Retail Sales MoM" ind = { inds ? . retailSales ?? null } unit = "%" consensus = { fc ? . retailSales ?? null } surpriseVsCons = { fc ? . retailSalesSurprise ?? null } isNew = { indIsNew ( "retailSales" )} />
< IRow label = "PMI Manufacturing" ind = { inds ? . pmiMfg ?? null } consensus = { fc ? . pmiMfg ?? null } surpriseVsCons = { fc ? . pmiMfgSurprise ?? null } isNew = { indIsNew ( "pmiMfg" )} />
< IRow label = "PMI Services" ind = { inds ? . pmiServices ?? null } consensus = { fc ? . pmiSvc ?? null } surpriseVsCons = { fc ? . pmiSvcSurprise ?? null } isNew = { indIsNew ( "pmiServices" )} />
</ MacroBlock >
)}
2026-06-02 20:51:20 +02:00
2026-06-27 14:12:05 +02:00
{ /* EMPL. — Emploi */ }
{ macroSlide === "empl" && (
< MacroBlock title = "Emploi" >
< IRow label = "Variation emploi" ind = { inds ? . employment ?? null } unit = "k" consensus = { fc ? . employment ?? null } surpriseVsCons = { fc ? . employmentSurprise ?? null } isNew = { indIsNew ( "employment" )} />
< IRow label = "Taux de chômage" ind = { inds ? . unemployment ?? null } unit = "%" invertSurprise consensus = { fc ? . unemployment ?? null } surpriseVsCons = { fc ? . unemploymentSurprise ?? null } isNew = { indIsNew ( "unemployment" )} />
</ MacroBlock >
)}
2026-06-02 20:51:20 +02:00
2026-06-27 14:12:05 +02:00
</ motion.div >
</ AnimatePresence >
</ div >
</ div >
);
})()}
2026-06-02 20:51:20 +02:00
</>
2026-06-01 23:24:32 +02:00
)}
2026-06-02 20:51:20 +02:00
{ /* ════ SIGNAUX / MISPRICING ════════════════════════════════════ */ }
{ activeTab === "mispricing" && (
<>
2026-06-27 14:12:05 +02:00
{ /* ── Slider : OIS / COT / Sentiment ──────────────────────── */ }
{(() => {
const sigTabs : { id : SignauxSlide ; label : string }[] = [];
if ( ratePath && ratePath . meetings . length > 0 ) sigTabs . push ({ id : "ois" , label : "OIS" });
if ( cot ) sigTabs . push ({ id : "cot" , label : "COT" });
if ( sentiment ) sigTabs . push ({ id : "sent" , label : "Sentiment" });
if ( ! sigTabs . length ) return < div className = "text-center text-slate-600 text-[11px] py-4" > Données insuffisantes </ div >;
return (
< div className = "space-y-1.5" >
{ sigTabs . length > 1 && (
< div className = "flex gap-1" >
{ sigTabs . map ( t => (
2026-06-27 18:55:16 +02:00
< button key = { t . id } onClick = {() => goToSignauxSlide ( t . id )} className = { `text-[9px] font-semibold px-2.5 py-0.5 rounded-full border transition-all ${ signauxSlide === t . id ? "bg-slate-700 border-slate-500 text-white" : "bg-transparent border-slate-700/60 text-slate-400 hover:text-white hover:border-slate-500" } ` }>{ t . label }</ button >
2026-06-27 14:12:05 +02:00
))}
2026-06-02 20:51:20 +02:00
</ div >
2026-06-27 14:12:05 +02:00
)}
< div className = { `relative ${ signauxSlide === "ois" && ratePath && ratePath . meetings . length > 0 ? "h-[460px]" : "h-[310px]" } overflow-hidden rounded-xl transition-all duration-300` }>
< AnimatePresence mode = "wait" custom = { signauxSlideDir }>
2026-06-02 20:51:20 +02:00
< motion.div
2026-06-27 14:12:05 +02:00
key = { signauxSlide }
custom = { signauxSlideDir }
variants = {{
enter : ( d : number ) => ({ 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 && (
< OISEnhancedBlock ratePath = { ratePath } />
)}
{ /* COT */ }
{ signauxSlide === "cot" && 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 ;
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 = "exposition baissière stable" ;
} 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 = "exposition haussière stable" ;
}
// Poids relatif AM vs HF (barre de dominance)
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 dFmt = ( v : number ) => ` ${ v > 0 ? "+" : "" }${ ( v / 1000 ). toFixed ( 1 ) } k` ;
return (
< div className = "rounded-xl border border-slate-700/40 bg-slate-800/20 p-3 space-y-2" >
{ /* ① Header */ }
< div className = "flex items-center justify-between" >
2026-06-27 18:55:16 +02:00
< div className = "flex items-center gap-1.5 text-[9px] text-slate-300 uppercase tracking-widest" >
2026-06-27 14:12:05 +02:00
< BarChart2 size = { 9 } />
COT CFTC · { cot . weekDate }
< button
onClick = {() => setShowCotInfo ( v => ! v )}
className = "ml-0.5 text-slate-500 hover:text-slate-300 transition-colors"
title = "Comprendre ces données"
2026-06-02 20:51:20 +02:00
>
2026-06-27 14:12:05 +02:00
< Info size = { 10 } />
</ button >
</ div >
2026-06-27 18:55:16 +02:00
< span className = "text-[9px] text-slate-400" >{ cot . prevWeekDate ? `vs ${ cot . prevWeekDate } ` : "" }</ span >
2026-06-27 14:12:05 +02:00
</ div >
{ /* ⓘ Tooltip explicatif */ }
{ showCotInfo && (
< div className = "bg-slate-900 border border-slate-700/60 rounded-lg px-2.5 py-2 text-[9px] text-slate-400 space-y-1 leading-relaxed" >
< p >< span className = "text-indigo-400 font-bold" > AM ( Asset Managers )</ span > — fonds pension , souverains , assurances . Prennent des positions pour < em > couvrir </ em > des expositions réelles ( hedging ). Leur flux pilote la devise à moyen terme .</ p >
< p >< span className = "text-amber-400 font-bold" > HF ( Hedge Funds / CTAs )</ span > — spéculation directionnelle à court terme . Réactifs aux catalyseurs macro . Retournements rapides possibles .</ p >
< p className = "pt-0.5 border-t border-slate-700/40" >< span className = "text-slate-300 font-semibold" > Lire : </ span > Net = longs − shorts · < span className = "text-emerald-400" > Δ sem </ span > = variation vs semaine précédente · < span className = "text-slate-300" > L % </ span > = part de longs dans le total . Un HF net short avec des longs qui augmentent ( Δ L ↑ ) signale un potentiel retournement haussier .</ p >
</ div >
)}
{ /* ② Signal de synthèse — lecture immédiate en premier */ }
{(() => {
const hfSignalColor =
( hfLongsGrowing && hfIsShort ) || ( hfShortsReducing && hfIsShort )
? "text-emerald-400 border-emerald-500/20 bg-emerald-500/5"
: ( hfShortsGrowing && ! hfIsShort ) || ( hfLongsReducing && ! hfIsShort )
? "text-red-400 border-red-500/20 bg-red-500/5"
: "text-slate-300 border-slate-600/30 bg-slate-800/30" ;
return (
< div className = { `rounded-lg border px-3 py-2 ${ hfSignalColor } ` }>
< div className = "text-[11px] font-bold leading-snug" >
→ HF { hfIsShort ? "net short" : "net long" } — { hfTrend }
</ div >
< div className = "text-[9px] mt-0.5 opacity-70" >
{ amDominates
? `AM domine le flux · ${ cot . amNet > 0 ? "hedge haussier" : "hedge baissier" } ( ${ dFmt ( cot . amNet ) } )`
: `HF domine le flux · net ${ dFmt ( cot . net ) } `
}
</ div >
</ div >
);
})()}
{ /* ③ Flux hebdomadaire HF — ΔL/ΔS en priorité */ }
< div className = "bg-slate-900/40 rounded-lg px-3 py-2 space-y-1.5" >
2026-06-27 18:55:16 +02:00
< div className = "text-[8px] text-slate-300 uppercase tracking-wider text-center" > Mouvement HF · semaine </ div >
2026-06-27 14:12:05 +02:00
< div className = "flex justify-center items-stretch gap-8" >
< div className = "space-y-0.5 text-center" >
2026-06-27 18:55:16 +02:00
< div className = "text-[8px] text-slate-400" > Δ Longs </ div >
2026-06-27 14:12:05 +02:00
< div className = { `text-[13px] font-black tabular-nums leading-none ${
cot . longsDelta === null ? "text-slate-600"
: cot . longsDelta > 0 ? "text-emerald-400" : "text-red-400"
} ` }>
{ cot . longsDelta !== null
? ` ${ cot . longsDelta > 0 ? "+" : "" }${ ( cot . longsDelta / 1000 ). toFixed ( 1 ) } k ${ cot . longsDelta > 0 ? "↑" : "↓" } `
: "—"
}
</ div >
2026-06-27 18:55:16 +02:00
< div className = "text-[9px] text-slate-400" >{( cot . hfLongs / 1000 ). toFixed ( 1 )} k acc .</ div >
2026-06-27 14:12:05 +02:00
</ div >
< div className = "w-px bg-slate-700/50" />
< div className = "space-y-0.5 text-center" >
2026-06-27 18:55:16 +02:00
< div className = "text-[8px] text-slate-400" > Δ Shorts </ div >
2026-06-27 14:12:05 +02:00
< div className = { `text-[13px] font-black tabular-nums leading-none ${
cot . shortsDelta === null ? "text-slate-600"
: cot . shortsDelta > 0 ? "text-red-400" : "text-emerald-400"
} ` }>
{ cot . shortsDelta !== null
? ` ${ cot . shortsDelta > 0 ? "+" : "" }${ ( cot . shortsDelta / 1000 ). toFixed ( 1 ) } k ${ cot . shortsDelta > 0 ? "↑" : "↓" } `
: "—"
}
2026-06-02 20:51:20 +02:00
</ div >
2026-06-27 18:55:16 +02:00
< div className = "text-[9px] text-slate-400" >{( cot . hfShorts / 1000 ). toFixed ( 1 )} k acc .</ div >
2026-06-27 14:12:05 +02:00
</ div >
</ div >
</ div >
{ /* ④ Dominance AM vs HF — poids + direction explicite */ }
< div className = "space-y-1.5" >
{ /* Barre de poids */ }
< div className = "flex rounded-full overflow-hidden h-1.5" >
< div className = "bg-indigo-500/70 transition-all" style = {{ width : ` ${ amPct } %` }} />
< div className = "bg-amber-500/60 transition-all" style = {{ width : ` ${ hfPct } %` }} />
</ div >
{ /* AM */ }
< div className = "flex items-center justify-between text-[9px]" >
< span className = "text-indigo-400 font-semibold" > AM · { amPct } % du flux </ span >
< span className = "flex items-center gap-1.5" >
< span className = { `font-bold px-1 rounded text-[8px] ${ cot . amNet > 0 ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400" } ` }>
{ cot . amNet > 0 ? "LONG" : "SHORT" }
</ span >
< span className = { `tabular-nums ${ cot . amNet > 0 ? "text-emerald-400" : "text-red-400" } ` }>
{ dFmt ( cot . amNet )}
</ span >
{ cot . amNetDelta !== null && (
2026-06-27 18:55:16 +02:00
< span className = "text-slate-400" > Δ { dFmt ( cot . amNetDelta )}{ cot . amNetDelta > 0 ? "↑" : "↓" }</ span >
2026-06-27 14:12:05 +02:00
)}
</ span >
</ div >
{ /* HF */ }
< div className = "flex items-center justify-between text-[9px]" >
< span className = "text-amber-400 font-semibold" > HF · { hfPct } % du flux </ span >
< span className = "flex items-center gap-1.5" >
< span className = { `font-bold px-1 rounded text-[8px] ${ cot . net > 0 ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400" } ` }>
{ cot . net > 0 ? "LONG" : "SHORT" }
</ span >
< span className = { `tabular-nums ${ cot . net > 0 ? "text-emerald-400" : "text-red-400" } ` }>
{ dFmt ( cot . net )}
</ span >
{ cot . netDelta !== null && (
2026-06-27 18:55:16 +02:00
< span className = "text-slate-400" > Δ { dFmt ( cot . netDelta )}{ cot . netDelta > 0 ? "↑" : "↓" }</ span >
2026-06-27 14:12:05 +02:00
)}
</ span >
</ div >
</ div >
</ div >
);
})()}
{ /* 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-red-400" : "text-slate-400" ;
const sentBg = sentDir === "bullish" ? "border-emerald-500/20 bg-emerald-500/5" : sentDir === "bearish" ? "border-red-500/20 bg-red-500/5" : "border-slate-700/40 bg-slate-800/20" ;
return (
< div className = "rounded-xl border border-slate-700/40 bg-slate-800/20 p-3 h-full space-y-3" >
2026-06-27 18:55:16 +02:00
< div className = "flex items-center gap-1.5 text-[9px] text-slate-300 uppercase tracking-widest" >
2026-06-27 14:12:05 +02:00
< Activity size = { 9 } />
2026-06-27 18:55:16 +02:00
Sentiment Retail ( DXM )
2026-06-27 14:12:05 +02:00
</ div >
< div className = "flex items-center gap-4" >
< div className = "text-center shrink-0" >
2026-06-27 18:55:16 +02:00
< div className = "text-[9px] text-slate-400 mb-0.5" > Long </ div >
2026-06-27 14:12:05 +02:00
< div className = "text-[26px] font-black tabular-nums text-emerald-400 leading-none" >{ sentiment . longPct . toFixed ( 0 )} % </ div >
</ div >
< div className = "flex-1 space-y-1" >
< div className = "flex rounded-full overflow-hidden h-2.5" >
< div className = "bg-emerald-500/70 transition-all" style = {{ width : ` ${ sentiment . longPct } %` }} />
< div className = "bg-red-500/60 transition-all" style = {{ width : ` ${ sentiment . shortPct } %` }} />
</ div >
2026-06-27 18:55:16 +02:00
< div className = "flex justify-between text-[8px] text-slate-400" >< span > Long </ span >< span > Short </ span ></ div >
2026-06-27 14:12:05 +02:00
</ div >
< div className = "text-center shrink-0" >
2026-06-27 18:55:16 +02:00
< div className = "text-[9px] text-slate-400 mb-0.5" > Short </ div >
2026-06-27 14:12:05 +02:00
< div className = "text-[26px] font-black tabular-nums text-red-400 leading-none" >{ sentiment . shortPct . toFixed ( 0 )} % </ div >
</ div >
</ div >
< div className = { `rounded-lg border px-3 py-2 ${ sentBg } ` }>
< div className = { `text-[11px] font-bold ${ sentCls } ` }>
{ sentDir === "bullish"
? `Signal contrarian HAUSSIER — majorité short ( ${ sentiment . shortPct . toFixed ( 0 ) } %)`
: sentDir === "bearish"
? `Signal contrarian BAISSIER — majorité long ( ${ sentiment . longPct . toFixed ( 0 ) } %)`
: "Sentiment équilibré — pas de signal contrarian" }
</ div >
< div className = "text-[9px] text-slate-500 mt-0.5" >
{ sentDir === "bullish" ? "Retail survendu → opportunité d'achat"
: sentDir === "bearish" ? "Retail suracheté → opportunité de vente"
: "Attendre un déséquilibre >70/30" }
</ div >
</ div >
</ div >
);
})()}
2026-06-02 20:51:20 +02:00
</ motion.div >
2026-06-27 14:12:05 +02:00
</ AnimatePresence >
</ div >
2026-06-02 20:51:20 +02:00
</ div >
2026-06-27 14:12:05 +02:00
);
})()}
2026-06-02 20:51:20 +02:00
</>
)}
{ /* ════ FOCUS DONNÉES ══════════════════════════════════════════════ */ }
{ activeTab === "focus" && (
<>
2026-06-08 19:41:50 +02:00
{ /* Context phase — source : OIS / probabilités de taux (fallback : trend FRED) */ }
2026-06-02 20:51:20 +02:00
< div className = { `rounded-xl border p-3 text-[11px] ${ sigBg ( trendDir ( phase === "tightening" ? "up" : phase === "easing" ? "down" : null )) } ` }>
2026-06-08 19:41:50 +02:00
< div className = "flex items-center justify-between mb-1" >
< span className = { `font-semibold ${ sigColor ( trendDir ( phase === "tightening" ? "up" : phase === "easing" ? "down" : null )) } ` }>
Phase { phaseLabel ( phase )}
</ span >
< span className = "text-[9px] text-slate-600 italic" >
{ ratePath ? "Source : OIS / probabilités" : "Source : trend taux FRED" }
</ span >
2026-06-02 20:51:20 +02:00
</ div >
< p className = "text-slate-400 leading-relaxed" >
2026-06-08 19:41:50 +02:00
{ phaseDescription ( phase , ratePath )}
2026-06-01 23:24:32 +02:00
</ p >
2026-06-02 20:51:20 +02:00
</ div >
{ /* Données clés selon phase */ }
< MacroBlock title = { phase === "easing" || phase === "dovish_pause" ? "Données Assouplissement" : "Données Resserrement" }>
{( phase === "easing" || phase === "dovish_pause" )
? <>
2026-06-02 22:11:39 +02:00
< FocusRow importance = "critical" >< IRow label = "Taux de chômage" ind = { inds ? . unemployment ?? null } unit = "%" invertSurprise consensus = { fc ? . unemployment ?? null } /></ FocusRow >
< FocusRow importance = "critical" >< IRow label = "Variation emploi" ind = { inds ? . employment ?? null } unit = "k" consensus = { fc ? . employment ?? null } /></ FocusRow >
< FocusRow importance = "high" >< IRow label = "PIB (QoQ%)" ind = { inds ? . gdp ?? null } unit = "%" consensus = { fc ? . gdp ?? null } /></ FocusRow >
< FocusRow importance = "high" >< IRow label = "PMI Composite" ind = { inds ? . pmiComposite ?? null } consensus = { fc ? . pmiComposite ?? null } /></ FocusRow >
2026-06-08 19:41:50 +02:00
< FocusRow importance = "medium" >< IRow label = "Retail Sales MoM" ind = { inds ? . retailSales ?? null } unit = "%" consensus = { fc ? . retailSales ?? null } /></ FocusRow >
2026-06-02 20:51:20 +02:00
</>
: <>
2026-06-02 22:11:39 +02:00
< FocusRow importance = "critical" >< IRow label = "CPI MoM" ind = { inds ? . cpiMoM ?? null } unit = "%" consensus = { fc ? . cpiMoM ?? null } /></ FocusRow >
< FocusRow importance = "critical" >< IRow label = "Core CPI YoY" ind = { inds ? . cpiCore ?? null } unit = "%" consensus = { fc ? . cpiCore ?? null } /></ FocusRow >
< FocusRow importance = "critical" >< IRow label = "Inflation Rate YoY" ind = { inds ? . cpiYoY ?? null } unit = "%" consensus = { fc ? . cpi ?? null } /></ FocusRow >
< FocusRow importance = "high" >< IRow label = "PMI Services" ind = { inds ? . pmiServices ?? null } consensus = { fc ? . pmiSvc ?? null } /></ FocusRow >
< FocusRow importance = "high" >< IRow label = "Variation emploi" ind = { inds ? . employment ?? null } unit = "k" consensus = { fc ? . employment ?? null } /></ FocusRow >
2026-06-02 20:51:20 +02:00
</>
}
</ MacroBlock >
2026-06-27 14:12:05 +02:00
{ /* Taux directeur */ }
2026-06-02 20:51:20 +02:00
< 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" > 10 Y Yield </ span >
</ div >
< span className = "font-semibold text-slate-200 tabular-nums" >{ yield10Y . toFixed ( 2 )} % </ span >
</ div >
)}
</ MacroBlock >
</>
2026-06-01 23:24:32 +02:00
)}
2026-05-27 13:18:45 +02:00
2026-06-02 20:51:20 +02:00
</ motion.div >
</ AnimatePresence >
2026-05-27 13:18:45 +02:00
</ div >
</ div >
);
}