feat: calendrier économique TE+Investing, OIS SNB, yields 10Y, taux directeurs corrects

Calendrier économique
- Nouvelles sources : TE HTML scraping (483 events/2 sem.) + Investing.com en co-primaires
- Dédupe déterministe par clé currency+category+date (±1j pour policy_rate)
- Filtre catégorie "other", filtre discours CB (fix doublon TE vs Investing)
- CHF/Switzerland via page séparée TE (hors G20)
- Fenêtre 14 jours avec ?startDate=&endDate= passés à TE
- ForexFactory+FRED en secours uniquement si les deux scrapers tombent à vide

Probabilités OIS
- rateprobability.com (7 CBs : FED/ECB/BOJ/BOE/BOC/RBA/RBNZ)
- investinglive.com (Dellamotta) : scan URL-date 14 jours → couvre SNB/CHF manquant
- SNB : 3% probabilité hausse au 19 juin 2026 (source : IL article 2026-05-29)
- rate_expectations.json MAJ : RBNZ 79% hike (corrigé depuis 70% no-change stale)
- Dédup ±1 jour pour policy_rate (fix doublon SNB TE-18/06 vs IL-19/06)

Taux directeurs
- USD : scrapeTeRate() = 3.75% (fin midpoint FRED 3.625%)
- AUD prev : 4.10% (corrigé depuis 4.60%)
- NZD/CHF/JPY/CAD : cohérents avec TE officiel

Yields 10Y souverains
- Nouvelle lib lib/tebonds.ts : scrape TE bonds, données du jour, cache 1h
- Remplace FRED IRLTLT01XXM156N mensuel (JPY/CHF/AUD/NZD avaient 1 mois de retard)
- 8 devises + spread vs USD calculé automatiquement
- /api/yields et /api/drivers utilisent la même lib

Drivers globaux
- BTC : ticker/price Binance (plus précis que 24hr lastPrice)
- HY Spread cache : 86400s → 3600s (données FRED du jour)
- DriversBar : suppression affichage direct US 10Y, tooltip Crb 2-10 affiche US10Y+US2Y

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Capucine Gest
2026-06-01 23:16:18 +02:00
parent 3a39904ce5
commit eb416be780
14 changed files with 2615 additions and 245 deletions
+536
View File
@@ -0,0 +1,536 @@
import { NextResponse } from "next/server";
import rateDecisionsData from "@/data/rate_decisions.json";
import { fetchFFEvents, nextWeekAvailable } from "@/lib/forexfactory";
import type { FFEvent } from "@/lib/forexfactory";
import type { Currency } from "@/lib/types";
import { fetchAllCBPaths, extractMeetingEvents } from "@/lib/rateprobability";
import { fetchTECalendarHTML } from "@/lib/tradingeconomics";
import { fetchInvestingCalendar } from "@/lib/investing";
// ── Types ─────────────────────────────────────────────────────────────────────
export type EventCategory =
| "employment"
| "pmi"
| "policy_rate"
| "cb_speech"
| "inflation"
| "gdp"
| "retail_sales"
| "trade_balance"
| "other";
export interface CalendarEvent {
id: string;
date: string; // ISO string from FF
currency: Currency;
category: EventCategory;
title: string; // display-friendly
rawTitle: string; // original FF title
impact: "high" | "medium" | "low";
actual: string | null;
forecast: string | null;
previous: string | null;
isPublished: boolean;
week: "current" | "next" | "next2"; // semaine de l'événement
source: "ff" | "fred"; // source de la donnée
groupKey: string | null;
isGroupParent: boolean;
isGroupChild: boolean;
}
export interface CalendarResponse {
events: CalendarEvent[];
nextWeekAvail: boolean;
fetchedAt: string;
source: string;
}
// ── Currencies supported ───────────────────────────────────────────────────────
const CURRENCIES = new Set<string>(["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]);
// ── Category detection ─────────────────────────────────────────────────────────
function detectCategory(title: string): EventCategory {
const t = title.toLowerCase();
if (/nonfarm|non.farm|employment\s+change|jobs\s+added|employment\s+report|claimant|jobless\s+claims|unemployment\s+rate|jobless\s+rate/.test(t))
return "employment";
if (/\bpmi\b|purchasing\s+managers/.test(t))
return "pmi";
if (/interest\s+rate|rate\s+decision|monetary\s+policy\s+decision|fomc\s+(fed\s+funds|statement)|bank\s+rate\s+vote|mpc\s+.*rate|boe.*rate|ecb.*rate|boj.*rate|rba.*rate|rbnz.*rate|snb.*rate|boc.*rate/.test(t))
return "policy_rate";
if (/speaks?|press\s+conf|testimony|speech|statement\b|governor|chair\b|president\b/.test(t))
return "cb_speech";
if (/\bcpi\b|\bhicp\b|core\s+inflation|flash\s+cpi|inflation\s+rate|consumer\s+price/.test(t))
return "inflation";
if (/\bgdp\b|gross\s+domestic/.test(t))
return "gdp";
if (/retail\s+sales|core\s+retail/.test(t))
return "retail_sales";
if (/trade\s+balance|current\s+account/.test(t))
return "trade_balance";
return "other";
}
// ── Impact mapping ─────────────────────────────────────────────────────────────
function detectImpact(ff: FFEvent, category: EventCategory): "high" | "medium" | "low" {
const raw = (ff.impact ?? "").toLowerCase();
if (raw.includes("high")) return "high";
if (raw.includes("medium")) return "medium";
if (raw.includes("low")) return "low";
// Fallback by category
if (["policy_rate", "inflation", "employment"].includes(category)) return "high";
if (["pmi", "gdp", "retail_sales"].includes(category)) return "medium";
return "low";
}
// ── Display title mapping ──────────────────────────────────────────────────────
function displayTitle(rawTitle: string, currency: string): string {
const t = rawTitle;
// USD-specific
if (/nonfarm\s+payrolls/i.test(t) && currency === "USD") return "NFP (Non-Farm Payrolls)";
if (/adp\s+non.farm/i.test(t)) return "ADP Employment";
if (/unemployment\s+claims/i.test(t)) return "Demandes d'allocations";
if (/unemployment\s+rate/i.test(t)) return "Taux de chômage";
if (/employment\s+change/i.test(t)) return "Emploi Δ";
if (/manufacturing\s+pmi|mfg\s+pmi/i.test(t)) return "PMI Manufacturier";
if (/services?\s+pmi/i.test(t)) return "PMI Services";
if (/composite\s+pmi/i.test(t)) return "PMI Composite";
if (/ism\s+non.manufactur/i.test(t)) return "ISM Services PMI";
if (/ism\s+manufactur/i.test(t)) return "ISM Manufacturier";
if (/flash.*cpi|cpi.*flash/i.test(t)) return "IPC Flash (YoY)";
if (/core.*cpi/i.test(t)) return "IPC Core";
if (/\bhicp\b/i.test(t)) return "HICP (YoY)";
if (/\bcpi\b.*y.*y/i.test(t)) return "IPC (YoY)";
if (/\bcpi\b.*m.*m/i.test(t)) return "IPC (MoM)";
if (/\bcpi\b/i.test(t)) return "IPC";
if (/gdp.*q.*q/i.test(t)) return "PIB (QoQ)";
if (/gdp.*m.*m/i.test(t)) return "PIB (MoM)";
if (/\bgdp\b/i.test(t)) return "PIB";
if (/core\s+retail/i.test(t)) return "Ventes détail Core";
if (/retail\s+sales/i.test(t)) return "Ventes au détail";
if (/trade\s+balance/i.test(t)) return "Balance commerciale";
if (/interest\s+rate|rate\s+decision/i.test(t)) return "Décision de taux";
if (/speaks?\b|speech\b/i.test(t)) return "Discours BC";
if (/press\s+conf/i.test(t)) return "Conférence de presse BC";
return rawTitle;
}
// ── Grouping logic ─────────────────────────────────────────────────────────────
function dayKey(dateStr: string): string {
return dateStr.slice(0, 10); // YYYY-MM-DD
}
interface GroupingResult {
groupKey: string | null;
isGroupParent: boolean;
isGroupChild: boolean;
}
const EMPLOYMENT_PARENTS = /nonfarm|non.farm|employment\s+change|claimant|jobless\s+claims/i;
const EMPLOYMENT_CHILDREN = /unemployment\s+rate|jobless\s+rate/i;
const PMI_PARENTS = /composite\s+pmi|ism\s+(manufactur|non.manufactur)/i;
const PMI_CHILDREN = /manufacturing\s+pmi|services?\s+pmi|mfg\s+pmi/i;
const CPI_PARENTS = /\bcpi\b.*y.*y|flash.*cpi|\bhicp\b/i;
const CPI_CHILDREN = /core.*cpi|core.*inflation/i;
function resolveGrouping(ev: FFEvent, category: EventCategory): GroupingResult {
const t = ev.title;
const day = dayKey(ev.date ?? "");
const base = `${ev.country}_${day}`;
if (category === "employment") {
if (EMPLOYMENT_PARENTS.test(t)) return { groupKey: `emp_${base}`, isGroupParent: true, isGroupChild: false };
if (EMPLOYMENT_CHILDREN.test(t)) return { groupKey: `emp_${base}`, isGroupParent: false, isGroupChild: true };
}
if (category === "pmi") {
if (PMI_PARENTS.test(t)) return { groupKey: `pmi_${base}`, isGroupParent: true, isGroupChild: false };
if (PMI_CHILDREN.test(t)) return { groupKey: `pmi_${base}`, isGroupParent: false, isGroupChild: true };
}
if (category === "inflation") {
if (CPI_PARENTS.test(t)) return { groupKey: `cpi_${base}`, isGroupParent: true, isGroupChild: false };
if (CPI_CHILDREN.test(t)) return { groupKey: `cpi_${base}`, isGroupParent: false, isGroupChild: true };
}
return { groupKey: null, isGroupParent: false, isGroupChild: false };
}
// ── Map FF event → CalendarEvent ──────────────────────────────────────────────
function mapEvent(ff: FFEvent): CalendarEvent | null {
if (!CURRENCIES.has(ff.country)) return null;
const category = detectCategory(ff.title);
if (category === "other") return null; // filter low-relevance events
const { groupKey, isGroupParent, isGroupChild } = resolveGrouping(ff, category);
const actual = ff.actual?.trim() || null;
const forecast = ff.forecast?.trim() || null;
const previous = ff.previous?.trim() || null;
return {
id: `${ff.country}_${ff.title}_${ff.date}`.replace(/\s+/g, "_"),
date: ff.date,
currency: ff.country as Currency,
category,
title: displayTitle(ff.title, ff.country),
rawTitle: ff.title,
impact: detectImpact(ff, category),
actual: actual,
forecast: forecast,
previous: previous,
isPublished: actual !== null && actual !== "",
week: "current" as const, // overridden in GET()
source: "ff" as const,
groupKey,
isGroupParent,
isGroupChild,
};
}
// ── FRED release calendar ──────────────────────────────────────────────────────
// Complément fiable pour les semaines où ForexFactory n'est pas disponible
// (samedi/dimanche) ou pour la semaine +2 (non couverte par FF).
interface FREDReleaseDef {
title: string;
currency: Currency;
category: EventCategory;
impact: "high" | "medium" | "low";
utcHour: number; // heure UTC approximative de la publication
}
const FRED_KEY_RELEASES: Record<number, FREDReleaseDef> = {
// USD — publications majeures BLS/BEA/Census
50: { title: "NFP & Taux de chômage (Employment Situation)", currency: "USD", category: "employment", impact: "high", utcHour: 13 },
10: { title: "IPC (CPI) — USD", currency: "USD", category: "inflation", impact: "high", utcHour: 13 },
9: { title: "Ventes au détail — USD", currency: "USD", category: "retail_sales", impact: "high", utcHour: 13 },
194: { title: "ADP National Employment — USD", currency: "USD", category: "employment", impact: "medium", utcHour: 13 },
180: { title: "Demandes allocations chômage hebdo — USD", currency: "USD", category: "employment", impact: "medium", utcHour: 13 },
13: { title: "Production industrielle — USD", currency: "USD", category: "gdp", impact: "medium", utcHour: 14 },
321: { title: "Empire State Manufacturing Survey — USD", currency: "USD", category: "pmi", impact: "medium", utcHour: 13 },
// EUR — publications Eurostat/ECB
267: { title: "PIB Zone Euro — Eurostat Flash", currency: "EUR", category: "gdp", impact: "high", utcHour: 10 },
251: { title: "HICP / IPC Zone Euro", currency: "EUR", category: "inflation", impact: "high", utcHour: 10 },
// JPY
269: { title: "PIB Japon — Comptes nationaux", currency: "JPY", category: "gdp", impact: "high", utcHour: 1 },
266: { title: "Comptes BoJ (Bank of Japan)", currency: "JPY", category: "policy_rate", impact: "medium", utcHour: 2 },
};
// CB_MEETINGS_2026 supprimé — dates désormais dynamiques via lib/rateprobability.ts
// (rateprobability.com OIS futures, mis à jour en temps réel, dates toujours exactes)
async function fetchFREDCalendar(
fredKey: string,
fromDate: string,
toDate: string,
): Promise<CalendarEvent[]> {
try {
const url = [
"https://api.stlouisfed.org/fred/releases/dates",
`?api_key=${fredKey}`,
"&file_type=json",
`&realtime_start=${fromDate}`,
`&realtime_end=${toDate}`,
"&include_release_dates_with_no_data=true",
"&limit=500",
].join("");
const res = await fetch(url, { next: { revalidate: 3600 } });
if (!res.ok) return [];
const data = await res.json();
const releaseDates: Array<{ release_id: number; date: string; release_name: string }> =
data.release_dates ?? [];
const events: CalendarEvent[] = [];
const seen = new Set<string>();
for (const rd of releaseDates) {
const def = FRED_KEY_RELEASES[rd.release_id];
if (!def) continue;
const key = `${rd.release_id}_${rd.date}`;
if (seen.has(key)) continue;
seen.add(key);
// Construire une date ISO avec l'heure UTC approximative
const isoDate = `${rd.date}T${String(def.utcHour).padStart(2, "0")}:30:00Z`;
const evDate = new Date(isoDate);
events.push({
id: `fred_${rd.release_id}_${rd.date}`,
date: isoDate,
currency: def.currency,
category: def.category,
title: def.title,
rawTitle: def.title,
impact: def.impact,
actual: null,
forecast: null,
previous: null,
isPublished: evDate < new Date(),
week: "current", // recalculé dans GET()
source: "fred",
groupKey: null,
isGroupParent: false,
isGroupChild: false,
});
}
return events;
} catch { return []; }
}
// ── Dedup key ─────────────────────────────────────────────────────────────────
// Clé déterministe pour identifier un doublon entre TE et Investing.
// PMI et discours BC peuvent avoir plusieurs events dans la même journée →
// on affine à l'heure UTC pour les distinguer.
// Toutes les autres catégories sont uniques par (devise, catégorie, jour).
function dedupeKey(currency: string, category: EventCategory, isoDate: string): string {
if (category === "pmi" || category === "cb_speech") {
return `${currency}_${category}_${isoDate.slice(0, 13)}`; // YYYY-MM-DDTHH
}
return `${currency}_${category}_${isoDate.slice(0, 10)}`; // YYYY-MM-DD
}
// ── GET ────────────────────────────────────────────────────────────────────────
// Calcule les bornes des 3 semaines (cette semaine, prochaine, +2)
function getWeekBounds(): { nextMonday: Date; next2Monday: Date } {
const now = new Date();
const day = now.getDay(); // 0=dim
const daysToNext = day === 0 ? 1 : 8 - day;
const nextMonday = new Date(now);
nextMonday.setDate(now.getDate() + daysToNext);
nextMonday.setHours(0, 0, 0, 0);
const next2Monday = new Date(nextMonday);
next2Monday.setDate(nextMonday.getDate() + 7);
return { nextMonday, next2Monday };
}
export async function GET() {
const fredKey = process.env.FRED_API_KEY;
const { nextMonday, next2Monday } = getWeekBounds();
const fromDate = new Date().toISOString().slice(0, 10);
const toDateObj = new Date();
toDateObj.setDate(toDateObj.getDate() + 14);
const toDate = toDateObj.toISOString().slice(0, 10);
// Fetch TE HTML + Investing + CB paths toujours en parallèle
// FF+FRED uniquement si les deux scraping tombent à vide
const [teEvents, invEvents, cbPaths] = await Promise.all([
fetchTECalendarHTML(fromDate, toDate),
fetchInvestingCalendar(fromDate, toDate),
fetchAllCBPaths(),
]);
const useScraping = teEvents.length > 0 || invEvents.length > 0;
// Fetch FF+FRED en secours seulement si les deux scrapers ont échoué
const [ffEvents, fredEvents] = useScraping
? [[], []]
: await Promise.all([
fetchFFEvents(),
fredKey ? fetchFREDCalendar(fredKey, fromDate, toDate) : Promise.resolve([]),
]);
const events: CalendarEvent[] = [];
const weekOf = (date: Date): "current" | "next" | "next2" =>
date >= next2Monday ? "next2" :
date >= nextMonday ? "next" : "current";
if (useScraping) {
// ── BASE : TE HTML ────────────────────────────────────────────────────────
// Index de dédupe : clé → index dans events[]
const dedupeIndex = new Map<string, number>();
for (const te of teEvents) {
if (te.category === "other") continue; // filtre les events sans catégorie pertinente
const evDate = new Date(te.date);
const key = dedupeKey(te.currency, te.category, te.date);
dedupeIndex.set(key, events.length);
events.push({
id: te.id,
date: te.date,
currency: te.currency,
category: te.category,
title: te.title,
rawTitle: te.title,
impact: te.impact,
actual: te.actual,
forecast: te.forecast,
previous: te.previous,
isPublished: te.isPublished,
week: weekOf(evDate),
source: "fred",
groupKey: null,
isGroupParent: false,
isGroupChild: false,
});
}
// ── COMPLÉMENT : Investing.com ────────────────────────────────────────────
// Dédupe immédiat via dedupeIndex : même clé = doublon, on enrichit seulement.
// Absent de TE = on ajoute l'event Investing directement.
for (const inv of invEvents) {
if (inv.category === "other") continue;
const key = dedupeKey(inv.currency, inv.category, inv.date);
const existingIdx = dedupeIndex.get(key);
if (existingIdx !== undefined) {
// Doublon — enrichir avec les valeurs manquantes d'Investing
const ev = events[existingIdx];
if (!ev.actual && inv.actual) ev.actual = inv.actual;
if (!ev.forecast && inv.forecast) ev.forecast = inv.forecast;
if (!ev.previous && inv.previous) ev.previous = inv.previous;
} else {
// Présent sur Investing mais absent de TE — on l'ajoute
const evDate = new Date(inv.date);
dedupeIndex.set(key, events.length);
events.push({
id: inv.id,
date: inv.date,
currency: inv.currency,
category: inv.category,
title: inv.title,
rawTitle: inv.title,
impact: inv.impact,
actual: inv.actual,
forecast: inv.forecast,
previous: inv.previous,
isPublished: inv.isPublished,
week: weekOf(evDate),
source: "fred",
groupKey: null,
isGroupParent: false,
isGroupChild: false,
});
}
}
} else {
// ── SECOURS : ForexFactory + FRED ─────────────────────────────────────────
const ffKeys = new Set<string>();
for (const ff of ffEvents as FFEvent[]) {
const ev = mapEvent(ff);
if (!ev) continue;
ev.week = weekOf(new Date(ff.date));
events.push(ev);
ffKeys.add(`${ev.currency}_${ev.category}_${ff.date.slice(0, 10)}`);
}
for (const fredEv of fredEvents as CalendarEvent[]) {
const key = `${fredEv.currency}_${fredEv.category}_${fredEv.date.slice(0, 10)}`;
if (ffKeys.has(key)) continue;
fredEv.week = weekOf(new Date(fredEv.date));
events.push(fredEv);
}
}
// ── Réunions CB + probabilités OIS (rateprobability.com / InvestingLive) ───
const cbMeetings = extractMeetingEvents(cbPaths, fromDate);
// Cherche un event policy_rate existant dans une fenêtre ±1 jour
// (nécessaire car certains providers décalent d'un jour : TE ≠ rateprobability pour le SNB)
function findExistingRateDecision(currency: Currency, dateIso: string): CalendarEvent | undefined {
const target = new Date(dateIso).getTime();
return events.find(e =>
e.currency === currency &&
e.category === "policy_rate" &&
Math.abs(new Date(e.date).getTime() - target) <= 86_400_000 // ±1 jour
);
}
const cbDedup = new Set(
events
.filter(e => e.category === "policy_rate")
.map(e => `${e.currency}_${e.date.slice(0, 10)}`)
);
for (const meeting of cbMeetings) {
const existing = findExistingRateDecision(meeting.currency, meeting.dateIso);
if (existing) {
// Enrichir l'event existant avec la probabilité OIS
if (meeting.probMovePct > 0) {
const probStr = `${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼" : "▲"}`;
if (!existing.forecast) existing.forecast = probStr;
if (meeting.probMovePct > 50 && !existing.title.includes("▼") && !existing.title.includes("▲")) {
existing.title += ` · ${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼ baisse" : "▲ hausse"}`;
}
}
continue;
}
// Vérifier aussi par la clé jour (pour ne pas dupliquer si le meeting IL tombe le même jour qu'un event TE)
const dayKey2 = `${meeting.currency}_${meeting.dateIso}`;
if (cbDedup.has(dayKey2)) continue;
cbDedup.add(dayKey2);
const isoDate = `${meeting.dateIso}T${String(meeting.utcHour).padStart(2, "0")}:30:00Z`;
const evDate = new Date(isoDate);
const probLabel = meeting.probMovePct > 50
? ` · ${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼ baisse" : "▲ hausse"}`
: "";
events.push({
id: `cb_${meeting.currency}_${meeting.dateIso}`,
date: isoDate,
currency: meeting.currency,
category: "policy_rate",
title: meeting.title + probLabel,
rawTitle: meeting.title,
impact: "high",
actual: null,
forecast: meeting.probMovePct > 0 ? `${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼" : "▲"}` : null,
previous: null,
isPublished: evDate < new Date(),
week: weekOf(evDate),
source: "fred",
groupKey: null,
isGroupParent: false,
isGroupChild: false,
});
}
// ── Remplissage previous pour décisions de taux sans valeur ────────────────
// Les events futurs n'ont pas de previous dans TE/Investing ; on utilise
// rate_decisions.json (taux directeur actuel) comme valeur de référence.
// On exclut : discours, minutes, projections, votes, Beige Book, press conf.
const SKIP_RATE_PREV = /speech|speaks?|testimony|press\s+conf|minutes|projection|beige\s+book|vote\s+(cut|hike|unchanged)|balance\s+sheet|chart\s+pack|payments\s+system/i;
const rateDecEntry = (rateDecisionsData as Array<{ decisions: Record<string, { current: number }> }>)[0];
const currentRates: Record<string, string> = {};
if (rateDecEntry?.decisions) {
for (const [ccy, d] of Object.entries(rateDecEntry.decisions)) {
currentRates[ccy] = `${d.current}%`;
}
}
for (const ev of events) {
if (ev.category === "policy_rate" && !ev.previous && !SKIP_RATE_PREV.test(ev.rawTitle)) {
ev.previous = currentRates[ev.currency] ?? null;
}
}
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const sourceLabel = useScraping
? `tradingeconomics-html(${teEvents.length})+investing(${invEvents.length})+rateprobability`
: (fredKey ? "forexfactory+fred+rateprobability" : "forexfactory+rateprobability");
const result: CalendarResponse = {
events,
nextWeekAvail: useScraping ? true : nextWeekAvailable(),
fetchedAt: new Date().toISOString(),
source: sourceLabel,
};
return NextResponse.json(result, {
headers: { "Cache-Control": "s-maxage=1800, stale-while-revalidate=3600" },
});
}
+70 -32
View File
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
import { fetchTEBondYields } from "@/lib/tebonds";
// ── FRED (macro + taux + spread) ──────────────────────────────────────────────
// limit=1 → valeur seule. limit=10 → filtre les "." pour trouver la dernière valeur valide.
// ── FRED (spreads crédit + rendements) ───────────────────────────────────────
// VIX et S&P500 ne passent plus par FRED (lag 1-2j) → Yahoo Finance (temps réel)
async function fredObs(series: string, apiKey: string): Promise<number | null> {
try {
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=1`;
const res = await fetch(url, { next: { revalidate: 86400 } });
const res = await fetch(url, { next: { revalidate: 3600 } }); // 1h — FRED publie 1x/jour mais réduit le délai d'affichage
if (!res.ok) return null;
const obs = ((await res.json())?.observations ?? []).find(
(o: { value: string }) => o.value !== "."
@@ -17,22 +18,32 @@ async function fredObs(series: string, apiKey: string): Promise<number | null> {
type FredResult = { value: number | null; delta: number | null; deltaPct: number | null };
/** Fetche les 10 dernières obs pour calculer valeur + delta vs session précédente */
async function fredObsDelta(series: string, apiKey: string): Promise<FredResult> {
// ── Yahoo Finance (VIX, S&P 500 — temps réel via regularMarketPrice) ──────────
// Query v8 chart API : meta.regularMarketPrice + meta.chartPreviousClose
// Cache 5 min → données fraîches pendant la séance boursière
async function yahooQuote(symbol: string): Promise<FredResult> {
const empty: FredResult = { value: null, delta: null, deltaPct: null };
try {
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=10`;
const res = await fetch(url, { next: { revalidate: 86400 } });
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=2d`;
const res = await fetch(url, {
next: { revalidate: 300 }, // cache 5 min
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
});
if (!res.ok) return empty;
const obs: number[] = ((await res.json())?.observations ?? [])
.filter((o: { value: string }) => o.value !== ".")
.map((o: { value: string }) => parseFloat(o.value));
if (!obs.length) return empty;
const value = obs[0];
const prev = obs[1] ?? null;
const delta = prev !== null ? parseFloat((value - prev).toFixed(2)) : null;
const deltaPct = prev !== null ? parseFloat(((value - prev) / prev * 100).toFixed(2)) : null;
return { value, delta, deltaPct };
const data = await res.json();
const meta = data?.chart?.result?.[0]?.meta as {
regularMarketPrice?: number;
chartPreviousClose?: number;
} | undefined;
const current = meta?.regularMarketPrice ?? null;
const prevClose = meta?.chartPreviousClose ?? null;
if (current === null) return empty;
const delta = prevClose !== null ? parseFloat((current - prevClose).toFixed(2)) : null;
const deltaPct = prevClose !== null && prevClose > 0
? parseFloat(((current - prevClose) / prevClose * 100).toFixed(2))
: null;
return { value: parseFloat(current.toFixed(2)), delta, deltaPct };
} catch { return empty; }
}
@@ -65,16 +76,18 @@ async function stooqMetal(symbol: string): Promise<FredResult> {
}
// ── Binance (Bitcoin — gratuit, sans clé, temps réel) ────────────────────────
// ticker/price = prix spot instantané (plus précis que ticker/24hr lastPrice)
// ticker/24hr = stats 24h pour le % de variation
async function binanceBTC(): Promise<{ value: number | null; change24h: number | null }> {
try {
const res = await fetch("https://api.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT", { cache: "no-store" });
if (!res.ok) return { value: null, change24h: null };
const d = await res.json();
const price = parseFloat(d.lastPrice);
const pctChg = parseFloat(d.priceChangePercent);
const [priceRes, statsRes] = await Promise.all([
fetch("https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT", { cache: "no-store" }),
fetch("https://api.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT", { cache: "no-store" }),
]);
const price = priceRes.ok ? parseFloat((await priceRes.json()).price) : NaN;
const pctChg = statsRes.ok ? parseFloat((await statsRes.json()).priceChangePercent) : NaN;
return {
value: isNaN(price) ? null : Math.round(price),
value: isNaN(price) ? null : parseFloat(price.toFixed(2)),
change24h: isNaN(pctChg) ? null : parseFloat(pctChg.toFixed(2)),
};
} catch { return { value: null, change24h: null }; }
@@ -103,11 +116,11 @@ export async function GET() {
const fredKey = process.env.FRED_API_KEY;
if (!fredKey) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
// 1. Marchés indices — FRED (données fin de journée, cache 24h)
// VIXCLS = VIX clôture CBOE | SP500 = S&P 500
// 1. Indices — Yahoo Finance (temps réel, cache 5 min)
// ^VIX = CBOE Volatility Index | ^GSPC = S&P 500
const [vixQ, sp500Q] = await Promise.all([
fredObsDelta("VIXCLS", fredKey),
fredObsDelta("SP500", fredKey),
yahooQuote("^VIX"),
yahooQuote("^GSPC"),
]);
// Pétrole — Stooq futures (quasi temps réel, cache 5 min, sans clé API)
@@ -128,14 +141,18 @@ export async function GET() {
const btcBin = await binanceBTC();
const btcCg = btcBin.value === null ? await coingeckoBTC() : { value: null, change24h: null };
// 4. FRED — spreads crédit + taux directeurs (cache 24h)
const [hyRaw, igRaw, us10y, us2y] = await Promise.all([
// 4. FRED — spreads crédit (cache 1h)
// Yields 10Y — TE bonds (cache 1h, données du jour)
// US 2Y — FRED DGS2 (garde pour la courbe 2-10)
const [hyRaw, igRaw, us2y, bondYields] = await Promise.all([
fredObs("BAMLH0A0HYM2", fredKey),
fredObs("BAMLC0A0CM", fredKey),
fredObs("DGS10", fredKey),
fredObs("DGS2", fredKey),
fetchTEBondYields(),
]);
const us10y = bondYields["USD"]?.yield10y ?? null;
return NextResponse.json({
// Sentiment / Risk-On
vix: vixQ.value,
@@ -145,10 +162,31 @@ export async function GET() {
sp500ChangePct: sp500Q.deltaPct,
btc: btcBin.value ?? btcCg.value,
btcChange24h: btcBin.change24h ?? btcCg.change24h,
// Crédit (FRED, % × 100 = bps)
// Crédit (FRED, bps)
hySpread: hyRaw != null ? Math.round(hyRaw * 100) : null,
igSpread: igRaw != null ? Math.round(igRaw * 100) : null,
// Taux (DXY injecté par page.tsx depuis /api/fx)
// Yields 10Y par devise — TE bonds (temps réel, cache 1h)
bondYields10y: {
USD: bondYields["USD"]?.yield10y ?? null,
EUR: bondYields["EUR"]?.yield10y ?? null,
GBP: bondYields["GBP"]?.yield10y ?? null,
JPY: bondYields["JPY"]?.yield10y ?? null,
AUD: bondYields["AUD"]?.yield10y ?? null,
CAD: bondYields["CAD"]?.yield10y ?? null,
CHF: bondYields["CHF"]?.yield10y ?? null,
NZD: bondYields["NZD"]?.yield10y ?? null,
},
bondYieldsDay: {
USD: bondYields["USD"]?.dayDelta ?? null,
EUR: bondYields["EUR"]?.dayDelta ?? null,
GBP: bondYields["GBP"]?.dayDelta ?? null,
JPY: bondYields["JPY"]?.dayDelta ?? null,
AUD: bondYields["AUD"]?.dayDelta ?? null,
CAD: bondYields["CAD"]?.dayDelta ?? null,
CHF: bondYields["CHF"]?.dayDelta ?? null,
NZD: bondYields["NZD"]?.dayDelta ?? null,
},
// Courbe USD (2Y de FRED, 10Y de TE)
us10y,
us2y,
curveSlope: us10y !== null && us2y !== null ? Math.round((us10y - us2y) * 100) : null,
+245 -8
View File
@@ -2,15 +2,252 @@ import { NextResponse } from "next/server";
import { readFileSync } from "fs";
import { join } from "path";
export async function GET() {
const AUTHOR_URL = "https://investinglive.com/author/giuseppe-dellamotta/";
const CATEGORY_URL = "https://investinglive.com/CentralBanks";
const RSS_URLS = [
"https://investinglive.com/CentralBanks/feed/",
"https://investinglive.com/feed/",
];
const KEYWORDS = ["rate hikes by year-end", "interest rate expectations", "rate cuts by year-end"];
const 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";
const CB_NAMES: Record<string, string> = {
fed: "Fed (USD)",
ecb: "ECB (EUR)",
boe: "BoE (GBP)",
boj: "BoJ (JPY)",
snb: "SNB (CHF)",
boc: "BoC (CAD)",
rba: "RBA (AUD)",
rbnz: "RBNZ (NZD)",
};
function normalizeCb(raw: string): string {
const key = raw.toLowerCase().replace(/[^a-z]/g, "");
return CB_NAMES[key] ?? raw.trim().replace(/[:*]/g, "");
}
function dateFromUrl(url: string): string {
// YYYYMMDD dans le slug
const m1 = url.match(/(\d{8})/);
if (m1) return m1[1];
// Format WordPress /YYYY/MM/DD/
const m2 = url.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//);
if (m2) return `${m2[1]}${m2[2]}${m2[3]}`;
return "00000000";
}
function toText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, "\n")
.replace(/\n+/g, "\n")
.replace(/\s+/g, " ")
.trim();
}
function extractArticleData(rawHtml: string, url: string) {
const text = toText(rawHtml);
if (!KEYWORDS.some((kw) => text.toLowerCase().includes(kw))) return null;
const result = {
url,
title: "Rate expectations",
date: new Date().toISOString().slice(0, 10),
scraped_at: new Date().toISOString(),
rate_cuts: [] as Array<{ cb: string; bps: number; prob_pct: number; prob_desc: string; direction: string }>,
rate_hikes: [] as Array<{ cb: string; bps: number; prob_pct: number; prob_desc: string; direction: string }>,
};
const titleMatch = rawHtml.match(/<h1[^>]*>(.*?)<\/h1>/i);
if (titleMatch) result.title = titleMatch[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const dateMatch = url.match(/(\d{8})/);
if (dateMatch) {
const d = dateMatch[1];
result.date = `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
} else {
const d2 = url.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//);
if (d2) result.date = `${d2[1]}-${d2[2]}-${d2[3]}`;
}
let currentSection: "cuts" | "hikes" | null = null;
const lines = rawHtml
.replace(/<script[\s\S]*?<\/script>/gi, "\n")
.replace(/<style[\s\S]*?<\/style>/gi, "\n")
.replace(/<[^>]+>/g, "\n")
.split(/\n+/)
.map((l) => l.replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim())
.filter(Boolean);
for (const line of lines) {
const lower = line.toLowerCase();
if (lower.includes("rate cuts by year-end")) { currentSection = "cuts"; continue; }
if (lower.includes("rate hikes by year-end")) { currentSection = "hikes"; continue; }
if (currentSection && line.length < 250) {
const match = line.match(
/([A-Za-z0-9\/\s\-]+?)\s*:\s*(\d+)\s*bps\s*\((\d+)%\s+probability\s+of\s+([^\)]+)\)/i
);
if (match) {
const entry = {
cb: normalizeCb(match[1]),
bps: Number(match[2]),
prob_pct: Number(match[3]),
prob_desc: match[4].trim(),
direction: currentSection === "cuts" ? "cut" : "hike",
};
if (currentSection === "cuts") result.rate_cuts.push(entry);
else result.rate_hikes.push(entry);
}
}
}
if (result.rate_cuts.length === 0 && result.rate_hikes.length === 0) return null;
return result;
}
async function fetchHtml(url: string): Promise<string | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
const res = await fetch(url, {
headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
signal: controller.signal,
cache: "no-store",
});
if (!res.ok) return null;
return await res.text();
} catch { return null; }
finally { clearTimeout(timeout); }
}
// ── Étape 1 : RSS feeds (plus stables, moins bloqués que les pages HTML) ─────
async function candidatesFromRss(): Promise<string[]> {
const candidates: string[] = [];
for (const rssUrl of RSS_URLS) {
const xml = await fetchHtml(rssUrl);
if (!xml) continue;
// Extraire les <item> individuellement pour éviter de confondre avec <channel><link>
const items = Array.from(xml.matchAll(/<item>([\s\S]*?)<\/item>/gi));
for (const [, itemXml] of items) {
const titleMatch = itemXml.match(/<title[^>]*>(?:<!\[CDATA\[)?([^\]<]+)/i);
const creatorMatch = itemXml.match(/<dc:creator[^>]*>(?:<!\[CDATA\[)?([^\]<]+)/i);
const linkMatch = itemXml.match(/<link>([^<]+)<\/link>/i)
?? itemXml.match(/<guid[^>]*>([^<]+)<\/guid>/i);
if (!linkMatch) continue;
const url = linkMatch[1].trim();
const title = (titleMatch?.[1] ?? "").toLowerCase();
const creator = (creatorMatch?.[1] ?? "").toLowerCase();
if (
KEYWORDS.some((kw) => title.includes(kw)) ||
creator.includes("dellamotta")
) {
candidates.push(url);
}
}
if (candidates.length > 0) break;
}
return candidates;
}
// ── Étape 2 : pages HTML auteur/catégorie avec regex flexible ─────────────────
async function candidatesFromHtml(): Promise<string[]> {
const seen = new Set<string>();
const candidates: string[] = [];
for (const pageUrl of [AUTHOR_URL, CATEGORY_URL]) {
const html = await fetchHtml(pageUrl);
if (!html) continue;
// Pattern A : URL investinglive.com avec date YYYYMMDD dans le slug
for (const m of Array.from(html.matchAll(/href=["']([^"']*investinglive\.com\/[^"']*\d{8}[^"']*)["']/gi))) {
const url = m[1];
if (!seen.has(url)) { seen.add(url); candidates.push(url); }
}
// Pattern B : URL relative avec date YYYYMMDD
for (const m of Array.from(html.matchAll(/href=["'](\/[^"']*\d{8}[^"']*)["']/gi))) {
const url = `https://investinglive.com${m[1]}`;
if (!seen.has(url)) { seen.add(url); candidates.push(url); }
}
// Pattern C : URL WordPress /YYYY/MM/DD/
for (const m of Array.from(html.matchAll(/href=["']([^"']*\/\d{4}\/\d{2}\/\d{2}\/[^"']*)["']/gi))) {
const href = m[1];
const url = href.startsWith("http") ? href : `https://investinglive.com${href.startsWith("/") ? href : `/${href}`}`;
if (!seen.has(url)) { seen.add(url); candidates.push(url); }
}
}
return candidates;
}
// ── Étape 0 : scan URL-date direct (le plus fiable, publié chaque semaine) ────
// Pattern : /news/how-have-interest-rate-expectations-changed-after-this-weeks-event-YYYYMMDD/
// On teste les 14 derniers jours (une publication par semaine environ)
async function candidatesFromUrlScan(): Promise<string[]> {
const results: string[] = [];
const now = Date.now();
const checks = Array.from({ length: 14 }, (_, i) => {
const d = new Date(now - i * 86400000);
const s = d.toISOString().slice(0, 10).replace(/-/g, "");
return `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${s}/`;
});
// HEAD requests en parallèle (rapide, peu de bande passante)
const settled = await Promise.allSettled(
checks.map(url =>
fetch(url, { method: "HEAD", headers: { "User-Agent": USER_AGENT }, cache: "no-store" })
.then(r => r.ok ? url : null)
.catch(() => null)
)
);
for (const r of settled) {
if (r.status === "fulfilled" && r.value) results.push(r.value);
}
return results; // déjà triés du plus récent au plus ancien
}
async function loadRemoteExpectation() {
// 0. Scan direct URL-date (le plus fiable et précis)
const urlScan = await candidatesFromUrlScan();
// 1. RSS (stables, moins bloqués)
const rss = await candidatesFromRss();
// 2. HTML auteur/catégorie
const html = await candidatesFromHtml();
// Fusion : URL-scan en priorité (le plus récent), puis RSS, puis HTML trié par date
const htmlSorted = html.slice().sort((a, b) => dateFromUrl(b).localeCompare(dateFromUrl(a)));
const candidates = [...urlScan, ...rss, ...htmlSorted].slice(0, 15);
for (const url of candidates) {
const body = await fetchHtml(url);
if (!body) continue;
const parsed = extractArticleData(body, url);
if (parsed) return parsed;
}
return null;
}
function loadFallback() {
try {
const filePath = join(process.cwd(), "data", "rate_expectations.json");
const raw = readFileSync(filePath, "utf-8");
const raw = readFileSync(filePath, "utf-8");
const data = JSON.parse(raw);
// Return the most recent snapshot (first item)
const latest = Array.isArray(data) ? data[0] : data;
return NextResponse.json(latest);
} catch {
return NextResponse.json({ error: "rate_expectations.json not found. Run the scraper first." }, { status: 404 });
}
return Array.isArray(data) ? data[0] : data;
} catch { return null; }
}
export async function GET() {
try {
const remote = await loadRemoteExpectation();
if (remote) return NextResponse.json(remote);
} catch (err) {
console.warn("[expectations] scrape failed, fallback", err);
}
const fallback = loadFallback();
if (fallback) return NextResponse.json(fallback);
return NextResponse.json({ error: "Unable to load expectation data." }, { status: 404 });
}
+597 -73
View File
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { FRED_SERIES } from "@/lib/constants";
import type { Currency } from "@/lib/types";
import cpiOverridesRaw from "@/data/cpi_overrides.json";
import cpiOverridesRaw from "@/data/cpi_overrides.json";
import rateDecisionsRaw from "@/data/rate_decisions.json";
import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
import type { FFEvent } from "@/lib/forexfactory";
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
const REVALIDATE = 86400; // cache 24h
@@ -147,32 +150,176 @@ async function dbnomicsObs(provider: string, dataset: string, seriesCode: string
} catch { return []; }
}
// ── ForexFactory calendar (PMI primaire) ──────────────────────────────────────
// ── ForexFactory — fetchFFThisWeek importé depuis @/lib/forexfactory ───────────
async function fetchFFPMI(currency: string): Promise<{
mfg: { value: number; prev: number | null } | null;
svc: { value: number; prev: number | null } | null;
mfg: { value: number; prev: number | null } | null;
svc: { value: number; prev: number | null } | null;
composite: { value: number; prev: number | null } | null;
}> {
const empty = { mfg: null, svc: null };
const empty = { mfg: null, svc: null, composite: 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;
}>;
const forCcy = events.filter((e) => e.country === currency && e.actual);
const isMfg = (t: string) => /manufacturing\s+pmi|mfg\s+pmi/i.test(t);
const isSvc = (t: string) => /services?\s+pmi|ism\s+non.manufactur|composite\s+pmi/i.test(t);
const parse = (e: typeof forCcy[0] | undefined) => {
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) => {
if (!e?.actual) return null;
const val = parseFloat(e.actual);
const prev = parseFloat(e.previous ?? "");
return isNaN(val) ? null : { value: val, prev: isNaN(prev) ? null : prev };
};
return { mfg: parse(forCcy.find((e) => isMfg(e.title))), svc: parse(forCcy.find((e) => isSvc(e.title))) };
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,
};
} catch { return empty; }
}
@@ -185,7 +332,7 @@ const TE_COUNTRY: Record<string, string> = {
async function scrapePMI(
currency: string,
indicator: "manufacturing-pmi" | "services-pmi",
indicator: "manufacturing-pmi" | "services-pmi" | "composite-pmi",
): Promise<{ value: number | null; prev: number | null }> {
const country = TE_COUNTRY[currency];
if (!country) return { value: null, prev: null };
@@ -206,8 +353,8 @@ async function scrapePMI(
if (!res.ok) return { value: null, prev: null };
const html = await res.text();
const metaMatch =
html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i) ??
html.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);
html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) ??
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
if (!metaMatch) return { value: null, prev: null };
const desc = metaMatch[1];
const numRe = /(?:increased|decreased|declined|rose|fell|eased)\s+to\s*([\d.]+)\s+points?.+?from\s+([\d.]+)\s+points?/i;
@@ -219,6 +366,135 @@ async function scrapePMI(
} catch { return { value: null, prev: null }; }
}
// ── 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; }
}
// ── 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;
}
/** 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,
};
}
// ── Shared helpers ────────────────────────────────────────────────────────────
type Obs = { date: string; value: number };
@@ -268,6 +544,54 @@ function toIndicatorPct(obs: Obs[]) {
return toIndicator(pctObs);
}
/**
* 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;
const raw = obs[0].value - obs[1].value;
const valK = personsToK
? parseFloat((raw / 1000).toFixed(1))
: parseFloat(raw.toFixed(1));
return {
value: valK,
prev: null,
surprise: valK, // surprise = la valeur elle-même (signe = direction)
trend: valK > 0 ? "up" : valK < 0 ? "down" : "flat",
lastUpdated: obs[0].date,
};
}
function toPmiIndicator(raw: { value: number | null; prev: number | null }): IndicatorResult {
if (raw.value === null) return null;
const surprise = raw.prev !== null ? parseFloat((raw.value - raw.prev).toFixed(2)) : null;
@@ -296,13 +620,22 @@ export async function GET(req: NextRequest) {
const key = process.env.FRED_API_KEY;
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
// policyRate / unemployment / retailSales → already % → toIndicator
// cpiCore / gdp / employment → index/level → toIndicatorPct
const PCT_FIELDS = new Set(["cpiCore", "gdp", "employment"]);
// 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,
};
const fieldMap: Record<string, string | null> = {
policyRate: series.policyRate,
cpiCore: series.cpiCore,
cpiHeadline: series.cpiHeadline, // headline pour MoM — peut être = cpiCore pour GBP
gdp: series.gdp,
retailSales: series.retailSales,
unemployment: series.unemployment,
@@ -310,24 +643,52 @@ export async function GET(req: NextRequest) {
};
const fredFields = Object.entries(fieldMap).filter(([, id]) => id !== null) as [string, string][];
const fredResults = await Promise.all(fredFields.map(([, id]) => fredObs(id, key)));
const fredResults = await Promise.all(
fredFields.map(([field, id]) => fredObs(id, key, FIELD_LIMITS[field] ?? 5))
);
const indicators: Record<string, IndicatorResult> = {};
let _cpiCoreObs: Obs[] = []; // sauvegarde obs brutes pour fallback cpiMoM
fredFields.forEach(([field], i) => {
indicators[field] = PCT_FIELDS.has(field)
? toIndicatorPct(fredResults[i])
: toIndicator(fredResults[i]);
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]);
}
});
// ── EUR alternative sources ────────────────────────────────────────────────
if (currency === "EUR") {
if (!indicators.cpiCore) {
// CP0000EZCCM086NEST indisponible → fallback Eurostat prc_hicp_midx (I15 index → MoM%)
// prc_hicp_mmr (404 depuis 2025) remplacé par prc_hicp_midx + toIndicatorPct
// CP0000EZCCM086NEST indisponible → fallback Eurostat prc_hicp_midx (I15 index → YoY%)
const hicp = await eurostatSorted("prc_hicp_midx", {
geo: "EA", coicop: "CP00", unit: "I15", freq: "M",
}, 6);
indicators.cpiCore = toIndicatorPct(hicp);
}, 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);
}
if (!indicators.gdp) {
// Essayer EA20 d'abord (données 2023-2025), puis EA19 (fallback automatique via eurostatSorted)
@@ -353,25 +714,28 @@ export async function GET(req: NextRequest) {
// ── JPY CPI — IMF/IFS (DBnomics) ─────────────────────────────────────────
// FRED n'a pas de série JPY CPI mensuelle récente.
// M.JP.PCPI_PC_PP_PT = CPI All Items, % change previous period (MoM%), mensuel.
// Dernière donnée disponible : 2025-06 (délai ~2 mois vs publication MIC).
// La série est DÉJÀ en % → toIndicator (pas toIndicatorPct).
// 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)
if (currency === "JPY" && !indicators.cpiCore) {
const obs = await dbnomicsObs("IMF", "IFS", "M.JP.PCPI_PC_PP_PT");
if (obs.length) indicators.cpiCore = toIndicator(obs);
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); }
}
}
// ── AUD/NZD CPI fallback — IMF/IFS (DBnomics) ────────────────────────────
// FRED AUSCPIALLQINMEI / NZLCPIALLQINMEI = trimestriels index.
// Si FRED échoue ou est absent, IMF/IFS fournit les données trimestrielles
// via Q.AU.PCPI_IX / Q.NZ.PCPI_IX (index → QoQ% via toIndicatorPct).
// Si FRED échoue, IMF/IFS Q.AU.PCPI_IX / Q.NZ.PCPI_IX → YoY% trimestriel
if (currency === "AUD" && !indicators.cpiCore) {
const obs = await dbnomicsObs("IMF", "IFS", "Q.AU.PCPI_IX");
if (obs.length) indicators.cpiCore = toIndicatorPct(obs);
const obs = await dbnomicsObs("IMF", "IFS", "Q.AU.PCPI_IX", 6);
if (obs.length) indicators.cpiCore = toIndicatorYoY(obs, 4);
}
if (currency === "NZD" && !indicators.cpiCore) {
const obs = await dbnomicsObs("IMF", "IFS", "Q.NZ.PCPI_IX");
if (obs.length) indicators.cpiCore = toIndicatorPct(obs);
const obs = await dbnomicsObs("IMF", "IFS", "Q.NZ.PCPI_IX", 6);
if (obs.length) indicators.cpiCore = toIndicatorYoY(obs, 4);
}
// ── GBP BoE policy rate ───────────────────────────────────────────────────
@@ -398,43 +762,147 @@ export async function GET(req: NextRequest) {
// • Banque du Canada Valet API : taux annoncé exact (V80691311)
// ══════════════════════════════════════════════════════════════════════════
// USD — DFEDTARU = borne haute de la cible Fed (quotidien, annonce FOMC)
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%)
// USD — TE scraping (taux Fed upper bound officiel = 3.75%)
// Ancien calcul midpoint FRED (DFEDTARU+DFEDTARL)/2 = 3.625% → pas le taux annoncé
if (currency === "USD") {
const obs = await fredObs("DFEDTARU", key, 90);
if (obs.length) indicators.policyRate = toIndicatorDeduped(obs);
const te = await scrapeTeRate(TE_COUNTRY.USD);
const ovr = getRateDecision("USD");
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);
}
}
}
// EUR — ECBDFR déjà utilisé mais mensuel → re-fetch 90j + dédupliqué
// 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"
if (currency === "EUR") {
const obs = await fredObs("ECBDFR", key, 90);
if (obs.length) indicators.policyRate = toIndicatorDeduped(obs);
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);
}
}
}
}
// JPY — IRSTCB01JPM156N (taux BoJ officiel, mis à jour depuis hausses 2024)
// fallback IR3TIB01JPM156N (TIBOR 3M, trop élevé vs taux BoJ réel)
// 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%)
if (currency === "JPY") {
const obs = await fredObsFreshest("IRSTCB01JPM156N", "IR3TIB01JPM156N", key);
if (obs.length) indicators.policyRate = toIndicator(obs);
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);
}
}
// CADAPI Banque du Canada (Valet, gratuit, officiel, JSON)
// V80691311 = Taux directeur annoncé (pas le marché)
// CHFTE scraping (taux SNB officiel exact = 0.00%)
// IR3TIB01CHM156N = SARON 3M (~-0.04%) ≠ taux SNB officiel
if (currency === "CHF") {
const te = await scrapeTeRate(TE_COUNTRY.CHF);
const ovr = getRateDecision("CHF");
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 };
}
}
// CAD — TE scraping (taux BoC cible officiel = 2.25%)
// V80691311 = taux marché overnight (4.45%) ≠ cible BoC
if (currency === "CAD") {
const boc = await bocRate();
if (boc.length) indicators.policyRate = toIndicatorDeduped(boc);
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);
}
}
// NZD — IRSTCB01NZM156N (OCR RBNZ officiel) si plus récent que IR3TIB01
// 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 };
}
}
// NZD — TE scraping (taux RBNZ officiel exact = 2.25%)
// IRSTCB01NZM156N n'existe pas sur FRED ; IR3TIB01NZM156N = taux marché
if (currency === "NZD") {
const obs = await fredObsFreshest("IRSTCB01NZM156N", "IR3TIB01NZM156N", key);
if (obs.length) indicators.policyRate = toIndicator(obs);
}
// GBP — fallback FRED si BoE API a échoué ci-dessus
// IRSTCB01GBM156N n'existe pas sur FRED → IR3TIB01GBM156N (3M interbank mensuel, actif)
if (currency === "GBP" && !indicators.policyRate) {
const obs = await fredObs("IR3TIB01GBM156N", key, 6);
if (obs.length) indicators.policyRate = toIndicator(obs);
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);
}
}
// ══════════════════════════════════════════════════════════════════════════
@@ -465,14 +933,49 @@ export async function GET(req: NextRequest) {
// GBP unemployment: Eurostat UK retiré — données stoppées en sept. 2020 (Brexit).
// On conserve LRHUTTTTGBM156S (FRED, ILO harmonisé, mis à jour mensuellement).
// ── PMI : ForexFactory (semaine courante) + fallback TE scraping ───────────
const [ffPMI, pmiMfgRaw, pmiSvcRaw] = await Promise.all([
// ── 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);
}
// ── PMI (Mfg + Services + Composite) + consensus FF ──────────────────────────
const [ffPMI, pmiMfgRaw, pmiSvcRaw, pmiCompositeRaw, ffForecasts] = await Promise.all([
fetchFFPMI(currency),
scrapePMI(currency, "manufacturing-pmi"),
scrapePMI(currency, "services-pmi"),
scrapePMI(currency, "composite-pmi"),
fetchFFForecasts(currency),
]);
indicators.pmiMfg = ffPMI.mfg ? toPmiIndicator(ffPMI.mfg) : toPmiIndicator(pmiMfgRaw);
indicators.pmiServices = ffPMI.svc ? toPmiIndicator(ffPMI.svc) : toPmiIndicator(pmiSvcRaw);
// 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,
};
}
}
}
// ── Overrides manuels CPI (investing.com) ─────────────────────────────────
// Appliqués quand la source automatique (FRED/DBnomics) est en retard.
@@ -505,7 +1008,28 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ ...(staleCache.data as object), stale: true });
}
const data = { currency, indicators, fetchedAt: new Date().toISOString() };
const data = {
currency, indicators,
forecasts: {
cpi: ffForecasts.cpi,
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(),
};
_cache.set(currency, { data, ts: Date.now() });
return NextResponse.json(data);
}
+31 -80
View File
@@ -1,95 +1,46 @@
import { NextResponse } from "next/server";
import { fetchTEBondYields } from "@/lib/tebonds";
// 10Y sovereign yields — mixed sources per CDC §6.4
// USD: FRED DGS10 (daily)
// EUR: ECB API (daily Bund)
// GBP: BoE API IUDMNPY (daily)
// Others: FRED monthly as fallback
const FRED_KEY = () => process.env.FRED_API_KEY ?? "";
async function fredObs(series: string): Promise<number | null> {
try {
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${FRED_KEY()}&file_type=json&sort_order=desc&limit=3`;
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const val = (data?.observations ?? []).find((o: { value: string }) => o.value !== ".")?.value;
return val ? parseFloat(val) : null;
} catch {
return null;
}
}
async function ecbBund10Y(): Promise<number | null> {
try {
const url = "https://data-api.ecb.europa.eu/service/data/YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y?format=jsondata&lastNObservations=1";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const obs = data?.dataSets?.[0]?.series?.["0:0:0:0:0:0:0"]?.observations;
if (!obs) return null;
const last = Object.values(obs).at(-1) as number[] | undefined;
return last?.[0] ?? null;
} catch {
return null;
}
}
async function boeGilt10Y(): Promise<number | null> {
try {
// BoE API series IUDMNPY = UK Nominal Par Yield 10Y
const url = "https://www.bankofengland.co.uk/boeapps/database/_iadb-FromShowColumns.asp?csv.x=yes&Datefrom=01/Jan/2024&Dateto=now&SeriesCodes=IUDMNPY&CSVF=TN&UsingCodes=Y";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const text = await res.text();
const lines = text.trim().split("\n").filter((l) => l.trim());
const last = lines.at(-1)?.split(",");
const val = last?.at(-1)?.trim();
return val ? parseFloat(val) : null;
} catch {
return null;
}
}
async function bocYield10Y(): Promise<number | null> {
try {
const url = "https://www.bankofcanada.ca/valet/observations/BD.CDN.10YR.DQ.YLD/json?recent=5";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const obs: { d: string; "BD.CDN.10YR.DQ.YLD": { v: string } }[] =
data?.observations ?? [];
const last = obs.findLast((o) => o["BD.CDN.10YR.DQ.YLD"]?.v);
return last ? parseFloat(last["BD.CDN.10YR.DQ.YLD"].v) : null;
} catch {
return null;
}
}
// 10Y sovereign yields — source unique : tradingeconomics.com/bonds (HTML statique)
// Remplace les sources précédentes (FRED DGS10 + IRLTLT01XXM156N mensuel + ECB/BoE APIs)
// qui avaient des décalages allant de 1 jour (FRED daily) à 1 mois (FRED monthly JPY/CHF/AUD/NZD).
// TE bonds = données du jour pour les 8 devises, cache 1h.
export async function GET() {
const [usd, eur, gbp, jpy, chf, cad, aud, nzd] = await Promise.all([
fredObs("DGS10"),
ecbBund10Y(),
boeGilt10Y(),
fredObs("IRLTLT01JPM156N"),
fredObs("IRLTLT01CHM156N"),
bocYield10Y(),
fredObs("IRLTLT01AUM156N"),
fredObs("IRLTLT01NZM156N"),
]);
const bondData = await fetchTEBondYields();
const yields = { USD: usd, EUR: eur, GBP: gbp, JPY: jpy, CHF: chf, CAD: cad, AUD: aud, NZD: nzd };
const yields: Record<string, number | null> = {
USD: bondData.USD?.yield10y ?? null,
EUR: bondData.EUR?.yield10y ?? null,
GBP: bondData.GBP?.yield10y ?? null,
JPY: bondData.JPY?.yield10y ?? null,
CHF: bondData.CHF?.yield10y ?? null,
CAD: bondData.CAD?.yield10y ?? null,
AUD: bondData.AUD?.yield10y ?? null,
NZD: bondData.NZD?.yield10y ?? null,
};
// Compute spreads vs USD
const dayDeltas: Record<string, number | null> = {
USD: bondData.USD?.dayDelta ?? null,
EUR: bondData.EUR?.dayDelta ?? null,
GBP: bondData.GBP?.dayDelta ?? null,
JPY: bondData.JPY?.dayDelta ?? null,
CHF: bondData.CHF?.dayDelta ?? null,
CAD: bondData.CAD?.dayDelta ?? null,
AUD: bondData.AUD?.dayDelta ?? null,
NZD: bondData.NZD?.dayDelta ?? null,
};
// Spread vs USD (bps)
const usd = yields.USD;
const spreads: Record<string, number | null> = {};
for (const [ccy, yld] of Object.entries(yields)) {
if (ccy === "USD" || yld === null || usd === null) {
spreads[ccy] = null;
} else {
spreads[ccy] = Math.round((yld - usd) * 100); // bps
spreads[ccy] = Math.round((yld - usd) * 100);
}
}
return NextResponse.json({ yields, spreads, timestamp: Date.now() });
return NextResponse.json({ yields, spreads, dayDeltas, timestamp: Date.now() });
}