Files
forex-dashboard/app/api/macro/route.ts
T
2026-05-27 13:18:45 +02:00

196 lines
8.6 KiB
TypeScript

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);
}