Forex Dashboard v8.0

This commit is contained in:
Capucine Gest
2026-05-27 13:18:45 +02:00
commit 236afa3087
31 changed files with 9292 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { COT_CODES } from "@/lib/constants";
import type { Currency } 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";
// In-memory cache (server lifetime)
let cotCache: { data: Record<string, unknown>; ts: number } | null = null;
const TTL = 7 * 24 * 3600_000; // 1 week
export async function GET() {
if (cotCache && Date.now() - cotCache.ts < TTL) {
return NextResponse.json(cotCache.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 } }
);
if (!res.ok) {
return NextResponse.json(
{ error: `CFTC fetch failed: ${res.status}`, note: "COT data may be unavailable temporarily." },
{ status: 502 }
);
}
const text = await res.text();
const result = parseCOT(text);
cotCache = { 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 }> = {};
const targetCodes = new Set(Object.values(COT_CODES));
for (let i = 1; i < lines.length; i++) {
const row = lines[i].split(",").map((v) => v.replace(/"/g, "").trim());
if (row.length < 10) 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");
if (codeIdx < 0 || longIdx < 0 || shortIdx < 0) continue;
const code = row[codeIdx];
if (!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 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,
shortPct: total > 0 ? Math.round((shorts / total) * 100) : 50,
};
}
return result;
}
+148
View File
@@ -0,0 +1,148 @@
import { NextResponse } from "next/server";
// ── Yahoo Finance v7/quote ────────────────────────────────────────────────────
// Un seul appel pour tous les prix marchés : VIX, S&P, commodités.
// Pas de clé API. Gratuit. regularMarketChange = delta vs clôture j-1.
type YQuote = {
symbol: string;
regularMarketPrice: number;
regularMarketChange: number;
regularMarketChangePercent: number;
regularMarketPreviousClose: number;
};
async function yahooQuotes(symbols: string[]): Promise<Record<string, YQuote>> {
try {
const url = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${symbols.join(",")}&fields=regularMarketPrice,regularMarketChange,regularMarketChangePercent,regularMarketPreviousClose`;
const res = await fetch(url, {
next: { revalidate: 3600 }, // 1h — prix de marché
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
},
});
if (!res.ok) return {};
const results: YQuote[] = (await res.json())?.quoteResponse?.result ?? [];
return Object.fromEntries(results.map((q) => [q.symbol, q]));
} catch { return {}; }
}
function yVal(q: YQuote | undefined): number | null {
return q?.regularMarketPrice ?? null;
}
function yDelta(q: YQuote | undefined): number | null {
const d = q?.regularMarketChange;
return d != null ? parseFloat(d.toFixed(2)) : null;
}
function yDeltaPct(q: YQuote | undefined): number | null {
const p = q?.regularMarketChangePercent;
return p != null ? parseFloat(p.toFixed(2)) : null;
}
// ── FRED (spreads crédit + taux — 24h cache) ──────────────────────────────────
async function fredObs(series: string, apiKey: string): Promise<number | null> {
try {
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=1`;
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const obs = ((await res.json())?.observations ?? []).find(
(o: { value: string }) => o.value !== "."
);
return obs ? parseFloat(obs.value) : null;
} catch { return null; }
}
// ── CoinGecko (Bitcoin — gratuit, sans clé, cache 1h) ────────────────────────
async function coingeckoBTC() {
try {
const url = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_24hr_change=true";
const res = await fetch(url, { next: { revalidate: 3600 } });
if (!res.ok) return { value: null, change24h: null };
const d = await res.json();
return { value: d?.bitcoin?.usd ?? null, change24h: d?.bitcoin?.usd_24h_change ?? null };
} catch { return { value: null, change24h: null }; }
}
// ── Alpha Vantage (S&P 500 via SPY — fallback si Yahoo échoue) ───────────────
async function avGlobalQuote(symbol: string, avKey: string) {
try {
const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${symbol}&apikey=${avKey}`;
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return { value: null, changePct: null };
const q = (await res.json())?.["Global Quote"];
if (!q) return { value: null, changePct: null };
const value = parseFloat(q["05. price"]);
const changePct = parseFloat((q["10. change percent"] ?? "0%").replace("%", ""));
return {
value: isNaN(value) ? null : value,
changePct: isNaN(changePct) ? null : changePct,
};
} catch { return { value: null, changePct: null }; }
}
// ── GET ───────────────────────────────────────────────────────────────────────
export async function GET() {
const fredKey = process.env.FRED_API_KEY;
const avKey = process.env.ALPHA_VANTAGE_KEY;
if (!fredKey) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
// 1. Yahoo Finance — tout en un seul appel (VIX + S&P + commodités + BTC)
const [quotes, btcCgRes] = await Promise.all([
yahooQuotes(["^VIX", "^GSPC", "GC=F", "SI=F", "BZ=F", "CL=F", "BTC-USD"]),
coingeckoBTC(), // fallback si Yahoo échoue pour BTC
]);
const vix = quotes["^VIX"];
const sp500 = quotes["^GSPC"];
const gold = quotes["GC=F"];
const silver = quotes["SI=F"];
const brent = quotes["BZ=F"];
const wti = quotes["CL=F"];
const btcYQ = quotes["BTC-USD"]; // Yahoo primary pour BTC
// Fallback S&P via AV si Yahoo a échoué (rare)
const sp500Fallback = !sp500 && avKey ? await avGlobalQuote("SPY", avKey) : null;
// 2. FRED — spreads crédit + taux (24h cache, données macro)
const [hyRaw, igRaw, us10y, us2y] = await Promise.all([
fredObs("BAMLH0A0HYM2", fredKey),
fredObs("BAMLC0A0CM", fredKey),
fredObs("DGS10", fredKey),
fredObs("DGS2", fredKey),
]);
return NextResponse.json({
// Sentiment / Risk-On
vix: yVal(vix),
vixDelta: yDelta(vix),
sp500: yVal(sp500) ?? sp500Fallback?.value,
sp500Change: yDelta(sp500),
sp500ChangePct: yDeltaPct(sp500) ?? sp500Fallback?.changePct,
btc: yVal(btcYQ) ?? btcCgRes.value,
btcChange24h: yDeltaPct(btcYQ) ?? btcCgRes.change24h,
// Crédit (FRED, fraction × 100 = bps)
hySpread: hyRaw != null ? Math.round(hyRaw * 100) : null,
igSpread: igRaw != null ? Math.round(igRaw * 100) : null,
// Taux (dxy injecté par page.tsx depuis /api/fx)
us10y,
us2y,
curveSlope: us10y !== null && us2y !== null ? Math.round((us10y - us2y) * 100) : null,
// Commodités — Yahoo Finance (quasi temps-réel, delta vs clôture j-1)
gold: yVal(gold),
goldDelta: yDelta(gold),
silver: yVal(silver),
silverDelta: yDelta(silver),
brent: yVal(brent),
brentDelta: yDelta(brent),
wti: yVal(wti),
wtiDelta: yDelta(wti),
// Compat
copper: null,
timestamp: Date.now(),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { readFileSync } from "fs";
import { join } from "path";
export async function GET() {
try {
const filePath = join(process.cwd(), "data", "rate_expectations.json");
const raw = readFileSync(filePath, "utf-8");
const data = JSON.parse(raw);
// Return the most recent snapshot (first item)
const latest = Array.isArray(data) ? data[0] : data;
return NextResponse.json(latest);
} catch {
return NextResponse.json({ error: "rate_expectations.json not found. Run the scraper first." }, { status: 404 });
}
}
+50
View File
@@ -0,0 +1,50 @@
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 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();
}
async function fetchAV(apiKey: string) {
// Sequential (not parallel) to respect AV's 5 req/min limit
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
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 */ }
}
if (Object.keys(rates).length < 4) return null; // too many failures → fall back
// Approximate DXY from fetched pairs (no extra AV call needed)
// DXY = 50.14 × EUR^0.576 × (1/JPY)^0.136 × GBP^0.119 × (1/CAD)^0.091 × (1/CHF)^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(1/j,0.136) * Math.pow(g,0.119) * Math.pow(1/c,0.091) * Math.pow(1/ch,0.036)).toFixed(3))
: null;
return { rates, dxy, base: "USD", source: "alphavantage", timestamp: Date.now() };
}
async function fetchFrankfurter() {
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();
return NextResponse.json({ rates: data.rates, dxy: null, base: "USD", source: "frankfurter", date: data.date });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 502 });
}
}
+195
View File
@@ -0,0 +1,195 @@
import { NextRequest, NextResponse } from "next/server";
import { FRED_SERIES } from "@/lib/constants";
import type { Currency } from "@/lib/types";
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
// Macro data changes monthly/quarterly — cache 24h
const REVALIDATE = 86400;
// ── FRED ─────────────────────────────────────────────────────────────────────
// Note: we use original index/level URLs (no units= param) so Next.js fetch cache
// stays warm. MoM%/QoQ% are computed locally via toIndicatorPct.
async function fredObs(seriesId: string, apiKey: string, limit = 5) {
const url = `${FRED_BASE}?series_id=${seriesId}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=${limit}`;
try {
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
if (!res.ok) return [];
const json = await res.json();
return (json.observations ?? [])
.filter((o: { value: string }) => o.value !== ".")
.map((o: { date: string; value: string }) => ({ date: o.date, value: parseFloat(o.value) }));
} catch { return []; }
}
// ── Eurostat SDMX-JSON API ────────────────────────────────────────────────────
async function eurostatObs(datasetCode: string, params: Record<string, string>) {
try {
const qs = new URLSearchParams({ ...params, format: "JSON" }).toString();
const url = `https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/${datasetCode}?${qs}`;
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
if (!res.ok) return [];
const json = await res.json();
const timeIndex = json?.dimension?.time?.category?.index ?? {};
const values = json?.value ?? {};
return Object.entries(timeIndex)
.map(([period, idx]) => ({ date: period, value: values[idx as number] as number | null }))
.filter((o) => o.value !== null && o.value !== undefined) as { date: string; value: number }[];
} catch { return []; }
}
async function eurostatObsSorted(datasetCode: string, params: Record<string, string>, limit = 5) {
const obs = await eurostatObs(datasetCode, params);
return obs.sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit);
}
// ── BoE API (GBP policy rate) ─────────────────────────────────────────────────
async function boeRate() {
try {
// URL dynamique : fenêtre glissante de 3 ans → évite la date hardcodée
const now = new Date();
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const td = now.getDate();
const tm = MONTHS[now.getMonth()];
const ty = now.getFullYear();
const fy = ty - 3; // 3 ans d'historique suffisent
const url = [
"https://www.bankofengland.co.uk/boeapps/database/fromshowcolumns.asp",
`?Travel=NIxIRx&FromSeries=1&ToSeries=50&DAT=RNG`,
`&FD=1&FM=Jan&FY=${fy}`,
`&TD=${td}&TM=${tm}&TY=${ty}`,
`&VPD=Y&html.x=66&html.y=26&SeriesCodes=IUDBEDR&UnitId=GBP&CSVF=TT&csv.x=47&csv.y=26`,
].join("");
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
if (!res.ok) return [];
const text = await res.text();
// Le CSV BoE peut avoir des guillemets et des en-têtes variables — on filtre proprement
const lines = text.trim().split(/\r?\n/).filter((l) => l.trim() && !l.startsWith('"DATE"') && !l.startsWith('DATE'));
return lines
.reverse()
.slice(0, 5)
.map((line) => {
const cols = line.split(",").map((c) => c.replace(/"/g, "").trim());
const val = parseFloat(cols[1] ?? "NaN");
return { date: cols[0] ?? "", value: val };
})
.filter((o) => o.date && !isNaN(o.value));
} catch { return []; }
}
// ── Shared helpers ────────────────────────────────────────────────────────────
type Obs = { date: string; value: number };
/** Direct levels/rates (already in %) */
function toIndicator(obs: Obs[]) {
if (!obs.length) return null;
const value = obs[0].value;
const prev = obs[1]?.value ?? null;
return {
value,
prev,
surprise: prev !== null ? parseFloat((value - prev).toFixed(4)) : null,
trend: prev !== null ? (value > prev ? "up" : value < prev ? "down" : "flat") : null,
lastUpdated: obs[0].date,
};
}
/**
* Converts raw level/index observations → period-over-period % change.
* MoM% for monthly series, QoQ% for quarterly.
* Requires ≥2 observations (newest first).
*/
function toIndicatorPct(obs: Obs[]) {
if (obs.length < 2) return null;
const pctObs: Obs[] = obs.slice(0, -1).map((cur, i) => ({
date: cur.date,
value: parseFloat(((cur.value / obs[i + 1].value - 1) * 100).toFixed(3)),
}));
return toIndicator(pctObs);
}
// ── Server-side cache ─────────────────────────────────────────────────────────
const _cache = new Map<string, { data: unknown; ts: number }>();
export async function GET(req: NextRequest) {
const currency = (new URL(req.url).searchParams.get("currency") ?? "").toUpperCase() as Currency;
const series = FRED_SERIES[currency];
if (!series) return NextResponse.json({ error: "Unknown currency" }, { status: 400 });
const cached = _cache.get(currency);
if (cached && Date.now() - cached.ts < 86_400_000) return NextResponse.json(cached.data);
const key = process.env.FRED_API_KEY;
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
// Fields and which need period-over-period % conversion
// policyRate / unemployment → already in % → toIndicator
// cpiCore / gdp / retailSales / employment → index/level → toIndicatorPct (MoM% or QoQ%)
const PCT_FIELDS = new Set(["cpiCore", "gdp", "retailSales", "employment"]);
const fieldMap: Record<string, string | null> = {
policyRate: series.policyRate,
cpiCore: series.cpiCore,
gdp: series.gdp,
retailSales: series.retailSales,
unemployment: series.unemployment,
employment: series.employment,
};
const fredFields = Object.entries(fieldMap).filter(([, id]) => id !== null) as [string, string][];
const fredResults = await Promise.all(fredFields.map(([, id]) => fredObs(id, key)));
const indicators: Record<string, ReturnType<typeof toIndicator>> = {};
fredFields.forEach(([field], i) => {
indicators[field] = PCT_FIELDS.has(field)
? toIndicatorPct(fredResults[i])
: toIndicator(fredResults[i]);
});
// ── EUR alternative sources ────────────────────────────────────────────────
if (currency === "EUR") {
// CPI: Eurostat HICP monthly rate of change (MoM%) — CP00 = all items
if (!indicators.cpiCore) {
const hicp = await eurostatObsSorted("prc_hicp_mmr", { geo: "EA20", coicop: "CP00" });
indicators.cpiCore = toIndicator(hicp);
}
// GDP: Eurostat chained volumes → compute QoQ%
if (!indicators.gdp) {
const gdpRaw = await eurostatObsSorted("namq_10_gdp", { geo: "EA20", unit: "CLV10_MEUR", s_adj: "SCA", na_item: "B1GQ" }, 6);
indicators.gdp = toIndicatorPct(gdpRaw);
}
// Unemployment: Eurostat monthly SA rate
if (!indicators.unemployment) {
const unObs = await eurostatObsSorted("une_rt_m", { geo: "EA20", s_adj: "SA", age: "TOTAL", sex: "T", unit: "PC_ACT" });
indicators.unemployment = toIndicator(unObs);
}
// Retail sales for EUR: FRED uses German proxy index → apply MoM% if available
// (already handled above via toIndicatorPct if FRED returned data)
}
// ── GBP alternative sources ───────────────────────────────────────────────
if (currency === "GBP" && !indicators.policyRate) {
const boe = await boeRate();
indicators.policyRate = toIndicator(boe);
}
// Ensure all keys exist (null for missing)
for (const field of Object.keys(fieldMap)) {
if (!(field in indicators)) indicators[field] = null;
}
indicators.pmiMfg = null;
indicators.pmiServices = null;
const data = { currency, indicators, fetchedAt: new Date().toISOString() };
_cache.set(currency, { data, ts: Date.now() });
return NextResponse.json(data);
}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
// Bytez uses an OpenAI-compatible API
const bytez = new OpenAI({
apiKey: process.env.BYTEZ_API_KEY ?? "",
baseURL: "https://api.bytez.com/models/openai",
});
const SYSTEM_PROMPT = `Tu es un analyste macro Forex senior. Tu analyses les données macroéconomiques de 8 devises majeures (USD, EUR, GBP, JPY, CHF, CAD, AUD, NZD) et tu fournis des synthèses concises et actionnables pour un trader particulier.
Tes analyses sont :
- Directes et factuelles — pas de conditionnel excessif
- Structurées en 3-4 points maximum
- Focalisées sur les divergences et signaux de trading
- En français
- Sans disclaimers légaux
Format de réponse : texte court, 80-120 mots maximum par devise.`;
export async function POST(req: NextRequest) {
const apiKey = process.env.BYTEZ_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "BYTEZ_API_KEY not configured" }, { status: 503 });
}
let body: {
mode: "cb_analysis" | "expert_opinion" | "summary" | "divergence";
currency?: string;
data?: unknown;
userInput?: string;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { mode, currency, data, userInput } = body;
let userMessage = "";
switch (mode) {
case "cb_analysis":
userMessage = `Analyse le communiqué de la banque centrale pour ${currency}.
Données contextuelles : ${JSON.stringify(data, null, 2)}
Fournis une analyse en 4 points :
1. Changement de ton (hawkish/dovish/neutre)
2. Évolution des projections de taux
3. Phrases clés ajoutées ou supprimées vs précédent
4. Impact suggéré sur ${currency} (+1 haussier / 0 neutre / -1 baissier)`;
break;
case "expert_opinion":
userMessage = `Point de Vérité — Confrontation IA :
Avis expert injecté : "${userInput}"
Données actuelles du dashboard pour ${currency} : ${JSON.stringify(data, null, 2)}
Analyse :
- Convergences entre l'avis expert et les données quantitatives
- Divergences et points de tension
- Score avant/après ajustement suggéré
- Validation ou rejet de l'avis par devise`;
break;
case "divergence":
userMessage = `Analyse les divergences de positionnement détectées pour ${currency} :
${JSON.stringify(data, null, 2)}
Explique en 3 phrases : pourquoi cette configuration est significative et quelle action de trading elle suggère.`;
break;
case "summary":
default:
userMessage = `Génère une synthèse macro hebdomadaire pour ${currency} basée sur ces données :
${JSON.stringify(data, null, 2)}
Résumé en 3 points : situation actuelle, signal directionnel, risque principal.`;
}
try {
const completion = await bytez.chat.completions.create({
model: "meta-llama/Llama-3.1-8B-Instruct",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
max_tokens: 300,
temperature: 0.3,
});
const text = completion.choices[0]?.message?.content ?? "";
return NextResponse.json({ analysis: text, model: completion.model, mode });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: `Bytez error: ${message}` }, { status: 502 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
// OANDA v20 API — position book (% long/short by pair)
const OANDA_BASE = "https://api-fxtrade.oanda.com/v3";
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",
];
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const pair = searchParams.get("pair");
const apiKey = process.env.OANDA_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "OANDA_API_KEY not configured. Add it to .env.local." },
{ status: 503 }
);
}
const pairsToFetch = pair ? [pair] : MAJOR_PAIRS;
const results: Record<string, { longPct: number; shortPct: number; pair: string }> = {};
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 ?? [];
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
}
})
);
return NextResponse.json({ pairs: results, source: "OANDA", timestamp: Date.now() });
}
+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
// 10Y sovereign yields — mixed sources per CDC §6.4
// USD: FRED DGS10 (daily)
// EUR: ECB API (daily Bund)
// GBP: BoE API IUDMNPY (daily)
// Others: FRED monthly as fallback
const FRED_KEY = () => process.env.FRED_API_KEY ?? "";
async function fredObs(series: string): Promise<number | null> {
try {
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${FRED_KEY()}&file_type=json&sort_order=desc&limit=3`;
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const val = (data?.observations ?? []).find((o: { value: string }) => o.value !== ".")?.value;
return val ? parseFloat(val) : null;
} catch {
return null;
}
}
async function ecbBund10Y(): Promise<number | null> {
try {
const url = "https://data-api.ecb.europa.eu/service/data/YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y?format=jsondata&lastNObservations=1";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const obs = data?.dataSets?.[0]?.series?.["0:0:0:0:0:0:0"]?.observations;
if (!obs) return null;
const last = Object.values(obs).at(-1) as number[] | undefined;
return last?.[0] ?? null;
} catch {
return null;
}
}
async function boeGilt10Y(): Promise<number | null> {
try {
// BoE API series IUDMNPY = UK Nominal Par Yield 10Y
const url = "https://www.bankofengland.co.uk/boeapps/database/_iadb-FromShowColumns.asp?csv.x=yes&Datefrom=01/Jan/2024&Dateto=now&SeriesCodes=IUDMNPY&CSVF=TN&UsingCodes=Y";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const text = await res.text();
const lines = text.trim().split("\n").filter((l) => l.trim());
const last = lines.at(-1)?.split(",");
const val = last?.at(-1)?.trim();
return val ? parseFloat(val) : null;
} catch {
return null;
}
}
async function bocYield10Y(): Promise<number | null> {
try {
const url = "https://www.bankofcanada.ca/valet/observations/BD.CDN.10YR.DQ.YLD/json?recent=5";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const obs: { d: string; "BD.CDN.10YR.DQ.YLD": { v: string } }[] =
data?.observations ?? [];
const last = obs.findLast((o) => o["BD.CDN.10YR.DQ.YLD"]?.v);
return last ? parseFloat(last["BD.CDN.10YR.DQ.YLD"].v) : null;
} catch {
return null;
}
}
export async function GET() {
const [usd, eur, gbp, jpy, chf, cad, aud, nzd] = await Promise.all([
fredObs("DGS10"),
ecbBund10Y(),
boeGilt10Y(),
fredObs("IRLTLT01JPM156N"),
fredObs("IRLTLT01CHM156N"),
bocYield10Y(),
fredObs("IRLTLT01AUM156N"),
fredObs("IRLTLT01NZM156N"),
]);
const yields = { USD: usd, EUR: eur, GBP: gbp, JPY: jpy, CHF: chf, CAD: cad, AUD: aud, NZD: nzd };
// Compute spreads vs USD
const spreads: Record<string, number | null> = {};
for (const [ccy, yld] of Object.entries(yields)) {
if (ccy === "USD" || yld === null || usd === null) {
spreads[ccy] = null;
} else {
spreads[ccy] = Math.round((yld - usd) * 100); // bps
}
}
return NextResponse.json({ yields, spreads, timestamp: Date.now() });
}
+19
View File
@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground: #111827;
--background: #f9fafb;
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
}
/* Bias badges */
.badge-bull { @apply bg-green-100 text-green-800 border border-green-200; }
.badge-bear { @apply bg-red-100 text-red-800 border border-red-200; }
.badge-neutral { @apply bg-gray-100 text-gray-700 border border-gray-200; }
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Forex Macro Dashboard",
description: "Tableau de bord macroéconomique Forex — 8 devises majeures",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className="min-h-screen bg-gray-50">{children}</body>
</html>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { RefreshCw, TrendingUp, AlertTriangle, Zap } from "lucide-react";
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
import type { Currency, DriverData } from "@/lib/types";
import CurrencyCard from "@/components/CurrencyCard";
import DriversBar from "@/components/DriversBar";
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 [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [driversRes, expectRes, yieldsRes, fxRes] = 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()),
]);
if (driversRes.status === "fulfilled") {
const driversData = driversRes.value;
// Merge DXY from /api/fx into drivers
if (fxRes.status === "fulfilled" && fxRes.value?.dxy != null) {
driversData.dxy = fxRes.value.dxy;
}
setDrivers(driversData);
}
if (expectRes.status === "fulfilled") setExpectations(expectRes.value);
if (yieldsRes.status === "fulfilled") setYields(yieldsRes.value);
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>
<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">
{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="text-xs text-gray-400">
{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>
{/* 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}
/>
))}
</div>
{/* 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
</p>
<p>
LLM: Bytez (Llama 3.1) · Données à titre informatif uniquement pas de conseil financier
</p>
</footer>
</div>
);
}