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() });
}
+36 -11
View File
@@ -129,7 +129,7 @@ export default function DriversBar({ drivers }: Props) {
sp500, sp500ChangePct,
btc, btcChange24h,
hySpread, igSpread,
us10y, curveSlope,
us10y, us2y, curveSlope,
gold, goldDelta,
silver, silverDelta,
brent, brentDelta,
@@ -158,9 +158,18 @@ export default function DriversBar({ drivers }: Props) {
<div className="flex items-start gap-4 overflow-x-auto pb-0.5">
{/* Sentiment / Risk-On */}
<M label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1} />
<M label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2} />
<M label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2} />
<M
label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1}
tooltip="Delta = clôture actuelle clôture précédente issue de Yahoo Finance (métrique intraday/close de session)."
/>
<M
label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2}
tooltip="Changement = clôture actuelle clôture précédente de Yahoo Finance. Le % est calculé sur la clôture précédente."
/>
<M
label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2}
tooltip="Variation 24h issue de Binance / CoinGecko via le ticker 24h (priceChangePercent)."
/>
<VSep />
@@ -177,20 +186,36 @@ export default function DriversBar({ drivers }: Props) {
<VSep />
{/* Taux & FX */}
<M label="DXY" value={dxy} dec={2} />
<M label="US 10Y" value={us10y} dec={2} unit="%" />
<M
label="DXY" value={dxy} dec={2}
delta={(drivers as DriverData & { dxyDelta?: number | null }).dxyDelta ?? null}
deltaDec={2}
tooltip="ICE Dollar Index Futures (DX=F) via Yahoo Finance — temps réel, cache 5 min. Delta = clôture actuelle clôture précédente."
/>
<M
label="Crb 2-10" value={curveSlope} dec={0} unit=" bps"
tooltip="Spread US 10Y US 2Y. Positif = courbe normale. Négatif = courbe inversée (signal récessif historiquement, se matérialise ~1218 mois après)."
tooltip={`Spread US 10Y US 2Y. Positif = courbe normale. Négatif = courbe inversée (signal récessif historiquement).\nUS 10Y: ${us10y != null ? us10y.toFixed(4) + "%" : "N/A"} | US 2Y: ${us2y != null ? us2y.toFixed(2) + "%" : "N/A"}`}
/>
<VSep />
{/* Commodités */}
<M label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1} />
<M label="Argent $/oz" value={silver} dec={2} delta={silverDelta} deltaDec={2} />
<M label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1} />
<M label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1} />
<M
label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1}
tooltip="Delta intraday = close open issue de Stooq (métaux précieux)."
/>
<M
label="Argent $/oz" value={silver} dec={2} delta={silverDelta} deltaDec={2}
tooltip="Delta intraday = close open issue de Stooq (métaux précieux)."
/>
<M
label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
tooltip="Delta intraday = close open issue de Stooq (pétrole Brent)."
/>
<M
label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
tooltip="Delta intraday = close open issue de Stooq (pétrole WTI)."
/>
</div>
</div>
+48
View File
@@ -0,0 +1,48 @@
[
{
"updated_at": "2026-05-30",
"note": "Taux directeurs officiels CB. current/prev = taux annoncés (pas interbancaires). EUR = MRO (DFR+15bps) pour cohérence avec investing.com/TE. Mettre à jour après chaque décision.",
"decisions": {
"USD": {
"current": 3.75,
"prev": 3.75,
"source": "Fed funds target upper bound — Trading Economics / TE mai 2026 (fourchette 3.50-3.75%, upper=3.75%)"
},
"EUR": {
"current": 2.15,
"prev": 2.40,
"source": "ECBDFR (FRED) + 0.15bps → MRO. DFR=2.00, MRO=DFR+15bps (corridor sep 2024)"
},
"GBP": {
"current": 3.75,
"prev": 4.00,
"source": "BoE Official Bank Rate — Trading Economics mai 2026"
},
"JPY": {
"current": 0.75,
"prev": 0.50,
"source": "BoJ — annonce jan 2026 (hausse 0.50% → 0.75%)"
},
"CHF": {
"current": 0.00,
"prev": 0.25,
"source": "SNB — annonce déc 2025 (baisse 0.25% → 0.00%)"
},
"CAD": {
"current": 2.25,
"prev": 2.50,
"source": "BoC — annonce avr 2026 (baisse 2.50% → 2.25%)"
},
"AUD": {
"current": 4.35,
"prev": 4.10,
"source": "RBA — Trading Economics mai 2026 (Last=4.35, Previous=4.10)"
},
"NZD": {
"current": 2.25,
"prev": 2.50,
"source": "RBNZ — annonce avr 2026 (baisse 2.50% → 2.25%)"
}
}
}
]
+40 -41
View File
@@ -1,66 +1,65 @@
[
{
"url": "manual-update",
"title": "Rate expectations — 2026-05-29",
"url": "https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-20260529/",
"title": "How have interest rate expectations changed after this week's event?",
"date": "2026-05-29",
"scraped_at": "2026-05-29T08:00:00.000Z",
"rate_cuts": [
"scraped_at": "2026-05-29T18:22:00.000Z",
"rate_cuts": [],
"rate_hikes": [
{
"cb": "Fed (USD)",
"bps": 68,
"cb": "RBNZ (NZD)",
"bps": 75,
"prob_pct": 79,
"prob_desc": "no change at the upcoming meeting",
"direction": "cut"
"prob_desc": "rate hike at the next meeting",
"direction": "hike"
},
{
"cb": "ECB (EUR)",
"bps": 49,
"prob_pct": 60,
"prob_desc": "rate cut at the upcoming meeting",
"direction": "cut"
"bps": 52,
"prob_pct": 89,
"prob_desc": "rate hike at the next meeting",
"direction": "hike"
},
{
"cb": "BoJ (JPY)",
"bps": 42,
"prob_pct": 71,
"prob_desc": "rate hike at the next meeting",
"direction": "hike"
},
{
"cb": "BoE (GBP)",
"bps": 53,
"prob_pct": 96,
"prob_desc": "no change at today's decision",
"direction": "cut"
"bps": 32,
"prob_pct": 94,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "BoC (CAD)",
"bps": 48,
"prob_pct": 66,
"prob_desc": "no change at the upcoming meeting",
"direction": "cut"
"bps": 28,
"prob_pct": 99,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "RBA (AUD)",
"bps": 65,
"prob_pct": 92,
"prob_desc": "no change at the upcoming meeting",
"direction": "cut"
"bps": 18,
"prob_pct": 93,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "RBNZ (NZD)",
"bps": 59,
"prob_pct": 64,
"prob_desc": "rate cut at the upcoming meeting",
"direction": "cut"
"cb": "Fed (USD)",
"bps": 13,
"prob_pct": 99,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "SNB (CHF)",
"bps": 4,
"prob_pct": 85,
"prob_desc": "no change at the upcoming meeting",
"direction": "cut"
}
],
"rate_hikes": [
{
"cb": "BoJ (JPY)",
"bps": 32,
"prob_pct": 80,
"prob_desc": "no change at the upcoming meeting",
"bps": 11,
"prob_pct": 97,
"prob_desc": "no change at the next meeting",
"direction": "hike"
}
]
+84
View File
@@ -0,0 +1,84 @@
// ── ForexFactory calendar — shared fetch utility ──────────────────────────────
// Utilisé par /api/macro (PMI + forecasts) et /api/calendar (calendrier complet).
//
// Disponibilité :
// • ff_calendar_thisweek.json : toujours disponible
// • ff_calendar_nextweek.json : disponible du lundi au samedi matin environ
// → Le samedi soir / dimanche matin le fichier est en 404 (FF ne l'a pas encore publié)
export interface FFEvent {
title: string;
country: string; // "USD", "EUR", "GBP", etc.
date: string; // ISO string: "2026-05-29T08:30:00-04:00"
impact: string; // "High", "Medium", "Low", "Holiday"
actual: string;
forecast: string;
previous: string;
}
// Cache séparé par semaine pour un retry rapide si nextweek est indisponible
let _cacheThisWeek: { events: FFEvent[]; ts: number } | null = null;
let _cacheNextWeek: { events: FFEvent[]; ts: number; ok: boolean } | null = null;
const TTL_OK = 3_600_000; // 1h si le JSON est dispo
const TTL_RETRY = 300_000; // 5 min si le JSON était absent (réessaie fréquemment)
async function fetchFFWeek(week: "thisweek" | "nextweek"): Promise<FFEvent[]> {
try {
const url = `https://nfs.faireconomy.media/ff_calendar_${week}.json`;
// cache: "no-store" pour éviter que Next.js mette en cache une 404
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
});
if (!res.ok) return [];
const text = await res.text();
const trimmed = text.trim();
// Rejeter silencieusement les réponses HTML (404/503 pages)
if (!trimmed.startsWith("[")) return [];
return JSON.parse(trimmed) as FFEvent[];
} catch { return []; }
}
/** Retourne les events ForexFactory de cette semaine + semaine prochaine (si dispo). */
export async function fetchFFEvents(): Promise<FFEvent[]> {
const now = Date.now();
// Fetch thisweek et nextweek en parallèle pour réduire la latence
const [thisWeek, nextWeek] = await Promise.all([
(async () => {
if (_cacheThisWeek && now - _cacheThisWeek.ts <= TTL_OK) return _cacheThisWeek.events;
const events = await fetchFFWeek("thisweek");
_cacheThisWeek = { events, ts: now };
return events;
})(),
(async () => {
const ttl = _cacheNextWeek?.ok ? TTL_OK : TTL_RETRY;
if (_cacheNextWeek && now - _cacheNextWeek.ts <= ttl) return _cacheNextWeek.events;
const events = await fetchFFWeek("nextweek");
_cacheNextWeek = { events, ts: now, ok: events.length > 0 };
return events;
})(),
]);
return [...thisWeek, ...nextWeek];
}
/** Events de cette semaine seulement (pour PMI/forecasts macro). */
export async function fetchFFThisWeek(): Promise<FFEvent[]> {
const all = await fetchFFEvents();
const monday = new Date();
monday.setDate(monday.getDate() - monday.getDay() + 1);
monday.setHours(0, 0, 0, 0);
const nextMonday = new Date(monday);
nextMonday.setDate(monday.getDate() + 7);
return all.filter((e) => {
const d = new Date(e.date);
return d >= monday && d < nextMonday;
});
}
/** Indique si les données de la semaine prochaine sont disponibles. */
export function nextWeekAvailable(): boolean {
return (_cacheNextWeek?.ok) ?? false;
}
+188
View File
@@ -0,0 +1,188 @@
// lib/investing.ts
// Investing.com Economic Calendar — POST API (JSON avec HTML interne)
// Endpoint : POST https://fr.investing.com/economic-calendar/Service/getCalendarFilteredData
// Auth : aucune — Cloudflare peut rate-limiter en test mais pas en prod avec cache 30min
// Timezone : 55 = UTC
import type { Currency } from "./types";
import type { EventCategory } from "@/app/api/calendar/route";
// ── Country IDs (Investing.com internal) ─────────────────────────────────────
const INV_COUNTRY_IDS: Record<Currency, number> = {
USD: 5,
GBP: 4,
AUD: 25,
CAD: 6,
EUR: 72, // Euro Area aggregate
NZD: 43,
JPY: 35,
CHF: 12,
};
// ── Currency code → Currency type ─────────────────────────────────────────────
const INV_CCY_MAP: Record<string, Currency> = {
USD: "USD", GBP: "GBP", AUD: "AUD", CAD: "CAD",
EUR: "EUR", NZD: "NZD", JPY: "JPY", CHF: "CHF",
};
// ── Category detection (noms en français) ─────────────────────────────────────
function invCategory(title: string): EventCategory {
const t = title.toLowerCase();
if (/\bpmi\b|purchasing\s+managers/.test(t)) return "pmi";
if (/taux d.intér|décision.*taux|politique monétaire|bank\s+rate/.test(t)) return "policy_rate";
if (/discours|allocution|s.exprime|parole|communiqué|banque central/.test(t)) return "cb_speech";
if (/\bipc\b|\bcpi\b|\bhicp\b|\bipch\b|inflation|prix à la conso|prix\s+consom/.test(t)) return "inflation";
if (/\bpib\b|\bgdp\b|croissance économ/.test(t)) return "gdp";
if (/ventes au détail|retail\s+sales/.test(t)) return "retail_sales";
if (/balance commerc|balance des paiements|exportations|importations/.test(t)) return "trade_balance";
if (/emploi|chômage|payrolls|nonfarm|chomage|travail|emplois créés/.test(t)) return "employment";
return "other";
}
// ── Importance (bull1/2/3 → impact) ───────────────────────────────────────────
function invImpact(bull: string): "high" | "medium" | "low" {
if (bull === "bull3") return "high";
if (bull === "bull2") return "medium";
return "low";
}
// ── Normalise valeurs numériques françaises ──────────────────────────────────
// "0,6%" → "0.6%" | "52,3" → "52.3" | "&nbsp;" → null
function normVal(raw: string | null | undefined): string | null {
if (!raw) return null;
const s = raw.replace(/&nbsp;/g, "").trim();
if (!s || s === "" || s === "-") return null;
return s.replace(",", ".");
}
// ── Public type ───────────────────────────────────────────────────────────────
export interface InvCalendarEvent {
id: string;
date: string; // ISO UTC
currency: Currency;
category: EventCategory;
title: string;
impact: "high" | "medium" | "low";
actual: string | null;
forecast: string | null;
previous: string | null;
isPublished: boolean;
}
// ── Parse HTML dans la réponse JSON de l'API ──────────────────────────────────
function parseInvestingHTML(html: string): InvCalendarEvent[] {
const events: InvCalendarEvent[] = [];
const now = new Date();
// Sépare chaque ligne événement
const ROW = /<tr\s+id="eventRowId_(\d+)"\s+class="js-event-item[^"]*"\s+event_attr_ID="(\d+)"\s+data-event-datetime="([^"]+)"[^>]*>([\s\S]*?)(?=<tr\s+id="eventRowId_|<\/tbody>)/g;
let m: RegExpExecArray | null;
while ((m = ROW.exec(html)) !== null) {
const [, rowId, , dateRaw, body] = m;
// Devise : " AUD" après le span ceFlags
const ccyMatch = body.match(/class="ceFlags[^"]*"[^>]*>&nbsp;<\/span>\s*([A-Z]{2,4})/);
if (!ccyMatch) continue;
const ccy = INV_CCY_MAP[ccyMatch[1].trim()];
if (!ccy) continue;
// DateTime UTC "2026/06/01 01:00:00" → "2026-06-01T01:00:00Z"
const isoDate = dateRaw.replace(/\//g, "-").replace(" ", "T") + "Z";
const evDate = new Date(isoDate);
// Importance : data-img_key="bull1/2/3"
const bullMatch = body.match(/data-img_key="(bull\d)"/);
const impact = bullMatch ? invImpact(bullMatch[1]) : "low";
// Nom de l'événement : texte dans <a href="/economic-calendar/...">
const nameMatch = body.match(/<a\s+href="\/economic-calendar\/[^"]*"[^>]*>\s*([\s\S]*?)\s*<\/a>/);
const title = nameMatch
? nameMatch[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim()
: "";
if (!title) continue;
// Valeurs dans les td id="eventActual_XXX", eventForecast_XXX, eventPrevious_XXX
const actualRaw = body.match(new RegExp(`id="eventActual_${rowId}"[^>]*>([^<]*)<`));
const forecastRaw = body.match(new RegExp(`id="eventForecast_${rowId}"[^>]*>([^<]*)<`));
const prevSpan = body.match(new RegExp(`id="eventPrevious_${rowId}"[^>]*>\\s*(?:<span[^>]*>)?([^<]*)(?:<\\/span>)?`));
const actual = normVal(actualRaw?.[1]);
const forecast = normVal(forecastRaw?.[1]);
const previous = normVal(prevSpan?.[1]);
events.push({
id: `inv_${rowId}`,
date: isoDate,
currency: ccy,
category: invCategory(title),
title,
impact,
actual,
forecast,
previous,
isPublished: actual !== null || evDate < now,
});
}
return events;
}
// ── Fetch depuis l'API Investing.com ─────────────────────────────────────────
export async function fetchInvestingCalendar(
fromDate: string,
toDate: string,
): Promise<InvCalendarEvent[]> {
// Paramètres : toutes les devises + toutes les importances
const countryParams = Object.values(INV_COUNTRY_IDS)
.map(id => `country%5B%5D=${id}`)
.join("&");
const body = [
countryParams,
"importance%5B%5D=1&importance%5B%5D=2&importance%5B%5D=3",
"timeZone=55", // UTC
"timeFilter=timeRemain",
"currentTab=custom",
`dateFrom=${fromDate}&dateTo=${toDate}`,
"submitFilters=1",
"limit_from=0",
].join("&");
try {
const res = await fetch(
"https://fr.investing.com/economic-calendar/Service/getCalendarFilteredData",
{
method: "POST",
next: { revalidate: 1800 }, // cache 30 min
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"X-Requested-With": "XMLHttpRequest",
"Referer": "https://fr.investing.com/economic-calendar/",
"Origin": "https://fr.investing.com",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json, text/javascript, */*; q=0.01",
},
body,
}
);
if (!res.ok) {
console.warn("[Investing] HTTP", res.status);
return [];
}
const json = await res.json() as { data?: string };
if (!json?.data) return [];
return parseInvestingHTML(json.data);
} catch (err) {
console.warn("[Investing] fetch error:", err);
return [];
}
}
+142
View File
@@ -0,0 +1,142 @@
// lib/investinglive.ts
// Scrapes Giuseppe Dellamotta's recurring rate-expectations article on investinglive.com
// URL pattern: /news/how-have-interest-rate-expectations-changed-after-this-weeks-event-YYYYMMDD/
// Published after major market events (typically on Fridays or post-CB-decision)
//
// Data format in articleBody JSON-LD:
// "CB: XX bps (YY% probability of rate hike/no change at the next meeting)"
// Covers all 8 CBs including SNB — the only free public source with CHF OIS-equivalent data.
import type { Currency } from "./types";
// ── CB name → Currency ────────────────────────────────────────────────────────
const CB_TO_CCY: Record<string, Currency> = {
"fed": "USD",
"fomc": "USD",
"ecb": "EUR",
"boe": "GBP",
"boj": "JPY",
"boc": "CAD",
"rba": "AUD",
"rbnz": "NZD",
"snb": "CHF",
};
// ── Types ─────────────────────────────────────────────────────────────────────
export interface ILRateExpectation {
currency: Currency;
nextMeetingProbPct: number; // probability of change at next meeting (0100)
nextMeetingIsHike: boolean; // true = hike, false = cut (no-change → isHike stays false)
nextMeetingIsNoChange: boolean;
bpsYearEnd: number; // cumulative bps priced in by year-end
publishedDate: string; // YYYY-MM-DD
}
export type ILExpectationsMap = Partial<Record<Currency, ILRateExpectation>>;
// ── URL discovery ─────────────────────────────────────────────────────────────
// Try the last 14 days to find the most recently published article.
async function findLatestArticleUrl(): Promise<{ url: string; dateStr: string } | null> {
for (let daysAgo = 0; daysAgo <= 14; daysAgo++) {
const d = new Date(Date.now() - daysAgo * 86400000);
const yyyymmdd = d.toISOString().slice(0, 10).replace(/-/g, "");
const url = `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`;
try {
const res = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
next: { revalidate: 21600 }, // re-check every 6h
});
if (res.ok) return { url, dateStr: `${yyyymmdd.slice(0,4)}-${yyyymmdd.slice(4,6)}-${yyyymmdd.slice(6,8)}` };
} catch { /* try next day */ }
}
return null;
}
// ── Article parser ─────────────────────────────────────────────────────────────
// The structured data JSON-LD "articleBody" contains the raw text we need.
function parseArticleBody(text: string, publishedDate: string): ILExpectationsMap {
const result: ILExpectationsMap = {};
// Matches: "RBNZ: 75 bps (79% probability of rate hike at the next meeting)"
// "Fed: 13 bps (99% probability of no change at the next meeting)"
const linePattern = /([A-Za-z]+)\s*:\s*(\d+)\s*bps\s*\(\s*(\d+)%\s*probability\s+of\s+(rate\s+(?:hike|cut)|no\s+change)\s+at\s+the\s+next\s+meeting\)/gi;
let m: RegExpExecArray | null;
while ((m = linePattern.exec(text)) !== null) {
const [, cbRaw, bpsStr, probStr, directionRaw] = m;
const ccy = CB_TO_CCY[cbRaw.toLowerCase()];
if (!ccy) continue;
const bpsYearEnd = parseInt(bpsStr);
const probPct = parseInt(probStr);
const direction = directionRaw.toLowerCase();
const nextMeetingIsNoChange = direction === "no change";
const nextMeetingIsHike = !nextMeetingIsNoChange && direction.includes("hike");
// Probability of change = 100 - probNoChange OR directProb if it's a hike/cut
const nextMeetingProbPct = nextMeetingIsNoChange ? 100 - probPct : probPct;
result[ccy] = {
currency: ccy,
nextMeetingProbPct,
nextMeetingIsHike,
nextMeetingIsNoChange,
bpsYearEnd,
publishedDate,
};
}
return result;
}
// ── Main fetch ────────────────────────────────────────────────────────────────
export async function fetchILExpectations(): Promise<ILExpectationsMap> {
try {
const found = await findLatestArticleUrl();
if (!found) {
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
return {};
}
const res = await fetch(found.url, {
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",
"Accept-Language": "en-US,en;q=0.9",
},
next: { revalidate: 21600 }, // cache 6h — new article only published after major events
});
if (!res.ok) {
console.warn("[IL] Fetch failed:", res.status);
return {};
}
const html = await res.text();
// Extract articleBody from JSON-LD structured data
const jsonLdMatch = html.match(/"articleBody"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (!jsonLdMatch) {
console.warn("[IL] articleBody not found in JSON-LD");
return {};
}
const articleBody = jsonLdMatch[1]
.replace(/\\n/g, "\n")
.replace(/\\"/g, '"')
.replace(/\\u003c/g, "<")
.replace(/\\u003e/g, ">");
const parsed = parseArticleBody(articleBody, found.dateStr);
const count = Object.keys(parsed).length;
console.log(`[IL] Parsed ${count} CBs from article dated ${found.dateStr}`);
return parsed;
} catch (err) {
console.error("[IL] error:", err);
return {};
}
}
+244
View File
@@ -0,0 +1,244 @@
// lib/rateprobability.ts
// Données de probabilités de taux depuis rateprobability.com (API publique OIS/futures)
// Endpoint pattern: https://rateprobability.com/api/{cb}/latest
import type { Currency } from "./types";
import { fetchILExpectations } from "./investinglive";
import type { ILExpectationsMap } from "./investinglive";
// ── Types publics ──────────────────────────────────────────────────────────────
export interface RateProbMeeting {
label: string; // "Jun 11" — 6 chars max
dateIso: string; // "2026-06-11"
impliedRate: number; // taux implicite post-réunion
probMovePct: number; // 0100 : probabilité d'un mouvement
probIsCut: boolean; // true = baisse, false = hausse
changeBps: number; // bps attendus à cette réunion (cumulatif)
}
export interface CBRatePath {
currency: Currency;
asOf: string; // "2026-05-31"
currentRate: number;
meetings: RateProbMeeting[];
peakMeeting: RateProbMeeting | null; // réunion avec proba max de mouvement
yearEndImplied: number | null; // taux impliqué à la dernière réunion connue
}
export type RateProbData = Partial<Record<Currency, CBRatePath>>;
// ── Mapping CB → endpoint ──────────────────────────────────────────────────────
const CB_KEYS: [Currency, string][] = [
["USD", "fed"],
["EUR", "ecb"],
["GBP", "boe"],
["JPY", "boj"],
["CAD", "boc"],
["AUD", "rba"],
["NZD", "rbnz"],
];
// Heures UTC approximatives des annonces
const ANNOUNCE_UTC: Partial<Record<Currency, number>> = {
USD: 18, EUR: 12, GBP: 11, JPY: 2, CAD: 14, AUD: 3, NZD: 2, CHF: 8,
};
// Titres pour le calendrier
const MEETING_TITLES: Partial<Record<Currency, string>> = {
USD: "FOMC — Décision taux Fed",
EUR: "BCE — Governing Council",
GBP: "BoE MPC — Décision taux",
JPY: "BoJ — Policy Board",
CAD: "BoC — Décision taux",
AUD: "RBA — Décision taux",
NZD: "RBNZ — Décision taux",
CHF: "SNB — Décision taux",
};
// ── Extraction des champs (nommage hétérogène selon les CB) ───────────────────
function getCurrentRate(ccy: Currency, today: Record<string, unknown>): number {
switch (ccy) {
case "USD": return (today["midpoint"] as number) ?? 0;
case "EUR": return (today["ecb_main_refinancing"] as number) ?? (today["ecb_deposit_facility"] as number) ?? 0;
case "GBP": return (today["current_target"] as number) ?? 0;
case "JPY": return (today["current_target"] as number) ?? 0;
case "CAD": return (today["Overnight Rate Target"] as number) ?? 0;
case "AUD": return (today["cash_rate_target"] as number) ?? 0;
case "NZD": return (today["Official Cash Rate (OCR)"] as number) ?? (today["current_target"] as number) ?? 0;
default: return 0;
}
}
function getAsOf(today: Record<string, unknown>): string {
const raw = String(today["As of"] ?? today["as_of"] ?? today["run_date"] ?? "");
return raw.slice(0, 10);
}
// ── Fetch une CB ───────────────────────────────────────────────────────────────
async function fetchCBPath(ccy: Currency, slug: string): Promise<CBRatePath | null> {
try {
const res = await fetch(`https://rateprobability.com/api/${slug}/latest`, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"Referer": `https://rateprobability.com/${slug}`,
"Accept": "application/json, */*",
},
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const body = await res.json() as Record<string, unknown>;
const today = body["today"] as Record<string, unknown> | undefined;
if (!today) return null;
const currentRate = getCurrentRate(ccy, today);
const asOf = getAsOf(today);
const nowIso = new Date().toISOString().slice(0, 10);
const maxIso = new Date(Date.now() + 380 * 86400000).toISOString().slice(0, 10);
const rawRows = (today["rows"] as Array<Record<string, unknown>> | undefined) ?? [];
const meetings: RateProbMeeting[] = rawRows
.filter(r =>
typeof r["meeting_iso"] === "string" &&
(r["meeting_iso"] as string) >= nowIso &&
(r["meeting_iso"] as string) <= maxIso
)
.map(r => ({
label: (r["meeting"] as string).slice(0, 6),
dateIso: r["meeting_iso"] as string,
impliedRate: parseFloat(String(r["implied_rate_post_meeting"] ?? currentRate)),
probMovePct: parseFloat(String(r["prob_move_pct"] ?? 0)),
probIsCut: Boolean(r["prob_is_cut"]),
changeBps: parseFloat(String(r["change_bps"] ?? 0)),
}));
const peakMeeting = meetings.length > 0
? meetings.reduce((best, m) => m.probMovePct > best.probMovePct ? m : best, meetings[0])
: null;
const yearEndImplied = meetings.length > 0
? meetings[meetings.length - 1].impliedRate
: null;
return { currency: ccy, asOf, currentRate, meetings, peakMeeting, yearEndImplied };
} catch { return null; }
}
// ── SNB meeting dates (published par la SNB, trimestrielles) ─────────────────
// Mise à jour annuelle : mars, juin, septembre, décembre
const SNB_MEETINGS: string[] = [
// 2026
"2026-06-19",
"2026-09-25",
"2026-12-11",
// 2027
"2027-03-18",
"2027-06-17",
"2027-09-23",
"2027-12-09",
];
// Construit un CBRatePath CHF depuis les données InvestingLive (probabilités OIS-équivalent)
function buildSNBPath(il: ILExpectationsMap, currentRate: number): CBRatePath | null {
const chfData = il["CHF"];
if (!chfData) return null;
const nowIso = new Date().toISOString().slice(0, 10);
const upcomingMeetings = SNB_MEETINGS.filter(d => d >= nowIso);
if (upcomingMeetings.length === 0) return null;
// Direction par défaut : bpsYearEnd > 0 = hausse, < 0 = baisse
const yearEndIsHike = chfData.bpsYearEnd >= 0;
const meetings: RateProbMeeting[] = upcomingMeetings.map((dateIso, i) => {
const isNext = i === 0;
const probMovePct = isNext ? chfData.nextMeetingProbPct : 0;
// Si "no change" à la prochaine réunion, la direction vient du biais year-end
const probIsCut = isNext
? (chfData.nextMeetingIsNoChange ? !yearEndIsHike : !chfData.nextMeetingIsHike)
: !yearEndIsHike;
const changeBps = isNext ? (probIsCut ? -chfData.bpsYearEnd : chfData.bpsYearEnd) : 0;
const impliedRate = isNext && probMovePct > 50
? currentRate + (probIsCut ? -0.25 : 0.25)
: currentRate;
return {
label: dateIso.slice(0, 7), // "2026-06"
dateIso,
impliedRate,
probMovePct,
probIsCut,
changeBps,
};
});
const peakMeeting = meetings.reduce((best, m) =>
m.probMovePct > best.probMovePct ? m : best, meetings[0]
);
return {
currency: "CHF",
asOf: chfData.publishedDate,
currentRate,
meetings,
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
yearEndImplied: meetings.at(-1)?.impliedRate ?? null,
};
}
// ── Fetch toutes les CB en parallèle ──────────────────────────────────────────
export async function fetchAllCBPaths(): Promise<RateProbData> {
// rateprobability.com (7 CBs) + InvestingLive (tous CBs + CHF) en parallèle
const [rpResults, ilData] = await Promise.all([
Promise.allSettled(CB_KEYS.map(([ccy, slug]) => fetchCBPath(ccy, slug))),
fetchILExpectations(),
]);
const data: RateProbData = {};
// Intègre les données rateprobability.com
for (let i = 0; i < CB_KEYS.length; i++) {
const [ccy] = CB_KEYS[i];
const r = rpResults[i];
if (r.status === "fulfilled" && r.value) data[ccy] = r.value;
}
// CHF/SNB : rateprobability ne couvre pas la SNB → InvestingLive est la seule source
if (!data["CHF"] && ilData["CHF"]) {
const snbPath = buildSNBPath(ilData, 0.00); // taux actuel SNB = 0%
if (snbPath) data["CHF"] = snbPath;
}
return data;
}
// ── Helper calendrier : dates de réunions extraites des paths ─────────────────
export interface CBMeetingEvent {
currency: Currency;
dateIso: string;
utcHour: number;
title: string;
probMovePct: number;
probIsCut: boolean;
changeBps: number;
}
export function extractMeetingEvents(data: RateProbData, fromDate: string): CBMeetingEvent[] {
const events: CBMeetingEvent[] = [];
for (const entry of Object.entries(data) as [Currency, CBRatePath][]) {
const [ccy, path] = entry;
const utcHour = ANNOUNCE_UTC[ccy] ?? 12;
const title = MEETING_TITLES[ccy] ?? `Décision taux ${ccy}`;
for (const m of path.meetings) {
if (m.dateIso < fromDate) continue;
events.push({ currency: ccy, dateIso: m.dateIso, utcHour, title, probMovePct: m.probMovePct, probIsCut: m.probIsCut, changeBps: m.changeBps });
}
}
return events;
}
+80
View File
@@ -0,0 +1,80 @@
// lib/tebonds.ts
// Scrape tradingeconomics.com/bonds pour les rendements 10Y souverains des 8 devises.
// Source : HTML statique de la page (données du jour, pas de Socket.IO nécessaire)
// EUR benchmark = Allemagne (Bund 10Y)
// Cache : 1h (la page TE est mise à jour en continu mais le cache évite l'abus)
import type { Currency } from "./types";
const TE_BOND_COUNTRIES: Record<Currency, string> = {
USD: "United States",
EUR: "Germany",
GBP: "United Kingdom",
JPY: "Japan",
AUD: "Australia",
CAD: "Canada",
CHF: "Switzerland",
NZD: "New Zealand",
};
export interface BondYield {
yield10y: number; // rendement 10Y en %
dayDelta: number; // variation journalière en points
}
export type BondYields = Partial<Record<Currency, BondYield>>;
export async function fetchTEBondYields(): Promise<BondYields> {
try {
const res = await fetch("https://tradingeconomics.com/bonds", {
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,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
});
if (!res.ok) {
console.warn("[tebonds] HTTP", res.status);
return {};
}
const html = await res.text();
return parseBondsHTML(html);
} catch (err) {
console.error("[tebonds] error:", err);
return {};
}
}
function parseBondsHTML(html: string): BondYields {
const result: BondYields = {};
const seen = new Set<Currency>();
const rowPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
let m: RegExpExecArray | null;
while ((m = rowPattern.exec(html)) !== null) {
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
for (const [ccy, country] of Object.entries(TE_BOND_COUNTRIES) as [Currency, string][]) {
if (seen.has(ccy)) continue;
if (!text.startsWith(country)) continue;
// Ligne format : "Country Yield DayDelta WeekPct ..."
// On cherche les deux premiers nombres avec 3+ décimales
const nums = text.match(/(\d+\.\d{3,4})/g);
if (nums && nums.length >= 2) {
result[ccy] = {
yield10y: parseFloat(nums[0]),
dayDelta: parseFloat(nums[1]),
};
seen.add(ccy);
}
break;
}
if (seen.size === 8) break; // toutes les devises trouvées
}
return result;
}
+274
View File
@@ -0,0 +1,274 @@
// lib/tradingeconomics.ts
// Trading Economics Economic Calendar
//
// Strategy:
// 1. Primary : TE paid API (TRADING_ECONOMICS_API_KEY in .env.local)
// 2. Fallback : HTML scraping of https://tradingeconomics.com/calendar
// → The HTML includes previous/consensus/forecast in static DOM.
// Actual values are pushed via Socket.IO when released (real-time only),
// so upcoming events get full forecast data; recently published events
// may show null actual (supplemented by ForexFactory/FRED for key releases).
//
// Socket.IO details (for reference):
// URL : https://live.tradingeconomics.com?key=sun
// Auth : JWT token embedded in page (epoch+IP bound, refreshes each fetch)
// Crypto : NaCl secretbox (XSalsa20-Poly1305) + pako inflate
// → Not used here: Socket.IO is for real-time actuals, not the snapshot we need.
import type { Currency } from "./types";
import type { EventCategory } from "@/app/api/calendar/route";
// ── Country → Currency ────────────────────────────────────────────────────────
const TE_COUNTRY_TO_CCY: Record<string, Currency> = {
"united states": "USD",
"euro area": "EUR",
"united kingdom": "GBP",
"japan": "JPY",
"switzerland": "CHF",
"canada": "CAD",
"australia": "AUD",
"new zealand": "NZD",
};
// ── Category ──────────────────────────────────────────────────────────────────
// eventName = data-event attribute (titre brut TE), utilisé pour détecter les discours
// indépendamment du data-category (qui vaut souvent "interest rate" pour les speeches CB)
function teCategory(cat: string, eventName?: string): EventCategory {
const e = (eventName ?? "").toLowerCase();
if (/\bspeech\b|speaks?\b|testimony|\bpress\s+conf/.test(e)) return "cb_speech";
const c = cat.toLowerCase();
if (/\bpmi\b|purchasing\s+managers/.test(c)) return "pmi";
if (/interest\s+rate|monetary\s+policy|rate\s+decision|bank\s+rate/.test(c)) return "policy_rate";
if (/speech|speaks?|testimony|press\s+conf|central\s+bank/.test(c)) return "cb_speech";
if (/inflation|cpi|hicp|consumer\s+price|ppi/.test(c)) return "inflation";
if (/\bgdp\b|gross\s+domestic|growth\s+rate/.test(c)) return "gdp";
if (/retail\s+sales|core\s+retail/.test(c)) return "retail_sales";
if (/trade\s+balance|current\s+account|balance\s+of\s+trade/.test(c)) return "trade_balance";
if (/employment|payrolls|nonfarm|jobless|unemployment|job\s+creation/.test(c)) return "employment";
return "other";
}
// ── Importance (1/2/3) → impact ───────────────────────────────────────────────
function teImpact(n: number): "high" | "medium" | "low" {
if (n >= 3) return "high";
if (n >= 2) return "medium";
return "low";
}
// ── 12h → ISO time ────────────────────────────────────────────────────────────
// TE times are in UTC (default when no timezone cookie is set on the server)
function to24h(t: string): string {
const m = t.trim().match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (!m) return "12:00";
let h = parseInt(m[1]);
const pm = m[3].toUpperCase() === "PM";
if (pm && h !== 12) h += 12;
if (!pm && h === 12) h = 0;
return `${String(h).padStart(2, "0")}:${m[2]}`;
}
// ── Decode HTML entities ───────────────────────────────────────────────────────
function decodeHTMLEntities(s: string): string {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.trim();
}
// ── Text from HTML element ─────────────────────────────────────────────────────
function extractText(html: string, id: string): string | null {
// Matches <span id='X'>VALUE</span> or <a id='X' ...>VALUE</a>
const m = html.match(new RegExp(`<(?:span|a)[^>]+id=['"]${id}['"][^>]*>([^<]*)<`, "i"));
const val = m?.[1]?.trim();
return val && val.length > 0 ? decodeHTMLEntities(val) : null;
}
// ── Public type ───────────────────────────────────────────────────────────────
export interface TECalendarEvent {
id: string;
date: string; // ISO with UTC time
currency: Currency;
category: EventCategory;
title: string;
impact: "high" | "medium" | "low";
actual: string | null;
forecast: string | null; // consensus preferred over TE forecast
previous: string | null;
isPublished: boolean;
teId: string;
}
// ── HTML scraper ──────────────────────────────────────────────────────────────
// La page /calendar couvre le G20 (USD/EUR/GBP/JPY/CAD/AUD/NZD) mais PAS la Suisse (CHF).
// On fetche aussi /switzerland/calendar pour avoir les events CHF.
async function fetchOneTEPage(url: string): Promise<string> {
try {
const res = await fetch(url, {
next: { revalidate: 1800 },
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,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
});
if (!res.ok) return "";
return res.text();
} catch { return ""; }
}
// fromDate / toDate au format YYYY-MM-DD
export async function fetchTECalendarHTML(fromDate?: string, toDate?: string): Promise<TECalendarEvent[]> {
const qs = fromDate && toDate ? `?startDate=${fromDate}&endDate=${toDate}` : "";
const pages = await Promise.all([
fetchOneTEPage(`https://tradingeconomics.com/calendar${qs}`),
fetchOneTEPage(`https://tradingeconomics.com/switzerland/calendar${qs}`), // CHF — hors G20
]);
const allEvents: TECalendarEvent[] = [];
const seen = new Set<string>();
for (const html of pages) {
if (!html) continue;
for (const ev of parseCalendarHTML(html)) {
if (seen.has(ev.id)) continue;
seen.add(ev.id);
allEvents.push(ev);
}
}
allEvents.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return allEvents;
}
function parseCalendarHTML(html: string): TECalendarEvent[] {
const events: TECalendarEvent[] = [];
const now = new Date();
// Split on each event row — each <tr data-url= ... data-id= ... data-country= ...>
const rowPattern = /<tr\s+data-url="[^"]*"\s+data-id="(\d+)"\s+data-country="([^"]+)"\s+data-category="([^"]+)"\s+data-event="([^"]+)"[^>]*>([\s\S]*?)(?=<tr\s+data-url=|<\/tbody>)/g;
let match: RegExpExecArray | null;
while ((match = rowPattern.exec(html)) !== null) {
const [, id, country, category, eventAttr, body] = match;
const ccy = TE_COUNTRY_TO_CCY[country.toLowerCase()];
if (!ccy) continue; // only our 8 currencies
// Date: <td ... class=' 2026-06-01'>
const dateMatch = body.match(/class=' (\d{4}-\d{2}-\d{2})'/);
if (!dateMatch) continue;
const dateStr = dateMatch[1];
// Time + importance: <span class="event-52 calendar-date-3"> 02:00 PM </span>
const timeMatch = body.match(/calendar-date-(\d)[^"]*"[^>]*>\s*([\d:]+\s*[AP]M)\s*/i);
const importance = timeMatch ? parseInt(timeMatch[1]) : 1;
const timeStr = timeMatch ? timeMatch[2].trim() : "00:00 AM";
const time24 = to24h(timeStr);
const isoDate = `${dateStr}T${time24}:00Z`;
const evDate = new Date(isoDate);
// Display title: <a class='calendar-event' ...>ISM Manufacturing PMI</a>
const titleMatch = body.match(/<a\s+class='calendar-event'[^>]*>([^<]+)<\/a>/);
const title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : decodeHTMLEntities(eventAttr);
// Reference period: <span class="calendar-reference">MAY</span>
const refMatch = body.match(/class="calendar-reference"\s*>([^<]*)</);
const ref = refMatch ? refMatch[1].trim() : "";
// Values — all live in id='' span/anchor elements
const actual = extractText(body, "actual");
const previous = extractText(body, "previous");
const consensus = extractText(body, "consensus");
const forecast = extractText(body, "forecast");
const displayTitle = ref ? `${title} ${ref}` : title;
events.push({
id: `te_${id}`,
date: isoDate,
currency: ccy,
category: teCategory(category, eventAttr),
title: displayTitle,
impact: teImpact(importance),
actual,
forecast: consensus ?? forecast, // TE consensus > TE proprietary forecast
previous,
isPublished: actual !== null || evDate < now,
teId: id,
});
}
// Sort by date ascending
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return events;
}
// ── Paid API (when TRADING_ECONOMICS_API_KEY is set) ─────────────────────────
export async function fetchTECalendar(
apiKey: string,
fromDate: string,
toDate: string,
): Promise<TECalendarEvent[]> {
const countries = [
"united states", "euro area", "united kingdom", "japan",
"switzerland", "canada", "australia", "new zealand",
].join(",");
const url = [
`https://api.tradingeconomics.com/calendar/country/${encodeURIComponent(countries)}`,
`/${fromDate}/${toDate}`,
`?c=${encodeURIComponent(apiKey)}&f=json`,
].join("");
try {
const res = await fetch(url, {
next: { revalidate: 1800 },
headers: { "Accept": "application/json" },
});
if (!res.ok) return [];
const raw = await res.json() as Array<Record<string, unknown>>;
if (!Array.isArray(raw)) return [];
const now = new Date();
return raw
.map(ev => {
const ccy = TE_COUNTRY_TO_CCY[(ev["Country"] as string)?.toLowerCase()];
if (!ccy) return null;
const dateRaw = ev["Date"] as string ?? "";
const isoDate = dateRaw.endsWith("Z") ? dateRaw : dateRaw + "Z";
const evDate = new Date(isoDate);
const actual = (ev["Actual"] as string | null) || null;
const forecast = (ev["Forecast"] as string | null) || (ev["TEForecast"] as string | null) || null;
const previous = (ev["Previous"] as string | null) || null;
return {
id: `te_${ev["CalendarId"]}`,
date: isoDate,
currency: ccy,
category: teCategory(ev["Category"] as string ?? ""),
title: (ev["Event"] as string ?? ""),
impact: teImpact(ev["Importance"] as number ?? 1),
actual: actual !== "" ? actual : null,
forecast: forecast !== "" ? forecast : null,
previous: previous !== "" ? previous : null,
isPublished: actual !== null || evDate < now,
teId: String(ev["CalendarId"] ?? ""),
} satisfies TECalendarEvent;
})
.filter((e): e is TECalendarEvent => e !== null);
} catch {
return [];
}
}