2026-05-27 13:18:45 +02:00
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
|
|
import { FRED_SERIES } from "@/lib/constants";
|
|
|
|
|
|
import type { Currency } from "@/lib/types";
|
2026-06-29 12:17:11 +02:00
|
|
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
2026-06-01 23:16:18 +02:00
|
|
|
|
import cpiOverridesRaw from "@/data/cpi_overrides.json";
|
|
|
|
|
|
import rateDecisionsRaw from "@/data/rate_decisions.json";
|
2026-07-08 16:46:05 +02:00
|
|
|
|
import moneySupplyM3Raw from "@/data/money-supply-m3.json";
|
2026-06-01 23:16:18 +02:00
|
|
|
|
import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
|
|
|
|
|
|
import type { FFEvent } from "@/lib/forexfactory";
|
2026-06-27 14:12:05 +02:00
|
|
|
|
import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate, fetchTESTIRRate, fetchTEEmploymentChange } from "@/lib/tecpi";
|
2026-06-02 00:03:37 +02:00
|
|
|
|
import { fetchTEInflationForecasts } from "@/lib/tradingeconomics";
|
2026-05-27 13:18:45 +02:00
|
|
|
|
|
|
|
|
|
|
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
|
2026-06-08 19:41:50 +02:00
|
|
|
|
const REVALIDATE = 3600; // cache 1h (les données FRED sont disponibles ~1h après publication)
|
2026-05-27 13:18:45 +02:00
|
|
|
|
|
|
|
|
|
|
// ── FRED ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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 []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Récupère deux séries FRED en parallèle et retourne celle avec la date la plus récente.
|
|
|
|
|
|
* Utilisé pour choisir la meilleure source disponible (ex: IRSTCB01 vs IR3TIB01).
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function fredObsFreshest(s1: string, s2: string, apiKey: string, limit = 5): Promise<Obs[]> {
|
|
|
|
|
|
const [a, b] = await Promise.all([fredObs(s1, apiKey, limit), fredObs(s2, apiKey, limit)]);
|
|
|
|
|
|
if (!a.length) return b;
|
|
|
|
|
|
if (!b.length) return a;
|
|
|
|
|
|
return a[0].date >= b[0].date ? a : b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Banque du Canada — Valet API ──────────────────────────────────────────────
|
|
|
|
|
|
// V80691311 = Taux d'intérêt directeur de la Banque du Canada (quotidien officiel)
|
|
|
|
|
|
// Source fiable, gratuite, sans clé, JSON structuré.
|
|
|
|
|
|
|
|
|
|
|
|
async function bocRate(): Promise<Obs[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = "https://www.bankofcanada.ca/valet/observations/V80691311/json?recent=10";
|
|
|
|
|
|
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
|
|
|
|
|
|
if (!res.ok) return [];
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
type BoCObs = Record<string, unknown> & { d?: unknown; V80691311?: { v: string } };
|
|
|
|
|
|
return ((json?.observations ?? []) as BoCObs[])
|
|
|
|
|
|
.filter((o) => typeof o.V80691311?.v === "string")
|
|
|
|
|
|
.map((o) => ({ date: String(o.d ?? ""), value: parseFloat(o.V80691311!.v) }))
|
|
|
|
|
|
.filter((o) => o.date && !isNaN(o.value))
|
|
|
|
|
|
.sort((a, b) => b.date.localeCompare(a.date)); // newest first
|
|
|
|
|
|
} catch { return []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 23:13:40 +02:00
|
|
|
|
// ── Eurostat SDMX-JSON API ─────────────────────────────────────────────────────
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// IMPORTANT : toutes les dimensions non-temporelles DOIVENT avoir une valeur
|
|
|
|
|
|
// unique dans les params (freq, unit, s_adj…) → position value[]=timeIndex correct.
|
2026-05-27 13:18:45 +02:00
|
|
|
|
|
|
|
|
|
|
async function eurostatObs(datasetCode: string, params: Record<string, string>) {
|
|
|
|
|
|
try {
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const qs = new URLSearchParams({ ...params, format: "JSON" }).toString();
|
2026-05-27 13:18:45 +02:00
|
|
|
|
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 []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 23:13:40 +02:00
|
|
|
|
async function eurostatSorted(
|
|
|
|
|
|
datasetCode: string,
|
|
|
|
|
|
params: Record<string, string>,
|
|
|
|
|
|
limit = 5,
|
2026-05-29 12:13:05 +02:00
|
|
|
|
): Promise<Obs[]> {
|
2026-05-28 23:13:40 +02:00
|
|
|
|
let obs = await eurostatObs(datasetCode, params);
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// Fallback automatique EA20 → EA19 pour les agrégats zone euro
|
2026-05-28 23:13:40 +02:00
|
|
|
|
if (!obs.length && params.geo === "EA20") {
|
|
|
|
|
|
obs = await eurostatObs(datasetCode, { ...params, geo: "EA19" });
|
|
|
|
|
|
}
|
2026-05-27 13:18:45 +02:00
|
|
|
|
return obs.sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── BoE API (GBP policy rate) ─────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
async function boeRate(): Promise<Obs[]> {
|
2026-05-27 13:18:45 +02:00
|
|
|
|
try {
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const now = new Date();
|
2026-05-27 13:18:45 +02:00
|
|
|
|
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();
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const fy = ty - 3;
|
2026-05-27 13:18:45 +02:00
|
|
|
|
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("");
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
|
next: { revalidate: REVALIDATE },
|
|
|
|
|
|
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",
|
2026-05-29 12:13:05 +02:00
|
|
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
2026-05-28 23:13:40 +02:00
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-05-27 13:18:45 +02:00
|
|
|
|
if (!res.ok) return [];
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const text = await res.text();
|
|
|
|
|
|
const lines = text.trim().split(/\r?\n/).filter(
|
|
|
|
|
|
(l) => l.trim() && !l.startsWith('"DATE"') && !l.startsWith("DATE")
|
|
|
|
|
|
);
|
2026-05-27 13:18:45 +02:00
|
|
|
|
return lines
|
|
|
|
|
|
.reverse()
|
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
|
.map((line) => {
|
|
|
|
|
|
const cols = line.split(",").map((c) => c.replace(/"/g, "").trim());
|
2026-05-29 12:13:05 +02:00
|
|
|
|
return { date: cols[0] ?? "", value: parseFloat(cols[1] ?? "NaN") };
|
2026-05-27 13:18:45 +02:00
|
|
|
|
})
|
|
|
|
|
|
.filter((o) => o.date && !isNaN(o.value));
|
|
|
|
|
|
} catch { return []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
// ── SNB officiel (CHF policy rate) ─────────────────────────────────────────────
|
|
|
|
|
|
// snb.ch publie ses décisions sous /press-releases-restricted/pre_YYYYMMDD.
|
|
|
|
|
|
// On découvre la plus récente depuis la page listing (1er lien du pattern),
|
|
|
|
|
|
// puis on lit le taux dans le titre : "leaves ... unchanged at X%" / "lowers/raises ... to X%".
|
|
|
|
|
|
async function scrapeSnbRate(): Promise<number | null> {
|
|
|
|
|
|
const 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": "text/html,application/xhtml+xml,*/*;q=0.8",
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const listRes = await fetch("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions", {
|
|
|
|
|
|
next: { revalidate: REVALIDATE }, headers,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!listRes.ok) return null;
|
|
|
|
|
|
const listHtml = await listRes.text();
|
|
|
|
|
|
const linkMatch = listHtml.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_\d{8}[^"]*)"/);
|
|
|
|
|
|
if (!linkMatch) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const prRes = await fetch(`https://www.snb.ch${linkMatch[1]}`, {
|
|
|
|
|
|
next: { revalidate: REVALIDATE }, headers,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!prRes.ok) return null;
|
|
|
|
|
|
const prHtml = await prRes.text();
|
|
|
|
|
|
const rateMatch = prHtml.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i);
|
|
|
|
|
|
return rateMatch ? parseFloat(rateMatch[1]) : null;
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fed officiel (USD policy rate) ──────────────────────────────────────────────
|
|
|
|
|
|
// fomccalendars.htm liste, pour chaque réunion, un lien de statement
|
|
|
|
|
|
// /newsevents/pressreleases/monetary(YYYYMMDD)a.htm. On prend la réunion passée
|
|
|
|
|
|
// la plus récente puis on lit la fourchette officielle : "target range for the
|
|
|
|
|
|
// federal funds rate at/to X to Y percent" (X/Y en fractions type "3-1/2").
|
|
|
|
|
|
// On retourne le haut de fourchette (convention "upper bound" déjà utilisée ici).
|
|
|
|
|
|
function parseFedFraction(s: string): number {
|
|
|
|
|
|
const m = s.match(/^(\d+)(?:-(\d+)\/(\d+))?$/);
|
|
|
|
|
|
if (!m) return NaN;
|
|
|
|
|
|
const whole = parseFloat(m[1]);
|
|
|
|
|
|
return m[2] ? whole + parseFloat(m[2]) / parseFloat(m[3]) : whole;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function scrapeFedRate(): Promise<number | null> {
|
|
|
|
|
|
const 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": "text/html,application/xhtml+xml,*/*;q=0.8",
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const calRes = await fetch("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", {
|
|
|
|
|
|
next: { revalidate: REVALIDATE }, headers,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!calRes.ok) return null;
|
|
|
|
|
|
const calHtml = await calRes.text();
|
|
|
|
|
|
|
|
|
|
|
|
const todayCompact = new Date().toISOString().slice(0,10).replace(/-/g,"");
|
|
|
|
|
|
const dates = new Set<string>();
|
|
|
|
|
|
for (const m of Array.from(calHtml.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g))) dates.add(m[1]);
|
|
|
|
|
|
const latestPast = Array.from(dates).filter(d => d <= todayCompact).sort().reverse()[0];
|
|
|
|
|
|
if (!latestPast) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const stRes = await fetch(`https://www.federalreserve.gov/newsevents/pressreleases/monetary${latestPast}a.htm`, {
|
|
|
|
|
|
next: { revalidate: REVALIDATE }, headers,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!stRes.ok) return null;
|
|
|
|
|
|
const stHtml = await stRes.text();
|
|
|
|
|
|
const rateMatch = stHtml.match(/target range for the federal funds rate[\s\S]{0,60}?\d+(?:-\d\/\d)?\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i);
|
|
|
|
|
|
if (!rateMatch) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const upper = parseFedFraction(rateMatch[1]);
|
|
|
|
|
|
return isNaN(upper) ? null : upper;
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// ── DBnomics API (agrégateur IMF/IFS, BIS, OECD…) ────────────────────────────
|
|
|
|
|
|
// Format : https://api.db.nomics.world/v22/series/{provider}/{dataset}/{code}?observations=1
|
|
|
|
|
|
// Utilisé pour les séries absentes de FRED : JPY CPI, AUD/NZD CPI fallback
|
|
|
|
|
|
// Réponse : series.docs[0].period[] + series.docs[0].value[]
|
|
|
|
|
|
|
|
|
|
|
|
async function dbnomicsObs(provider: string, dataset: string, seriesCode: string, limit = 8): Promise<Obs[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = `https://api.db.nomics.world/v22/series/${provider}/${dataset}/${seriesCode}?observations=1`;
|
|
|
|
|
|
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
|
|
|
|
|
|
if (!res.ok) return [];
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
const s = json?.series?.docs?.[0] as { period?: string[]; value?: (number | null)[] } | undefined;
|
|
|
|
|
|
const periods = s?.period ?? [];
|
|
|
|
|
|
const values = s?.value ?? [];
|
|
|
|
|
|
const obs: Obs[] = [];
|
|
|
|
|
|
for (let i = periods.length - 1; i >= 0 && obs.length < limit; i--) {
|
|
|
|
|
|
const v = values[i];
|
|
|
|
|
|
if (v !== null && v !== undefined && !isNaN(Number(v))) {
|
|
|
|
|
|
obs.push({ date: periods[i], value: Number(v) });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return obs;
|
|
|
|
|
|
} catch { return []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// ── ForexFactory — fetchFFThisWeek importé depuis @/lib/forexfactory ───────────
|
2026-05-28 23:13:40 +02:00
|
|
|
|
|
|
|
|
|
|
async function fetchFFPMI(currency: string): Promise<{
|
2026-06-01 23:16:18 +02:00
|
|
|
|
mfg: { value: number; prev: number | null } | null;
|
|
|
|
|
|
svc: { value: number; prev: number | null } | null;
|
|
|
|
|
|
composite: { value: number; prev: number | null } | null;
|
2026-05-28 23:13:40 +02:00
|
|
|
|
}> {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const empty = { mfg: null, svc: null, composite: null };
|
2026-05-28 23:13:40 +02:00
|
|
|
|
try {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const events = await fetchFFThisWeek();
|
|
|
|
|
|
const forCcy = events.filter((e) => e.country === currency && e.actual);
|
|
|
|
|
|
const isMfg = (t: string) => /manufacturing\s+pmi|mfg\s+pmi/i.test(t);
|
|
|
|
|
|
const isComposite = (t: string) => /composite\s+pmi/i.test(t);
|
|
|
|
|
|
const isSvc = (t: string) => /services?\s+pmi|ism\s+non.manufactur/i.test(t);
|
|
|
|
|
|
const parse = (e: FFEvent | undefined) => {
|
2026-05-28 23:13:40 +02:00
|
|
|
|
if (!e?.actual) return null;
|
|
|
|
|
|
const val = parseFloat(e.actual);
|
|
|
|
|
|
const prev = parseFloat(e.previous ?? "");
|
2026-05-29 12:13:05 +02:00
|
|
|
|
return isNaN(val) ? null : { value: val, prev: isNaN(prev) ? null : prev };
|
2026-05-28 23:13:40 +02:00
|
|
|
|
};
|
2026-06-01 23:16:18 +02:00
|
|
|
|
return {
|
|
|
|
|
|
mfg: parse(forCcy.find((e) => isMfg(e.title))),
|
|
|
|
|
|
svc: parse(forCcy.find((e) => isSvc(e.title))),
|
|
|
|
|
|
composite: parse(forCcy.find((e) => isComposite(e.title))),
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch { return empty; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── ForexFactory — consensus + surprise post-publication ─────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// Règle :
|
|
|
|
|
|
// • Événement UPCOMING (forecast non vide, actual vide)
|
|
|
|
|
|
// → afficher le consensus (forecast) dans la colonne "Cons."
|
|
|
|
|
|
// • Événement RÉCENT (forecast + actual, release ≤ 5 jours)
|
|
|
|
|
|
// → afficher la surprise = actual − forecast dans la colonne "Surpr."
|
|
|
|
|
|
// • Événement ANCIEN (> 5 jours) ou sans forecast
|
|
|
|
|
|
// → null (rien à afficher)
|
|
|
|
|
|
|
|
|
|
|
|
interface FFForecasts {
|
|
|
|
|
|
cpi: number | null;
|
|
|
|
|
|
cpiSurprise: number | null;
|
|
|
|
|
|
unemployment: number | null;
|
|
|
|
|
|
unemploymentSurprise: number | null;
|
|
|
|
|
|
pmiMfg: number | null;
|
|
|
|
|
|
pmiMfgSurprise: number | null;
|
|
|
|
|
|
pmiSvc: number | null;
|
|
|
|
|
|
pmiSvcSurprise: number | null;
|
|
|
|
|
|
pmiComposite: number | null; // PMI Composite — séparé des Services
|
|
|
|
|
|
pmiCompositeSurprise: number | null;
|
|
|
|
|
|
retailSales: number | null;
|
|
|
|
|
|
retailSalesSurprise: number | null;
|
|
|
|
|
|
gdp: number | null;
|
|
|
|
|
|
gdpSurprise: number | null;
|
|
|
|
|
|
employment: number | null;
|
|
|
|
|
|
employmentSurprise: number | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchFFForecasts(currency: string): Promise<FFForecasts> {
|
|
|
|
|
|
const empty: FFForecasts = {
|
|
|
|
|
|
cpi: null, cpiSurprise: null,
|
|
|
|
|
|
unemployment: null, unemploymentSurprise: null,
|
|
|
|
|
|
pmiMfg: null, pmiMfgSurprise: null,
|
|
|
|
|
|
pmiSvc: null, pmiSvcSurprise: null,
|
|
|
|
|
|
pmiComposite: null, pmiCompositeSurprise: null,
|
|
|
|
|
|
retailSales: null, retailSalesSurprise: null,
|
|
|
|
|
|
gdp: null, gdpSurprise: null,
|
|
|
|
|
|
employment: null, employmentSurprise: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const events = await fetchFFEvents(); // cette semaine + semaine prochaine
|
|
|
|
|
|
// EUR : ForexFactory tague country="EUR" pour TOUS les pays de la zone (Italie, Espagne…).
|
|
|
|
|
|
// On garde uniquement les événements dont le titre commence par "Euro Zone" ou "Euro Area"
|
|
|
|
|
|
// pour éviter de matcher Italian Unemployment, Spanish CPI, etc. à la place des données agrégées.
|
|
|
|
|
|
const forCcy = currency === "EUR"
|
|
|
|
|
|
? events.filter((e) => e.country === "EUR" && /^euro(?:zone|[\s-](?:zone|area))/i.test(e.title))
|
|
|
|
|
|
: events.filter((e) => e.country === currency);
|
|
|
|
|
|
|
|
|
|
|
|
const parseNum = (raw: string, min: number, max: number): number | null => {
|
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
|
const v = parseFloat(raw.replace(/[^0-9.-]/g, ""));
|
|
|
|
|
|
return isNaN(v) || v < min || v > max ? null : v;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** Premier event dont le titre correspond au pattern ET qui a un forecast. */
|
|
|
|
|
|
const find = (re: RegExp) => forCcy.find((e) => re.test(e.title) && e.forecast);
|
|
|
|
|
|
|
|
|
|
|
|
/** Calcul du nombre de jours depuis la date FF de l'événement. */
|
|
|
|
|
|
const daysSince = (e: FFEvent): number => {
|
|
|
|
|
|
const d = new Date(e.date);
|
|
|
|
|
|
return Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Retourne { forecast, surprise } pour un event FF :
|
|
|
|
|
|
* - upcoming : forecast = consensus, surprise = null
|
|
|
|
|
|
* - ≤5j après : forecast = null, surprise = actual − forecast
|
|
|
|
|
|
* - >5j après : forecast = null, surprise = null
|
|
|
|
|
|
*/
|
|
|
|
|
|
const classify = (e: FFEvent | undefined, min: number, max: number): { forecast: number | null; surprise: number | null } => {
|
|
|
|
|
|
if (!e) return { forecast: null, surprise: null };
|
|
|
|
|
|
const fc = parseNum(e.forecast, min, max);
|
|
|
|
|
|
if (fc === null) return { forecast: null, surprise: null };
|
|
|
|
|
|
if (!e.actual) {
|
|
|
|
|
|
// Upcoming : pas encore publié → afficher le consensus
|
|
|
|
|
|
return { forecast: fc, surprise: null };
|
|
|
|
|
|
}
|
|
|
|
|
|
const ac = parseNum(e.actual, min, max);
|
|
|
|
|
|
if (ac === null) return { forecast: null, surprise: null };
|
|
|
|
|
|
if (daysSince(e) <= 5) {
|
|
|
|
|
|
// Publié récemment : afficher la surprise (actual − forecast)
|
|
|
|
|
|
return { forecast: null, surprise: parseFloat((ac - fc).toFixed(2)) };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { forecast: null, surprise: null };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// CPI y/y — pour JPY on exclut Tokyo/Flash (≠ CPI national)
|
|
|
|
|
|
const cpiEvent = currency === "JPY"
|
|
|
|
|
|
? find(/(?:national|all\s+items).*\bcpi\b.*y\s*\/\s*y|\bcpi\b.*(?:national|all\s+items).*y\s*\/\s*y/i)
|
|
|
|
|
|
: find(/\bcpi\b.*y\s*\/\s*y|y\s*\/\s*y.*\bcpi\b/i);
|
|
|
|
|
|
const cpiC = classify(cpiEvent, -5, 20);
|
|
|
|
|
|
|
|
|
|
|
|
// Chômage
|
|
|
|
|
|
const uneEvent = currency === "EUR"
|
|
|
|
|
|
? find(/unemployment\s+rate/i)
|
|
|
|
|
|
: find(/unemployment\s+rate|jobless\s+rate/i);
|
|
|
|
|
|
const uneC = classify(uneEvent, 0.5, 25);
|
|
|
|
|
|
|
|
|
|
|
|
// PMI Mfg
|
|
|
|
|
|
const pmiMfgEvent = find(/manufacturing\s+pmi|flash\s+manufacturing\s+pmi|mfg\s+pmi/i);
|
|
|
|
|
|
const pmiMfgC = classify(pmiMfgEvent, 20, 80);
|
|
|
|
|
|
|
|
|
|
|
|
// PMI Services (strict : exclut composite)
|
|
|
|
|
|
const pmiSvcEvent = find(/services?\s+pmi|ism\s+non.manufactur/i);
|
|
|
|
|
|
const pmiSvcC = classify(pmiSvcEvent, 20, 80);
|
|
|
|
|
|
|
|
|
|
|
|
// PMI Composite (séparé du Services)
|
|
|
|
|
|
const pmiCompositeEvent = find(/composite\s+pmi/i);
|
|
|
|
|
|
const pmiCompositeC = classify(pmiCompositeEvent, 20, 80);
|
|
|
|
|
|
|
|
|
|
|
|
// Retail Sales m/m
|
|
|
|
|
|
const rsEvent = find(/retail\s+sales/i);
|
|
|
|
|
|
const rsC = classify(rsEvent, -10, 10);
|
|
|
|
|
|
|
|
|
|
|
|
// PIB (GDP q/q%) : généralement entre -5% et +5%
|
|
|
|
|
|
const gdpEvent = find(/\bgdp\b.*q\s*\/\s*q|q\s*\/\s*q.*\bgdp\b|gdp\s+growth/i);
|
|
|
|
|
|
const gdpC = classify(gdpEvent, -5, 5);
|
|
|
|
|
|
|
|
|
|
|
|
// Emploi Δk — NFP, Employment Change, ADP : en milliers (-500k à +1000k)
|
|
|
|
|
|
// ForexFactory affiche souvent les valeurs avec "K" ex: "185K"
|
|
|
|
|
|
const empEvent = find(/non.?farm\s+payrolls|nfp\b|employment\s+change|adp\s+non.?farm/i);
|
|
|
|
|
|
const empC = classify(empEvent, -500, 1000);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
cpi: cpiC.forecast,
|
|
|
|
|
|
cpiSurprise: cpiC.surprise,
|
|
|
|
|
|
unemployment: uneC.forecast,
|
|
|
|
|
|
unemploymentSurprise: uneC.surprise,
|
|
|
|
|
|
pmiMfg: pmiMfgC.forecast,
|
|
|
|
|
|
pmiMfgSurprise: pmiMfgC.surprise,
|
|
|
|
|
|
pmiSvc: pmiSvcC.forecast,
|
|
|
|
|
|
pmiSvcSurprise: pmiSvcC.surprise,
|
|
|
|
|
|
pmiComposite: pmiCompositeC.forecast,
|
|
|
|
|
|
pmiCompositeSurprise: pmiCompositeC.surprise,
|
|
|
|
|
|
retailSales: rsC.forecast,
|
|
|
|
|
|
retailSalesSurprise: rsC.surprise,
|
|
|
|
|
|
gdp: gdpC.forecast,
|
|
|
|
|
|
gdpSurprise: gdpC.surprise,
|
|
|
|
|
|
employment: empC.forecast,
|
|
|
|
|
|
employmentSurprise: empC.surprise,
|
|
|
|
|
|
};
|
2026-05-28 23:13:40 +02:00
|
|
|
|
} catch { return empty; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Trading Economics PMI scraping (fallback) ─────────────────────────────────
|
2026-05-28 22:52:49 +02:00
|
|
|
|
|
|
|
|
|
|
const TE_COUNTRY: Record<string, string> = {
|
2026-05-29 12:13:05 +02:00
|
|
|
|
USD: "united-states", EUR: "euro-area", GBP: "united-kingdom",
|
|
|
|
|
|
JPY: "japan", CHF: "switzerland", CAD: "canada", AUD: "australia", NZD: "new-zealand",
|
2026-05-28 22:52:49 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function scrapePMI(
|
|
|
|
|
|
currency: string,
|
2026-06-01 23:16:18 +02:00
|
|
|
|
indicator: "manufacturing-pmi" | "services-pmi" | "composite-pmi",
|
2026-05-28 22:52:49 +02:00
|
|
|
|
): Promise<{ value: number | null; prev: number | null }> {
|
|
|
|
|
|
const country = TE_COUNTRY[currency];
|
|
|
|
|
|
if (!country) return { value: null, prev: null };
|
|
|
|
|
|
try {
|
2026-05-29 12:13:05 +02:00
|
|
|
|
const res = await fetch(`https://tradingeconomics.com/${country}/${indicator}`, {
|
2026-05-28 23:13:40 +02:00
|
|
|
|
next: { revalidate: 3600 },
|
2026-05-28 22:52:49 +02:00
|
|
|
|
headers: {
|
2026-05-28 23:13:40 +02:00
|
|
|
|
"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",
|
2026-05-28 22:52:49 +02:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) return { value: null, prev: null };
|
2026-05-29 12:13:05 +02:00
|
|
|
|
const html = await res.text();
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const metaMatch =
|
2026-06-01 23:16:18 +02:00
|
|
|
|
html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) ??
|
|
|
|
|
|
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
2026-05-28 22:52:49 +02:00
|
|
|
|
if (!metaMatch) return { value: null, prev: null };
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const desc = metaMatch[1];
|
2026-05-29 12:13:05 +02:00
|
|
|
|
const numRe = /(?:increased|decreased|declined|rose|fell|eased)\s+to\s*([\d.]+)\s+points?.+?from\s+([\d.]+)\s+points?/i;
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const m = desc.match(numRe);
|
|
|
|
|
|
if (m) return { value: parseFloat(m[1]), prev: parseFloat(m[2]) };
|
|
|
|
|
|
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 };
|
2026-05-28 22:52:49 +02:00
|
|
|
|
} catch { return { value: null, prev: null }; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// ── Trading Economics — taux directeur officiel ───────────────────────────────
|
|
|
|
|
|
// Scrape la meta description de la page interest-rate, ex :
|
|
|
|
|
|
// "The benchmark interest rate in Japan was last recorded at 0.75 percent."
|
|
|
|
|
|
|
|
|
|
|
|
async function scrapeTeRate(country: string): Promise<number | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`https://tradingeconomics.com/${country}/interest-rate`, {
|
|
|
|
|
|
next: { revalidate: 3600 },
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
|
|
|
|
|
"Accept-Language": "en-US,en;q=0.5",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
|
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
|
|
|
|
|
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
|
|
|
|
|
if (!meta) return null;
|
|
|
|
|
|
const m = meta[1].match(/at\s+([\d.]+)\s*percent/i);
|
|
|
|
|
|
return m ? parseFloat(m[1]) : null;
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Trading Economics — GDP QoQ% (pays dont FRED est stale) ─────────────────
|
|
|
|
|
|
// Meta description : "GDP Growth Rate in X increased to 0.3 percent in Q4 2025 from 0.2 percent..."
|
|
|
|
|
|
// Retourne { value, prev } directement depuis la description TE.
|
|
|
|
|
|
|
|
|
|
|
|
async function scrapeTeGdp(country: string): Promise<{ value: number | null; prev: number | null }> {
|
|
|
|
|
|
const empty = { value: null, prev: null };
|
|
|
|
|
|
try {
|
|
|
|
|
|
// TE utilise "gdp-growth" (pas "gdp-growth-rate") pour le taux QoQ
|
|
|
|
|
|
const res = await fetch(`https://tradingeconomics.com/${country}/gdp-growth`, {
|
|
|
|
|
|
next: { revalidate: 3600 },
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
|
|
|
|
|
"Accept-Language": "en-US,en;q=0.5",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) return empty;
|
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
|
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
|
|
|
|
|
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
|
|
|
|
|
if (!meta) return empty;
|
|
|
|
|
|
const desc = meta[1];
|
|
|
|
|
|
// Format TE GDP : "expanded 0.50 percent in Q1 2026 over the previous quarter"
|
|
|
|
|
|
// "contracted 0.1 percent in Q3 2025 from 0.9 percent"
|
|
|
|
|
|
const m1 = desc.match(
|
|
|
|
|
|
/(?:expanded|contracted|grew|grew by|declined|increased|decreased|rose|fell)\s+(?:by\s+)?([-\d.]+)\s*percent.{0,120}?from\s+([-\d.]+)\s*percent/i
|
|
|
|
|
|
);
|
|
|
|
|
|
if (m1) return { value: parseFloat(m1[1]), prev: parseFloat(m1[2]) };
|
|
|
|
|
|
const m2 = desc.match(
|
|
|
|
|
|
/(?:expanded|contracted|grew|declined|increased|decreased|rose|fell)\s+(?:by\s+)?([-\d.]+)\s*percent/i
|
|
|
|
|
|
);
|
|
|
|
|
|
if (m2) return { value: parseFloat(m2[1]), prev: null };
|
|
|
|
|
|
return empty;
|
|
|
|
|
|
} catch { return empty; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 19:41:50 +02:00
|
|
|
|
// ── Trading Economics — Retail Sales MoM (country-list, toutes devises en une requête) ─────
|
|
|
|
|
|
// Parse le tableau https://tradingeconomics.com/country-list/retail-sales-mom
|
|
|
|
|
|
// Valeurs positives : <td data-heatmap-value="N">0.5</td>
|
|
|
|
|
|
// Valeurs négatives : <td data-heatmap-value="N"><span class='te-value-negative'>-0.4</span></td>
|
|
|
|
|
|
|
|
|
|
|
|
let _teRetailMoMCache: { data: Map<string, { value: number; prev: number | null }>; ts: number } | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchTeRetailSalesMoM(): Promise<Map<string, { value: number; prev: number | null }>> {
|
|
|
|
|
|
if (_teRetailMoMCache && Date.now() - _teRetailMoMCache.ts < 3_600_000) {
|
|
|
|
|
|
return _teRetailMoMCache.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
const result = new Map<string, { value: number; prev: number | null }>();
|
|
|
|
|
|
const slugToCurrency: Record<string, string> = {
|
|
|
|
|
|
"united-states": "USD", "euro-area": "EUR", "united-kingdom": "GBP",
|
|
|
|
|
|
"japan": "JPY", "switzerland": "CHF", "canada": "CAD",
|
|
|
|
|
|
"australia": "AUD", "new-zealand": "NZD",
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch("https://tradingeconomics.com/country-list/retail-sales-mom", {
|
|
|
|
|
|
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": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
|
|
|
|
"Accept-Language": "en-US,en;q=0.5",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) return result;
|
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
|
// Regex robuste : capture slug + valeur courante + valeur précédente
|
|
|
|
|
|
// Gère les valeurs négatives wrappées dans <span class='te-value-negative'>
|
|
|
|
|
|
const re = /<a href='\/([^']+)\/retail-sales'>\s*[^<]+\s*<\/a><\/td>\s*<td[^>]*>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>\s*<td>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>/g;
|
|
|
|
|
|
let m: RegExpExecArray | null;
|
|
|
|
|
|
while ((m = re.exec(html)) !== null) {
|
|
|
|
|
|
const currency = slugToCurrency[m[1]];
|
|
|
|
|
|
if (currency) {
|
|
|
|
|
|
result.set(currency, { value: parseFloat(m[2]), prev: parseFloat(m[3]) });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { /* retourner map vide si TE indisponible */ }
|
|
|
|
|
|
_teRetailMoMCache = { data: result, ts: Date.now() };
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// ── Trading Economics — Balance commerciale (niveau, normalisé en Milliards devise locale)
|
|
|
|
|
|
// Format meta TE :
|
|
|
|
|
|
// "X recorded a trade deficit/surplus of Y.YY EUR/USD/etc. Billion/Million in Month of Year"
|
|
|
|
|
|
// Normalisation : Million → diviser par 1000 → Milliards
|
|
|
|
|
|
// Signe : déficit = négatif, surplus = positif
|
|
|
|
|
|
async function scrapeTeTradeBalance(country: string): Promise<{ value: number | null; prev: number | null }> {
|
|
|
|
|
|
const empty = { value: null, prev: null };
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`https://tradingeconomics.com/${country}/balance-of-trade`, {
|
|
|
|
|
|
next: { revalidate: 3600 },
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
|
|
|
|
|
"Accept-Language": "en-US,en;q=0.5",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) return empty;
|
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
|
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
|
|
|
|
|
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
|
|
|
|
|
if (!meta) return empty;
|
|
|
|
|
|
const desc = meta[1];
|
|
|
|
|
|
// "recorded a trade deficit of 60.31 USD Billion" / "recorded a trade surplus of 7820.60 EUR Million"
|
|
|
|
|
|
const m = desc.match(
|
|
|
|
|
|
/recorded a trade (deficit|surplus) of ([\d,.]+)\s+\w+\s+(Billion|Million)/i
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!m) return empty;
|
|
|
|
|
|
const rawVal = parseFloat(m[2].replace(/,/g, ""));
|
|
|
|
|
|
const inBillion = m[3].toLowerCase() === "billion";
|
|
|
|
|
|
const sign = m[1].toLowerCase() === "surplus" ? 1 : -1;
|
|
|
|
|
|
const value = parseFloat((sign * (inBillion ? rawVal : rawVal / 1000)).toFixed(2));
|
|
|
|
|
|
return { value, prev: null }; // TE meta ne donne pas le mois précédent
|
|
|
|
|
|
} catch { return empty; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildGdpFromTe(te: { value: number; prev: number | null }, date: string): IndicatorResult {
|
|
|
|
|
|
const surprise = te.prev !== null ? parseFloat((te.value - te.prev).toFixed(4)) : null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: te.value,
|
|
|
|
|
|
prev: te.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
|
|
|
|
|
|
lastUpdated: date,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── rate_decisions.json — taux officiels des CB (actuel + précédent) ──────────
|
|
|
|
|
|
// Mettre à jour data/rate_decisions.json après chaque décision CB.
|
|
|
|
|
|
|
|
|
|
|
|
type RateDecision = { current: number; prev: number };
|
|
|
|
|
|
|
|
|
|
|
|
function getRateDecision(ccy: string): RateDecision | null {
|
|
|
|
|
|
type RdRaw = [{ decisions: Record<string, RateDecision> }];
|
|
|
|
|
|
const entry = (rateDecisionsRaw as unknown as RdRaw)[0];
|
|
|
|
|
|
return entry?.decisions?.[ccy] ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
// ── money-supply-m3.json — masse monétaire M3 (niveau, statique) ─────────────
|
|
|
|
|
|
|
|
|
|
|
|
type MoneySupplyM3 = {
|
|
|
|
|
|
value: number; unit: string; period: string; isProxy: boolean;
|
|
|
|
|
|
proxyLabel?: string; source: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function getMoneySupplyM3(ccy: string): MoneySupplyM3 | null {
|
|
|
|
|
|
type MsRaw = [{ series: Record<string, MoneySupplyM3> }];
|
|
|
|
|
|
const entry = (moneySupplyM3Raw as unknown as MsRaw)[0];
|
|
|
|
|
|
return entry?.series?.[ccy] ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
/** Construit un IndicatorResult à partir de la valeur TE + prev de l'override */
|
|
|
|
|
|
function buildRateIndicator(current: number, prev: number, today: string): IndicatorResult {
|
|
|
|
|
|
const surprise = parseFloat((current - prev).toFixed(4));
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: current,
|
|
|
|
|
|
prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: surprise > 0 ? "up" : surprise < 0 ? "down" : "flat",
|
|
|
|
|
|
lastUpdated: today,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 13:18:45 +02:00
|
|
|
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
type Obs = { date: string; value: number };
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// Type commun pour tous les indicateurs (toIndicator, toPmiIndicator, overrides)
|
|
|
|
|
|
type IndicatorResult = {
|
|
|
|
|
|
value: number;
|
|
|
|
|
|
prev: number | null;
|
|
|
|
|
|
surprise: number | null;
|
|
|
|
|
|
trend: "up" | "down" | "flat" | null;
|
|
|
|
|
|
lastUpdated: string | null;
|
2026-06-02 16:41:34 +02:00
|
|
|
|
consensus?: number | null;
|
2026-05-29 12:13:05 +02:00
|
|
|
|
} | null;
|
|
|
|
|
|
|
|
|
|
|
|
function toIndicator(obs: Obs[]): IndicatorResult {
|
2026-05-27 13:18:45 +02:00
|
|
|
|
if (!obs.length) return null;
|
|
|
|
|
|
const value = obs[0].value;
|
2026-05-29 12:13:05 +02:00
|
|
|
|
const prev = (obs[1]?.value ?? null) as number | null;
|
2026-05-27 13:18:45 +02:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Pour les séries quotidiennes de taux directeurs (DFEDTARU, ECBDFR…),
|
|
|
|
|
|
* supprime les doublons consécutifs pour n'avoir que les dates de décision.
|
|
|
|
|
|
* prev = taux avant la dernière décision (pas hier).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toIndicatorDeduped(obs: Obs[]) {
|
|
|
|
|
|
const deduped: Obs[] = [];
|
|
|
|
|
|
let last = NaN;
|
|
|
|
|
|
for (const o of obs) {
|
|
|
|
|
|
if (o.value !== last) { deduped.push(o); last = o.value; }
|
|
|
|
|
|
}
|
|
|
|
|
|
return toIndicator(deduped);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 13:18:45 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Calcul glissement annuel (Year-over-Year) depuis un indice niveau.
|
|
|
|
|
|
* periods = 12 pour mensuel (M/M-12), 4 pour trimestriel (Q/Q-4).
|
|
|
|
|
|
* obs[0] = plus récent, obs[periods] = même période an passé.
|
|
|
|
|
|
* Si données insuffisantes, fallback sur toIndicatorPct (MoM/QoQ).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toIndicatorYoY(obs: Obs[], periods = 12): IndicatorResult {
|
|
|
|
|
|
if (obs.length < periods + 1) return toIndicatorPct(obs); // fallback MoM si pas assez de données
|
|
|
|
|
|
const curr = obs[0];
|
|
|
|
|
|
const yearAgo = obs[periods];
|
|
|
|
|
|
const yoy = parseFloat(((curr.value / yearAgo.value - 1) * 100).toFixed(2));
|
|
|
|
|
|
|
|
|
|
|
|
// Calcul du YoY précédent (mois/trimestre d'avant) pour surprise & trend
|
|
|
|
|
|
const prevYoY = obs.length >= periods + 2
|
|
|
|
|
|
? parseFloat(((obs[1].value / obs[periods + 1].value - 1) * 100).toFixed(2))
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
const surprise = prevYoY !== null ? parseFloat((yoy - prevYoY).toFixed(4)) : null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: yoy,
|
|
|
|
|
|
prev: prevYoY,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: surprise !== null ? (yoy > prevYoY! ? "up" : yoy < prevYoY! ? "down" : "flat") : null,
|
|
|
|
|
|
lastUpdated: curr.date,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Calcul du changement absolu en milliers de personnes pour Employment Change.
|
|
|
|
|
|
* personsToK = true si la série FRED est en personnes réelles (pas en milliers).
|
|
|
|
|
|
* USD PAYEMS est déjà en milliers → personsToK=false.
|
|
|
|
|
|
* AUD/CAD/JPY LFEMTTTT* sont en personnes → personsToK=true.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toIndicatorDeltaK(obs: Obs[], personsToK: boolean): IndicatorResult {
|
|
|
|
|
|
if (obs.length < 2) return null;
|
2026-06-27 14:12:05 +02:00
|
|
|
|
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;
|
2026-06-01 23:16:18 +02:00
|
|
|
|
return {
|
|
|
|
|
|
value: valK,
|
2026-06-27 14:12:05 +02:00
|
|
|
|
prev: prevK,
|
|
|
|
|
|
surprise: prevK !== null ? parseFloat((valK - prevK).toFixed(1)) : valK,
|
2026-06-01 23:16:18 +02:00
|
|
|
|
trend: valK > 0 ? "up" : valK < 0 ? "down" : "flat",
|
|
|
|
|
|
lastUpdated: obs[0].date,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
function toPmiIndicator(raw: { value: number | null; prev: number | null }): IndicatorResult {
|
2026-05-28 23:13:40 +02:00
|
|
|
|
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,
|
2026-05-29 12:13:05 +02:00
|
|
|
|
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
|
|
|
|
|
|
lastUpdated: null,
|
2026-05-28 23:13:40 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 00:03:37 +02:00
|
|
|
|
// ── Parser forecast string TE ("2.8%" ou "0.4") → number ─────────────────────
|
|
|
|
|
|
function parseTeF(s: string | null | undefined): number | null {
|
|
|
|
|
|
if (!s) return null;
|
|
|
|
|
|
const n = parseFloat(s.replace("%", "").trim());
|
|
|
|
|
|
return isNaN(n) ? null : n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 13:18:45 +02:00
|
|
|
|
// ── Server-side cache ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const _cache = new Map<string, { data: unknown; ts: number }>();
|
|
|
|
|
|
|
2026-06-08 19:41:50 +02:00
|
|
|
|
// TTL adaptatif : 15 min si un event à fort/moyen impact vient d'être publié dans les 2h
|
|
|
|
|
|
// pour cette devise, sinon 1h.
|
|
|
|
|
|
async function getSmartTtl(currency: string): Promise<number> {
|
|
|
|
|
|
const BASE_TTL = 3_600_000; // 1h par défaut
|
|
|
|
|
|
const HOT_TTL = 900_000; // 15min après publication récente
|
|
|
|
|
|
const WINDOW_MS = 7_200_000; // fenêtre de 2h
|
|
|
|
|
|
try {
|
|
|
|
|
|
const events = await fetchFFThisWeek();
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const hasRecent = events.some(e => {
|
|
|
|
|
|
const t = new Date(e.date).getTime();
|
|
|
|
|
|
return (
|
|
|
|
|
|
e.country === currency &&
|
|
|
|
|
|
e.actual !== "" && // données publiées (actual rempli)
|
|
|
|
|
|
(e.impact === "High" || e.impact === "Medium") &&
|
|
|
|
|
|
t <= now && // passé (pas futur)
|
|
|
|
|
|
now - t < WINDOW_MS // dans les 2h
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
return hasRecent ? HOT_TTL : BASE_TTL;
|
|
|
|
|
|
} catch { return BASE_TTL; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 13:18:45 +02:00
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-06-08 19:41:50 +02:00
|
|
|
|
// TTL smart : 15 min si publication récente, sinon 1h
|
|
|
|
|
|
const ttl = await getSmartTtl(currency);
|
2026-05-28 23:13:40 +02:00
|
|
|
|
const cached = _cache.get(currency);
|
2026-05-28 22:58:27 +02:00
|
|
|
|
const staleCache = cached ?? null;
|
2026-06-08 19:41:50 +02:00
|
|
|
|
if (cached && Date.now() - cached.ts < ttl) return NextResponse.json(cached.data);
|
2026-05-27 13:18:45 +02:00
|
|
|
|
|
|
|
|
|
|
const key = process.env.FRED_API_KEY;
|
|
|
|
|
|
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// Limites de fetch par champ :
|
|
|
|
|
|
// cpiCore → 14 obs (12 mois YoY + 1 pour prev YoY + 1 tampon)
|
|
|
|
|
|
// cpiCore AUD/NZD (quarterly) → 6 obs (4 trimestres YoY + 1 prev + 1 tampon)
|
|
|
|
|
|
// gdp / employment → 6 obs (QoQ/MoM avec prev)
|
|
|
|
|
|
// autres → 5 obs (valeur + prev)
|
|
|
|
|
|
const isQuarterlyCpi = (currency === "AUD" || currency === "NZD");
|
|
|
|
|
|
const FIELD_LIMITS: Record<string, number> = {
|
|
|
|
|
|
cpiCore: isQuarterlyCpi ? 7 : 16, // +2 tampon pour les valeurs "." filtrées par FRED
|
|
|
|
|
|
gdp: 6,
|
|
|
|
|
|
employment: 6,
|
|
|
|
|
|
};
|
2026-05-27 13:18:45 +02:00
|
|
|
|
|
|
|
|
|
|
const fieldMap: Record<string, string | null> = {
|
|
|
|
|
|
policyRate: series.policyRate,
|
|
|
|
|
|
cpiCore: series.cpiCore,
|
2026-06-01 23:16:18 +02:00
|
|
|
|
cpiHeadline: series.cpiHeadline, // headline pour MoM — peut être = cpiCore pour GBP
|
2026-05-27 13:18:45 +02:00
|
|
|
|
gdp: series.gdp,
|
|
|
|
|
|
retailSales: series.retailSales,
|
|
|
|
|
|
unemployment: series.unemployment,
|
|
|
|
|
|
employment: series.employment,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fredFields = Object.entries(fieldMap).filter(([, id]) => id !== null) as [string, string][];
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const fredResults = await Promise.all(
|
|
|
|
|
|
fredFields.map(([field, id]) => fredObs(id, key, FIELD_LIMITS[field] ?? 5))
|
|
|
|
|
|
);
|
2026-05-29 12:13:05 +02:00
|
|
|
|
const indicators: Record<string, IndicatorResult> = {};
|
2026-06-01 23:16:18 +02:00
|
|
|
|
let _cpiCoreObs: Obs[] = []; // sauvegarde obs brutes pour fallback cpiMoM
|
2026-05-27 13:18:45 +02:00
|
|
|
|
|
|
|
|
|
|
fredFields.forEach(([field], i) => {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
if (field === "cpiCore") {
|
|
|
|
|
|
_cpiCoreObs = fredResults[i]; // sauvegarder pour le fallback
|
|
|
|
|
|
// YoY : 12 périodes pour mensuel, 4 pour trimestriel (AUD/NZD)
|
|
|
|
|
|
indicators[field] = toIndicatorYoY(fredResults[i], isQuarterlyCpi ? 4 : 12);
|
|
|
|
|
|
} else if (field === "cpiHeadline") {
|
|
|
|
|
|
// MoM% : variation mensuelle headline (ou QoQ pour AUD/NZD trimestriel)
|
|
|
|
|
|
// Si la série headline FRED est identique à cpiCore (GBP) ou manquante (CHF/CAD) :
|
|
|
|
|
|
// le fallback ci-dessous prendra le relais avec les obs cpiCore
|
|
|
|
|
|
if (fredResults[i].length >= 2) indicators["cpiMoM"] = toIndicatorPct(fredResults[i]);
|
|
|
|
|
|
} else if (field === "gdp") {
|
|
|
|
|
|
// NZD NAEXKP01NZQ657S = déjà en QoQ% → toIndicator
|
|
|
|
|
|
// Autres = indice niveau → toIndicatorPct (calcul QoQ)
|
|
|
|
|
|
indicators[field] = currency === "NZD"
|
|
|
|
|
|
? toIndicator(fredResults[i])
|
|
|
|
|
|
: toIndicatorPct(fredResults[i]);
|
|
|
|
|
|
} else if (field === "employment") {
|
|
|
|
|
|
// Delta absolu en milliers. USD PAYEMS = déjà en milliers. Autres = personnes → /1000.
|
|
|
|
|
|
const personsToK = currency !== "USD";
|
|
|
|
|
|
indicators[field] = toIndicatorDeltaK(fredResults[i], personsToK);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
indicators[field] = toIndicator(fredResults[i]);
|
|
|
|
|
|
}
|
2026-05-27 13:18:45 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── EUR alternative sources ────────────────────────────────────────────────
|
|
|
|
|
|
if (currency === "EUR") {
|
|
|
|
|
|
if (!indicators.cpiCore) {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// CP0000EZCCM086NEST indisponible → fallback Eurostat prc_hicp_midx (I15 index → YoY%)
|
2026-05-29 12:13:05 +02:00
|
|
|
|
const hicp = await eurostatSorted("prc_hicp_midx", {
|
|
|
|
|
|
geo: "EA", coicop: "CP00", unit: "I15", freq: "M",
|
2026-06-01 23:16:18 +02:00
|
|
|
|
}, 14);
|
|
|
|
|
|
indicators.cpiCore = toIndicatorYoY(hicp, 12);
|
|
|
|
|
|
// cpiMoM depuis mêmes obs Eurostat HICP
|
|
|
|
|
|
if (!indicators.cpiMoM) indicators.cpiMoM = toIndicatorPct(hicp);
|
|
|
|
|
|
} else if (!indicators.cpiMoM) {
|
|
|
|
|
|
// cpiCore disponible depuis FRED → recalculer MoM depuis mêmes obs
|
|
|
|
|
|
// On re-fetch avec limite réduite (2 obs suffisent pour MoM)
|
|
|
|
|
|
const hicpMoM = await fredObs("CP0000EZCCM086NEST", key, 3);
|
|
|
|
|
|
if (hicpMoM.length) indicators.cpiMoM = toIndicatorPct(hicpMoM);
|
2026-05-27 13:18:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (!indicators.gdp) {
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// Essayer EA20 d'abord (données 2023-2025), puis EA19 (fallback automatique via eurostatSorted)
|
2026-05-28 23:13:40 +02:00
|
|
|
|
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);
|
2026-05-27 13:18:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (!indicators.unemployment) {
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// EA21 = code actuel Eurostat pour Zone Euro 21 pays (depuis 2026)
|
|
|
|
|
|
// Fallback EA20 si EA21 vide (transition de nomenclature)
|
|
|
|
|
|
let unObs = await eurostatSorted("une_rt_m", {
|
|
|
|
|
|
geo: "EA21", s_adj: "SA", age: "TOTAL", sex: "T", unit: "PC_ACT", freq: "M",
|
2026-05-28 23:13:40 +02:00
|
|
|
|
});
|
2026-05-29 12:13:05 +02:00
|
|
|
|
if (!unObs.length) {
|
|
|
|
|
|
unObs = await eurostatSorted("une_rt_m", {
|
|
|
|
|
|
geo: "EA20", s_adj: "SA", age: "TOTAL", sex: "T", unit: "PC_ACT", freq: "M",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-27 13:18:45 +02:00
|
|
|
|
indicators.unemployment = toIndicator(unObs);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// ── JPY CPI — IMF/IFS (DBnomics) ─────────────────────────────────────────
|
|
|
|
|
|
// FRED n'a pas de série JPY CPI mensuelle récente.
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// M.JP.PCPI_IX = CPI All Items, indice niveau mensuel → YoY% via toIndicatorYoY
|
|
|
|
|
|
// Fallback : M.JP.PCPI_PC_PP_PT = MoM% déjà calculé (si index indisponible)
|
2026-05-29 12:13:05 +02:00
|
|
|
|
if (currency === "JPY" && !indicators.cpiCore) {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const obsIdx = await dbnomicsObs("IMF", "IFS", "M.JP.PCPI_IX", 14);
|
|
|
|
|
|
if (obsIdx.length >= 13) {
|
|
|
|
|
|
indicators.cpiCore = toIndicatorYoY(obsIdx, 12);
|
|
|
|
|
|
indicators.cpiMoM = toIndicatorPct(obsIdx); // MoM depuis mêmes obs
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const obsMoM = await dbnomicsObs("IMF", "IFS", "M.JP.PCPI_PC_PP_PT");
|
|
|
|
|
|
if (obsMoM.length) { indicators.cpiCore = toIndicator(obsMoM); indicators.cpiMoM = toIndicator(obsMoM); }
|
|
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── AUD/NZD CPI fallback — IMF/IFS (DBnomics) ────────────────────────────
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// Si FRED échoue, IMF/IFS Q.AU.PCPI_IX / Q.NZ.PCPI_IX → YoY% trimestriel
|
2026-05-29 12:13:05 +02:00
|
|
|
|
if (currency === "AUD" && !indicators.cpiCore) {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const obs = await dbnomicsObs("IMF", "IFS", "Q.AU.PCPI_IX", 6);
|
|
|
|
|
|
if (obs.length) indicators.cpiCore = toIndicatorYoY(obs, 4);
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (currency === "NZD" && !indicators.cpiCore) {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const obs = await dbnomicsObs("IMF", "IFS", "Q.NZ.PCPI_IX", 6);
|
|
|
|
|
|
if (obs.length) indicators.cpiCore = toIndicatorYoY(obs, 4);
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── GBP BoE policy rate ───────────────────────────────────────────────────
|
2026-05-27 13:18:45 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-28 23:13:40 +02:00
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// ── TAUX DIRECTEURS : sources corrigées ───────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// Problème : les séries mensuelles (FEDFUNDS) ont un lag d'1 mois,
|
|
|
|
|
|
// les séries IR3TIB01 sont des taux interbancaires 3M (≠ taux CB).
|
|
|
|
|
|
//
|
|
|
|
|
|
// Solution :
|
|
|
|
|
|
// • Séries quotidiennes (DFEDTARU, ECBDFR, IRSTCB01GBM156N)
|
|
|
|
|
|
// → toIndicatorDeduped : prev = avant-dernière décision, pas hier
|
|
|
|
|
|
// • IRSTCB01 (OCDE) : taux CB officiel, plus fiable que IR3TIB01
|
|
|
|
|
|
// • Banque du Canada Valet API : taux annoncé exact (V80691311)
|
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
|
|
|
|
|
|
// USD — midpoint de la fourchette cible (DFEDTARU + DFEDTARL) / 2
|
|
|
|
|
|
// Rateprobability.com / marchés quotent le midpoint (3.625%) pas l'upper bound (3.75%)
|
2026-07-08 16:46:05 +02:00
|
|
|
|
// USD — Fed officiel en primaire (source directe federalreserve.gov, cf. scrapeFedRate)
|
|
|
|
|
|
// Repli TE scraping puis FRED upper bound si federalreserve.gov indisponible.
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// Ancien calcul midpoint FRED (DFEDTARU+DFEDTARL)/2 = 3.625% → pas le taux annoncé
|
2026-05-29 12:13:05 +02:00
|
|
|
|
if (currency === "USD") {
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const fed = await scrapeFedRate();
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const ovr = getRateDecision("USD");
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const te = fed ?? await scrapeTeRate(TE_COUNTRY.USD);
|
2026-06-01 23:16:18 +02:00
|
|
|
|
if (te !== null && ovr) {
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
|
|
|
|
|
} else if (te !== null) {
|
|
|
|
|
|
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fallback FRED upper bound (jamais le midpoint)
|
|
|
|
|
|
if (!indicators.policyRate) {
|
|
|
|
|
|
const upper = await fredObs("DFEDTARU", key, 365);
|
|
|
|
|
|
if (upper.length) {
|
|
|
|
|
|
indicators.policyRate = toIndicatorDeduped(upper);
|
|
|
|
|
|
if (indicators.policyRate && ovr)
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(indicators.policyRate.value!, ovr.prev, today);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// EUR — ECBDFR (365j) → on affiche le taux MRO = DFR + 0.15
|
|
|
|
|
|
// Depuis sep 2024 : corridor ECB = DFR | MRO = DFR+15bps | MLF = DFR+40bps
|
|
|
|
|
|
// Investing.com / Trading Economics affichent le MRO comme "taux BCE"
|
2026-05-29 12:13:05 +02:00
|
|
|
|
if (currency === "EUR") {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const obs = await fredObs("ECBDFR", key, 365);
|
|
|
|
|
|
if (obs.length) {
|
|
|
|
|
|
const base = toIndicatorDeduped(obs);
|
|
|
|
|
|
if (base) {
|
|
|
|
|
|
// Convertit DFR → MRO pour correspondre aux sources de référence
|
|
|
|
|
|
const mro: typeof base = {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
value: parseFloat((base.value + 0.15).toFixed(2)),
|
|
|
|
|
|
prev: base.prev !== null ? parseFloat((base.prev + 0.15).toFixed(2)) : null,
|
|
|
|
|
|
};
|
|
|
|
|
|
indicators.policyRate = mro;
|
|
|
|
|
|
if (mro.prev === null) {
|
|
|
|
|
|
const ovr = getRateDecision("EUR");
|
|
|
|
|
|
if (ovr) indicators.policyRate = buildRateIndicator(mro.value!, ovr.prev + 0.15, today);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// GBP — TE scraping en primaire (taux BoE officiel 3.75%)
|
|
|
|
|
|
// IUDBEDR (BoE API) = taux SONIA effective, légèrement différent du taux annoncé
|
|
|
|
|
|
// IR3TIB01GBM156N = LIBOR 3M UK → incorrect
|
|
|
|
|
|
if (currency === "GBP") {
|
|
|
|
|
|
const te = await scrapeTeRate(TE_COUNTRY.GBP);
|
|
|
|
|
|
const ovr = getRateDecision("GBP");
|
|
|
|
|
|
if (te !== null && ovr) {
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
|
|
|
|
|
} else if (te !== null) {
|
|
|
|
|
|
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fallback : BoE API puis FRED
|
|
|
|
|
|
if (!indicators.policyRate) {
|
|
|
|
|
|
const boe = await boeRate();
|
|
|
|
|
|
if (boe.length) indicators.policyRate = toIndicator(boe);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!indicators.policyRate) {
|
|
|
|
|
|
const obs = await fredObs("IR3TIB01GBM156N", key, 6);
|
|
|
|
|
|
if (obs.length) indicators.policyRate = toIndicator(obs);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// JPY — TE scraping (taux BoJ officiel exact) + prev depuis rate_decisions.json
|
|
|
|
|
|
// IRSTCB01JPM156N est stale (données arrêtées fin 2023 sur FRED)
|
|
|
|
|
|
// IR3TIB01JPM156N = TIBOR 3M (~1.27%) ≠ taux BoJ réel (~0.75%)
|
2026-05-29 12:13:05 +02:00
|
|
|
|
if (currency === "JPY") {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const te = await scrapeTeRate(TE_COUNTRY.JPY);
|
|
|
|
|
|
const ovr = getRateDecision("JPY");
|
|
|
|
|
|
if (te !== null && ovr) {
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
|
|
|
|
|
} else if (te !== null) {
|
|
|
|
|
|
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fallback FRED si TE indisponible
|
|
|
|
|
|
if (!indicators.policyRate) {
|
|
|
|
|
|
const obs = await fredObsFreshest("IRSTCB01JPM156N", "IR3TIB01JPM156N", key);
|
|
|
|
|
|
if (obs.length) indicators.policyRate = toIndicator(obs);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
// CHF — SNB officiel en primaire (source directe snb.ch, cf. scrapeSnbRate)
|
|
|
|
|
|
// Repli TE scraping si snb.ch indisponible.
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// IR3TIB01CHM156N = SARON 3M (~-0.04%) ≠ taux SNB officiel
|
|
|
|
|
|
if (currency === "CHF") {
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const snb = await scrapeSnbRate();
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const ovr = getRateDecision("CHF");
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const rate = snb ?? await scrapeTeRate(TE_COUNTRY.CHF);
|
|
|
|
|
|
if (rate !== null && ovr) {
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(rate, ovr.prev, today);
|
|
|
|
|
|
} else if (rate !== null) {
|
|
|
|
|
|
indicators.policyRate = { value: rate, prev: null, surprise: null, trend: null, lastUpdated: today };
|
2026-06-01 23:16:18 +02:00
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// CAD — TE scraping (taux BoC cible officiel = 2.25%)
|
|
|
|
|
|
// V80691311 = taux marché overnight (4.45%) ≠ cible BoC
|
2026-05-29 12:13:05 +02:00
|
|
|
|
if (currency === "CAD") {
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const te = await scrapeTeRate(TE_COUNTRY.CAD);
|
|
|
|
|
|
const ovr = getRateDecision("CAD");
|
|
|
|
|
|
if (te !== null && ovr) {
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
|
|
|
|
|
} else if (te !== null) {
|
|
|
|
|
|
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback BoC Valet
|
|
|
|
|
|
const boc = await bocRate();
|
|
|
|
|
|
if (boc.length) indicators.policyRate = toIndicatorDeduped(boc);
|
|
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// AUD — TE scraping (taux RBA officiel exact = 4.35%)
|
|
|
|
|
|
// IR3TIB01AUM156N = taux interbancaire 3M (~4.34%) légèrement différent
|
|
|
|
|
|
if (currency === "AUD") {
|
|
|
|
|
|
const te = await scrapeTeRate(TE_COUNTRY.AUD);
|
|
|
|
|
|
const ovr = getRateDecision("AUD");
|
|
|
|
|
|
if (te !== null && ovr) {
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
|
|
|
|
|
} else if (te !== null) {
|
|
|
|
|
|
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
|
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// NZD — TE scraping (taux RBNZ officiel exact = 2.25%)
|
|
|
|
|
|
// IRSTCB01NZM156N n'existe pas sur FRED ; IR3TIB01NZM156N = taux marché
|
|
|
|
|
|
if (currency === "NZD") {
|
|
|
|
|
|
const te = await scrapeTeRate(TE_COUNTRY.NZD);
|
|
|
|
|
|
const ovr = getRateDecision("NZD");
|
|
|
|
|
|
if (te !== null && ovr) {
|
|
|
|
|
|
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
|
|
|
|
|
} else if (te !== null) {
|
|
|
|
|
|
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const obs = await fredObsFreshest("IRSTCB01NZM156N", "IR3TIB01NZM156N", key);
|
|
|
|
|
|
if (obs.length) indicators.policyRate = toIndicator(obs);
|
|
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// ── CHÔMAGE : sources corrigées ───────────────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// CHF — LRHUTTTTCHQ156S = taux OCDE harmonisé ILO (~5%) ≠ taux SECO (~2.3%)
|
|
|
|
|
|
// On tente la série CHEUNP01CHQ661S (taux national CH sur FRED)
|
|
|
|
|
|
// puis Eurostat geo=CH (Suisse incluse dans les datasets statistiques)
|
|
|
|
|
|
//
|
|
|
|
|
|
// GBP — On tente Eurostat geo=UK (UK inclus dans datasets Eurostat post-Brexit
|
|
|
|
|
|
// pour comparabilité statistique) avant LRHUTTTTGBM156S
|
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
if (currency === "CHF") {
|
|
|
|
|
|
const national = await fredObs("CHEUNP01CHQ661S", key);
|
|
|
|
|
|
if (national.length) {
|
|
|
|
|
|
indicators.unemployment = toIndicator(national);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Eurostat geo=CH : taux ILO mensuel (plus récent que FRED trimestriel)
|
|
|
|
|
|
const eurostatCH = await eurostatSorted("une_rt_m", {
|
|
|
|
|
|
geo: "CH", s_adj: "SA", age: "TOTAL", sex: "T", unit: "PC_ACT", freq: "M",
|
|
|
|
|
|
});
|
|
|
|
|
|
if (eurostatCH.length) indicators.unemployment = toIndicator(eurostatCH);
|
|
|
|
|
|
// Else: on garde LRHUTTTTCHQ156S (harmonisé OCDE) déjà calculé ci-dessus
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GBP unemployment: Eurostat UK retiré — données stoppées en sept. 2020 (Brexit).
|
|
|
|
|
|
// On conserve LRHUTTTTGBM156S (FRED, ILO harmonisé, mis à jour mensuellement).
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// ── GDP : sources TE pour CHF et NZD (FRED stale depuis 2023) ───────────────
|
|
|
|
|
|
// CHNGDPNQDSMEI / NAEXKP01NZQ661S dernière obs = 2023-07-01 → utiliser TE
|
|
|
|
|
|
if (currency === "CHF" || currency === "NZD") {
|
|
|
|
|
|
const te = await scrapeTeGdp(TE_COUNTRY[currency]);
|
|
|
|
|
|
if (te.value !== null) indicators.gdp = buildGdpFromTe(te as { value: number; prev: number | null }, today);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fallback cpiMoM : si cpiHeadline FRED absent/vide (CHF/CAD/AUD/NZD)
|
|
|
|
|
|
// → calculer MoM depuis les obs cpiCore déjà disponibles (Core MoM ou QoQ trimestriel)
|
|
|
|
|
|
if (!indicators.cpiMoM && _cpiCoreObs.length >= 2) {
|
|
|
|
|
|
indicators.cpiMoM = toIndicatorPct(_cpiCoreObs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 19:41:50 +02:00
|
|
|
|
// ── Retail Sales MoM : override depuis Trading Economics (plus à jour que FRED OCDE) ────────
|
|
|
|
|
|
{
|
|
|
|
|
|
const teRS = await fetchTeRetailSalesMoM();
|
|
|
|
|
|
const rs = teRS.get(currency);
|
|
|
|
|
|
if (rs !== undefined) {
|
|
|
|
|
|
const surprise = rs.prev !== null ? parseFloat((rs.value - rs.prev).toFixed(2)) : null;
|
|
|
|
|
|
indicators.retailSales = {
|
|
|
|
|
|
value: rs.value,
|
|
|
|
|
|
prev: rs.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
|
|
|
|
|
|
lastUpdated: today,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// ── PMI (Mfg + Services + Composite) + consensus FF ──────────────────────────
|
2026-06-02 00:03:37 +02:00
|
|
|
|
const toDateObj = new Date();
|
|
|
|
|
|
toDateObj.setDate(toDateObj.getDate() + 21);
|
|
|
|
|
|
const toDate = toDateObj.toISOString().slice(0, 10);
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const [ffPMI, pmiMfgRaw, pmiSvcRaw, pmiCompositeRaw, ffForecasts, teForecastMap] = await Promise.all([
|
2026-05-28 23:13:40 +02:00
|
|
|
|
fetchFFPMI(currency),
|
2026-05-28 22:52:49 +02:00
|
|
|
|
scrapePMI(currency, "manufacturing-pmi"),
|
|
|
|
|
|
scrapePMI(currency, "services-pmi"),
|
2026-06-01 23:16:18 +02:00
|
|
|
|
scrapePMI(currency, "composite-pmi"),
|
|
|
|
|
|
fetchFFForecasts(currency),
|
2026-06-02 00:03:37 +02:00
|
|
|
|
fetchTEInflationForecasts(today, toDate),
|
2026-05-28 22:52:49 +02:00
|
|
|
|
]);
|
2026-06-02 00:03:37 +02:00
|
|
|
|
|
|
|
|
|
|
const teCpiForecast = teForecastMap[currency];
|
2026-06-01 23:16:18 +02:00
|
|
|
|
// FF en priorité (forecast + actual) ; TE en fallback
|
|
|
|
|
|
indicators.pmiMfg = ffPMI.mfg ? toPmiIndicator(ffPMI.mfg) : toPmiIndicator(pmiMfgRaw);
|
|
|
|
|
|
indicators.pmiServices = ffPMI.svc ? toPmiIndicator(ffPMI.svc) : toPmiIndicator(pmiSvcRaw);
|
|
|
|
|
|
indicators.pmiComposite = ffPMI.composite ? toPmiIndicator(ffPMI.composite) : toPmiIndicator(pmiCompositeRaw);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Balance commerciale — Trading Economics (MoM, en milliards) ───────────
|
|
|
|
|
|
{
|
|
|
|
|
|
const country = TE_COUNTRY[currency];
|
|
|
|
|
|
if (country) {
|
|
|
|
|
|
const tb = await scrapeTeTradeBalance(country);
|
|
|
|
|
|
if (tb.value !== null) {
|
|
|
|
|
|
const surprise = tb.prev !== null ? parseFloat((tb.value - tb.prev).toFixed(3)) : null;
|
|
|
|
|
|
indicators.tradeBalance = {
|
|
|
|
|
|
value: tb.value,
|
|
|
|
|
|
prev: tb.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
|
|
|
|
|
|
lastUpdated: today,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-29 12:13:05 +02:00
|
|
|
|
|
|
|
|
|
|
// ── Overrides manuels CPI (investing.com) ─────────────────────────────────
|
|
|
|
|
|
// Appliqués quand la source automatique (FRED/DBnomics) est en retard.
|
|
|
|
|
|
// Règle : l'override est retenu ssi sa date > lastUpdated de la source auto.
|
|
|
|
|
|
// Mettre à jour data/cpi_overrides.json après chaque publication trimestrielle.
|
|
|
|
|
|
{
|
|
|
|
|
|
type OvrField = { value: number; prev: number | null; surprise: number | null; trend: string | null; lastUpdated: string; source?: string };
|
|
|
|
|
|
type OvrMap = Record<string, Record<string, OvrField>>;
|
|
|
|
|
|
const entry = (cpiOverridesRaw as unknown as [{ overrides: OvrMap }])[0];
|
|
|
|
|
|
const ovrFields = entry?.overrides?.[currency];
|
|
|
|
|
|
if (ovrFields) {
|
|
|
|
|
|
for (const [field, ovr] of Object.entries(ovrFields)) {
|
|
|
|
|
|
const auto = indicators[field];
|
|
|
|
|
|
const autoDate = auto?.lastUpdated ?? "";
|
|
|
|
|
|
if (!auto || autoDate < ovr.lastUpdated) {
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
|
|
|
|
const { source: _src, ...rest } = ovr;
|
|
|
|
|
|
indicators[field] = {
|
|
|
|
|
|
...rest,
|
|
|
|
|
|
trend: rest.trend as "up" | "down" | "flat" | null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-28 22:52:49 +02:00
|
|
|
|
|
2026-06-01 23:56:45 +02:00
|
|
|
|
// ── TE CPI override (priorité maximale — données du jour, toutes devises) ───
|
2026-06-02 00:53:39 +02:00
|
|
|
|
// Remplace séries FRED stale/erronées.
|
2026-06-02 15:35:58 +02:00
|
|
|
|
// Nouvelles données : cpiYoY headline, cpiCoreMoM (pages individuelles), ppiMoM
|
2026-06-01 23:46:41 +02:00
|
|
|
|
{
|
2026-06-27 14:12:05 +02:00
|
|
|
|
const [teCoreMap, teMoMMap, teYoYMap, teCoreMoMMap, teCoreIdxMap, tePPIMap, teCorePages, teYoYPages, teAUDComm, teGDPMap, teUneMap, teSTIRMap, teEmpMap] = await Promise.all([
|
2026-06-01 23:56:45 +02:00
|
|
|
|
fetchTECoreInflation(),
|
|
|
|
|
|
fetchTEMoMInflation(),
|
2026-06-02 00:53:39 +02:00
|
|
|
|
fetchTEInflationYoY(),
|
|
|
|
|
|
fetchTECoreCPIMoM(),
|
|
|
|
|
|
fetchTECoreConsumerPricesIndex(),
|
2026-06-02 15:35:58 +02:00
|
|
|
|
fetchTEPPIMoM(),
|
2026-06-02 16:57:22 +02:00
|
|
|
|
fetchTECoreInflationPages(),
|
|
|
|
|
|
fetchTEInflationYoYPages(),
|
|
|
|
|
|
currency === "AUD" ? fetchTEAUDCommodityYoY() : Promise.resolve(null),
|
2026-06-02 23:16:55 +02:00
|
|
|
|
fetchTEGDPGrowthRate(),
|
2026-06-02 23:52:05 +02:00
|
|
|
|
fetchTEUnemploymentRate(),
|
2026-06-03 01:04:07 +02:00
|
|
|
|
fetchTESTIRRate(),
|
2026-06-27 14:12:05 +02:00
|
|
|
|
fetchTEEmploymentChange(),
|
2026-06-01 23:56:45 +02:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-06-02 16:26:29 +02:00
|
|
|
|
const teCore = teCoreMap[currency];
|
|
|
|
|
|
const teCorePage = teCorePages[currency]; // EUR: valeur précise; JPY: + consensus
|
|
|
|
|
|
const coreValue = teCorePage?.value ?? teCore?.value ?? null;
|
|
|
|
|
|
if (coreValue !== null) {
|
|
|
|
|
|
const prev = teCore?.prev ?? null;
|
|
|
|
|
|
const refMonth = teCorePage?.refMonth || teCore?.refMonth || "";
|
|
|
|
|
|
const surprise = prev !== null ? parseFloat((coreValue - prev).toFixed(3)) : null;
|
|
|
|
|
|
const consensus = teCorePage?.consensus ?? null;
|
2026-06-01 23:46:41 +02:00
|
|
|
|
indicators.cpiCore = {
|
2026-06-02 16:26:29 +02:00
|
|
|
|
value: coreValue,
|
|
|
|
|
|
prev,
|
2026-06-01 23:46:41 +02:00
|
|
|
|
surprise,
|
2026-06-02 16:26:29 +02:00
|
|
|
|
trend: prev !== null ? (coreValue > prev ? "up" : coreValue < prev ? "down" : "flat") : null,
|
|
|
|
|
|
lastUpdated: refMonth,
|
|
|
|
|
|
...(consensus !== null ? { consensus } : {}),
|
2026-06-01 23:56:45 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const teMoM = teMoMMap[currency];
|
|
|
|
|
|
if (teMoM) {
|
2026-06-02 13:32:52 +02:00
|
|
|
|
const surprise = teMoM.prev !== null
|
|
|
|
|
|
? parseFloat((teMoM.value - teMoM.prev).toFixed(3))
|
|
|
|
|
|
: null;
|
2026-06-01 23:56:45 +02:00
|
|
|
|
indicators.cpiMoM = {
|
|
|
|
|
|
value: teMoM.value,
|
|
|
|
|
|
prev: teMoM.prev,
|
|
|
|
|
|
surprise,
|
2026-06-02 13:32:52 +02:00
|
|
|
|
trend: teMoM.value > 0 ? "up" : teMoM.value < 0 ? "down" : "flat",
|
2026-06-01 23:56:45 +02:00
|
|
|
|
lastUpdated: teMoM.refMonth,
|
2026-06-01 23:46:41 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-06-02 00:53:39 +02:00
|
|
|
|
|
2026-06-02 16:31:31 +02:00
|
|
|
|
// Inflation Rate YoY (headline) — pages individuelles prioritaires pour EUR/GBP/JPY
|
|
|
|
|
|
const teYoY = teYoYMap[currency];
|
|
|
|
|
|
const teYoYPage = teYoYPages[currency];
|
|
|
|
|
|
const yoyValue = teYoYPage?.value ?? teYoY?.value ?? null;
|
|
|
|
|
|
if (yoyValue !== null) {
|
|
|
|
|
|
const yoyPrev = teYoYPage?.prev ?? teYoY?.prev ?? null;
|
|
|
|
|
|
const yoyRef = teYoYPage?.refMonth || teYoY?.refMonth || "";
|
|
|
|
|
|
const yoySurp = yoyPrev !== null ? parseFloat((yoyValue - yoyPrev).toFixed(3)) : null;
|
|
|
|
|
|
const yoyCons = teYoYPage?.consensus ?? null;
|
2026-06-02 00:53:39 +02:00
|
|
|
|
indicators.cpiYoY = {
|
2026-06-02 16:31:31 +02:00
|
|
|
|
value: yoyValue,
|
|
|
|
|
|
prev: yoyPrev,
|
|
|
|
|
|
surprise: yoySurp,
|
|
|
|
|
|
trend: yoySurp !== null ? (yoySurp > 0 ? "up" : yoySurp < 0 ? "down" : "flat") : null,
|
|
|
|
|
|
lastUpdated: yoyRef,
|
|
|
|
|
|
...(yoyCons !== null ? { consensus: yoyCons } : {}),
|
2026-06-02 00:53:39 +02:00
|
|
|
|
};
|
2026-06-02 17:27:32 +02:00
|
|
|
|
// Flash vs Final : si un Final est attendu, calculer le delta Final_forecast − Flash_actual
|
|
|
|
|
|
const finalForecastYoY = parseTeF(teCpiForecast?.cpiYoYFinal);
|
|
|
|
|
|
if (finalForecastYoY !== null && indicators.cpiYoY) {
|
|
|
|
|
|
const delta = parseFloat((finalForecastYoY - yoyValue).toFixed(2));
|
|
|
|
|
|
(indicators.cpiYoY as Record<string,unknown>)["_finalForecast"] = finalForecastYoY;
|
|
|
|
|
|
(indicators.cpiYoY as Record<string,unknown>)["_finalDelta"] = delta;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Idem pour cpiCore
|
|
|
|
|
|
const finalForecastCore = parseTeF(teCpiForecast?.cpiCoreFinal);
|
|
|
|
|
|
if (finalForecastCore !== null && coreValue !== null && indicators.cpiCore) {
|
|
|
|
|
|
const delta = parseFloat((finalForecastCore - coreValue).toFixed(2));
|
|
|
|
|
|
(indicators.cpiCore as Record<string,unknown>)["_finalForecast"] = finalForecastCore;
|
|
|
|
|
|
(indicators.cpiCore as Record<string,unknown>)["_finalDelta"] = delta;
|
|
|
|
|
|
}
|
2026-06-02 00:53:39 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Core CPI MoM (pages individuelles, décimales précises)
|
|
|
|
|
|
const teCoreMoM = teCoreMoMMap[currency];
|
|
|
|
|
|
if (teCoreMoM) {
|
2026-06-02 13:32:52 +02:00
|
|
|
|
const surprise = teCoreMoM.prev !== null
|
|
|
|
|
|
? parseFloat((teCoreMoM.value - teCoreMoM.prev).toFixed(3))
|
|
|
|
|
|
: null;
|
2026-06-02 00:53:39 +02:00
|
|
|
|
indicators.cpiCoreMoM = {
|
|
|
|
|
|
value: teCoreMoM.value,
|
|
|
|
|
|
prev: teCoreMoM.prev,
|
|
|
|
|
|
surprise,
|
2026-06-02 13:32:52 +02:00
|
|
|
|
trend: teCoreMoM.value > 0 ? "up" : teCoreMoM.value < 0 ? "down" : "flat",
|
2026-06-02 00:53:39 +02:00
|
|
|
|
lastUpdated: teCoreMoM.refMonth,
|
2026-06-02 16:41:34 +02:00
|
|
|
|
consensus: parseTeF(teCpiForecast?.cpiCoreMoM) ?? null,
|
2026-06-02 00:53:39 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 15:35:58 +02:00
|
|
|
|
// PPI MoM
|
|
|
|
|
|
const tePPI = tePPIMap[currency];
|
|
|
|
|
|
if (tePPI) {
|
|
|
|
|
|
const surprise = tePPI.prev !== null
|
|
|
|
|
|
? parseFloat((tePPI.value - tePPI.prev).toFixed(3))
|
|
|
|
|
|
: null;
|
|
|
|
|
|
indicators.ppiMoM = {
|
|
|
|
|
|
value: tePPI.value,
|
|
|
|
|
|
prev: tePPI.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: tePPI.value > 0 ? "up" : tePPI.value < 0 ? "down" : "flat",
|
|
|
|
|
|
lastUpdated: tePPI.refMonth,
|
2026-06-02 16:41:34 +02:00
|
|
|
|
consensus: parseTeF(teCpiForecast?.ppiMoM) ?? null,
|
2026-06-02 00:53:39 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 01:04:07 +02:00
|
|
|
|
// STIR 3M — Taux interbancaire 3 mois (SOFR 3M, EURIBOR 3M, SONIA 3M, TIBOR 3M…)
|
|
|
|
|
|
// Signal clé : STIR > policyRate = marché price des hausses (hawkish)
|
|
|
|
|
|
// STIR < policyRate = marché price des baisses (dovish)
|
|
|
|
|
|
// Tendance ↑ = conditions crédit plus restrictives (prêter plus risqué/cher)
|
|
|
|
|
|
// Tendance ↓ = conditions crédit plus souples (prêter plus sûr/moins cher)
|
|
|
|
|
|
{
|
|
|
|
|
|
const teSTIR = teSTIRMap[currency];
|
|
|
|
|
|
if (teSTIR) {
|
|
|
|
|
|
const surprise = teSTIR.prev !== null
|
|
|
|
|
|
? parseFloat((teSTIR.value - teSTIR.prev).toFixed(4))
|
|
|
|
|
|
: null;
|
|
|
|
|
|
indicators.stir3m = {
|
|
|
|
|
|
value: teSTIR.value,
|
|
|
|
|
|
prev: teSTIR.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: teSTIR.prev !== null
|
|
|
|
|
|
? (teSTIR.value > teSTIR.prev ? "up" : teSTIR.value < teSTIR.prev ? "down" : "flat")
|
|
|
|
|
|
: null,
|
|
|
|
|
|
lastUpdated: teSTIR.refMonth,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 23:16:55 +02:00
|
|
|
|
// GDP Growth Rate QoQ% — TE country-list (source autoritaire, remplace FRED stale)
|
|
|
|
|
|
// FRED : retourne souvent l'indice niveau brut → QoQ% faux ou en retard de plusieurs trimestres
|
|
|
|
|
|
// TE country-list/gdp-growth-rate : QoQ% publié directement, mis à jour à chaque release
|
|
|
|
|
|
{
|
|
|
|
|
|
const teGDP = teGDPMap[currency];
|
|
|
|
|
|
if (teGDP) {
|
|
|
|
|
|
const surprise = teGDP.prev !== null
|
|
|
|
|
|
? parseFloat((teGDP.value - teGDP.prev).toFixed(4))
|
|
|
|
|
|
: null;
|
|
|
|
|
|
indicators.gdp = {
|
|
|
|
|
|
value: teGDP.value,
|
|
|
|
|
|
prev: teGDP.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: teGDP.prev !== null
|
|
|
|
|
|
? (teGDP.value > teGDP.prev ? "up" : teGDP.value < teGDP.prev ? "down" : "flat")
|
|
|
|
|
|
: null,
|
|
|
|
|
|
lastUpdated: teGDP.refMonth,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 23:52:05 +02:00
|
|
|
|
// Unemployment Rate — TE country-list (taux national officiel, toutes devises)
|
|
|
|
|
|
// Remplace FRED qui publie souvent le taux ILO harmonisé (2-3pp supérieur au taux national,
|
|
|
|
|
|
// notamment CHF ~5% ILO vs ~3% SECO, GBP ~4% ILO vs ONS, etc.)
|
|
|
|
|
|
{
|
|
|
|
|
|
const teUne = teUneMap[currency];
|
|
|
|
|
|
if (teUne) {
|
|
|
|
|
|
const surprise = teUne.prev !== null
|
|
|
|
|
|
? parseFloat((teUne.value - teUne.prev).toFixed(4))
|
|
|
|
|
|
: null;
|
|
|
|
|
|
indicators.unemployment = {
|
|
|
|
|
|
value: teUne.value,
|
|
|
|
|
|
prev: teUne.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: teUne.prev !== null
|
|
|
|
|
|
? (teUne.value > teUne.prev ? "up" : teUne.value < teUne.prev ? "down" : "flat")
|
|
|
|
|
|
: null,
|
|
|
|
|
|
lastUpdated: teUne.refMonth,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 14:12:05 +02:00
|
|
|
|
// 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 16:57:22 +02:00
|
|
|
|
// AUD Commodity Prices YoY
|
|
|
|
|
|
if (teAUDComm) {
|
|
|
|
|
|
const surprise = teAUDComm.prev !== null
|
|
|
|
|
|
? parseFloat((teAUDComm.value - teAUDComm.prev).toFixed(3))
|
|
|
|
|
|
: null;
|
|
|
|
|
|
indicators.commodityPricesYoY = {
|
|
|
|
|
|
value: teAUDComm.value,
|
|
|
|
|
|
prev: teAUDComm.prev,
|
|
|
|
|
|
surprise,
|
|
|
|
|
|
trend: teAUDComm.value > 0 ? "up" : teAUDComm.value < 0 ? "down" : "flat",
|
|
|
|
|
|
lastUpdated: teAUDComm.refMonth,
|
|
|
|
|
|
consensus: teAUDComm.consensus,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 15:35:58 +02:00
|
|
|
|
// Raw Core CPI index values for tooltip on cpiCoreMoM
|
2026-06-02 00:53:39 +02:00
|
|
|
|
const teRawCore = teCoreIdxMap[currency];
|
2026-06-02 15:35:58 +02:00
|
|
|
|
const rawCoreIndex = teRawCore ? { last: teRawCore.rawLast, prev: teRawCore.rawPrev, refMonth: teRawCore.refMonth } : null;
|
2026-06-02 00:53:39 +02:00
|
|
|
|
if (indicators.cpiCoreMoM && rawCoreIndex) (indicators.cpiCoreMoM as Record<string,unknown>)["_raw"] = rawCoreIndex;
|
2026-06-01 23:46:41 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 12:13:05 +02:00
|
|
|
|
// Stale-if-error
|
2026-05-28 22:58:27 +02:00
|
|
|
|
const hasAnyValue = Object.values(indicators).some((v) => v !== null);
|
|
|
|
|
|
if (!hasAnyValue && staleCache) {
|
2026-05-29 12:13:05 +02:00
|
|
|
|
return NextResponse.json({ ...(staleCache.data as object), stale: true });
|
2026-05-28 22:58:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
const data = {
|
|
|
|
|
|
currency, indicators,
|
2026-07-08 16:46:05 +02:00
|
|
|
|
moneySupplyM3: getMoneySupplyM3(currency),
|
2026-06-01 23:16:18 +02:00
|
|
|
|
forecasts: {
|
2026-06-02 00:03:37 +02:00
|
|
|
|
// CPI — TE calendar forecast (priorité) puis ForexFactory
|
2026-07-08 16:46:05 +02:00
|
|
|
|
cpi: parseTeF(teCpiForecast?.cpiYoY) ?? ffForecasts.cpi ?? null,
|
|
|
|
|
|
cpiCore: parseTeF(teCpiForecast?.cpiCore) ?? null,
|
|
|
|
|
|
cpiMoM: parseTeF(teCpiForecast?.cpiMoM) ?? null,
|
|
|
|
|
|
cpiCoreMoM: parseTeF(teCpiForecast?.cpiCoreMoM) ?? null,
|
|
|
|
|
|
ppiMoM: parseTeF(teCpiForecast?.ppiMoM) ?? null,
|
2026-06-01 23:16:18 +02:00
|
|
|
|
cpiSurprise: ffForecasts.cpiSurprise,
|
|
|
|
|
|
unemployment: ffForecasts.unemployment,
|
|
|
|
|
|
unemploymentSurprise: ffForecasts.unemploymentSurprise,
|
|
|
|
|
|
pmiMfg: ffForecasts.pmiMfg,
|
|
|
|
|
|
pmiMfgSurprise: ffForecasts.pmiMfgSurprise,
|
|
|
|
|
|
pmiSvc: ffForecasts.pmiSvc,
|
|
|
|
|
|
pmiSvcSurprise: ffForecasts.pmiSvcSurprise,
|
|
|
|
|
|
pmiComposite: ffForecasts.pmiComposite,
|
|
|
|
|
|
pmiCompositeSurprise: ffForecasts.pmiCompositeSurprise,
|
|
|
|
|
|
retailSales: ffForecasts.retailSales,
|
|
|
|
|
|
retailSalesSurprise: ffForecasts.retailSalesSurprise,
|
|
|
|
|
|
gdp: ffForecasts.gdp,
|
|
|
|
|
|
gdpSurprise: ffForecasts.gdpSurprise,
|
|
|
|
|
|
employment: ffForecasts.employment,
|
|
|
|
|
|
employmentSurprise: ffForecasts.employmentSurprise,
|
|
|
|
|
|
},
|
|
|
|
|
|
fetchedAt: new Date().toISOString(),
|
|
|
|
|
|
};
|
2026-05-27 13:18:45 +02:00
|
|
|
|
_cache.set(currency, { data, ts: Date.now() });
|
|
|
|
|
|
return NextResponse.json(data);
|
|
|
|
|
|
}
|