auto: sync 2026-06-01 23:24

This commit is contained in:
Capucine Gest
2026-06-01 23:24:32 +02:00
parent eb416be780
commit c1b3e54c28
13 changed files with 1537 additions and 307 deletions
+401
View File
@@ -0,0 +1,401 @@
"use client";
import React, { useState, useMemo } from "react";
import { ChevronDown, ChevronRight, Loader2, Calendar } from "lucide-react";
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
import type { Currency } from "@/lib/types";
import type { CalendarEvent } from "@/app/api/calendar/route";
interface Props {
events: CalendarEvent[];
loading: boolean;
nextWeekAvail: boolean; // nextweek.json disponible sur le CDN FF
}
const CATEGORY_LABELS: Record<string, string> = {
employment: "Emploi",
pmi: "PMI",
policy_rate: "Taux directeur",
cb_speech: "Discours BC",
inflation: "Inflation",
gdp: "PIB",
retail_sales: "Ventes détail",
trade_balance: "Balance comm.",
};
const IMPACT_COLOR: Record<string, string> = {
high: "bg-red-500",
medium: "bg-amber-400",
low: "bg-gray-300",
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function isoToLocalDate(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
}
function fmtDate(iso: string): { day: string; time: string } {
const d = new Date(iso);
const day = d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "short" });
const time = d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
return { day, time };
}
function fmtDayLabel(dateStr: string): string {
return new Date(dateStr + "T12:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function nextMonday(): Date {
const now = new Date();
const day = now.getDay();
const d = new Date(now);
d.setDate(now.getDate() + (day === 0 ? 1 : 8 - day));
d.setHours(0, 0, 0, 0);
return d;
}
// ── Week bounds ───────────────────────────────────────────────────────────────
function getWeekBounds() {
const nm = nextMonday();
const currentStart = new Date(nm);
currentStart.setDate(nm.getDate() - 7);
const currentEnd = new Date(nm);
currentEnd.setDate(nm.getDate() - 1);
const nextEnd = new Date(nm);
nextEnd.setDate(nm.getDate() + 6);
const next2Start = new Date(nm);
next2Start.setDate(nm.getDate() + 7);
const next2End = new Date(nm);
next2End.setDate(nm.getDate() + 13);
const fmt = (d: Date) => d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
return {
currentWeekLabel: `${fmt(currentStart)} ${fmt(currentEnd)}`,
nextWeekLabel: `${fmt(nm)} ${fmt(nextEnd)}`,
next2WeekLabel: `${fmt(next2Start)} ${fmt(next2End)}`,
next2StartLabel: fmt(next2Start),
nextMondayIso: nm.toISOString().slice(0, 10),
};
}
// ── Sub-components ────────────────────────────────────────────────────────────
function ImpactDot({ impact }: { impact: string }) {
return <span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${IMPACT_COLOR[impact] ?? "bg-gray-300"}`} />;
}
function EventRow({ ev, isChild, expanded, onToggle }: {
ev: CalendarEvent; isChild: boolean; expanded: boolean; onToggle: () => void;
}) {
const { day, time } = fmtDate(ev.date);
const meta = CURRENCY_META[ev.currency];
const rowCls = [
"border-b border-gray-100 hover:bg-gray-50 transition-colors",
isChild ? "bg-gray-50/70" : "",
!ev.isPublished && ev.impact === "high" ? "border-l-2 border-l-red-400" : "",
!ev.isPublished && ev.impact === "medium" ? "border-l-2 border-l-amber-400" : "",
ev.isPublished ? "opacity-70" : "",
].join(" ");
return (
<tr className={rowCls}>
<td className="py-2 px-3 whitespace-nowrap">
<div className="text-xs font-medium text-gray-700">{day}</div>
<div className="text-[10px] text-gray-400">{time}</div>
</td>
<td className="py-2 px-2 whitespace-nowrap">
<span className="text-sm">{meta?.flag}</span>
<span className="ml-1 text-xs font-semibold text-gray-700">{ev.currency}</span>
</td>
<td className="py-2 px-3">
<button
onClick={ev.isGroupParent ? onToggle : undefined}
className={`flex items-center gap-1 text-left text-sm ${ev.isGroupParent ? "cursor-pointer font-medium text-gray-800 hover:text-blue-600" : "text-gray-600"} ${isChild ? "pl-4 text-[11px]" : ""}`}
>
{ev.isGroupParent && (expanded ? <ChevronDown size={12} className="text-gray-400 flex-shrink-0" /> : <ChevronRight size={12} className="text-gray-400 flex-shrink-0" />)}
{isChild && <span className="text-gray-300 mr-1"></span>}
{ev.title}
</button>
<div className="text-[9px] text-gray-400 mt-0.5 pl-4">{CATEGORY_LABELS[ev.category]}</div>
</td>
<td className="py-2 px-3 text-right">
<span className="text-xs text-gray-500 tabular-nums">{ev.previous ?? "—"}</span>
</td>
<td className="py-2 px-3 text-right">
{ev.forecast
? <span className="text-xs font-medium text-blue-600 tabular-nums">{ev.forecast}</span>
: <span className="text-xs text-gray-300"></span>}
</td>
<td className="py-2 px-3 text-right">
{ev.actual
? <span className={`text-xs font-semibold tabular-nums ${ev.isPublished ? "text-gray-800" : "text-gray-400"}`}>{ev.actual}</span>
: <span className="text-xs text-gray-200"></span>}
</td>
<td className="py-2 px-3 text-center">
<ImpactDot impact={ev.impact} />
</td>
</tr>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type WeekTab = "current" | "next" | "next2" | "all";
export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showLow, setShowLow] = useState(false);
const [weekTab, setWeekTab] = useState<WeekTab>("all");
const [fromDate, setFromDate] = useState<string>(todayIso());
const { currentWeekLabel, nextWeekLabel, next2WeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
const toggle = (groupKey: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(groupKey)) next.delete(groupKey); else next.add(groupKey);
return next;
});
// Filtrage
const filtered = useMemo(() => {
return events.filter((ev) => {
if (filterCcy !== "ALL" && ev.currency !== filterCcy) return false;
if (!showLow && ev.impact === "low") return false;
if (ev.isGroupChild && ev.groupKey && !expanded.has(ev.groupKey)) return false;
// Filtre semaine
if (weekTab === "current" && ev.week !== "current") return false;
if (weekTab === "next" && ev.week !== "next") return false;
if (weekTab === "next2" && ev.week !== "next2") return false;
// Filtre date depuis
const evDate = isoToLocalDate(ev.date);
if (evDate < fromDate) return false;
return true;
});
}, [events, filterCcy, showLow, expanded, weekTab, fromDate]);
// Grouper par jour
const days: string[] = [];
const dayMap: Record<string, CalendarEvent[]> = {};
for (const ev of filtered) {
const d = isoToLocalDate(ev.date);
if (!dayMap[d]) { dayMap[d] = []; days.push(d); }
dayMap[d].push(ev);
}
days.sort();
// Compteurs par semaine pour les onglets
const countCurrent = events.filter(e => e.week === "current").length;
const countNext = events.filter(e => e.week === "next").length;
const countNext2 = events.filter(e => e.week === "next2").length;
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-gray-800">Calendrier économique</h2>
<p className="text-[10px] text-gray-400 mt-0.5">Sources : ForexFactory · FRED · Banques centrales</p>
</div>
<label className="flex items-center gap-1.5 text-[10px] text-gray-500 cursor-pointer">
<input type="checkbox" checked={showLow} onChange={(e) => setShowLow(e.target.checked)} className="w-3 h-3" />
Impact faible
</label>
</div>
</div>
{/* ── Onglets semaine ──────────────────────────────────────────────────── */}
<div className="flex gap-0 border-b border-gray-200 bg-gray-50/50">
{([
["all", "Tout", null, null],
["current","Sem. en cours", currentWeekLabel, countCurrent],
["next", "Sem. prochaine", nextWeekLabel, countNext],
["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2],
] as [WeekTab, string, string | null, number | null][]).map(([tab, label, sub, count]) => {
const isActive = weekTab === tab;
const disabled = tab === "next" && !nextWeekAvail && countNext === 0;
const noData = typeof count === "number" && count === 0 && tab !== "all";
return (
<button
key={tab}
onClick={() => !disabled && setWeekTab(tab)}
disabled={disabled}
className={`px-3 py-2.5 text-xs font-medium border-b-2 transition-colors text-left ${
isActive ? "border-blue-500 text-blue-600 bg-white" :
disabled ? "border-transparent text-gray-300 cursor-not-allowed" :
"border-transparent text-gray-500 hover:text-gray-700 hover:bg-white"
}`}
>
<div className="flex items-center gap-1">
{label}
{tab === "next2" && countNext2 > 0 && (
<span className="text-[8px] bg-amber-100 text-amber-700 px-1 rounded">FRED</span>
)}
</div>
{sub && (
<div className={`text-[9px] mt-0.5 ${isActive ? "text-blue-400" : disabled ? "text-gray-300" : "text-gray-400"}`}>
{disabled ? "Dispo lundi (retry auto)" : sub}
</div>
)}
{tab !== "all" && typeof count === "number" && (
<div className={`text-[9px] ${noData ? "text-gray-300" : "text-gray-400"}`}>
{count} événement{count !== 1 ? "s" : ""}
</div>
)}
</button>
);
})}
</div>
{/* ── Filtre devise + date ──────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-gray-100">
{/* Date depuis */}
<div className="flex items-center gap-1.5 shrink-0">
<Calendar size={11} className="text-gray-400" />
<span className="text-[10px] text-gray-500">Depuis</span>
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="text-[10px] border border-gray-200 rounded px-1.5 py-0.5 text-gray-700 focus:outline-none focus:border-blue-400"
/>
<button
onClick={() => setFromDate(todayIso())}
className="text-[9px] text-blue-500 hover:text-blue-700 underline"
>
Aujourd&apos;hui
</button>
</div>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Filtre devise */}
<div className="flex flex-wrap gap-1">
<button
onClick={() => setFilterCcy("ALL")}
className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${filterCcy === "ALL" ? "bg-gray-800 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
>
Tout
</button>
{CURRENCIES.map((ccy) => (
<button
key={ccy}
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
className={`flex items-center gap-0.5 px-2 py-0.5 rounded-full text-[10px] font-medium ${filterCcy === ccy ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
>
{CURRENCY_META[ccy].flag} {ccy}
</button>
))}
</div>
</div>
{/* ── Table ────────────────────────────────────────────────────────────── */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={20} className="animate-spin text-gray-300" />
</div>
) : filtered.length === 0 ? (
<div className="py-10 text-center">
<p className="text-sm text-gray-400">Aucun événement pour cette sélection</p>
{weekTab === "next" && !nextWeekAvail && (
<p className="text-[10px] text-gray-400 mt-1">
ForexFactory ne publie la semaine prochaine que du lundi au vendredi.<br />
Données disponibles dans quelques heures.
</p>
)}
{fromDate > todayIso() && (
<button onClick={() => setFromDate(todayIso())} className="mt-2 text-[10px] text-blue-500 underline">
Revenir à aujourd&apos;hui
</button>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[700px]">
<thead>
<tr className="bg-gray-50 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
<th className="py-2 px-3 text-left">Date / Heure</th>
<th className="py-2 px-2 text-left">Devise</th>
<th className="py-2 px-3 text-left">Événement</th>
<th className="py-2 px-3 text-right">Précédent</th>
<th className="py-2 px-3 text-right">Prévision</th>
<th className="py-2 px-3 text-right">Actuel</th>
<th className="py-2 px-3 text-center">Impact</th>
</tr>
</thead>
<tbody>
{(() => {
const rows: React.ReactNode[] = [];
let lastWeek: "current" | "next" | "next2" | null = null;
for (const day of days) {
const dayEvents = dayMap[day];
if (!dayEvents?.length) continue;
const w = dayEvents[0].week;
// Séparateur de semaine
if (weekTab === "all" && w !== lastWeek) {
lastWeek = w;
const weekBanners: Record<string, string> = {
current: `📅 Semaine en cours — ${currentWeekLabel}`,
next: `📅 Semaine prochaine — ${nextWeekLabel}`,
next2: `📅 À partir du ${next2StartLabel} — réunions CB + données économiques`,
};
rows.push(
<tr key={`wsep_${w}`} className={w === "next2" ? "bg-amber-600" : "bg-indigo-600"}>
<td colSpan={7} className="px-4 py-1.5 text-[10px] font-bold text-white uppercase tracking-widest">
{weekBanners[w] ?? w}
</td>
</tr>
);
}
// Séparateur de jour
rows.push(
<tr key={`dsep_${day}`} className="bg-blue-50">
<td colSpan={7} className="px-3 py-1.5 text-[10px] font-semibold text-blue-700 capitalize">
{fmtDayLabel(day)}
</td>
</tr>
);
for (const ev of dayEvents) {
rows.push(
<EventRow
key={ev.id}
ev={ev}
isChild={ev.isGroupChild}
expanded={ev.groupKey ? expanded.has(ev.groupKey) : false}
onToggle={() => ev.groupKey && toggle(ev.groupKey)}
/>
);
}
}
return rows;
})()}
</tbody>
</table>
</div>
)}
{/* Legend */}
<div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500 inline-block" /> Impact élevé</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400 inline-block" /> Impact moyen</span>
<span>· Cliquer sur une ligne groupée pour voir les sous-indicateurs</span>
<span>· Prévision = consensus marché avant publication</span>
</div>
</div>
);
}
+268 -45
View File
@@ -2,19 +2,34 @@
import { useEffect, useState, useCallback } from "react";
import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp, Loader2, Database } from "lucide-react";
import { CURRENCY_META } from "@/lib/constants";
import { CURRENCY_META, COUNTRY_PROFILES } from "@/lib/constants";
import { biasLabel, biasColor, calcMacroScore } from "@/lib/scoring";
import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
import type { Currency, BiasPhase, RateExpectation } from "@/lib/types";
import type { CBRatePath } from "@/lib/rateprobability";
import type { SentimentEntry, CotEntry } from "@/lib/types";
import NarrativeButton from "./NarrativeButton";
interface Ind { value: number | null; prev: number | null; surprise: number | null; trend: "up"|"down"|"flat"|null; lastUpdated: string | null }
interface MacroData { currency: string; indicators: Record<string, Ind | null>; fetchedAt: string }
interface MacroForecasts {
cpi: number | null; cpiSurprise: number | null;
unemployment: number | null; unemploymentSurprise: number | null;
pmiMfg: number | null; pmiMfgSurprise: number | null;
pmiSvc: number | null; pmiSvcSurprise: number | null;
pmiComposite: number | null; pmiCompositeSurprise: number | null;
retailSales: number | null; retailSalesSurprise: number | null;
gdp: number | null; gdpSurprise: number | null;
employment: number | null; employmentSurprise: number | null;
}
interface MacroData { currency: string; indicators: Record<string, Ind | null>; forecasts?: MacroForecasts | null; fetchedAt: string }
interface Props {
currency: Currency;
expectations: Record<string, unknown> | null;
yields: { yields: Record<string, number | null>; spreads: Record<string, number | null> } | null;
sentiment: SentimentEntry | null;
cot: CotEntry | null;
ratePath: CBRatePath | null;
onDivergenceUpdate: (currency: Currency, score: number) => void;
}
@@ -26,19 +41,30 @@ const PHASES: Record<BiasPhase, { label: string; color: string }> = {
transition: { label: "🟠 Transition", color: "text-orange-500" },
};
function SectionHeader({ label }: { label: string }) {
return (
<div className="flex items-center gap-1.5 pt-1.5 pb-0.5">
<span className="text-[8px] font-bold text-gray-300 uppercase tracking-widest whitespace-nowrap">{label}</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
);
}
function TrendIcon({ trend }: { trend: "up"|"down"|"flat"|null }) {
if (trend === "up") return <TrendingUp size={11} className="text-green-500 flex-shrink-0" />;
if (trend === "down") return <TrendingDown size={11} className="text-red-500 flex-shrink-0" />;
return <Minus size={11} className="text-gray-300 flex-shrink-0" />;
}
function Row({ label, ind, unit = "", invertSurprise = false, warn = false, consensus = null }: {
label: string; ind: Ind | null; unit?: string; invertSurprise?: boolean; warn?: boolean; consensus?: number | null;
function Row({ label, ind, unit = "", invertSurprise = false, warn = false, consensus = null, surpriseVsCons = null }: {
label: string; ind: Ind | null; unit?: string; invertSurprise?: boolean; warn?: boolean;
consensus?: number | null; // consensus à venir (upcoming)
surpriseVsCons?: number | null; // actual consensus si ≤5j post-release
}) {
const value = ind?.value ?? null;
const prev = ind?.prev ?? null;
// Colorer la valeur actuelle selon la direction du mouvement
// Colorer la valeur actuelle selon la direction du mouvement vs période précédente
const s = ind?.surprise ?? null;
const effectiveS = invertSurprise && s !== null ? -s : s;
const valCls =
@@ -50,6 +76,14 @@ function Row({ label, ind, unit = "", invertSurprise = false, warn = false, cons
const fmt = (v: number | null) =>
v !== null ? `${v.toFixed(2)}${unit}` : "—";
// Coloration de la surprise vs consensus (inversion pour chômage/unemployment)
const effSurprise = invertSurprise && surpriseVsCons !== null ? -surpriseVsCons : surpriseVsCons;
const surpriseCls = effSurprise === null ? ""
: effSurprise > 0 ? "text-green-600"
: effSurprise < 0 ? "text-red-600"
: "text-gray-500";
const surpriseArrow = effSurprise === null ? "" : effSurprise > 0 ? "▲" : effSurprise < 0 ? "▼" : "▬";
return (
<div className="py-1.5 border-b border-gray-50 last:border-0">
{/* Ligne 1 : label + valeur actuelle publiée */}
@@ -64,23 +98,34 @@ function Row({ label, ind, unit = "", invertSurprise = false, warn = false, cons
{fmt(value)}
</span>
</div>
{/* Ligne 2 : précédent + consensus marché */}
{/* Ligne 2 : précédent + consensus à venir OU surprise post-publication */}
<div className="flex items-center justify-between pl-5 mt-0.5">
<span className="text-[10px] text-gray-400 tabular-nums">
Préc.&nbsp;<span className="text-gray-500 font-medium">{fmt(prev)}</span>
</span>
<span className="text-[10px] text-gray-400 tabular-nums">
Cons.&nbsp;
{consensus !== null
? <span className="text-blue-500 font-medium">{fmt(consensus)}</span>
: <span className="text-gray-300"></span>}
</span>
{surpriseVsCons !== null ? (
// Surprise vs consensus (≤5 jours post-release)
<span className="text-[10px] tabular-nums">
<span className="text-gray-400">Surpr.&nbsp;</span>
<span className={`font-medium ${surpriseCls}`}>
{surpriseArrow}{surpriseVsCons > 0 ? "+" : ""}{surpriseVsCons.toFixed(2)}{unit}
</span>
</span>
) : (
// Consensus à venir (upcoming)
<span className="text-[10px] text-gray-400 tabular-nums">
Cons.&nbsp;
{consensus !== null
? <span className="text-blue-500 font-medium">{fmt(consensus)}</span>
: <span className="text-gray-300"></span>}
</span>
)}
</div>
</div>
);
}
export default function CurrencyCard({ currency, expectations, yields, onDivergenceUpdate }: Props) {
export default function CurrencyCard({ currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate }: Props) {
const meta = CURRENCY_META[currency];
const [data, setData] = useState<MacroData | null>(null);
const [phase, setPhase] = useState<BiasPhase>("hawkish_pause");
@@ -151,14 +196,15 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge
setRateExp(all.find((e) => e.cb.toLowerCase().includes(cbShort) || e.cb.toLowerCase().includes(currency.toLowerCase())) ?? null);
}, [expectations, currency, meta.cbShort]);
const inds = data?.indicators;
const inds = data?.indicators;
const fc = data?.forecasts ?? null; // ForexFactory forecasts
// Build a minimal indicator object for scoring
const forScoring = {
policyRate: { value: inds?.policyRate?.value ?? null, prev: inds?.policyRate?.prev ?? null, consensus: null, surprise: inds?.policyRate?.surprise ?? null, trend: inds?.policyRate?.trend ?? null, lastUpdated: "" },
cpiCore: { value: inds?.cpiCore?.value ?? null, prev: inds?.cpiCore?.prev ?? null, consensus: null, surprise: inds?.cpiCore?.surprise ?? null, trend: inds?.cpiCore?.trend ?? null, lastUpdated: "" },
pmiMfg: { value: null, prev: null, consensus: null, surprise: null, trend: null, lastUpdated: "" },
pmiServices: { value: null, prev: null, consensus: null, surprise: null, trend: null, lastUpdated: "" },
pmiMfg: { value: inds?.pmiMfg?.value ?? null, prev: inds?.pmiMfg?.prev ?? null, consensus: null, surprise: inds?.pmiMfg?.surprise ?? null, trend: inds?.pmiMfg?.trend ?? null, lastUpdated: "" },
pmiServices: { value: inds?.pmiServices?.value ?? null, prev: inds?.pmiServices?.prev ?? null, consensus: null, surprise: inds?.pmiServices?.surprise ?? null, trend: inds?.pmiServices?.trend ?? null, lastUpdated: "" },
gdp: { value: inds?.gdp?.value ?? null, prev: inds?.gdp?.prev ?? null, consensus: null, surprise: inds?.gdp?.surprise ?? null, trend: inds?.gdp?.trend ?? null, lastUpdated: "" },
retailSales: { value: inds?.retailSales?.value ?? null, prev: inds?.retailSales?.prev ?? null, consensus: null, surprise: inds?.retailSales?.surprise ?? null, trend: inds?.retailSales?.trend ?? null, lastUpdated: "" },
unemployment: { value: inds?.unemployment?.value ?? null, prev: inds?.unemployment?.prev ?? null, consensus: null, surprise: inds?.unemployment?.surprise ?? null, trend: inds?.unemployment?.trend ?? null, lastUpdated: "" },
@@ -173,6 +219,30 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge
const spread10Y = yields?.spreads[currency] ?? null;
const borderCls = macroScore >= 4 ? "border-green-200" : macroScore <= -4 ? "border-red-200" : "border-gray-200";
// Consensus = taux attendu à la prochaine réunion CB
// Priorité : ratePath (OIS temps réel) > rateExp (snapshot statique)
const rateConsensus = (() => {
const rate = inds?.policyRate?.value ?? null;
if (rate === null) return null;
// OIS live
if (ratePath && ratePath.meetings.length > 0) {
const next = ratePath.meetings[0];
if (next.probMovePct > 50) {
return next.probIsCut
? parseFloat((rate - 0.25).toFixed(2))
: parseFloat((rate + 0.25).toFixed(2));
}
return parseFloat(rate.toFixed(2));
}
// Fallback snapshot statique (CHF / si rateprobability indispo)
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));
})();
return (
<div className={`bg-white border rounded-xl overflow-hidden ${borderCls}`}>
{/* Header */}
@@ -206,49 +276,202 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge
</div>
</div>
{/* Rate expectation pill */}
{rateExp && (
{/* ── Probabilités OIS (rateprobability.com) ─────────────────────────── */}
{ratePath && ratePath.meetings.length > 0 ? (
<div className="mx-4 mb-2 px-3 py-2 bg-gray-50 rounded-lg">
{/* Header */}
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-semibold text-gray-400 uppercase tracking-wider">OIS · marchés</span>
<span className="text-[9px] text-gray-400">au {ratePath.asOf}</span>
</div>
{/* Pic + taux fin d'année */}
{ratePath.peakMeeting && (
<div className="flex items-center justify-between text-[10px] mb-1.5">
<span>
<span className="text-gray-500">Pic </span>
<span className="font-semibold text-gray-800">{ratePath.peakMeeting.label}</span>
<span className={`ml-1 font-bold ${ratePath.peakMeeting.probIsCut ? "text-green-600" : "text-red-500"}`}>
{ratePath.peakMeeting.probMovePct.toFixed(0)}%
{ratePath.peakMeeting.probIsCut ? " ▼" : " ▲"}
{ratePath.peakMeeting.changeBps > 0.5 &&
<span className="font-normal text-gray-400"> +{ratePath.peakMeeting.changeBps.toFixed(0)}bps</span>
}
</span>
</span>
{ratePath.yearEndImplied !== null && (
<span className="text-gray-400 text-[9px]">
fin d&apos;an {ratePath.yearEndImplied.toFixed(2)}%
</span>
)}
</div>
)}
{/* Timeline réunion par réunion */}
<div className="flex flex-col gap-[3px]">
{ratePath.meetings.slice(0, 6).map(m => (
<div key={m.dateIso} className="flex items-center gap-1.5">
<span className="text-[9px] text-gray-400 w-11 shrink-0 tabular-nums">{m.label}</span>
<div className="flex-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${m.probIsCut ? "bg-green-400" : "bg-red-400"}`}
style={{ width: `${m.probMovePct}%` }}
/>
</div>
<span className={`text-[9px] w-7 text-right tabular-nums shrink-0 ${m.probMovePct >= 50 ? "font-bold text-gray-800" : "text-gray-400"}`}>
{m.probMovePct.toFixed(0)}%
</span>
</div>
))}
</div>
</div>
) : rateExp ? (
/* Fallback snapshot statique (ex: CHF sans données OIS) */
<div className="mx-4 mb-2 px-3 py-1.5 bg-gray-50 rounded-lg text-xs leading-snug">
<span className="font-semibold">{rateExp.direction === "hike" ? "▲" : "▼"} {rateExp.bps} bps</span>
<span className="relative group inline-flex items-center ml-0.5 cursor-help align-middle">
<span className="text-[9px] text-gray-400 border border-gray-300 rounded-full w-3 h-3 flex items-center justify-center leading-none select-none">i</span>
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 hidden group-hover:block bg-gray-800 text-white text-[10px] rounded px-2 py-1.5 w-56 z-50 leading-snug shadow-lg whitespace-normal">
1 bp (basis point) = 0,01% de taux. Variation cumulée attendue d&apos;ici fin d&apos;année selon les marchés (OIS / futures de taux). Distinct de la probabilité à la prochaine réunion ci-dessous.
</span>
</span>
<span className="text-gray-500"> · {rateExp.prob_pct}% prob. </span>
<span className={`font-medium ${rateExp.prob_desc.includes("no change") || rateExp.prob_desc.includes("sans") ? "text-gray-600" : rateExp.direction === "hike" ? "text-red-600" : "text-green-600"}`}>
<span className={`font-medium ${rateExp.prob_desc.includes("no change") ? "text-gray-600" : rateExp.direction === "hike" ? "text-red-600" : "text-green-600"}`}>
{rateExp.prob_desc}
</span>
</div>
)}
) : null}
{/* Core indicators (always visible) */}
{/* ── Indicateurs macro — organisation "prisme" ─────────────────────── */}
<div className="px-4 pb-2">
<Row label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" />
<Row label="CPI (MoM%)" ind={inds?.cpiCore ?? null} unit="%" />
<Row label="PIB (QoQ %)" ind={inds?.gdp ?? null} unit="%" />
<Row label="Chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise />
{/* Expanded indicators */}
{/* ── POLITIQUE MONÉTAIRE ─────────────────────────────────────────── */}
<SectionHeader label="Politique monétaire" />
<Row label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" consensus={rateConsensus} />
<div className="flex items-center justify-between py-1.5 text-xs border-b border-gray-50">
<span className="text-gray-500 text-xs">10Y Yield</span>
<span className="font-semibold text-gray-800 tabular-nums text-xs">
{yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"}
{spread10Y !== null && (
<span className={`ml-1 text-[10px] ${spread10Y > 0 ? "text-green-600" : "text-red-600"}`}>
({spread10Y > 0 ? "+" : ""}{spread10Y}bps vs US)
</span>
)}
</span>
</div>
{/* ── INFLATION ───────────────────────────────────────────────────── */}
<SectionHeader label="Inflation" />
<Row label="CPI Core YoY" ind={inds?.cpiCore ?? null} unit="%" consensus={fc?.cpi ?? null} surpriseVsCons={fc?.cpiSurprise ?? null} />
<Row label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" />
{/* ── CROISSANCE ──────────────────────────────────────────────────── */}
<SectionHeader label="Croissance" />
<Row label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} surpriseVsCons={fc?.gdpSurprise ?? null} />
<Row label="PMI Composite" ind={inds?.pmiComposite ?? null} warn={!inds?.pmiComposite} consensus={fc?.pmiComposite ?? null} surpriseVsCons={fc?.pmiCompositeSurprise ?? null} />
{/* ── EMPLOI ──────────────────────────────────────────────────────── */}
<SectionHeader label="Emploi" />
{/* Variation emploi = NFP/Employment Change en milliers — ex: +115k = 115 000 emplois créés */}
<Row label="Variation emploi" ind={inds?.employment ?? null} unit="k" warn={!inds?.employment} consensus={fc?.employment ?? null} surpriseVsCons={fc?.employmentSurprise ?? null} />
<Row label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise consensus={fc?.unemployment ?? null} surpriseVsCons={fc?.unemploymentSurprise ?? null} />
{/* ── Données supplémentaires (expanded) ──────────────────────────── */}
{expanded && (
<>
<Row label="PMI Mfg" ind={null} warn />
<Row label="PMI Services" ind={null} warn />
<Row label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" warn={!inds?.retailSales} />
<Row label="Emploi (MoM%)" ind={inds?.employment ?? null} unit="%" warn={!inds?.employment} />
{/* 10Y yield */}
<div className="flex items-center justify-between py-1.5 text-xs">
<span className="text-gray-500">10Y Yield</span>
<span className="font-semibold text-gray-800 tabular-nums">
{yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"}
{spread10Y !== null && (
<span className={`ml-1 text-[10px] ${spread10Y > 0 ? "text-green-600" : "text-red-600"}`}>
({spread10Y > 0 ? "+" : ""}{spread10Y}bps)
{/* PMI détail */}
<SectionHeader label="PMI détail" />
<Row label="PMI Mfg" ind={inds?.pmiMfg ?? null} warn={!inds?.pmiMfg} consensus={fc?.pmiMfg ?? null} surpriseVsCons={fc?.pmiMfgSurprise ?? null} />
<Row label="PMI Services" ind={inds?.pmiServices ?? null} warn={!inds?.pmiServices} consensus={fc?.pmiSvc ?? null} surpriseVsCons={fc?.pmiSvcSurprise ?? null} />
<Row label="Ventes détail" ind={inds?.retailSales ?? null} unit="%" warn={!inds?.retailSales} consensus={fc?.retailSales ?? null} surpriseVsCons={fc?.retailSalesSurprise ?? null} />
{/* Géopolitique */}
<SectionHeader label="Géopolitique" />
{inds?.tradeBalance ? (
<div className="flex items-center justify-between py-1.5 border-b border-gray-50">
<span className="text-gray-500 text-xs">Balance comm.</span>
<span className={`text-xs font-semibold tabular-nums ${(inds.tradeBalance.value ?? 0) >= 0 ? "text-green-600" : "text-red-500"}`}>
{(inds.tradeBalance.value ?? 0) >= 0 ? "+" : ""}{inds.tradeBalance.value?.toFixed(1)}B
<span className="text-[9px] text-gray-400 font-normal ml-0.5">
{(inds.tradeBalance.value ?? 0) >= 0 ? " surplus" : " déficit"}
</span>
</span>
</div>
) : null}
{/* Profil énergie + matières premières */}
{(() => {
const profile = COUNTRY_PROFILES[currency];
if (!profile) return null;
const energyColor = profile.energy === "exporter" ? "text-green-700 bg-green-50" : profile.energy === "importer" ? "text-red-700 bg-red-50" : "text-gray-600 bg-gray-100";
const energyLabel = profile.energy === "exporter" ? "🛢 Export. énergie" : profile.energy === "importer" ? "⚡ Import. énergie" : "⚖ Énergie ~neutre";
return (
<div className="py-1.5 border-b border-gray-50 space-y-1">
<div className="flex items-start justify-between gap-2">
<span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${energyColor}`}>{energyLabel}</span>
<span className="text-[9px] text-gray-400 text-right leading-tight max-w-[55%]">{profile.energyNote}</span>
</div>
{profile.commodities.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{profile.commodities.map(c => (
<span key={c} className="text-[8px] bg-amber-50 text-amber-700 border border-amber-200 px-1.5 py-0.5 rounded-full">{c}</span>
))}
</div>
)}
</div>
);
})()}
{/* ── Sentiment & Positionnement ──────────────────────────────── */}
<SectionHeader label="Sentiment & Positionnement" />
{sentiment ? (
<div className="py-1.5 border-b border-gray-50">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500 text-[10px]">{sentiment.pair} · Myfxbook</span>
<span className="text-[10px] tabular-nums">
<span className="text-green-600 font-semibold">{sentiment.longPct}% L</span>
<span className="text-gray-300 mx-0.5">/</span>
<span className="text-red-500 font-semibold">{sentiment.shortPct}% S</span>
</span>
</div>
{/* Barre visuelle long/short */}
<div className="flex h-1.5 rounded-full overflow-hidden">
<div className="bg-green-400 transition-all" style={{ width: `${sentiment.longPct}%` }} />
<div className="bg-red-400 flex-1" />
</div>
{/* Signal contrarien */}
{(sentiment.longPct >= 70 || sentiment.shortPct >= 70) && (
<p className={`text-[9px] mt-0.5 font-medium ${sentiment.longPct >= 70 ? "text-red-500" : "text-green-600"}`}>
{sentiment.longPct >= 70 ? "⚠ Retail très long — signal contrarien baissier" : "⚠ Retail très short — signal contrarien haussier"}
</p>
)}
</span>
</div>
) : (
<div className="py-1 text-[10px] text-gray-300 border-b border-gray-50"> (Myfxbook indisponible)</div>
)}
{/* ── COT CFTC ────────────────────────────────────────────────── */}
<div className="pt-1 pb-0.5">
<span className="text-[9px] font-semibold text-gray-400 uppercase tracking-wider">COT Hedge Funds</span>
</div>
{cot ? (
<div className="py-1.5">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500 text-[10px]">Lev. Money · {cot.weekDate}</span>
<span className="text-[10px] tabular-nums">
<span className="text-green-600 font-semibold">{cot.longPct}% L</span>
<span className="text-gray-300 mx-0.5">/</span>
<span className="text-red-500 font-semibold">{cot.shortPct}% S</span>
<span className="text-gray-400 ml-1">({cot.net > 0 ? "+" : ""}{cot.net.toLocaleString("fr-FR")})</span>
</span>
</div>
<div className="flex h-1.5 rounded-full overflow-hidden">
<div className="bg-green-400 transition-all" style={{ width: `${cot.longPct}%` }} />
<div className="bg-red-400 flex-1" />
</div>
{/* Divergence COT vs Sentiment */}
{sentiment && Math.abs(cot.longPct - sentiment.longPct) >= 20 && (
<p className="text-[9px] mt-0.5 text-amber-600 font-medium">
Divergence COT/Retail : {Math.abs(cot.longPct - sentiment.longPct)}pts
</p>
)}
</div>
) : (
<div className="py-1 text-[10px] text-gray-300"> (CFTC indisponible)</div>
)}
</>
)}
</div>
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { CURRENCY_META } from "@/lib/constants";
import type { Currency } from "@/lib/types";
interface MyfxSymbol {
name: string;
longPercentage: number;
shortPercentage: number;
totalPositions: number;
}
interface Props {
symbols: MyfxSymbol[] | null;
}
// Toutes les 28 combinaisons (C(8,2)) des 8 devises — base/quote dans l'ordre standard Forex
const PAIRS: { base: Currency; quote: Currency; std: string }[] = [
// Majeures USD
{ base: "EUR", quote: "USD", std: "EURUSD" },
{ base: "GBP", quote: "USD", std: "GBPUSD" },
{ base: "USD", quote: "JPY", std: "USDJPY" },
{ base: "USD", quote: "CHF", std: "USDCHF" },
{ base: "USD", quote: "CAD", std: "USDCAD" },
{ base: "AUD", quote: "USD", std: "AUDUSD" },
{ base: "NZD", quote: "USD", std: "NZDUSD" },
// Crosses EUR
{ base: "EUR", quote: "GBP", std: "EURGBP" },
{ base: "EUR", quote: "JPY", std: "EURJPY" },
{ base: "EUR", quote: "CHF", std: "EURCHF" },
{ base: "EUR", quote: "CAD", std: "EURCAD" },
{ base: "EUR", quote: "AUD", std: "EURAUD" },
{ base: "EUR", quote: "NZD", std: "EURNZD" },
// Crosses GBP
{ base: "GBP", quote: "JPY", std: "GBPJPY" },
{ base: "GBP", quote: "CHF", std: "GBPCHF" },
{ base: "GBP", quote: "CAD", std: "GBPCAD" },
{ base: "GBP", quote: "AUD", std: "GBPAUD" },
{ base: "GBP", quote: "NZD", std: "GBPNZD" },
// Crosses AUD
{ base: "AUD", quote: "JPY", std: "AUDJPY" },
{ base: "AUD", quote: "CAD", std: "AUDCAD" },
{ base: "AUD", quote: "CHF", std: "AUDCHF" },
{ base: "AUD", quote: "NZD", std: "AUDNZD" },
// Crosses CAD
{ base: "CAD", quote: "JPY", std: "CADJPY" },
// Crosses CHF
{ base: "CHF", quote: "JPY", std: "CHFJPY" },
// Crosses NZD
{ base: "NZD", quote: "JPY", std: "NZDJPY" },
{ base: "NZD", quote: "CAD", std: "NZDCAD" },
{ base: "NZD", quote: "CHF", std: "NZDCHF" },
// Croisée manquante CAD/CHF
{ base: "CAD", quote: "CHF", std: "CADCHF" },
];
// Groupes pour l'affichage
const GROUPS = [
{ label: "Majeures USD", pairs: ["EURUSD","GBPUSD","USDJPY","USDCHF","USDCAD","AUDUSD","NZDUSD"] },
{ label: "Crosses EUR", pairs: ["EURGBP","EURJPY","EURCHF","EURCAD","EURAUD","EURNZD"] },
{ label: "Crosses GBP", pairs: ["GBPJPY","GBPCHF","GBPCAD","GBPAUD","GBPNZD"] },
{ label: "Crosses AUD/NZD",pairs: ["AUDJPY","AUDCAD","AUDCHF","AUDNZD","NZDJPY","NZDCAD","NZDCHF"] },
{ label: "Crosses CAD/CHF",pairs: ["CADJPY","CHFJPY","CADCHF"] },
];
function SentimentBar({ longPct }: { longPct: number }) {
const isContrarian = longPct >= 70 || longPct <= 30;
return (
<div className="flex items-center gap-1.5 min-w-[120px]">
<span className={`text-[10px] tabular-nums font-medium w-8 text-right ${isContrarian ? "text-amber-600 font-bold" : "text-green-600"}`}>
{longPct}%
</span>
<div className="flex h-2 w-20 rounded-full overflow-hidden">
<div className="bg-green-400 transition-all" style={{ width: `${longPct}%` }} />
<div className="bg-red-400 flex-1" />
</div>
<span className={`text-[10px] tabular-nums font-medium w-8 ${isContrarian ? "text-amber-600 font-bold" : "text-red-500"}`}>
{100 - longPct}%
</span>
{isContrarian && (
<span className="text-[9px] text-amber-500 font-semibold"></span>
)}
</div>
);
}
export default function SentimentPairsTab({ symbols }: Props) {
const symMap: Record<string, MyfxSymbol> = {};
for (const s of symbols ?? []) symMap[s.name] = s;
const pairMap: Record<string, { base: Currency; quote: Currency }> = {};
for (const p of PAIRS) pairMap[p.std] = { base: p.base, quote: p.quote };
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-800">Sentiment retail toutes les paires</h2>
<p className="text-[10px] text-gray-400 mt-0.5">
Source : Myfxbook Community Outlook · {symbols ? `${Object.keys(symMap).length} paires disponibles` : "chargement…"}
· Long = retail haussier sur la devise de base · = signal contrarien (&gt;70% ou &lt;30%)
</p>
</div>
<div className="overflow-x-auto">
{GROUPS.map((group) => (
<div key={group.label}>
{/* Group header */}
<div className="px-4 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-[9px] font-semibold text-gray-500 uppercase tracking-wider">{group.label}</span>
</div>
<table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="text-[9px] text-gray-400 uppercase tracking-wider border-b border-gray-100">
<th className="py-1.5 px-4 text-left w-32">Paire</th>
<th className="py-1.5 px-4 text-left">Long L / Short S</th>
<th className="py-1.5 px-4 text-right w-28">Positions totales</th>
</tr>
</thead>
<tbody>
{group.pairs.map((pairName) => {
const def = pairMap[pairName];
const sym = symMap[pairName];
const baseMeta = def ? CURRENCY_META[def.base] : null;
const quoteMeta = def ? CURRENCY_META[def.quote] : null;
return (
<tr key={pairName} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors">
{/* Pair name */}
<td className="py-2 px-4 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<span className="text-sm leading-none">{baseMeta?.flag}</span>
<span className="text-sm leading-none">{quoteMeta?.flag}</span>
<span className="text-xs font-semibold text-gray-800">{pairName}</span>
</div>
</td>
{/* Sentiment bar */}
<td className="py-2 px-4">
{sym ? (
<SentimentBar longPct={sym.longPercentage} />
) : (
<span className="text-[10px] text-gray-300 italic">Non disponible sur Myfxbook</span>
)}
</td>
{/* Total positions */}
<td className="py-2 px-4 text-right">
{sym ? (
<span className="text-[10px] text-gray-500 tabular-nums">
{sym.totalPositions.toLocaleString("fr-FR")}
</span>
) : (
<span className="text-gray-200"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
</div>
<div className="px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400">
Long % = % des positions retail haussières sur la devise de base de la paire · Données Myfxbook Community Outlook
</div>
</div>
);
}