Files
forex-dashboard/app/page.tsx
T
2026-06-01 23:24:32 +02:00

346 lines
16 KiB
TypeScript

"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 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<DriverData | null>(null);
const [expectations, setExpectations] = useState<Record<string, unknown> | null>(null);
const [yields, setYields] = useState<{ yields: Record<string, number | null>; spreads: Record<string, number | null> } | null>(null);
const [sentiment, setSentiment] = useState<Record<string, SentimentEntry> | null>(null);
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
const [nextWeekAvail, setNextWeekAvail] = useState(false);
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs">("dashboard");
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }> | null>(null);
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]);
const [driversFromCache, setDriversFromCache] = useState(false);
const [driversCacheAge, setDriversCacheAge] = useState<string | null>(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<string, SentimentEntry> {
// base → long base currency; quote → long = short base = long quote
// Format: { base: "EUR", quote: "USD" }
const PAIR_DEF: Record<string, { base: string; quote: string }> = {
// 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<string, number> = {};
const totalPos: Record<string, number> = {};
const pairCount: Record<string, number> = {};
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<string, SentimentEntry> = {};
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<DriverData>("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<Record<string, unknown>>("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<typeof yields>("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<Record<string, SentimentEntry>>("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<string, CotEntry>);
saveCache("cot", cotRes.value);
} else {
const cached = loadCache<Record<string, CotEntry>>("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 (
<div className="max-w-[1600px] mx-auto px-4 py-4">
{/* Header */}
<header className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-semibold text-gray-900">
Forex Macro Dashboard
</h1>
</div>
<div className="flex items-center gap-3">
{divergenceCount > 0 && (
<div className="flex items-center gap-1.5 bg-amber-50 border border-amber-200 rounded-full px-3 py-1.5">
<Zap size={13} className="text-amber-600" />
<span className="text-xs font-medium text-amber-700">
{divergenceCount} divergence{divergenceCount > 1 ? "s" : ""} active{divergenceCount > 1 ? "s" : ""}
</span>
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{driversFromCache && driversCacheAge && (
<span className="flex items-center gap-0.5 text-amber-500" title="Marchés affichés depuis le cache local">
<Database size={11} />
<span>cache {driversCacheAge}</span>
</span>
)}
{lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
</div>
<button
onClick={refresh}
disabled={loading}
className="flex items-center gap-1.5 text-xs text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
<RefreshCw size={13} className={loading ? "animate-spin" : ""} />
{loading ? "Chargement…" : "Rafraîchir"}
</button>
</div>
</header>
{/* Tab navigation */}
<div className="flex gap-0 border-b border-gray-200 mb-4">
{(["dashboard", "calendar", "pairs"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{tab === "dashboard" ? "Dashboard" : tab === "calendar" ? "📅 Calendrier" : "↕ Paires"}
</button>
))}
</div>
{/* Global drivers bar — visible sur les deux onglets */}
{drivers && <DriversBar drivers={drivers} />}
{activeTab === "dashboard" && (
<>
{/* Active divergences summary */}
{activeDivergences.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{activeDivergences
.sort((a, b) => Math.abs(b.score) - Math.abs(a.score))
.map(({ currency, score }) => (
<div
key={currency}
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium ${
score < 0
? "bg-red-50 border-red-200 text-red-700"
: "bg-green-50 border-green-200 text-green-700"
}`}
>
<Zap size={10} />
{CURRENCY_META[currency].flag} {currency} SD:{score > 0 ? "+" : ""}{score}
</div>
))}
</div>
)}
{/* Currency cards grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{CURRENCIES.map((currency) => (
<CurrencyCard
key={currency}
currency={currency}
expectations={expectations}
yields={yields}
sentiment={sentiment?.[currency] ?? null}
cot={cot?.[currency] ?? null}
ratePath={rateProbabilities?.[currency] ?? null}
onDivergenceUpdate={handleDivergenceUpdate}
/>
))}
</div>
</>
)}
{activeTab === "calendar" && (
<CalendarTab events={calEvents} loading={loading} nextWeekAvail={nextWeekAvail} />
)}
{activeTab === "pairs" && (
<SentimentPairsTab symbols={rawSymbols} />
)}
{/* Footer */}
<footer className="mt-6 text-center text-xs text-gray-400 space-y-1">
<p>
Sources: FRED · ECB · BoE · BoC · CFTC · Frankfurter · Myfxbook · ForexFactory
</p>
<p>
LLM: Groq (Llama 3.1) · Données à titre informatif uniquement pas de conseil financier
</p>
</footer>
</div>
);
}