diff --git a/app/api/calendar/route.ts b/app/api/calendar/route.ts new file mode 100644 index 0000000..e5a7a33 --- /dev/null +++ b/app/api/calendar/route.ts @@ -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(["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 = { + // 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 { + 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(); + 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(); + + 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(); + 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 }>)[0]; + const currentRates: Record = {}; + 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" }, + }); +} diff --git a/app/api/drivers/route.ts b/app/api/drivers/route.ts index fe7eab3..28dc5bc 100644 --- a/app/api/drivers/route.ts +++ b/app/api/drivers/route.ts @@ -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 { 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 { 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 { +// ── 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 { 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 { } // ── 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, diff --git a/app/api/expectations/route.ts b/app/api/expectations/route.ts index 5f7747f..bee95ff 100644 --- a/app/api/expectations/route.ts +++ b/app/api/expectations/route.ts @@ -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 = { + 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(//gi, " ") + .replace(//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>/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(//gi, "\n") + .replace(//gi, "\n") + .replace(/<[^>]+>/g, "\n") + .split(/\n+/) + .map((l) => l.replace(/ /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 { + 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 { + const candidates: string[] = []; + for (const rssUrl of RSS_URLS) { + const xml = await fetchHtml(rssUrl); + if (!xml) continue; + + // Extraire les individuellement pour éviter de confondre avec + const items = Array.from(xml.matchAll(/([\s\S]*?)<\/item>/gi)); + for (const [, itemXml] of items) { + const titleMatch = itemXml.match(/]*>(?:]*>(?:([^<]+)<\/link>/i) + ?? itemXml.match(/]*>([^<]+)<\/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 { + const seen = new Set(); + 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 { + 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 }); } diff --git a/app/api/macro/route.ts b/app/api/macro/route.ts index a55f11c..005396f 100644 --- a/app/api/macro/route.ts +++ b/app/api/macro/route.ts @@ -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 { + 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 = { 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(/]*name=["']description["'][^>]*content=["']([^"']+)["']/i) ?? + html.match(/]*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 { + 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(/]*name=["']description["'][^>]*content=["']([^"']+)["']/i) + ?? html.match(/]*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(/]*name=["']description["'][^>]*content=["']([^"']+)["']/i) + ?? html.match(/]*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(/]*name=["']description["'][^>]*content=["']([^"']+)["']/i) + ?? html.match(/]*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 }]; + 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 = { + cpiCore: isQuarterlyCpi ? 7 : 16, // +2 tampon pour les valeurs "." filtrées par FRED + gdp: 6, + employment: 6, + }; const fieldMap: Record = { 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 = {}; + 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); + } } - // CAD — API Banque du Canada (Valet, gratuit, officiel, JSON) - // V80691311 = Taux directeur annoncé (pas le marché) + // CHF — TE 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); } diff --git a/app/api/yields/route.ts b/app/api/yields/route.ts index 65476e5..523d6c1 100644 --- a/app/api/yields/route.ts +++ b/app/api/yields/route.ts @@ -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 { - 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 { - 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 { - 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 { - 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 = { + 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 = { + 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 = {}; 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() }); } diff --git a/components/DriversBar.tsx b/components/DriversBar.tsx index 504262b..8dd21ee 100644 --- a/components/DriversBar.tsx +++ b/components/DriversBar.tsx @@ -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) {
{/* Sentiment / Risk-On */} - - - + + + @@ -177,20 +186,36 @@ export default function DriversBar({ drivers }: Props) { {/* Taux & FX */} - - + {/* Commodités */} - - - - + + + +
diff --git a/data/rate_decisions.json b/data/rate_decisions.json new file mode 100644 index 0000000..ba94549 --- /dev/null +++ b/data/rate_decisions.json @@ -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%)" + } + } + } +] diff --git a/data/rate_expectations.json b/data/rate_expectations.json index 1d6bcea..f6fdb0c 100644 --- a/data/rate_expectations.json +++ b/data/rate_expectations.json @@ -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" } ] diff --git a/lib/forexfactory.ts b/lib/forexfactory.ts new file mode 100644 index 0000000..2ba4870 --- /dev/null +++ b/lib/forexfactory.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/lib/investing.ts b/lib/investing.ts new file mode 100644 index 0000000..834383b --- /dev/null +++ b/lib/investing.ts @@ -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 = { + 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 = { + 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" | " " → null + +function normVal(raw: string | null | undefined): string | null { + if (!raw) return null; + const s = raw.replace(/ /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 = /]*>([\s\S]*?)(?=)/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[^"]*"[^>]*> <\/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 + const nameMatch = body.match(/]*>\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>)?`)); + + 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 { + // 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 []; + } +} diff --git a/lib/investinglive.ts b/lib/investinglive.ts new file mode 100644 index 0000000..c8c36de --- /dev/null +++ b/lib/investinglive.ts @@ -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 = { + "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 (0–100) + 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>; + +// ── 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 { + 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 {}; + } +} diff --git a/lib/rateprobability.ts b/lib/rateprobability.ts new file mode 100644 index 0000000..cd4f6c1 --- /dev/null +++ b/lib/rateprobability.ts @@ -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; // 0–100 : 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>; + +// ── 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> = { + USD: 18, EUR: 12, GBP: 11, JPY: 2, CAD: 14, AUD: 3, NZD: 2, CHF: 8, +}; + +// Titres pour le calendrier +const MEETING_TITLES: Partial> = { + 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): 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 { + 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 { + 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; + const today = body["today"] as Record | 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> | 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 { + // 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; +} diff --git a/lib/tebonds.ts b/lib/tebonds.ts new file mode 100644 index 0000000..098039c --- /dev/null +++ b/lib/tebonds.ts @@ -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 = { + 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>; + +export async function fetchTEBondYields(): Promise { + 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(); + + const rowPattern = /]*>([\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; +} diff --git a/lib/tradingeconomics.ts b/lib/tradingeconomics.ts new file mode 100644 index 0000000..5ca71f0 --- /dev/null +++ b/lib/tradingeconomics.ts @@ -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 = { + "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(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") + .trim(); +} + +// ── Text from HTML element ───────────────────────────────────────────────────── + +function extractText(html: string, id: string): string | null { + // Matches VALUE or VALUE + 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 { + 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 { + 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(); + + 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 + const rowPattern = /]*>([\s\S]*?)(?=)/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: + const dateMatch = body.match(/class=' (\d{4}-\d{2}-\d{2})'/); + if (!dateMatch) continue; + const dateStr = dateMatch[1]; + + // Time + importance: 02:00 PM + 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: ISM Manufacturing PMI + const titleMatch = body.match(/]*>([^<]+)<\/a>/); + const title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : decodeHTMLEntities(eventAttr); + + // Reference period: MAY + const refMatch = body.match(/class="calendar-reference"\s*>([^<]*) 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 { + 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>; + 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 []; + } +}