mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
346 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|