diff --git a/app/api/macro/route.ts b/app/api/macro/route.ts index 5a08d75..9b87683 100644 --- a/app/api/macro/route.ts +++ b/app/api/macro/route.ts @@ -7,8 +7,6 @@ const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations"; 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}`; @@ -22,11 +20,14 @@ async function fredObs(seriesId: string, apiKey: string, limit = 5) { } catch { return []; } } -// ── Eurostat SDMX-JSON API ──────────────────────────────────────────────────── +// ── Eurostat SDMX-JSON API ───────────────────────────────────────────────────── +// IMPORTANT: toutes les dimensions non-temporelles DOIVENT avoir une valeur unique +// dans les paramètres (freq, unit, s_adj, coicop…) sinon la correspondance +// position → indice temporel est fausse et on obtient des NaN. async function eurostatObs(datasetCode: string, params: Record) { try { - const qs = new URLSearchParams({ ...params, format: "JSON" }).toString(); + 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 []; @@ -39,8 +40,16 @@ async function eurostatObs(datasetCode: string, params: Record) } catch { return []; } } -async function eurostatObsSorted(datasetCode: string, params: Record, limit = 5) { - const obs = await eurostatObs(datasetCode, params); +async function eurostatSorted( + datasetCode: string, + params: Record, + limit = 5, +): Promise<{ date: string; value: number }[]> { + // Essaie d'abord les params fournis, puis fallback geo EA20→EA19 + let obs = await eurostatObs(datasetCode, params); + if (!obs.length && params.geo === "EA20") { + obs = await eurostatObs(datasetCode, { ...params, geo: "EA19" }); + } return obs.sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit); } @@ -48,13 +57,12 @@ async function eurostatObsSorted(datasetCode: string, params: Record l.trim() && !l.startsWith('"DATE"') && !l.startsWith('DATE')); + const text = await res.text(); + 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"); + const val = parseFloat(cols[1] ?? "NaN"); return { date: cols[0] ?? "", value: val }; }) .filter((o) => o.date && !isNaN(o.value)); } catch { return []; } } -// ── Trading Economics PMI scraping ─────────────────────────────────────────── -// URL pattern: https://tradingeconomics.com/{country}/{indicator} -// Source : balise dans le HTML -// Ex: "Manufacturing PMI in the United States increased to 55.30 points in May -// from 54.50 points in April of 2026" +// ── ForexFactory calendar (PMI source primaire) ─────────────────────────────── +// JSON structuré, pas de scraping, ~98 événements/semaine avec actual/previous/forecast. +// Limité à la semaine courante — complété par le scraping TE si l'event n'est pas encore publié. + +async function fetchFFPMI(currency: string): Promise<{ + mfg: { value: number; prev: number | null } | null; + svc: { value: number; prev: number | null } | null; +}> { + const empty = { mfg: null, svc: null }; + try { + const res = await fetch("https://nfs.faireconomy.media/ff_calendar_thisweek.json", { + next: { revalidate: 3600 }, + headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" }, + }); + if (!res.ok) return empty; + const events = await res.json() as Array<{ + title: string; country: string; actual: string; previous: string; forecast: string; + }>; + + const forCcy = events.filter((e) => e.country === currency && e.actual); + const isMfg = (t: string) => /manufacturing\s+pmi|mfg\s+pmi|s&p.*manufacturing/i.test(t); + const isSvc = (t: string) => /services?\s+pmi|ism\s+non.manufactur|composite\s+pmi/i.test(t); + + const mfgEv = forCcy.find((e) => isMfg(e.title)); + const svcEv = forCcy.find((e) => isSvc(e.title)); + + const parse = (e: typeof mfgEv) => { + if (!e?.actual) return null; + const val = parseFloat(e.actual); + const prev = parseFloat(e.previous ?? ""); + if (isNaN(val)) return null; + return { value: val, prev: isNaN(prev) ? null : prev }; + }; + + return { mfg: parse(mfgEv), svc: parse(svcEv) }; + } catch { return empty; } +} + +// ── Trading Economics PMI scraping (fallback) ───────────────────────────────── +// Quand ForexFactory n'a pas l'event de la semaine courante, on scrape TE. +// Extrait la valeur et le précédent depuis la balise . const TE_COUNTRY: Record = { USD: "united-states", @@ -98,41 +150,40 @@ const TE_COUNTRY: Record = { async function scrapePMI( currency: string, - indicator: "manufacturing-pmi" | "services-pmi" + indicator: "manufacturing-pmi" | "services-pmi", ): Promise<{ value: number | null; prev: number | null }> { const country = TE_COUNTRY[currency]; if (!country) return { value: null, prev: null }; try { const url = `https://tradingeconomics.com/${country}/${indicator}`; - const res = await fetch(url, { - next: { revalidate: 3600 }, // cache 1h — données PMI mensuelles + const res = await fetch(url, { + next: { revalidate: 3600 }, headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Accept-Language": "en-US,en;q=0.9", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Cache-Control": "no-cache", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Pragma": "no-cache", }, }); if (!res.ok) return { value: null, prev: null }; const html = await res.text(); - // Cherche la balise meta description - const metaMatch = html.match(/= 1) { - return { - value: parseFloat(nums[0]), - prev: nums[1] ? parseFloat(nums[1]) : null, - }; - } - return { value: null, prev: null }; - } - return { value: parseFloat(m[1]), prev: parseFloat(m[2]) }; + const desc = metaMatch[1]; + const numRe = /(?:increased|decreased|declined|rose|fell|eased|stood)\s+(?:at\s+)?to?\s*([\d.]+)\s+points?.+?from\s+([\d.]+)\s+points?/i; + const m = desc.match(numRe); + if (m) return { value: parseFloat(m[1]), prev: parseFloat(m[2]) }; + // Fallback numérique : deux premiers décimaux dans la description + const nums = desc.match(/\b(\d{1,3}\.\d{1,2})\b/g); + if (nums?.length) return { value: parseFloat(nums[0]), prev: nums[1] ? parseFloat(nums[1]) : null }; + return { value: null, prev: null }; } catch { return { value: null, prev: null }; } } @@ -140,7 +191,6 @@ async function scrapePMI( 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; @@ -154,11 +204,6 @@ function toIndicator(obs: Obs[]) { }; } -/** - * 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) => ({ @@ -168,6 +213,18 @@ function toIndicatorPct(obs: Obs[]) { return toIndicator(pctObs); } +function toPmiIndicator(raw: { value: number | null; prev: number | null }) { + if (raw.value === null) return null; + const surprise = raw.prev !== null ? parseFloat((raw.value - raw.prev).toFixed(2)) : null; + return { + value: raw.value, + prev: raw.prev, + surprise, + trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") as "up"|"down"|"flat" : null, + lastUpdated: null as string | null, + }; +} + // ── Server-side cache ───────────────────────────────────────────────────────── const _cache = new Map(); @@ -177,19 +234,15 @@ export async function GET(req: NextRequest) { const series = FRED_SERIES[currency]; if (!series) return NextResponse.json({ error: "Unknown currency" }, { status: 400 }); - const cached = _cache.get(currency); - // Sert le cache pendant 24h sans re-fetcher - if (cached && Date.now() - cached.ts < 86_400_000) return NextResponse.json(cached.data); - // Garde une référence au cache précédent pour fallback stale-if-error + const cached = _cache.get(currency); const staleCache = cached ?? null; + 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 / retailSales → already in % → toIndicator - // (retailSales uses *SLRTTO01GPSAM series which report MoM% directly) - // cpiCore / gdp / employment → index/level → toIndicatorPct (MoM% or QoQ%) + // policyRate / unemployment / retailSales → already % → toIndicator + // cpiCore / gdp / employment → index/level → toIndicatorPct const PCT_FIELDS = new Set(["cpiCore", "gdp", "employment"]); const fieldMap: Record = { @@ -212,32 +265,36 @@ export async function GET(req: NextRequest) { }); // ── EUR alternative sources ──────────────────────────────────────────────── - if (currency === "EUR") { - // CPI: Eurostat HICP monthly rate of change (MoM%) — CP00 = all items + // CPI: Eurostat HICP MoM% (RCH_MOM = rate of change month-on-month, déjà en %) + // freq=M et unit=RCH_MOM garantissent que chaque dimension ≤ 1 valeur + // → position dans value[] = indice temporel, pas de décalage multi-dim. if (!indicators.cpiCore) { - const hicp = await eurostatObsSorted("prc_hicp_mmr", { geo: "EA20", coicop: "CP00" }); + const hicp = await eurostatSorted("prc_hicp_mmr", { + geo: "EA20", coicop: "CP00", unit: "RCH_MOM", freq: "M", + }); indicators.cpiCore = toIndicator(hicp); } - // GDP: Eurostat chained volumes → compute QoQ% + // GDP: Eurostat QoQ% directement (CLV_PCH_PRE = % variation vs période précédente) + // → toIndicator suffit, pas besoin de toIndicatorPct 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); + const gdpObs = await eurostatSorted("namq_10_gdp", { + geo: "EA20", unit: "CLV_PCH_PRE", s_adj: "SCA", na_item: "B1GQ", freq: "Q", + }, 6); + indicators.gdp = toIndicator(gdpObs); } // 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" }); + const unObs = await eurostatSorted("une_rt_m", { + geo: "EA20", s_adj: "SA", age: "TOTAL", sex: "T", unit: "PC_ACT", freq: "M", + }); 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); @@ -247,29 +304,18 @@ export async function GET(req: NextRequest) { for (const field of Object.keys(fieldMap)) { if (!(field in indicators)) indicators[field] = null; } - // ── PMI scraping (Trading Economics) ────────────────────────────────────── - const [pmiMfgRaw, pmiSvcRaw] = await Promise.all([ + + // ── PMI : ForexFactory (semaine courante) + fallback TE scraping ─────────── + const [ffPMI, pmiMfgRaw, pmiSvcRaw] = await Promise.all([ + fetchFFPMI(currency), scrapePMI(currency, "manufacturing-pmi"), scrapePMI(currency, "services-pmi"), ]); - // Convertit le résultat { value, prev } en format Indicator (surprise = diff) - const toPmiIndicator = (raw: { value: number | null; prev: number | null }) => { - if (raw.value === null) return null; - const surprise = raw.prev !== null ? parseFloat((raw.value - raw.prev).toFixed(2)) : null; - return { - value: raw.value, - prev: raw.prev, - surprise, - trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") as "up"|"down"|"flat" : null, - lastUpdated: null, - }; - }; - indicators.pmiMfg = toPmiIndicator(pmiMfgRaw); - indicators.pmiServices = toPmiIndicator(pmiSvcRaw); + indicators.pmiMfg = ffPMI.mfg ? toPmiIndicator(ffPMI.mfg) : toPmiIndicator(pmiMfgRaw); + indicators.pmiServices = ffPMI.svc ? toPmiIndicator(ffPMI.svc) : toPmiIndicator(pmiSvcRaw); - // Stale-if-error : si tous les indicateurs sont null (API en panne), - // on renvoie le cache précédent plutôt que des tirets vides. + // Stale-if-error : si tous les indicateurs sont null (panne API), renvoie le cache précédent const hasAnyValue = Object.values(indicators).some((v) => v !== null); if (!hasAnyValue && staleCache) { return NextResponse.json({ ...staleCache.data, stale: true }); diff --git a/components/CurrencyCard.tsx b/components/CurrencyCard.tsx index 2f6a531..d8e78f1 100644 --- a/components/CurrencyCard.tsx +++ b/components/CurrencyCard.tsx @@ -100,13 +100,24 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge const json: MacroData = await res.json(); if ("error" in json) throw new Error(String((json as Record).error)); - setData(json); + // PMI est scraped mensuellement → si le serveur renvoie null cette semaine + // (PMI pas encore publié), on conserve la valeur précédente du cache local. + const prevCache = loadCache(cacheKey); + const merged: MacroData = { + ...json, + indicators: { + ...json.indicators, + pmiMfg: json.indicators.pmiMfg ?? prevCache?.data.indicators.pmiMfg ?? null, + pmiServices: json.indicators.pmiServices ?? prevCache?.data.indicators.pmiServices ?? null, + }, + }; + setData(merged); setFromCache(false); setCacheAge(null); - saveCache(cacheKey, json); + saveCache(cacheKey, merged); // Infer phase from policy rate trend - const rateInd = json.indicators.policyRate; + const rateInd = merged.indicators.policyRate; if (rateInd?.trend === "up") setPhase("tightening"); else if (rateInd?.trend === "down") setPhase("easing"); else setPhase("hawkish_pause");