auto: sync 2026-06-01 23:24

This commit is contained in:
Capucine Gest
2026-06-01 23:24:32 +02:00
parent eb416be780
commit c1b3e54c28
13 changed files with 1537 additions and 307 deletions
+87 -54
View File
@@ -1,89 +1,122 @@
import { NextResponse } from "next/server";
import { COT_CODES } from "@/lib/constants";
import type { Currency } from "@/lib/types";
import type { Currency, CotEntry } from "@/lib/types";
// CFTC CSV URL — updated weekly on Fridays
const CFTC_URL =
"https://www.cftc.gov/files/dea/history/fut_fin_txt_2024.zip";
// Current year CSV (plain text, no zip)
const CFTC_CURRENT =
"https://www.cftc.gov/sites/default/files/files/dea/cotarchives/2024/futures/FinFutWk062824.txt";
// ── CFTC Traders in Financial Futures (TFF) — format legacy CSV sans header ──
// URL : https://www.cftc.gov/dea/newcot/FinFutWk.txt (mis à jour chaque vendredi)
//
// 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)
// 15 Lev_Money_Positions_Short_All
// 16 Lev_Money_Positions_Spreading_All
// ...
// In-memory cache (server lifetime)
let cotCache: { data: Record<string, unknown>; ts: number } | null = null;
const TTL = 7 * 24 * 3600_000; // 1 week
const CFTC_URL = "https://www.cftc.gov/dea/newcot/FinFutWk.txt";
const IDX_CODE = 3;
const IDX_LEV_LONG = 14;
const IDX_LEV_SHORT = 15;
const IDX_DATE = 2;
// In-memory cache (1 semaine)
let _cache: { data: Record<string, unknown>; ts: number } | null = null;
const TTL = 7 * 24 * 3600_000;
export type { CotEntry } from "@/lib/types";
export async function GET() {
if (cotCache && Date.now() - cotCache.ts < TTL) {
return NextResponse.json(cotCache.data);
if (_cache && Date.now() - _cache.ts < TTL) {
return NextResponse.json(_cache.data);
}
try {
// Fetch latest COT "Disaggregated" or "Financial" futures CSV
// The public URL pattern for the most recent weekly file:
const now = new Date();
const year = now.getFullYear();
const csvUrl = `https://www.cftc.gov/files/dea/history/fut_fin_txt_${year}.zip`;
// Simpler approach: use the non-compressed annual file (available for current year)
const res = await fetch(
`https://www.cftc.gov/dea/newcot/FinFutWk.txt`,
{ next: { revalidate: 86400 * 7 } }
);
const res = await fetch(CFTC_URL, {
next: { revalidate: 86400 * 7 },
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
});
if (!res.ok) {
return NextResponse.json(
{ error: `CFTC fetch failed: ${res.status}`, note: "COT data may be unavailable temporarily." },
{ status: 502 }
);
return NextResponse.json({ error: `CFTC fetch failed: ${res.status}` }, { status: 502 });
}
const text = await res.text();
const text = await res.text();
const result = parseCOT(text);
cotCache = { data: result, ts: Date.now() };
_cache = { data: result, ts: Date.now() };
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 502 });
}
}
function parseCOT(csv: string): Record<string, unknown> {
const lines = csv.split("\n");
if (lines.length < 2) return {};
const header = lines[0].split(",").map((h) => h.replace(/"/g, "").trim());
const result: Record<string, { net: number; longPct: number; shortPct: number }> = {};
function parseCOT(csv: string): Record<string, CotEntry> {
const lines = csv.split("\n");
const targetCodes = new Set(Object.values(COT_CODES));
const result: Record<string, CotEntry> = {};
for (let i = 1; i < lines.length; i++) {
const row = lines[i].split(",").map((v) => v.replace(/"/g, "").trim());
if (row.length < 10) continue;
for (const line of lines) {
if (!line.trim()) continue;
const codeIdx = header.indexOf("CFTC_Contract_Market_Code");
const longIdx = header.indexOf("NonComm_Positions_Long_All");
const shortIdx = header.indexOf("NonComm_Positions_Short_All");
// Split respectant les guillemets
const cols = splitCsvLine(line);
if (cols.length < 16) continue;
if (codeIdx < 0 || longIdx < 0 || shortIdx < 0) continue;
const code = row[codeIdx];
if (!targetCodes.has(code)) continue;
const code = cols[IDX_CODE]?.trim();
if (!code || !targetCodes.has(code)) continue;
const longs = parseInt(row[longIdx] ?? "0", 10);
const shorts = parseInt(row[shortIdx] ?? "0", 10);
const total = longs + shorts;
const net = longs - shorts;
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 currency = (Object.entries(COT_CODES) as [Currency, string][]).find(
([, c]) => c === code
)?.[0];
const total = longs + shorts;
const net = longs - shorts;
const weekDate = cols[IDX_DATE]?.trim() ?? "";
const currency = (Object.entries(COT_CODES) as [Currency, string][])
.find(([, c]) => c === code)?.[0];
if (!currency) continue;
result[currency] = {
net,
longPct: total > 0 ? Math.round((longs / total) * 100) : 50,
longPct: total > 0 ? Math.round((longs / total) * 100) : 50,
shortPct: total > 0 ? Math.round((shorts / total) * 100) : 50,
totalLev: total,
weekDate,
};
}
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;
}
}
result.push(current);
return result;
}
+103 -43
View File
@@ -1,65 +1,125 @@
import { NextResponse } from "next/server";
// AV primary (real-time) → Frankfurter fallback (ECB daily)
// AV free plan: 25 req/day, 5 req/min
// With revalidate:86400, server fetches each URL at most once per day → 7 calls/day total
const CURRENCIES = ["EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
const CURRENCIES = ["EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "SEK"];
const AV_BASE = "https://www.alphavantage.co/query";
export async function GET() {
const avKey = process.env.ALPHA_VANTAGE_KEY;
if (avKey) {
const result = await fetchAV(avKey);
if (result) return NextResponse.json(result);
}
return fetchFrankfurter();
// ICE DXY official weights (USD as base, weights sum to 1)
// EUR and GBP are quote currencies in their conventional pairs (EURUSD, GBPUSD)
// so their rates from Frankfurter/AV (USD→CCY) are already the inverse → positive exponents
const DXY_WEIGHTS = {
EUR: 0.576,
JPY: 0.136,
GBP: 0.119,
CAD: 0.091,
SEK: 0.042,
CHF: 0.036,
};
function computeDxy(rates: Record<string, number>): number | null {
const required = ["EUR", "GBP", "JPY", "CAD", "CHF", "SEK"];
if (required.some((ccy) => rates[ccy] == null || Number.isNaN(rates[ccy]))) return null;
const { EUR, GBP, JPY, CAD, CHF, SEK } = rates;
return parseFloat(
(50.14348112 *
Math.pow(EUR, DXY_WEIGHTS.EUR) *
Math.pow(JPY, DXY_WEIGHTS.JPY) *
Math.pow(GBP, DXY_WEIGHTS.GBP) *
Math.pow(CAD, DXY_WEIGHTS.CAD) *
Math.pow(SEK, DXY_WEIGHTS.SEK) *
Math.pow(CHF, DXY_WEIGHTS.CHF)
).toFixed(2)
);
}
async function fetchAV(apiKey: string) {
// Sequential (not parallel) to respect AV's 5 req/min limit
// ── Yahoo Finance — DX=F (ICE Dollar Index Futures, temps réel) ──────────────
async function fetchYahooDXY(): Promise<{ value: number | null; delta: number | null }> {
try {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent("DX=F")}?interval=1d&range=2d`;
const res = await fetch(url, {
next: { revalidate: 300 },
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
});
if (!res.ok) return { value: null, delta: null };
const data = await res.json();
const meta = data?.chart?.result?.[0]?.meta as {
regularMarketPrice?: number;
chartPreviousClose?: number;
regularMarketPreviousClose?: number;
previousClose?: number;
} | undefined;
const current = meta?.regularMarketPrice ?? null;
const prevClose = meta?.chartPreviousClose
?? meta?.regularMarketPreviousClose
?? meta?.previousClose
?? null;
if (current == null) return { value: null, delta: null };
const delta = prevClose != null ? parseFloat((current - prevClose).toFixed(2)) : null;
return { value: parseFloat(current.toFixed(2)), delta };
} catch { return { value: null, delta: null }; }
}
// ── Alpha Vantage — taux FX (cache 5 min) ────────────────────────────────────
async function fetchAVRates(apiKey: string): Promise<Record<string, number> | null> {
const rates: Record<string, number> = {};
for (const ccy of CURRENCIES) {
try {
const url = `${AV_BASE}?function=CURRENCY_EXCHANGE_RATE&from_currency=USD&to_currency=${ccy}&apikey=${apiKey}`;
const res = await fetch(url, { next: { revalidate: 86400 } }); // 24h server cache
const res = await fetch(url, { next: { revalidate: 300 } });
if (!res.ok) continue;
const json = await res.json();
const rate = json?.["Realtime Currency Exchange Rate"]?.["5. Exchange Rate"];
if (rate) rates[ccy] = parseFloat(rate);
} catch { /* skip on error */ }
} catch { /* skip */ }
}
if (Object.keys(rates).length < 4) return null; // too many failures → fall back
// DXY = 50.14 × EURUSD^-0.576 × USDJPY^0.136 × GBPUSD^-0.119 × USDCAD^0.091 × USDCHF^0.036
// AV from_currency=USD → e=USD/EUR, j=JPY/USD, g=USD/GBP, c=CAD/USD, ch=CHF/USD
// ⟹ EURUSD=1/e → e^0.576 ; USDJPY=j → j^0.136 ; GBPUSD=1/g → g^0.119
// USDCAD=c → c^0.091 ; USDCHF=ch → ch^0.036
const e = rates.EUR, g = rates.GBP, j = rates.JPY, c = rates.CAD, ch = rates.CHF;
const dxy = (e && g && j && c && ch)
? parseFloat((50.14348112 * Math.pow(e,0.576) * Math.pow(j,0.136) * Math.pow(g,0.119) * Math.pow(c,0.091) * Math.pow(ch,0.036)).toFixed(2))
: null;
return { rates, dxy, base: "USD", source: "alphavantage", timestamp: Date.now() };
return Object.keys(rates).length >= 4 ? rates : null;
}
async function fetchFrankfurter() {
// ── Frankfurter (ECB daily fixing) — fallback ─────────────────────────────────
async function fetchFrankfurterRates(): Promise<{ rates: Record<string, number>; date: string } | null> {
try {
const res = await fetch("https://api.frankfurter.app/latest?from=USD", { next: { revalidate: 86400 } });
if (!res.ok) throw new Error(`Frankfurter ${res.status}`);
const data = await res.json();
const rates = data.rates as Record<string, number>;
const res = await fetch("https://api.frankfurter.app/latest?from=USD", { next: { revalidate: 300 } });
if (!res.ok) return null;
const data = await res.json();
return { rates: data.rates as Record<string, number>, date: data.date as string };
} catch { return null; }
}
// DXY approximé depuis les taux ECB (même formule que la branche AV)
// rates.X = "1 USD = X unités" — même convention que AV
const e = rates.EUR, g = rates.GBP, j = rates.JPY, c = rates.CAD, ch = rates.CHF;
// DXY = 50.14 × EURUSD^-0.576 × USDJPY^0.136 × GBPUSD^-0.119 × USDCAD^0.091 × USDCHF^0.036
// Frankfurter from=USD → e=USD/EUR, j=JPY/USD, g=USD/GBP, c=CAD/USD, ch=CHF/USD
// ⟹ EURUSD=1/e → e^0.576 ; USDJPY=j → j^0.136 ; GBPUSD=1/g → g^0.119
// USDCAD=c → c^0.091 ; USDCHF=ch → ch^0.036
const dxy = (e && g && j && c && ch)
? parseFloat((50.14348112 * Math.pow(e,0.576) * Math.pow(j,0.136) * Math.pow(g,0.119) * Math.pow(c,0.091) * Math.pow(ch,0.036)).toFixed(2))
: null;
// ── GET ───────────────────────────────────────────────────────────────────────
export async function GET() {
// Fetch Yahoo DXY in parallel with AV rates setup
const [yahooDxy] = await Promise.all([fetchYahooDXY()]);
return NextResponse.json({ rates, dxy, base: "USD", source: "frankfurter", date: data.date });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 502 });
const avKey = process.env.ALPHA_VANTAGE_KEY;
let rates: Record<string, number> = {};
let source = "none";
let date: string | undefined;
if (avKey) {
const avRates = await fetchAVRates(avKey);
if (avRates) { rates = avRates; source = "alphavantage"; }
}
if (Object.keys(rates).length < 4) {
const ff = await fetchFrankfurterRates();
if (ff) { rates = ff.rates; source = "frankfurter"; date = ff.date; }
}
// DXY : source directe Yahoo Finance (futures DX=F), proxy calculé en fallback
const dxy = yahooDxy.value ?? computeDxy(rates);
const dxyDelta = yahooDxy.delta ?? null;
if (dxy === null) {
return NextResponse.json({ error: "Unable to compute DXY — données FX insuffisantes" }, { status: 502 });
}
return NextResponse.json({
rates,
dxy,
dxyDelta,
dxySource: yahooDxy.value != null ? "Yahoo Finance DX=F" : "ICE proxy calculé",
basket: ["EUR", "JPY", "GBP", "CAD", "SEK", "CHF"],
base: "USD",
source,
...(date && { date }),
timestamp: Date.now(),
});
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { fetchAllCBPaths } from "@/lib/rateprobability";
import type { RateProbData } from "@/lib/rateprobability";
export type { RateProbData, CBRatePath, RateProbMeeting } from "@/lib/rateprobability";
export interface RateProbabilitiesResponse {
data: RateProbData;
fetchedAt: string;
}
export async function GET() {
const data = await fetchAllCBPaths();
return NextResponse.json(
{ data, fetchedAt: new Date().toISOString() } satisfies RateProbabilitiesResponse,
{ headers: { "Cache-Control": "s-maxage=3600, stale-while-revalidate=7200" } }
);
}
+133 -53
View File
@@ -1,66 +1,146 @@
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
// OANDA v20 API — position book (% long/short by pair)
const OANDA_BASE = "https://api-fxtrade.oanda.com/v3";
// ── Myfxbook Community Outlook API ────────────────────────────────────────────
// Source : https://www.myfxbook.com/community/outlook
// Auth : login.json → session token → get-community-outlook.json
// Session TTL : ~24h ; on la garde en mémoire le temps du process server.
const MAJOR_PAIRS = [
"EUR_USD", "GBP_USD", "USD_JPY", "USD_CHF",
"USD_CAD", "AUD_USD", "NZD_USD",
"EUR_GBP", "EUR_JPY", "GBP_JPY",
"AUD_JPY", "CAD_JPY", "NZD_JPY",
];
const MYFXBOOK_BASE = "https://www.myfxbook.com/api";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const pair = searchParams.get("pair");
// Server-side session cache
let _session: string | null = null;
let _sessionTs = 0;
const SESSION_TTL = 20 * 3600_000; // 20h
const apiKey = process.env.OANDA_API_KEY;
if (!apiKey) {
// Data cache (1h)
let _cache: { data: MyfxbookSentiment; ts: number } | null = null;
const DATA_TTL = 3600_000;
interface MyfxbookSymbol {
name: string; // "EURUSD"
longPercentage: number;
shortPercentage: number;
longVolume: number;
shortVolume: number;
longPositions: number;
shortPositions: number;
totalPositions: number;
}
interface MyfxbookSentiment {
symbols: MyfxbookSymbol[];
source: "myfxbook";
timestamp: number;
}
// ── Map pair → base currency (long = haussier base) ─────────────────────────
// Pour les paires USD/* on inverse (short = haussier base non-USD)
const PAIR_TO_CCY: Record<string, { ccy: string; inverse: boolean }> = {
EURUSD: { ccy: "EUR", inverse: false },
GBPUSD: { ccy: "GBP", inverse: false },
USDJPY: { ccy: "JPY", inverse: true },
USDCHF: { ccy: "CHF", inverse: true },
USDCAD: { ccy: "CAD", inverse: true },
AUDUSD: { ccy: "AUD", inverse: false },
NZDUSD: { ccy: "NZD", inverse: false },
XAUUSD: { ccy: "XAU", inverse: false },
};
// ── Login ─────────────────────────────────────────────────────────────────────
async function login(): Promise<string | null> {
const email = process.env.MYFXBOOK_EMAIL;
const password = process.env.MYFXBOOK_PASSWORD;
if (!email || !password) return null;
try {
const url = `${MYFXBOOK_BASE}/login.json?email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
const data = await res.json();
if (data.error) {
console.error("[sentiment] Myfxbook login error:", data.message);
return null;
}
_session = data.session;
_sessionTs = Date.now();
return data.session;
} catch (e) {
console.error("[sentiment] Myfxbook login exception:", e);
return null;
}
}
async function getSession(): Promise<string | null> {
if (_session && Date.now() - _sessionTs < SESSION_TTL) return _session;
return login();
}
// ── Fetch community outlook ───────────────────────────────────────────────────
async function fetchOutlook(session: string): Promise<MyfxbookSymbol[] | null> {
try {
const url = `${MYFXBOOK_BASE}/get-community-outlook.json?session=${session}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
const data = await res.json();
if (data.error) {
// Session expired → force re-login next time
if (data.message?.toLowerCase().includes("session")) _session = null;
return null;
}
return (data.symbols ?? []) as MyfxbookSymbol[];
} catch {
return null;
}
}
// ── GET ───────────────────────────────────────────────────────────────────────
export async function GET() {
// Return cached data if fresh
if (_cache && Date.now() - _cache.ts < DATA_TTL) {
return NextResponse.json(_cache.data);
}
const session = await getSession();
if (!session) {
return NextResponse.json(
{ error: "OANDA_API_KEY not configured. Add it to .env.local." },
{ error: "MYFXBOOK_EMAIL / MYFXBOOK_PASSWORD manquants dans .env.local — créez un compte gratuit sur myfxbook.com" },
{ status: 503 }
);
}
const pairsToFetch = pair ? [pair] : MAJOR_PAIRS;
const results: Record<string, { longPct: number; shortPct: number; pair: string }> = {};
let symbols = await fetchOutlook(session);
await Promise.allSettled(
pairsToFetch.map(async (p) => {
try {
const res = await fetch(
`${OANDA_BASE}/instruments/${p}/positionBook?time=current`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
next: { revalidate: 3600 },
}
);
if (!res.ok) return;
const data = await res.json();
const buckets: { price: string; longCountPercent: string; shortCountPercent: string }[] =
data?.positionBook?.buckets ?? [];
// Session expired → try once more with fresh login
if (!symbols) {
_session = null;
const fresh = await login();
if (fresh) symbols = await fetchOutlook(fresh);
}
let totalLong = 0;
let totalShort = 0;
for (const b of buckets) {
totalLong += parseFloat(b.longCountPercent ?? "0");
totalShort += parseFloat(b.shortCountPercent ?? "0");
}
const total = totalLong + totalShort;
if (total === 0) return;
results[p] = {
pair: p,
longPct: Math.round((totalLong / total) * 100),
shortPct: Math.round((totalShort / total) * 100),
};
} catch {
// silently skip unavailable pairs
}
})
);
if (!symbols) {
return NextResponse.json({ error: "Myfxbook community outlook unavailable" }, { status: 502 });
}
return NextResponse.json({ pairs: results, source: "OANDA", timestamp: Date.now() });
const result: MyfxbookSentiment = { symbols, source: "myfxbook", timestamp: Date.now() };
_cache = { data: result, ts: Date.now() };
return NextResponse.json(result);
}
// ── Helper interne : traduit symbols[] en {CCY: {longPct, shortPct}} ─────────
function symbolsToCurrencyMap(symbols: MyfxbookSymbol[]): Record<string, { longPct: number; shortPct: number; pair: string }> {
const result: Record<string, { longPct: number; shortPct: number; pair: string }> = {};
for (const sym of symbols) {
const mapping = PAIR_TO_CCY[sym.name];
if (!mapping) continue;
const { ccy, inverse } = mapping;
result[ccy] = {
pair: sym.name,
longPct: inverse ? sym.shortPercentage : sym.longPercentage,
shortPct: inverse ? sym.longPercentage : sym.shortPercentage,
};
}
return result;
}
+211 -51
View File
@@ -1,47 +1,146 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { RefreshCw, TrendingUp, AlertTriangle, Zap, Database } from "lucide-react";
import { RefreshCw, Zap, Database } from "lucide-react";
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
import type { Currency, DriverData } from "@/lib/types";
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 [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 [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
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);
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] = await Promise.allSettled([
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 (marchés globaux) ──────────────────────────────────────────
// ── 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.dxy = fxRes.value.dxy;
driversData.dxyDelta = fxRes.value.dxyDelta ?? null;
}
setDrivers(driversData);
setDriversFromCache(false);
setDriversCacheAge(null);
saveCache("drivers", driversData);
} else {
// Fallback localStorage
const cached = loadCache<DriverData>("drivers");
if (cached) {
setDrivers(cached.data);
@@ -68,6 +167,38 @@ export default function Dashboard() {
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);
@@ -98,9 +229,6 @@ export default function Dashboard() {
<h1 className="text-xl font-semibold text-gray-900">
Forex Macro Dashboard
</h1>
<p className="text-xs text-gray-500 mt-0.5">
USD · EUR · GBP · JPY · CHF · CAD · AUD · NZD v8.0
</p>
</div>
<div className="flex items-center gap-3">
@@ -115,7 +243,7 @@ export default function Dashboard() {
<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 — API indisponible">
<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>
@@ -134,47 +262,79 @@ export default function Dashboard() {
</div>
</header>
{/* Global drivers bar */}
{drivers && <DriversBar drivers={drivers} />}
{/* 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}
onDivergenceUpdate={handleDivergenceUpdate}
/>
{/* 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 · OANDA · investinglive.com
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