"use client"; import { useEffect, useState, useCallback } from "react"; import { RefreshCw, Zap, Database } from "lucide-react"; import { CURRENCIES, CURRENCY_META } from "@/lib/constants"; import type { Currency, DriverData, SentimentEntry, CotEntry } 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 SentimentPairsTab from "@/components/SentimentPairsTab"; import YieldsTab from "@/components/YieldsTab"; import type { CalendarEvent } from "@/app/api/calendar/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 } | 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" | "pairs" | "yields">("dashboard"); const [rawSymbols, setRawSymbols] = useState | null>(null); 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); // ── 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 = {}; const pairCount: Record = {}; for (const sym of symbols) { const def = PAIR_DEF[sym.name]; if (!def || sym.totalPositions <= 0) continue; const { base, quote } = def; // Base currency : long la paire = long la base if (OUR_CCYS.includes(base)) { longWeighted[base] = (longWeighted[base] ?? 0) + sym.longPercentage * sym.totalPositions; totalPos[base] = (totalPos[base] ?? 0) + sym.totalPositions; pairCount[base] = (pairCount[base] ?? 0) + 1; } // Quote currency : long la paire = short la cotation → long cotation = shortPercentage if (OUR_CCYS.includes(quote)) { longWeighted[quote] = (longWeighted[quote] ?? 0) + sym.shortPercentage * sym.totalPositions; totalPos[quote] = (totalPos[quote] ?? 0) + sym.totalPositions; pairCount[quote] = (pairCount[quote] ?? 0) + 1; } } const result: Record = {}; for (const ccy of OUR_CCYS) { const total = totalPos[ccy] ?? 0; if (total === 0) continue; const n = pairCount[ccy] ?? 1; const longPct = Math.round(longWeighted[ccy] / total); // Label : "DXY (7 paires)" pour USD, "EUR (6 paires)" pour EUR, etc. const label = ccy === "USD" ? `DXY (${n} paires)` : `${ccy} (${n} paire${n > 1 ? "s" : ""})`; result[ccy] = { pair: label, longPct, shortPct: 100 - longPct }; } return result; } const refresh = useCallback(async () => { setLoading(true); try { const [driversRes, expectRes, yieldsRes, fxRes, sentimentRes, cotRes, calRes, rateProbRes] = await Promise.allSettled([ fetch("/api/drivers").then((r) => r.json()), fetch("/api/expectations").then((r) => r.json()), fetch("/api/yields").then((r) => r.json()), fetch("/api/fx").then((r) => r.json()), fetch("/api/sentiment").then((r) => r.json()), fetch("/api/cot").then((r) => r.json()), fetch("/api/calendar").then((r) => r.json()), fetch("/api/rate-probabilities").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; totalPositions: number }>; setRawSymbols(syms); 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) { setRateProbabilities(rateProbRes.value.data as RateProbData); } setLastRefresh(new Date()); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); const id = setInterval(refresh, REFRESH_MS); return () => clearInterval(id); }, [refresh]); 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 */}

Forex Macro Dashboard

{divergenceCount > 0 && (
{divergenceCount} divergence{divergenceCount > 1 ? "s" : ""} active{divergenceCount > 1 ? "s" : ""}
)}
{driversFromCache && driversCacheAge && ( cache {driversCacheAge} )} {lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
{/* Tab navigation */}
{(["dashboard", "calendar", "pairs", "yields"] as const).map((tab) => ( ))}
{/* Global drivers bar — visible sur les deux onglets */} {drivers && } {activeTab === "dashboard" && ( <> {/* Active divergences summary */} {activeDivergences.length > 0 && (
{activeDivergences .sort((a, b) => Math.abs(b.score) - Math.abs(a.score)) .map(({ currency, score }) => (
{CURRENCY_META[currency].flag} {currency} SD:{score > 0 ? "+" : ""}{score}
))}
)} {/* Currency cards grid */}
{CURRENCIES.map((currency) => ( ))}
)} {activeTab === "calendar" && ( )} {activeTab === "pairs" && ( )} {activeTab === "yields" && ( )} {/* Footer */}
); }