"use client"; import { useEffect, useState, useCallback } from "react"; import { RefreshCw, Database, Maximize2, Minimize2, X, BarChart2 } from "lucide-react"; import { CURRENCIES, CURRENCY_META } from "@/lib/constants"; import type { Currency, DriverData, SentimentEntry, SentimentPair, CotEntry, MacroSection } from "@/lib/types"; import type { RateProbData } from "@/lib/rateprobability"; import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache"; import CurrencyCard from "@/components/CurrencyCard"; import DriversBar from "@/components/DriversBar"; import CalendarTab from "@/components/CalendarTab"; import NewsTab from "@/components/NewsTab"; import CotTab from "@/components/CotTab"; import IdeesTab from "@/components/IdeesTab"; import CentralBankSourcesTab from "@/components/CentralBankSourcesTab"; import ScenarioTab from "@/components/ScenarioTab"; import { TvAdvancedChart } from "@/components/TvChart"; import type { CalendarEvent } from "@/app/api/calendar/route"; import type { NewsItem } from "@/app/api/news/route"; import type { CotHistory } from "@/app/api/cot-history/route"; const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000"); export default function Dashboard() { const [drivers, setDrivers] = useState(null); const [expectations, setExpectations] = useState | null>(null); const [yields, setYields] = useState<{ yields: Record; spreads: Record; dayDeltas?: Record; fxDayPct?: Record } | null>(null); const [sentiment, setSentiment] = useState | null>(null); const [cot, setCot] = useState | null>(null); const [calEvents, setCalEvents] = useState([]); const [nextWeekAvail, setNextWeekAvail] = useState(false); const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "news" | "cot" | "markets" | "idees" | "cbsources" | "scenario">("dashboard"); const [newsItems, setNewsItems] = useState([]); const [newsLoading, setNewsLoading] = useState(false); const [cotHistory, setCotHistory] = useState(null); const [cotLoading, setCotLoading] = useState(false); const [rateProbabilities, setRateProbabilities] = useState(null); const [lastRefresh, setLastRefresh] = useState(new Date()); const [loading, setLoading] = useState(true); const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]); const [driversFromCache, setDriversFromCache] = useState(false); const [driversCacheAge, setDriversCacheAge] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const [macroSection, setMacroSection] = useState("all"); const [comparisonOpen, setComparisonOpen] = useState(false); const [comparisonSection, setComparisonSection] = useState>("inflation"); const [comparisonCurrencies, setComparisonCurrencies] = useState("all"); const [focusCurrency, setFocusCurrency] = useState("all"); const [globalMacroSlide, setGlobalMacroSlide] = useState<"mon"|"infl"|"cro"|"empl">("mon"); const [globalCardTab, setGlobalCardTab] = useState<"overview"|"mispricing"|"focus">("overview"); const [globalSignauxSlide, setGlobalSignauxSlide] = useState<"ois"|"cot"|"sent">("ois"); const [globalOisChartTab, setGlobalOisChartTab] = useState<"curve"|"probas"|"meetings">("curve"); const [macroSyncEnabled, setMacroSyncEnabled] = useState(false); // ── Sentiment multi-paires Myfxbook → {CCY: {longPct, shortPct, pair}} ────── // Pour chaque devise, on calcule le % "long CCY" en moyenne pondérée (par volume) // sur toutes les paires disponibles où cette devise apparaît (base ou cotation). // - Si CCY est la BASE (ex: EUR dans EURUSD) → longPct = sym.longPercentage // - Si CCY est la COTATION (ex: JPY dans USDJPY) → longPct = sym.shortPercentage // (être short la paire = être long la monnaie de cotation) function parseSentimentSymbols(symbols: Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }>): Record { // base → long base currency; quote → long = short base = long quote // Format: { base: "EUR", quote: "USD" } const PAIR_DEF: Record = { // Majeures EURUSD: { base: "EUR", quote: "USD" }, GBPUSD: { base: "GBP", quote: "USD" }, USDJPY: { base: "USD", quote: "JPY" }, USDCHF: { base: "USD", quote: "CHF" }, USDCAD: { base: "USD", quote: "CAD" }, AUDUSD: { base: "AUD", quote: "USD" }, NZDUSD: { base: "NZD", quote: "USD" }, // Crosses EUR EURJPY: { base: "EUR", quote: "JPY" }, EURGBP: { base: "EUR", quote: "GBP" }, EURCHF: { base: "EUR", quote: "CHF" }, EURCAD: { base: "EUR", quote: "CAD" }, EURAUD: { base: "EUR", quote: "AUD" }, EURNZD: { base: "EUR", quote: "NZD" }, // Crosses GBP GBPJPY: { base: "GBP", quote: "JPY" }, GBPCHF: { base: "GBP", quote: "CHF" }, GBPCAD: { base: "GBP", quote: "CAD" }, GBPAUD: { base: "GBP", quote: "AUD" }, GBPNZD: { base: "GBP", quote: "NZD" }, // Crosses AUD AUDJPY: { base: "AUD", quote: "JPY" }, AUDCAD: { base: "AUD", quote: "CAD" }, AUDCHF: { base: "AUD", quote: "CHF" }, AUDNZD: { base: "AUD", quote: "NZD" }, // Crosses CAD CADJPY: { base: "CAD", quote: "JPY" }, // Crosses CHF CHFJPY: { base: "CHF", quote: "JPY" }, // Crosses NZD NZDJPY: { base: "NZD", quote: "JPY" }, NZDCAD: { base: "NZD", quote: "CAD" }, NZDCHF: { base: "NZD", quote: "CHF" }, }; const OUR_CCYS = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]; const longWeighted: Record = {}; const totalPos: Record = {}; // paire par paire pour affichage direct const rawPairs: Record = {}; for (const sym of symbols) { const def = PAIR_DEF[sym.name]; if (!def) continue; const { base, quote } = def; const hasPos = sym.totalPositions > 0 || sym.longPercentage > 0 || sym.shortPercentage > 0; if (!hasPos) continue; const total = sym.totalPositions || 100; // fallback si totalPositions absent // Base currency if (OUR_CCYS.includes(base)) { longWeighted[base] = (longWeighted[base] ?? 0) + sym.longPercentage * total; totalPos[base] = (totalPos[base] ?? 0) + total; if (!rawPairs[base]) rawPairs[base] = []; rawPairs[base].push({ name: sym.name, longPct: sym.longPercentage, shortPct: sym.shortPercentage, longIsBaseLong: true }); } // Quote currency : long paire = short cotation if (OUR_CCYS.includes(quote)) { longWeighted[quote] = (longWeighted[quote] ?? 0) + sym.shortPercentage * total; totalPos[quote] = (totalPos[quote] ?? 0) + total; if (!rawPairs[quote]) rawPairs[quote] = []; rawPairs[quote].push({ name: sym.name, longPct: sym.longPercentage, shortPct: sym.shortPercentage, longIsBaseLong: false }); } } const result: Record = {}; for (const ccy of OUR_CCYS) { const total = totalPos[ccy] ?? 0; const pairs = rawPairs[ccy] ?? []; if (total === 0 && pairs.length === 0) continue; const longPct = total > 0 ? Math.round(longWeighted[ccy] / total) : 50; const n = pairs.length; const label = ccy === "USD" ? `DXY (${n} paires)` : `${ccy} (${n} paire${n > 1 ? "s" : ""})`; result[ccy] = { pair: label, longPct, shortPct: 100 - longPct, pairs }; } return result; } const refresh = useCallback(async () => { setLoading(true); try { const NO_CACHE = { cache: "no-store" } as const; const [driversRes, expectRes, yieldsRes, fxRes, sentimentRes, cotRes, calRes, rateProbRes] = await Promise.allSettled([ fetch("/api/drivers", NO_CACHE).then((r) => r.json()), fetch("/api/expectations", NO_CACHE).then((r) => r.json()), fetch("/api/yields", NO_CACHE).then((r) => r.json()), fetch("/api/fx", NO_CACHE).then((r) => r.json()), fetch("/api/sentiment", NO_CACHE).then((r) => r.json()), fetch("/api/cot", NO_CACHE).then((r) => r.json()), fetch("/api/calendar", NO_CACHE).then((r) => r.json()), fetch("/api/rate-probabilities",NO_CACHE).then((r) => r.json()), ]); // ── Drivers ─────────────────────────────────────────────────────────── if (driversRes.status === "fulfilled" && !driversRes.value?.error) { const driversData = driversRes.value as DriverData; if (fxRes.status === "fulfilled" && fxRes.value?.dxy != null) { driversData.dxy = fxRes.value.dxy; driversData.dxyDelta = fxRes.value.dxyDelta ?? null; } setDrivers(driversData); setDriversFromCache(false); setDriversCacheAge(null); saveCache("drivers", driversData); } else { const cached = loadCache("drivers"); if (cached) { setDrivers(cached.data); setDriversFromCache(true); setDriversCacheAge(formatCacheDate(cached.savedAt)); } } // ── Attentes de taux ────────────────────────────────────────────────── if (expectRes.status === "fulfilled" && !expectRes.value?.error) { setExpectations(expectRes.value); saveCache("expectations", expectRes.value); } else { const cached = loadCache>("expectations"); if (cached) setExpectations(cached.data); } // ── Rendements obligataires ─────────────────────────────────────────── if (yieldsRes.status === "fulfilled" && !yieldsRes.value?.error) { setYields(yieldsRes.value); saveCache("yields", yieldsRes.value); } else { const cached = loadCache("yields"); if (cached && cached.data) setYields(cached.data); } // ── Sentiment Myfxbook ──────────────────────────────────────────────── if (sentimentRes.status === "fulfilled" && !sentimentRes.value?.error && sentimentRes.value?.symbols) { const syms = sentimentRes.value.symbols as Array<{ name: string; longPercentage: number; shortPercentage: number; longVolume: number; shortVolume: number; longPositions: number; shortPositions: number; totalPositions: number; avgLongPrice?: number; avgShortPrice?: number }>; const mapped = parseSentimentSymbols(syms); setSentiment(mapped); saveCache("sentiment", mapped); } else { const cached = loadCache>("sentiment"); if (cached) setSentiment(cached.data); } // ── COT CFTC ───────────────────────────────────────────────────────── if (cotRes.status === "fulfilled" && !cotRes.value?.error && Object.keys(cotRes.value ?? {}).length > 0) { setCot(cotRes.value as Record); saveCache("cot", cotRes.value); } else { const cached = loadCache>("cot"); if (cached) setCot(cached.data); } // ── Calendrier économique ───────────────────────────────────────────── if (calRes.status === "fulfilled" && Array.isArray(calRes.value?.events)) { setCalEvents(calRes.value.events as CalendarEvent[]); setNextWeekAvail(calRes.value.nextWeekAvail === true); } // ── Probabilités de taux (rateprobability.com OIS) ─────────────────── if (rateProbRes.status === "fulfilled" && rateProbRes.value?.data && Object.keys(rateProbRes.value.data).length > 0) { setRateProbabilities(rateProbRes.value.data as RateProbData); saveCache("rateProbabilities", rateProbRes.value.data); } else { const cached = loadCache("rateProbabilities"); if (cached) setRateProbabilities(cached.data); } setLastRefresh(new Date()); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); const id = setInterval(refresh, REFRESH_MS); return () => clearInterval(id); }, [refresh]); const refreshNews = useCallback(async () => { setNewsLoading(true); try { const res = await fetch("/api/news"); if (res.ok) { const json = await res.json(); setNewsItems(json.items ?? []); } } finally { setNewsLoading(false); } }, []); useEffect(() => { if (activeTab === "news" && newsItems.length === 0) refreshNews(); }, [activeTab, newsItems.length, refreshNews]); const refreshCotHistory = useCallback(async () => { setCotLoading(true); try { const res = await fetch("/api/cot-history"); if (res.ok) { const json = await res.json(); if (!json.error) setCotHistory(json as CotHistory); } } finally { setCotLoading(false); } }, []); useEffect(() => { if (activeTab === "cot" && !cotHistory) refreshCotHistory(); }, [activeTab, cotHistory, refreshCotHistory]); const toggleFullscreen = useCallback(() => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {}); } else { document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {}); } }, []); useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", handler); return () => document.removeEventListener("fullscreenchange", handler); }, []); const handleDivergenceUpdate = useCallback((currency: Currency, score: number) => { setActiveDivergences((prev) => { const filtered = prev.filter((d) => d.currency !== currency); if (Math.abs(score) >= 2) return [...filtered, { currency, score }]; return filtered; }); }, []); const divergenceCount = activeDivergences.filter((d) => Math.abs(d.score) >= 2).length; return (
{/* Header */}
{new Date().getHours() < 18 ? "Bonjour" : "Bonsoir"} 👋
{driversFromCache && driversCacheAge && ( cache {driversCacheAge} )} {lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
{/* Tab navigation */}
{(["dashboard", "markets", "idees", "calendar", "cbsources", "scenario", "news", "cot"] as const).map((tab) => ( ))}
{/* Global drivers bar — uniquement sur le dashboard */} {activeTab === "dashboard" && drivers && } {activeTab === "dashboard" && ( <> {/* ── Barre de contrôle : Sync + Comparer uniquement ────────────── */}
{/* Badges actifs (section filtre + focus devise) */} {macroSection !== "all" && (
{{ inflation:"📊 Inflation", pmi:"🏭 PMI", employment:"👷 Emploi", gdp:"📈 PIB", policy:"🏦 Politique" }[macroSection]}
)} {focusCurrency !== "all" && (
{CURRENCY_META[focusCurrency].flag} {focusCurrency}
)}
{/* ── Comparison panel ──────────────────────────────────────────── */} {comparisonOpen && (() => { type IndSnap = { value: number | null; trend: string | null; surprise: number | null } | null; type CacheData = { indicators: Record }; const COMP_SECTIONS: { id: Exclude; label: string }[] = [ { id: "inflation", label: "📊 Inflation" }, { id: "pmi", label: "🏭 PMI" }, { id: "employment", label: "👷 Emploi" }, { id: "gdp", label: "📈 PIB" }, { id: "policy", label: "🏦 Politique" }, ]; const SECTION_FIELDS: Record, { key: string; label: string; unit?: string; inv?: boolean }[]> = { inflation: [{ key:"cpiYoY", label:"CPI YoY", unit:"%" }, { key:"cpiCore", label:"Core CPI", unit:"%" }, { key:"cpiMoM", label:"CPI MoM", unit:"%" }], pmi: [{ key:"pmiComposite", label:"PMI Comp." }, { key:"pmiMfg", label:"PMI Mfg" }, { key:"pmiServices", label:"PMI Svc" }], employment: [{ key:"unemployment", label:"Chômage", unit:"%", inv:true }, { key:"employment", label:"Emploi", unit:"k" }], gdp: [{ key:"gdp", label:"PIB QoQ", unit:"%" }, { key:"retailSales", label:"Retail Sales", unit:"%" }], policy: [{ key:"policyRate", label:"Taux dir.", unit:"%" }], }; const fields = SECTION_FIELDS[comparisonSection]; const activeCurrencies = comparisonCurrencies === "all" ? CURRENCIES : comparisonCurrencies; const rows = activeCurrencies.map(c => { const cached = loadCache(`macro_${c}`); const inds = cached?.data?.indicators ?? {}; return { currency: c, inds }; }); const trendColor = (t: string | null, inv = false) => { if (!t) return "text-slate-500"; return (t === "up") !== inv ? "text-emerald-400" : "text-red-400"; }; const surpriseColor = (s: number | null, inv = false) => { if (s === null) return "text-slate-500"; return (s > 0) !== inv ? "text-emerald-400" : "text-red-400"; }; const toggleCurrency = (c: Currency) => { setComparisonCurrencies(prev => { const current = prev === "all" ? [...CURRENCIES] : [...prev]; const next = current.includes(c) ? current.filter(x => x !== c) : [...current, c]; const result = next.length === CURRENCIES.length ? "all" : next.length === 0 ? "all" : next; // 1 devise sélectionnée → zoom sur cette carte dans la grille if (Array.isArray(result) && result.length === 1) setFocusCurrency(result[0]); else setFocusCurrency("all"); return result; }); }; const resetCurrencies = () => { setComparisonCurrencies("all"); setFocusCurrency("all"); }; return (
{/* Header */}
Comparaison
{/* Config row */}
{/* Section picker */}
{COMP_SECTIONS.map(s => ( ))}
{/* Currency picker — Toutes = tableau complet | 1 seule = zoom carte */}
{Array.isArray(comparisonCurrencies) && comparisonCurrencies.length === 1 && ( zoom ↓ )} {CURRENCIES.map(c => { const active = comparisonCurrencies === "all" || comparisonCurrencies.includes(c); return ( ); })}
{/* Table */}
{fields.map(f => ( ))} {rows.map(({ currency: c, inds }) => ( {fields.map(f => { const ind = inds[f.key] as IndSnap; const val = ind?.value ?? null; const fmtVal = val !== null ? `${val % 1 === 0 ? val : val.toFixed(2)}${f.unit ?? ""}` : "—"; const tArrow = ind?.trend === "up" ? "↑" : ind?.trend === "down" ? "↓" : ""; return ( ); })} ))}
Devise{f.label}
{CURRENCY_META[c].flag} {c} {fmtVal} {tArrow && ( {tArrow} )} {ind?.surprise !== null && ind?.surprise !== undefined && ( {ind.surprise > 0 ? "▲" : "▼"} )}
); })()} {/* Currency cards grid */}
{CURRENCIES.filter(c => focusCurrency === "all" || c === focusCurrency).map((currency) => ( void) : undefined} syncSignauxSlide={macroSyncEnabled ? globalSignauxSlide : undefined} onSignauxSlideChange={macroSyncEnabled ? setGlobalSignauxSlide : undefined} syncOisChartTab={macroSyncEnabled ? globalOisChartTab : undefined} onOisChartTabChange={macroSyncEnabled ? setGlobalOisChartTab : undefined} isLoading={loading} /> ))}
)} {activeTab === "markets" && (
Vue d'ensemble · Marchés Globaux
)} {activeTab === "calendar" && ( )} {activeTab === "cbsources" && } {activeTab === "scenario" && } {activeTab === "news" && ( )} {activeTab === "cot" && ( )} {activeTab === "idees" && } {/* Legend */}
Haussier / Sous-évalué
Baissier / Sur-évalué
Ambigu / Divergence
Neutre / Pas de signal
·
Donnée critique
Donnée importante
Donnée secondaire
Cliquer sur chaque signal pour voir l'analyse détaillée
{/* Padding bas pour éviter que le contenu s'arrête brutalement au scroll */}
); }