mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
feat: OISEnhancedBlock — rate curve, implied pts, scénarios charts per CB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+106
-56
@@ -1,44 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { inflateRawSync } from "zlib";
|
||||
import { COT_CODES } from "@/lib/constants";
|
||||
import type { Currency, CotEntry } from "@/lib/types";
|
||||
|
||||
// ── CFTC Traders in Financial Futures (TFF) — format legacy CSV sans header ──
|
||||
// URL : https://www.cftc.gov/dea/newcot/FinFutWk.txt (mis à jour chaque vendredi)
|
||||
// ── CFTC Traders in Financial Futures (TFF) — fichier annuel ZIP ──────────────
|
||||
// URL : https://www.cftc.gov/files/dea/history/fut_fin_txt_YYYY.zip
|
||||
// Contient toutes les semaines de l'année en ordre décroissant.
|
||||
// On extrait les 2 dernières semaines par devise pour calculer les deltas.
|
||||
//
|
||||
// Colonnes (0-based, séparées par virgule) :
|
||||
// 0 Market_and_Exchange_Names
|
||||
// 1 As_of_Date_In_Form_YYMMDD
|
||||
// 2 Report_Date_as_YYYY-MM-DD
|
||||
// 3 CFTC_Contract_Market_Code
|
||||
// 4 CFTC_Market_Code
|
||||
// 5 CFTC_Region_Code
|
||||
// 6 CFTC_Commodity_Code
|
||||
// 7 Open_Interest_All
|
||||
// 8 Dealer_Positions_Long_All
|
||||
// 9 Dealer_Positions_Short_All
|
||||
// 10 Dealer_Positions_Spreading_All
|
||||
// 11 Asset_Mgr_Positions_Long_All
|
||||
// 12 Asset_Mgr_Positions_Short_All
|
||||
// 13 Asset_Mgr_Positions_Spreading_All
|
||||
// 14 Lev_Money_Positions_Long_All ← hedge funds (positions spéculatives)
|
||||
// 14 Lev_Money_Positions_Long_All ← hedge funds
|
||||
// 15 Lev_Money_Positions_Short_All
|
||||
// 16 Lev_Money_Positions_Spreading_All
|
||||
// ...
|
||||
|
||||
const CFTC_URL = "https://www.cftc.gov/dea/newcot/FinFutWk.txt";
|
||||
const IDX_CODE = 3;
|
||||
const IDX_CODE = 3;
|
||||
const IDX_DATE = 2;
|
||||
const IDX_AM_LONG = 11;
|
||||
const IDX_AM_SHORT = 12;
|
||||
const IDX_LEV_LONG = 14;
|
||||
const IDX_LEV_SHORT = 15;
|
||||
const IDX_DATE = 2;
|
||||
|
||||
function cftcZipUrl(): string {
|
||||
return `https://www.cftc.gov/files/dea/history/fut_fin_txt_${new Date().getFullYear()}.zip`;
|
||||
}
|
||||
|
||||
// In-memory cache — expire le vendredi suivant à 15h30 UTC (publication CFTC)
|
||||
// TTL max 4 jours pour garantir refresh chaque semaine
|
||||
let _cache: { data: Record<string, unknown>; ts: number; weekDate: string } | null = null;
|
||||
let _cache: { data: Record<string, unknown>; ts: number } | null = null;
|
||||
|
||||
function nextCftcRelease(): number {
|
||||
const now = new Date();
|
||||
const d = new Date(now);
|
||||
// Prochain vendredi 15:30 UTC
|
||||
const d = new Date();
|
||||
const daysUntilFriday = (5 - d.getUTCDay() + 7) % 7 || 7;
|
||||
d.setUTCDate(d.getUTCDate() + daysUntilFriday);
|
||||
d.setUTCHours(15, 30, 0, 0);
|
||||
@@ -46,9 +41,7 @@ function nextCftcRelease(): number {
|
||||
}
|
||||
|
||||
function cacheTtl(): number {
|
||||
const ttlToRelease = nextCftcRelease() - Date.now();
|
||||
// max 4 jours pour éviter de bloquer sur de vieilles données
|
||||
return Math.min(ttlToRelease, 4 * 24 * 3600_000);
|
||||
return Math.min(nextCftcRelease() - Date.now(), 4 * 24 * 3600_000);
|
||||
}
|
||||
|
||||
export type { CotEntry } from "@/lib/types";
|
||||
@@ -59,81 +52,138 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(CFTC_URL, {
|
||||
next: { revalidate: 86400 }, // revalidate quotidien — CFTC sort chaque vendredi
|
||||
const res = await fetch(cftcZipUrl(), {
|
||||
cache: "no-store",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
|
||||
cache: "no-store", // forcer fetch frais pour l'in-memory cache ci-dessus
|
||||
});
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `CFTC fetch failed: ${res.status}` }, { status: 502 });
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const result = parseCOT(text);
|
||||
const zipBuf = Buffer.from(await res.arrayBuffer());
|
||||
const text = extractFirstFileFromZip(zipBuf);
|
||||
if (!text) return NextResponse.json({ error: "ZIP parse failed" }, { status: 502 });
|
||||
|
||||
const weekDate = Object.values(result as Record<string, CotEntry>)[0]?.weekDate ?? "";
|
||||
_cache = { data: result, ts: Date.now(), weekDate };
|
||||
const result = parseCOT(text);
|
||||
_cache = { data: result, ts: Date.now() };
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: String(err) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── ZIP parser minimal (format PKZIP, méthode 8 = deflate) ───────────────────
|
||||
|
||||
function extractFirstFileFromZip(buf: Buffer): string | null {
|
||||
try {
|
||||
// Find End-of-Central-Directory (PK\x05\x06)
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) {
|
||||
if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) {
|
||||
eocd = i; break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) return null;
|
||||
|
||||
const localHdrOffset = buf.readUInt32LE(buf.readUInt32LE(eocd + 16) + 42);
|
||||
const lfnLen = buf.readUInt16LE(localHdrOffset + 26);
|
||||
const lexLen = buf.readUInt16LE(localHdrOffset + 28);
|
||||
const dataStart = localHdrOffset + 30 + lfnLen + lexLen;
|
||||
const compSize = buf.readUInt32LE(localHdrOffset + 18);
|
||||
const method = buf.readUInt16LE(localHdrOffset + 8);
|
||||
|
||||
const compressed = buf.slice(dataStart, dataStart + compSize);
|
||||
const decompressed = method === 8
|
||||
? inflateRawSync(compressed)
|
||||
: compressed; // stored (method 0)
|
||||
|
||||
return decompressed.toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parseur CSV TFF ──────────────────────────────────────────────────────────
|
||||
|
||||
type RawWeek = {
|
||||
hfLongs: number; hfShorts: number;
|
||||
amLongs: number; amShorts: number;
|
||||
weekDate: string;
|
||||
};
|
||||
|
||||
function parseCOT(csv: string): Record<string, CotEntry> {
|
||||
const lines = csv.split("\n");
|
||||
const lines = csv.split("\n");
|
||||
const targetCodes = new Set(Object.values(COT_CODES));
|
||||
const result: Record<string, CotEntry> = {};
|
||||
const raw: Record<string, RawWeek[]> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// Split respectant les guillemets
|
||||
const cols = splitCsvLine(line);
|
||||
if (cols.length < 16) continue;
|
||||
|
||||
const code = cols[IDX_CODE]?.trim();
|
||||
if (!code || !targetCodes.has(code)) continue;
|
||||
|
||||
const longs = parseInt(cols[IDX_LEV_LONG]?.trim() ?? "0", 10);
|
||||
const shorts = parseInt(cols[IDX_LEV_SHORT]?.trim() ?? "0", 10);
|
||||
if (isNaN(longs) || isNaN(shorts)) continue;
|
||||
const hfLongs = parseInt(cols[IDX_LEV_LONG]?.trim() ?? "0", 10);
|
||||
const hfShorts = parseInt(cols[IDX_LEV_SHORT]?.trim() ?? "0", 10);
|
||||
const amLongs = parseInt(cols[IDX_AM_LONG]?.trim() ?? "0", 10);
|
||||
const amShorts = parseInt(cols[IDX_AM_SHORT]?.trim() ?? "0", 10);
|
||||
if (isNaN(hfLongs) || isNaN(hfShorts) || isNaN(amLongs) || isNaN(amShorts)) continue;
|
||||
|
||||
const total = longs + shorts;
|
||||
const net = longs - shorts;
|
||||
const weekDate = cols[IDX_DATE]?.trim() ?? "";
|
||||
if (!raw[code]) raw[code] = [];
|
||||
// Keep only the 2 most-recent weeks (file is in descending date order)
|
||||
if (raw[code].length < 2) raw[code].push({ hfLongs, hfShorts, amLongs, amShorts, weekDate });
|
||||
}
|
||||
|
||||
const result: Record<string, CotEntry> = {};
|
||||
|
||||
for (const [code, weeks] of Object.entries(raw)) {
|
||||
const currency = (Object.entries(COT_CODES) as [Currency, string][])
|
||||
.find(([, c]) => c === code)?.[0];
|
||||
if (!currency) continue;
|
||||
if (!currency || weeks.length === 0) continue;
|
||||
|
||||
const cur = weeks[0];
|
||||
const prev = weeks[1] ?? null;
|
||||
|
||||
const hfTotal = cur.hfLongs + cur.hfShorts;
|
||||
const hfNet = cur.hfLongs - cur.hfShorts;
|
||||
const amTotal = cur.amLongs + cur.amShorts;
|
||||
const amNet = cur.amLongs - cur.amShorts;
|
||||
|
||||
result[currency] = {
|
||||
net,
|
||||
longPct: total > 0 ? Math.round((longs / total) * 100) : 50,
|
||||
shortPct: total > 0 ? Math.round((shorts / total) * 100) : 50,
|
||||
totalLev: total,
|
||||
weekDate,
|
||||
net: hfNet,
|
||||
hfLongs: cur.hfLongs,
|
||||
hfShorts: cur.hfShorts,
|
||||
longPct: hfTotal > 0 ? Math.round((cur.hfLongs / hfTotal) * 100) : 50,
|
||||
shortPct: hfTotal > 0 ? Math.round((cur.hfShorts / hfTotal) * 100) : 50,
|
||||
totalLev: hfTotal,
|
||||
amNet,
|
||||
amLongs: cur.amLongs,
|
||||
amShorts: cur.amShorts,
|
||||
amLongPct: amTotal > 0 ? Math.round((cur.amLongs / amTotal) * 100) : 50,
|
||||
amTotal,
|
||||
netDelta: prev !== null ? hfNet - (prev.hfLongs - prev.hfShorts) : null,
|
||||
longsDelta: prev !== null ? cur.hfLongs - prev.hfLongs : null,
|
||||
shortsDelta: prev !== null ? cur.hfShorts - prev.hfShorts : null,
|
||||
amNetDelta: prev !== null ? amNet - (prev.amLongs - prev.amShorts) : null,
|
||||
weekDate: cur.weekDate,
|
||||
prevWeekDate: prev?.weekDate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Gère les champs entourés de guillemets doubles dans un CSV */
|
||||
function splitCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === "," && !inQuotes) {
|
||||
result.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
if (ch === '"') { inQuotes = !inQuotes; }
|
||||
else if (ch === "," && !inQuotes) { result.push(current); current = ""; }
|
||||
else { current += ch; }
|
||||
}
|
||||
result.push(current);
|
||||
return result;
|
||||
|
||||
+144
-52
@@ -47,33 +47,6 @@ async function yahooQuote(symbol: string): Promise<FredResult> {
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── Stooq (Or XAU/USD, Argent XAG/USD — gratuit, sans clé, quasi temps réel) ─
|
||||
// delta = Close - Open = variation intraday vs ouverture de session
|
||||
|
||||
async function stooqMetal(symbol: string): Promise<FredResult> {
|
||||
const empty: FredResult = { value: null, delta: null, deltaPct: null };
|
||||
try {
|
||||
const url = `https://stooq.com/q/l/?s=${symbol}&f=sd2t2ohlcv&h&e=csv`;
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate: 300 }, // cache 5 min
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const text = await res.text();
|
||||
const lines = text.trim().split("\n");
|
||||
if (lines.length < 2) return empty;
|
||||
const cols = lines[1].split(",");
|
||||
// CSV header: Symbol,Date,Time,Open,High,Low,Close,Volume
|
||||
const open = parseFloat(cols[3]);
|
||||
const close = parseFloat(cols[6]);
|
||||
if (isNaN(close)) return empty;
|
||||
const delta = !isNaN(open) ? parseFloat((close - open).toFixed(2)) : null;
|
||||
const deltaPct = (!isNaN(open) && open > 0)
|
||||
? parseFloat(((close - open) / open * 100).toFixed(2))
|
||||
: null;
|
||||
return { value: parseFloat(close.toFixed(2)), delta, deltaPct };
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── Binance (Bitcoin — gratuit, sans clé, temps réel) ────────────────────────
|
||||
// ticker/price = prix spot instantané (plus précis que ticker/24hr lastPrice)
|
||||
@@ -110,36 +83,150 @@ async function coingeckoBTC(): Promise<{ value: number | null; change24h: number
|
||||
} catch { return { value: null, change24h: null }; }
|
||||
}
|
||||
|
||||
// ── investing.com — BTC/USD temps réel (data-test attributes, cache 1 min) ────
|
||||
// Sélecteurs stables : data-test="instrument-price-last/change/change-percent"
|
||||
async function investingBTC(): Promise<{ value: number | null; delta: number | null; deltaPct: number | null }> {
|
||||
const empty = { value: null, delta: null, deltaPct: null };
|
||||
try {
|
||||
const res = await fetch("https://www.investing.com/crypto/bitcoin/btc-usd", {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const html = await res.text();
|
||||
|
||||
// "61,307.0" → 61307
|
||||
const priceMatch = html.match(/data-test="instrument-price-last">([^<]+)/);
|
||||
const deltaMatch = html.match(/data-test="instrument-price-change">([^<]+)/);
|
||||
// "(-0.75%)" → -0.75
|
||||
const pctMatch = html.match(/data-test="instrument-price-change-percent">\(([^)%]+)%\)/);
|
||||
|
||||
if (!priceMatch) return empty;
|
||||
const value = parseFloat(priceMatch[1].replace(/,/g, ""));
|
||||
if (isNaN(value)) return empty;
|
||||
|
||||
const delta = deltaMatch ? parseFloat(deltaMatch[1].replace(/,/g, "")) : null;
|
||||
const deltaPct = pctMatch ? parseFloat(pctMatch[1])
|
||||
: delta !== null && value > 0 ? parseFloat(((delta / (value - delta)) * 100).toFixed(2))
|
||||
: null;
|
||||
|
||||
return {
|
||||
value: Math.round(value),
|
||||
delta: delta !== null && !isNaN(delta) ? Math.round(delta) : null,
|
||||
deltaPct: deltaPct !== null && !isNaN(deltaPct) ? deltaPct : null,
|
||||
};
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── Business Insider Markets — WTI & S&P 500 (JSON inline, cache 1 min) ──────
|
||||
// JSON pattern dans le HTML : "currentValue":XX.XX et "previousClose":XX.XX
|
||||
async function biMarket(url: string): Promise<{ value: number | null; delta: number | null; deltaPct: number | null }> {
|
||||
const empty = { value: null, delta: null, deltaPct: null };
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const html = await res.text();
|
||||
const curMatch = html.match(/"currentValue":([\d.]+)/);
|
||||
const prevMatch = html.match(/"previousClose":([\d.]+)/);
|
||||
if (!curMatch) return empty;
|
||||
const value = parseFloat(curMatch[1]);
|
||||
const prev = prevMatch ? parseFloat(prevMatch[1]) : null;
|
||||
if (isNaN(value)) return empty;
|
||||
const delta = prev !== null ? parseFloat((value - prev).toFixed(2)) : null;
|
||||
const deltaPct = delta !== null && prev !== null && prev > 0
|
||||
? parseFloat(((delta / prev) * 100).toFixed(2)) : null;
|
||||
return { value: parseFloat(value.toFixed(2)), delta, deltaPct };
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── abcbourse.com — Brent spot temps réel (Six Financial Information) ────────
|
||||
// Sélecteurs HTML stables : id="lastcx" (cours), id="veille" (clôture J-1), id="varcx" (%)
|
||||
async function abcbourseBrent(): Promise<{ value: number | null; delta: number | null; deltaPct: number | null }> {
|
||||
const empty = { value: null, delta: null, deltaPct: null };
|
||||
try {
|
||||
const res = await fetch("https://www.abcbourse.com/cotation/XBRUSDu", {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const html = await res.text();
|
||||
|
||||
const priceMatch = html.match(/id="lastcx">([^<]+)/);
|
||||
const prevMatch = html.match(/id="veille">([^<]+)/);
|
||||
const pctMatch = html.match(/id="varcx"[^>]*>([^<]+)/);
|
||||
|
||||
if (!priceMatch) return empty;
|
||||
|
||||
// "96,70 $" → 96.70
|
||||
const value = parseFloat(priceMatch[1].replace(/[^\d,]/g, "").replace(",", "."));
|
||||
if (isNaN(value)) return empty;
|
||||
|
||||
const prev = prevMatch ? parseFloat(prevMatch[1].replace(",", ".").trim()) : null;
|
||||
const delta = prev !== null && !isNaN(prev) ? parseFloat((value - prev).toFixed(2)) : null;
|
||||
|
||||
let deltaPct: number | null = null;
|
||||
if (pctMatch) {
|
||||
// + = "+" ; − ou − = "−"
|
||||
const pctStr = pctMatch[1]
|
||||
.replace(/[Bb];/g, "+")
|
||||
.replace(/−|−/g, "-")
|
||||
.replace("%", "")
|
||||
.replace(",", ".")
|
||||
.trim();
|
||||
const pctNum = parseFloat(pctStr);
|
||||
if (!isNaN(pctNum)) deltaPct = pctNum;
|
||||
} else if (delta !== null && prev !== null && prev > 0) {
|
||||
deltaPct = parseFloat(((delta / prev) * 100).toFixed(2));
|
||||
}
|
||||
|
||||
return { value, delta, deltaPct };
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── GET ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function GET() {
|
||||
const fredKey = process.env.FRED_API_KEY;
|
||||
if (!fredKey) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
|
||||
|
||||
// 1. Indices — Yahoo Finance (temps réel, cache 5 min)
|
||||
// ^VIX = CBOE Volatility Index | ^GSPC = S&P 500
|
||||
const [vixQ, sp500Q] = await Promise.all([
|
||||
// 1. Indices — Business Insider (JSON inline, cache 1 min) + fallback Yahoo Finance
|
||||
// VIX reste Yahoo (Business Insider n'a pas VIX)
|
||||
const [vixQ, sp500Raw] = await Promise.all([
|
||||
yahooQuote("^VIX"),
|
||||
yahooQuote("^GSPC"),
|
||||
biMarket("https://markets.businessinsider.com/index/s%26p_500"),
|
||||
]);
|
||||
const sp500Q = sp500Raw.value !== null ? sp500Raw : await yahooQuote("^GSPC");
|
||||
|
||||
// Pétrole — Stooq futures (quasi temps réel, cache 5 min, sans clé API)
|
||||
// cl.f = WTI NYMEX | cb.f = Brent ICE → delta = variation intraday vs ouverture
|
||||
const [brentQ, wtiQ] = await Promise.all([
|
||||
stooqMetal("cb.f"),
|
||||
stooqMetal("cl.f"),
|
||||
// Brent — abcbourse.com (Six Financial Information, temps réel, cache 1 min)
|
||||
// Fallback : Yahoo Finance BZ=F si le scraping échoue
|
||||
// WTI — Business Insider (JSON inline, cache 1 min) + fallback Yahoo Finance CL=F
|
||||
const [brentRaw, wtiRaw] = await Promise.all([
|
||||
abcbourseBrent(),
|
||||
biMarket("https://markets.businessinsider.com/commodities/oil-price?type=wti"),
|
||||
]);
|
||||
const brentQ = brentRaw.value !== null ? brentRaw : await yahooQuote("BZ=F").then(q => ({
|
||||
value: q.value, delta: q.delta, deltaPct: q.deltaPct,
|
||||
}));
|
||||
const wtiQ = wtiRaw.value !== null ? wtiRaw : await yahooQuote("CL=F");
|
||||
|
||||
// 2. Métaux précieux — Stooq (quasi temps réel, cache 5 min, sans clé API)
|
||||
// delta = variation intraday vs ouverture de session
|
||||
const [goldQ, silverQ] = await Promise.all([
|
||||
stooqMetal("xauusd"),
|
||||
stooqMetal("xagusd"),
|
||||
// 2. Métaux précieux — Business Insider (JSON inline, cache 1 min) + fallback Yahoo Finance
|
||||
const [goldRaw, silverRaw] = await Promise.all([
|
||||
biMarket("https://markets.businessinsider.com/commodities/gold-price"),
|
||||
biMarket("https://markets.businessinsider.com/commodities/silver-price"),
|
||||
]);
|
||||
const goldQ = goldRaw.value !== null ? goldRaw : await yahooQuote("GC=F");
|
||||
const silverQ = silverRaw.value !== null ? silverRaw : await yahooQuote("SI=F");
|
||||
|
||||
// 3. Bitcoin — Binance (temps réel), fallback CoinGecko
|
||||
const btcBin = await binanceBTC();
|
||||
const btcCg = btcBin.value === null ? await coingeckoBTC() : { value: null, change24h: null };
|
||||
// 3. BTC/USD — investing.com (data-test attrs, cache 1 min) + fallback Binance/CoinGecko
|
||||
const btcRaw = await investingBTC();
|
||||
const btcBin = btcRaw.value === null ? await binanceBTC() : { value: null, change24h: null };
|
||||
const btcCg = btcRaw.value === null && btcBin.value === null ? await coingeckoBTC() : { value: null, change24h: null };
|
||||
|
||||
// 4. FRED — spreads crédit (cache 1h)
|
||||
// Yields 10Y — TE bonds (cache 1h, données du jour)
|
||||
@@ -160,8 +247,9 @@ export async function GET() {
|
||||
sp500: sp500Q.value,
|
||||
sp500Change: sp500Q.delta,
|
||||
sp500ChangePct: sp500Q.deltaPct,
|
||||
btc: btcBin.value ?? btcCg.value,
|
||||
btc: btcRaw.value ?? btcBin.value ?? btcCg.value,
|
||||
btcChange24h: btcBin.change24h ?? btcCg.change24h,
|
||||
btcDeltaPct: btcRaw.deltaPct,
|
||||
// Crédit (FRED, bps)
|
||||
hySpread: hyRaw != null ? Math.round(hyRaw * 100) : null,
|
||||
igSpread: igRaw != null ? Math.round(igRaw * 100) : null,
|
||||
@@ -190,15 +278,19 @@ export async function GET() {
|
||||
us10y,
|
||||
us2y,
|
||||
curveSlope: us10y !== null && us2y !== null ? Math.round((us10y - us2y) * 100) : null,
|
||||
// Commodités — Or/Argent intraday (Stooq), Pétrole j-1 (FRED)
|
||||
gold: goldQ.value,
|
||||
goldDelta: goldQ.delta,
|
||||
silver: silverQ.value,
|
||||
silverDelta: silverQ.delta,
|
||||
brent: brentQ.value,
|
||||
brentDelta: brentQ.delta,
|
||||
// Commodités — Business Insider (cache 1 min) + fallback Yahoo Finance
|
||||
gold: goldQ.value,
|
||||
goldDelta: goldQ.delta,
|
||||
goldDeltaPct: goldQ.deltaPct,
|
||||
silver: silverQ.value,
|
||||
silverDelta: silverQ.delta,
|
||||
silverDeltaPct: silverQ.deltaPct,
|
||||
brent: brentQ.value,
|
||||
brentDelta: brentQ.delta,
|
||||
brentDeltaPct: brentQ.deltaPct,
|
||||
wti: wtiQ.value,
|
||||
wtiDelta: wtiQ.delta,
|
||||
wtiDeltaPct: wtiQ.deltaPct,
|
||||
// Compat
|
||||
copper: null,
|
||||
timestamp: Date.now(),
|
||||
|
||||
+28
-8
@@ -5,7 +5,7 @@ import cpiOverridesRaw from "@/data/cpi_overrides.json";
|
||||
import rateDecisionsRaw from "@/data/rate_decisions.json";
|
||||
import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
|
||||
import type { FFEvent } from "@/lib/forexfactory";
|
||||
import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate, fetchTESTIRRate } from "@/lib/tecpi";
|
||||
import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate, fetchTESTIRRate, fetchTEEmploymentChange } from "@/lib/tecpi";
|
||||
import { fetchTEInflationForecasts } from "@/lib/tradingeconomics";
|
||||
|
||||
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
|
||||
@@ -625,14 +625,14 @@ function toIndicatorYoY(obs: Obs[], periods = 12): IndicatorResult {
|
||||
*/
|
||||
function toIndicatorDeltaK(obs: Obs[], personsToK: boolean): IndicatorResult {
|
||||
if (obs.length < 2) return null;
|
||||
const raw = obs[0].value - obs[1].value;
|
||||
const valK = personsToK
|
||||
? parseFloat((raw / 1000).toFixed(1))
|
||||
: parseFloat(raw.toFixed(1));
|
||||
const toK = (v: number) => personsToK ? parseFloat((v / 1000).toFixed(1)) : parseFloat(v.toFixed(1));
|
||||
const valK = toK(obs[0].value - obs[1].value);
|
||||
// Période précédente : delta obs[1]-obs[2] si disponible
|
||||
const prevK = obs.length >= 3 ? toK(obs[1].value - obs[2].value) : null;
|
||||
return {
|
||||
value: valK,
|
||||
prev: null,
|
||||
surprise: valK, // surprise = la valeur elle-même (signe = direction)
|
||||
prev: prevK,
|
||||
surprise: prevK !== null ? parseFloat((valK - prevK).toFixed(1)) : valK,
|
||||
trend: valK > 0 ? "up" : valK < 0 ? "down" : "flat",
|
||||
lastUpdated: obs[0].date,
|
||||
};
|
||||
@@ -1109,7 +1109,7 @@ export async function GET(req: NextRequest) {
|
||||
// Remplace séries FRED stale/erronées.
|
||||
// Nouvelles données : cpiYoY headline, cpiCoreMoM (pages individuelles), ppiMoM
|
||||
{
|
||||
const [teCoreMap, teMoMMap, teYoYMap, teCoreMoMMap, teCoreIdxMap, tePPIMap, teCorePages, teYoYPages, teAUDComm, teGDPMap, teUneMap, teSTIRMap] = await Promise.all([
|
||||
const [teCoreMap, teMoMMap, teYoYMap, teCoreMoMMap, teCoreIdxMap, tePPIMap, teCorePages, teYoYPages, teAUDComm, teGDPMap, teUneMap, teSTIRMap, teEmpMap] = await Promise.all([
|
||||
fetchTECoreInflation(),
|
||||
fetchTEMoMInflation(),
|
||||
fetchTEInflationYoY(),
|
||||
@@ -1122,6 +1122,7 @@ export async function GET(req: NextRequest) {
|
||||
fetchTEGDPGrowthRate(),
|
||||
fetchTEUnemploymentRate(),
|
||||
fetchTESTIRRate(),
|
||||
fetchTEEmploymentChange(),
|
||||
]);
|
||||
|
||||
const teCore = teCoreMap[currency];
|
||||
@@ -1286,6 +1287,25 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Employment Change — TE pages individuelles (USD NFP, GBP MoM, AUD MoM, EUR QoQ%)
|
||||
// Remplace FRED : prev était null (toIndicatorDeltaK ne calculait pas le delta précédent)
|
||||
// EUR/GBP : pas de série FRED disponible → seule source
|
||||
{
|
||||
const teEmp = teEmpMap[currency];
|
||||
if (teEmp) {
|
||||
const surprise = teEmp.prev !== null
|
||||
? parseFloat((teEmp.value - teEmp.prev).toFixed(1))
|
||||
: teEmp.value;
|
||||
indicators.employment = {
|
||||
value: teEmp.value,
|
||||
prev: teEmp.prev,
|
||||
surprise,
|
||||
trend: teEmp.value > 0 ? "up" : teEmp.value < 0 ? "down" : "flat",
|
||||
lastUpdated: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// AUD Commodity Prices YoY
|
||||
if (teAUDComm) {
|
||||
const surprise = teAUDComm.prev !== null
|
||||
|
||||
+44
-2
@@ -1,13 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchTEBondYields } from "@/lib/tebonds";
|
||||
|
||||
// Variation % d'un pair FX vs clôture J-1 (Yahoo Finance, cache 5 min)
|
||||
// Valeur positive = devise X plus forte vs USD (ou USD plus fort si pair inversé)
|
||||
async function fxChangePct(symbol: string, invert = false): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=2d`,
|
||||
{ next: { revalidate: 300 }, headers: { "User-Agent": "Mozilla/5.0" } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const meta = (await res.json())?.chart?.result?.[0]?.meta as { regularMarketPrice?: number; chartPreviousClose?: number } | undefined;
|
||||
const cur = meta?.regularMarketPrice ?? null;
|
||||
const prev = meta?.chartPreviousClose ?? null;
|
||||
if (cur === null || prev === null || prev === 0) return null;
|
||||
const pct = (cur - prev) / prev * 100;
|
||||
return parseFloat((invert ? -pct : pct).toFixed(3));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// 10Y sovereign yields — source unique : tradingeconomics.com/bonds (HTML statique)
|
||||
// Remplace les sources précédentes (FRED DGS10 + IRLTLT01XXM156N mensuel + ECB/BoE APIs)
|
||||
// qui avaient des décalages allant de 1 jour (FRED daily) à 1 mois (FRED monthly JPY/CHF/AUD/NZD).
|
||||
// TE bonds = données du jour pour les 8 devises, cache 1h.
|
||||
|
||||
export async function GET() {
|
||||
const bondData = await fetchTEBondYields();
|
||||
const [bondData, fxResults] = await Promise.all([
|
||||
fetchTEBondYields(),
|
||||
Promise.all([
|
||||
fxChangePct("EURUSD=X"), // EUR: positif = EUR fort
|
||||
fxChangePct("GBPUSD=X"), // GBP: positif = GBP fort
|
||||
fxChangePct("USDJPY=X", true), // JPY: inversé (USD/JPY haut = JPY faible)
|
||||
fxChangePct("USDCHF=X", true), // CHF: inversé
|
||||
fxChangePct("USDCAD=X", true), // CAD: inversé
|
||||
fxChangePct("AUDUSD=X"), // AUD: positif = AUD fort
|
||||
fxChangePct("NZDUSD=X"), // NZD: positif = NZD fort
|
||||
]),
|
||||
]);
|
||||
const [eurFx, gbpFx, jpyFx, chfFx, cadFx, audFx, nzdFx] = fxResults;
|
||||
|
||||
const yields: Record<string, number | null> = {
|
||||
USD: bondData.USD?.yield10y ?? null,
|
||||
@@ -42,5 +72,17 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ yields, spreads, dayDeltas, timestamp: Date.now() });
|
||||
// Variation FX journalière par devise (positif = devise forte vs USD)
|
||||
const fxDayPct: Record<string, number | null> = {
|
||||
USD: 0,
|
||||
EUR: eurFx,
|
||||
GBP: gbpFx,
|
||||
JPY: jpyFx,
|
||||
CHF: chfFx,
|
||||
CAD: cadFx,
|
||||
AUD: audFx,
|
||||
NZD: nzdFx,
|
||||
};
|
||||
|
||||
return NextResponse.json({ yields, spreads, dayDeltas, fxDayPct, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
+216
-27
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { RefreshCw, Zap, Database, Activity, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { RefreshCw, Zap, Database, Activity, Maximize2, Minimize2, X, BarChart2 } from "lucide-react";
|
||||
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
|
||||
import type { Currency, DriverData, SentimentEntry, CotEntry } from "@/lib/types";
|
||||
import type { Currency, DriverData, SentimentEntry, CotEntry, MacroSection } from "@/lib/types";
|
||||
import type { RateProbData } from "@/lib/rateprobability";
|
||||
import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
|
||||
import CurrencyCard from "@/components/CurrencyCard";
|
||||
@@ -23,7 +23,7 @@ const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600
|
||||
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>; dayDeltas?: Record<string, number | null> } | null>(null);
|
||||
const [yields, setYields] = useState<{ yields: Record<string, number | null>; spreads: Record<string, number | null>; dayDeltas?: Record<string, number | null>; fxDayPct?: 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[]>([]);
|
||||
@@ -41,6 +41,13 @@ export default function Dashboard() {
|
||||
const [driversFromCache, setDriversFromCache] = useState(false);
|
||||
const [driversCacheAge, setDriversCacheAge] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [macroSection, setMacroSection] = useState<MacroSection>("all");
|
||||
const [comparisonOpen, setComparisonOpen] = useState(false);
|
||||
const [comparisonSection, setComparisonSection] = useState<Exclude<MacroSection,"all">>("inflation");
|
||||
const [comparisonCurrencies, setComparisonCurrencies] = useState<Currency[] | "all">("all");
|
||||
const [focusCurrency, setFocusCurrency] = useState<Currency | "all">("all");
|
||||
const [globalMacroSlide, setGlobalMacroSlide] = useState<"mon"|"infl"|"cro"|"empl">("mon");
|
||||
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)
|
||||
@@ -297,12 +304,16 @@ export default function Dashboard() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{divergenceCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full px-3 py-1.5">
|
||||
<button
|
||||
onClick={() => setActiveTab("dashboard")}
|
||||
title="Devises avec score macro ≥ 2 — cliquer pour voir le dashboard"
|
||||
className="flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full px-3 py-1.5 hover:bg-amber-500/20 transition-colors"
|
||||
>
|
||||
<Zap size={12} className="text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">
|
||||
{divergenceCount} divergence{divergenceCount > 1 ? "s" : ""} active{divergenceCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||
@@ -363,30 +374,204 @@ export default function Dashboard() {
|
||||
|
||||
{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-500/10 border-red-500/20 text-red-400"
|
||||
: "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
<Zap size={10} />
|
||||
{CURRENCY_META[currency].flag} {currency} SD:{score > 0 ? "+" : ""}{score}
|
||||
{/* ── Barre de contrôle : Sync + Comparer uniquement ────────────── */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setMacroSyncEnabled(v => !v)}
|
||||
className={`text-[11px] font-medium px-3 py-1 rounded-full border transition-all flex items-center gap-1.5 ${
|
||||
macroSyncEnabled
|
||||
? "bg-violet-500/15 border-violet-500/30 text-violet-400"
|
||||
: "border-slate-700 text-slate-500 hover:text-slate-300 hover:border-slate-600"
|
||||
}`}
|
||||
title="Synchroniser l'onglet macro affiché sur toutes les cartes"
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${macroSyncEnabled ? "bg-violet-400" : "bg-slate-600"}`} />
|
||||
Sync
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setComparisonOpen(v => !v)}
|
||||
className={`text-[11px] font-medium px-3 py-1 rounded-full border transition-all flex items-center gap-1.5 ${
|
||||
comparisonOpen
|
||||
? "bg-sky-500/15 border-sky-500/30 text-sky-400"
|
||||
: "border-slate-700 text-slate-500 hover:text-sky-400 hover:border-sky-500/40"
|
||||
}`}
|
||||
>
|
||||
<BarChart2 size={11} />
|
||||
Comparer
|
||||
</button>
|
||||
{/* Badges actifs (section filtre + focus devise) */}
|
||||
{macroSection !== "all" && (
|
||||
<div className="flex items-center gap-1 bg-amber-500/10 border border-amber-500/20 rounded-full px-2.5 py-1">
|
||||
<span className="text-[10px] text-amber-400 font-medium">
|
||||
{{ inflation:"📊 Inflation", pmi:"🏭 PMI", employment:"👷 Emploi", gdp:"📈 PIB", policy:"🏦 Politique" }[macroSection]}
|
||||
</span>
|
||||
<button onClick={() => { setMacroSection("all"); }} className="text-amber-500/50 hover:text-amber-400 transition-colors text-[10px] leading-none">✕</button>
|
||||
</div>
|
||||
)}
|
||||
{focusCurrency !== "all" && (
|
||||
<div className="flex items-center gap-1 bg-sky-500/10 border border-sky-500/20 rounded-full px-2.5 py-1">
|
||||
<span className="text-[10px] text-sky-400 font-medium">{CURRENCY_META[focusCurrency].flag} {focusCurrency}</span>
|
||||
<button onClick={() => { setFocusCurrency("all"); setComparisonCurrencies("all"); }} className="text-sky-500/50 hover:text-sky-400 transition-colors text-[10px] leading-none">✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Comparison panel ──────────────────────────────────────────── */}
|
||||
{comparisonOpen && (() => {
|
||||
type IndSnap = { value: number | null; trend: string | null; surprise: number | null } | null;
|
||||
type CacheData = { indicators: Record<string, IndSnap> };
|
||||
const COMP_SECTIONS: { id: Exclude<MacroSection,"all">; 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<Exclude<MacroSection,"all">, { 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<CacheData>(`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 (
|
||||
<div className="mb-4 bg-slate-900 border border-slate-700 rounded-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-800">
|
||||
<span className="text-[11px] font-semibold text-slate-300 uppercase tracking-wider">Comparaison</span>
|
||||
<button onClick={() => { setComparisonOpen(false); setMacroSection("all"); }} className="text-slate-600 hover:text-slate-400">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Config row */}
|
||||
<div className="px-4 py-2.5 border-b border-slate-800 flex flex-wrap gap-3 items-center">
|
||||
{/* Section picker */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{COMP_SECTIONS.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => { setComparisonSection(s.id); setMacroSection(s.id); }}
|
||||
className={`text-[10px] font-medium px-2.5 py-1 rounded-full border transition-all ${
|
||||
comparisonSection === s.id
|
||||
? "bg-sky-500/15 border-sky-500/30 text-sky-400"
|
||||
: "border-slate-700 text-slate-500 hover:text-slate-300 hover:border-slate-600"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-px bg-slate-800 self-stretch hidden sm:block" />
|
||||
{/* Currency picker — Toutes = tableau complet | 1 seule = zoom carte */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{Array.isArray(comparisonCurrencies) && comparisonCurrencies.length === 1 && (
|
||||
<span className="text-[9px] text-sky-400/60 mr-0.5">zoom ↓</span>
|
||||
)}
|
||||
<button
|
||||
onClick={resetCurrencies}
|
||||
className={`text-[10px] font-medium px-2.5 py-1 rounded-full border transition-all ${
|
||||
comparisonCurrencies === "all"
|
||||
? "bg-slate-700 border-slate-600 text-slate-200"
|
||||
: "border-slate-700 text-slate-500 hover:text-slate-300 hover:border-slate-600"
|
||||
}`}
|
||||
>
|
||||
Toutes
|
||||
</button>
|
||||
{CURRENCIES.map(c => {
|
||||
const active = comparisonCurrencies === "all" || comparisonCurrencies.includes(c);
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => toggleCurrency(c)}
|
||||
className={`text-[10px] font-medium px-2 py-1 rounded-full border transition-all ${
|
||||
active
|
||||
? "bg-slate-700 border-slate-600 text-slate-200"
|
||||
: "border-slate-700/50 text-slate-600 hover:text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{CURRENCY_META[c].flag} {c}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800">
|
||||
<th className="text-left px-4 py-2 text-slate-600 font-medium w-20">Devise</th>
|
||||
{fields.map(f => (
|
||||
<th key={f.key} className="text-right px-3 py-2 text-slate-600 font-medium">{f.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ currency: c, inds }) => (
|
||||
<tr key={c} className="border-b border-slate-800/50 hover:bg-slate-800/20 transition-colors">
|
||||
<td className="px-4 py-2 font-semibold text-slate-300">
|
||||
{CURRENCY_META[c].flag} {c}
|
||||
</td>
|
||||
{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 (
|
||||
<td key={f.key} className="text-right px-3 py-2">
|
||||
<span className={`font-semibold tabular-nums ${val !== null ? trendColor(ind?.trend ?? null, f.inv) : "text-slate-600"}`}>
|
||||
{fmtVal}
|
||||
</span>
|
||||
{tArrow && (
|
||||
<span className={`ml-0.5 text-[10px] ${trendColor(ind?.trend ?? null, f.inv)}`}>{tArrow}</span>
|
||||
)}
|
||||
{ind?.surprise !== null && ind?.surprise !== undefined && (
|
||||
<span className={`ml-1 text-[9px] ${surpriseColor(ind.surprise, f.inv)}`}>
|
||||
{ind.surprise > 0 ? "▲" : "▼"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Currency cards grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{CURRENCIES.map((currency) => (
|
||||
<div className={`grid gap-3 ${focusCurrency !== "all" ? "grid-cols-1 max-w-xl" : "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"}`}>
|
||||
{CURRENCIES.filter(c => focusCurrency === "all" || c === focusCurrency).map((currency) => (
|
||||
<CurrencyCard
|
||||
key={currency}
|
||||
currency={currency}
|
||||
@@ -396,6 +581,10 @@ export default function Dashboard() {
|
||||
cot={cot?.[currency] ?? null}
|
||||
ratePath={rateProbabilities?.[currency] ?? null}
|
||||
onDivergenceUpdate={handleDivergenceUpdate}
|
||||
calEvents={calEvents}
|
||||
macroSection={macroSection}
|
||||
syncMacroSlide={macroSyncEnabled ? globalMacroSlide : undefined}
|
||||
onMacroSlideChange={macroSyncEnabled ? setGlobalMacroSlide : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -411,7 +600,7 @@ export default function Dashboard() {
|
||||
)}
|
||||
|
||||
{activeTab === "yields" && (
|
||||
<YieldsTab yieldsData={yields} />
|
||||
<YieldsTab yieldsData={yields} fxDayPct={yields?.fxDayPct ?? null} />
|
||||
)}
|
||||
|
||||
{activeTab === "news" && (
|
||||
|
||||
Reference in New Issue
Block a user