mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
feat: calendrier économique TE+Investing, OIS SNB, yields 10Y, taux directeurs corrects
Calendrier économique - Nouvelles sources : TE HTML scraping (483 events/2 sem.) + Investing.com en co-primaires - Dédupe déterministe par clé currency+category+date (±1j pour policy_rate) - Filtre catégorie "other", filtre discours CB (fix doublon TE vs Investing) - CHF/Switzerland via page séparée TE (hors G20) - Fenêtre 14 jours avec ?startDate=&endDate= passés à TE - ForexFactory+FRED en secours uniquement si les deux scrapers tombent à vide Probabilités OIS - rateprobability.com (7 CBs : FED/ECB/BOJ/BOE/BOC/RBA/RBNZ) - investinglive.com (Dellamotta) : scan URL-date 14 jours → couvre SNB/CHF manquant - SNB : 3% probabilité hausse au 19 juin 2026 (source : IL article 2026-05-29) - rate_expectations.json MAJ : RBNZ 79% hike (corrigé depuis 70% no-change stale) - Dédup ±1 jour pour policy_rate (fix doublon SNB TE-18/06 vs IL-19/06) Taux directeurs - USD : scrapeTeRate() = 3.75% (fin midpoint FRED 3.625%) - AUD prev : 4.10% (corrigé depuis 4.60%) - NZD/CHF/JPY/CAD : cohérents avec TE officiel Yields 10Y souverains - Nouvelle lib lib/tebonds.ts : scrape TE bonds, données du jour, cache 1h - Remplace FRED IRLTLT01XXM156N mensuel (JPY/CHF/AUD/NZD avaient 1 mois de retard) - 8 devises + spread vs USD calculé automatiquement - /api/yields et /api/drivers utilisent la même lib Drivers globaux - BTC : ticker/price Binance (plus précis que 24hr lastPrice) - HY Spread cache : 86400s → 3600s (données FRED du jour) - DriversBar : suppression affichage direct US 10Y, tooltip Crb 2-10 affiche US10Y+US2Y Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import rateDecisionsData from "@/data/rate_decisions.json";
|
||||
import { fetchFFEvents, nextWeekAvailable } from "@/lib/forexfactory";
|
||||
import type { FFEvent } from "@/lib/forexfactory";
|
||||
import type { Currency } from "@/lib/types";
|
||||
import { fetchAllCBPaths, extractMeetingEvents } from "@/lib/rateprobability";
|
||||
import { fetchTECalendarHTML } from "@/lib/tradingeconomics";
|
||||
import { fetchInvestingCalendar } from "@/lib/investing";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EventCategory =
|
||||
| "employment"
|
||||
| "pmi"
|
||||
| "policy_rate"
|
||||
| "cb_speech"
|
||||
| "inflation"
|
||||
| "gdp"
|
||||
| "retail_sales"
|
||||
| "trade_balance"
|
||||
| "other";
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
date: string; // ISO string from FF
|
||||
currency: Currency;
|
||||
category: EventCategory;
|
||||
title: string; // display-friendly
|
||||
rawTitle: string; // original FF title
|
||||
impact: "high" | "medium" | "low";
|
||||
actual: string | null;
|
||||
forecast: string | null;
|
||||
previous: string | null;
|
||||
isPublished: boolean;
|
||||
week: "current" | "next" | "next2"; // semaine de l'événement
|
||||
source: "ff" | "fred"; // source de la donnée
|
||||
groupKey: string | null;
|
||||
isGroupParent: boolean;
|
||||
isGroupChild: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarResponse {
|
||||
events: CalendarEvent[];
|
||||
nextWeekAvail: boolean;
|
||||
fetchedAt: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ── Currencies supported ───────────────────────────────────────────────────────
|
||||
|
||||
const CURRENCIES = new Set<string>(["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]);
|
||||
|
||||
// ── Category detection ─────────────────────────────────────────────────────────
|
||||
|
||||
function detectCategory(title: string): EventCategory {
|
||||
const t = title.toLowerCase();
|
||||
|
||||
if (/nonfarm|non.farm|employment\s+change|jobs\s+added|employment\s+report|claimant|jobless\s+claims|unemployment\s+rate|jobless\s+rate/.test(t))
|
||||
return "employment";
|
||||
|
||||
if (/\bpmi\b|purchasing\s+managers/.test(t))
|
||||
return "pmi";
|
||||
|
||||
if (/interest\s+rate|rate\s+decision|monetary\s+policy\s+decision|fomc\s+(fed\s+funds|statement)|bank\s+rate\s+vote|mpc\s+.*rate|boe.*rate|ecb.*rate|boj.*rate|rba.*rate|rbnz.*rate|snb.*rate|boc.*rate/.test(t))
|
||||
return "policy_rate";
|
||||
|
||||
if (/speaks?|press\s+conf|testimony|speech|statement\b|governor|chair\b|president\b/.test(t))
|
||||
return "cb_speech";
|
||||
|
||||
if (/\bcpi\b|\bhicp\b|core\s+inflation|flash\s+cpi|inflation\s+rate|consumer\s+price/.test(t))
|
||||
return "inflation";
|
||||
|
||||
if (/\bgdp\b|gross\s+domestic/.test(t))
|
||||
return "gdp";
|
||||
|
||||
if (/retail\s+sales|core\s+retail/.test(t))
|
||||
return "retail_sales";
|
||||
|
||||
if (/trade\s+balance|current\s+account/.test(t))
|
||||
return "trade_balance";
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ── Impact mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
function detectImpact(ff: FFEvent, category: EventCategory): "high" | "medium" | "low" {
|
||||
const raw = (ff.impact ?? "").toLowerCase();
|
||||
if (raw.includes("high")) return "high";
|
||||
if (raw.includes("medium")) return "medium";
|
||||
if (raw.includes("low")) return "low";
|
||||
// Fallback by category
|
||||
if (["policy_rate", "inflation", "employment"].includes(category)) return "high";
|
||||
if (["pmi", "gdp", "retail_sales"].includes(category)) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
// ── Display title mapping ──────────────────────────────────────────────────────
|
||||
|
||||
function displayTitle(rawTitle: string, currency: string): string {
|
||||
const t = rawTitle;
|
||||
// USD-specific
|
||||
if (/nonfarm\s+payrolls/i.test(t) && currency === "USD") return "NFP (Non-Farm Payrolls)";
|
||||
if (/adp\s+non.farm/i.test(t)) return "ADP Employment";
|
||||
if (/unemployment\s+claims/i.test(t)) return "Demandes d'allocations";
|
||||
if (/unemployment\s+rate/i.test(t)) return "Taux de chômage";
|
||||
if (/employment\s+change/i.test(t)) return "Emploi Δ";
|
||||
if (/manufacturing\s+pmi|mfg\s+pmi/i.test(t)) return "PMI Manufacturier";
|
||||
if (/services?\s+pmi/i.test(t)) return "PMI Services";
|
||||
if (/composite\s+pmi/i.test(t)) return "PMI Composite";
|
||||
if (/ism\s+non.manufactur/i.test(t)) return "ISM Services PMI";
|
||||
if (/ism\s+manufactur/i.test(t)) return "ISM Manufacturier";
|
||||
if (/flash.*cpi|cpi.*flash/i.test(t)) return "IPC Flash (YoY)";
|
||||
if (/core.*cpi/i.test(t)) return "IPC Core";
|
||||
if (/\bhicp\b/i.test(t)) return "HICP (YoY)";
|
||||
if (/\bcpi\b.*y.*y/i.test(t)) return "IPC (YoY)";
|
||||
if (/\bcpi\b.*m.*m/i.test(t)) return "IPC (MoM)";
|
||||
if (/\bcpi\b/i.test(t)) return "IPC";
|
||||
if (/gdp.*q.*q/i.test(t)) return "PIB (QoQ)";
|
||||
if (/gdp.*m.*m/i.test(t)) return "PIB (MoM)";
|
||||
if (/\bgdp\b/i.test(t)) return "PIB";
|
||||
if (/core\s+retail/i.test(t)) return "Ventes détail Core";
|
||||
if (/retail\s+sales/i.test(t)) return "Ventes au détail";
|
||||
if (/trade\s+balance/i.test(t)) return "Balance commerciale";
|
||||
if (/interest\s+rate|rate\s+decision/i.test(t)) return "Décision de taux";
|
||||
if (/speaks?\b|speech\b/i.test(t)) return "Discours BC";
|
||||
if (/press\s+conf/i.test(t)) return "Conférence de presse BC";
|
||||
return rawTitle;
|
||||
}
|
||||
|
||||
// ── Grouping logic ─────────────────────────────────────────────────────────────
|
||||
|
||||
function dayKey(dateStr: string): string {
|
||||
return dateStr.slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
interface GroupingResult {
|
||||
groupKey: string | null;
|
||||
isGroupParent: boolean;
|
||||
isGroupChild: boolean;
|
||||
}
|
||||
|
||||
const EMPLOYMENT_PARENTS = /nonfarm|non.farm|employment\s+change|claimant|jobless\s+claims/i;
|
||||
const EMPLOYMENT_CHILDREN = /unemployment\s+rate|jobless\s+rate/i;
|
||||
const PMI_PARENTS = /composite\s+pmi|ism\s+(manufactur|non.manufactur)/i;
|
||||
const PMI_CHILDREN = /manufacturing\s+pmi|services?\s+pmi|mfg\s+pmi/i;
|
||||
const CPI_PARENTS = /\bcpi\b.*y.*y|flash.*cpi|\bhicp\b/i;
|
||||
const CPI_CHILDREN = /core.*cpi|core.*inflation/i;
|
||||
|
||||
function resolveGrouping(ev: FFEvent, category: EventCategory): GroupingResult {
|
||||
const t = ev.title;
|
||||
const day = dayKey(ev.date ?? "");
|
||||
const base = `${ev.country}_${day}`;
|
||||
|
||||
if (category === "employment") {
|
||||
if (EMPLOYMENT_PARENTS.test(t)) return { groupKey: `emp_${base}`, isGroupParent: true, isGroupChild: false };
|
||||
if (EMPLOYMENT_CHILDREN.test(t)) return { groupKey: `emp_${base}`, isGroupParent: false, isGroupChild: true };
|
||||
}
|
||||
if (category === "pmi") {
|
||||
if (PMI_PARENTS.test(t)) return { groupKey: `pmi_${base}`, isGroupParent: true, isGroupChild: false };
|
||||
if (PMI_CHILDREN.test(t)) return { groupKey: `pmi_${base}`, isGroupParent: false, isGroupChild: true };
|
||||
}
|
||||
if (category === "inflation") {
|
||||
if (CPI_PARENTS.test(t)) return { groupKey: `cpi_${base}`, isGroupParent: true, isGroupChild: false };
|
||||
if (CPI_CHILDREN.test(t)) return { groupKey: `cpi_${base}`, isGroupParent: false, isGroupChild: true };
|
||||
}
|
||||
return { groupKey: null, isGroupParent: false, isGroupChild: false };
|
||||
}
|
||||
|
||||
// ── Map FF event → CalendarEvent ──────────────────────────────────────────────
|
||||
|
||||
function mapEvent(ff: FFEvent): CalendarEvent | null {
|
||||
if (!CURRENCIES.has(ff.country)) return null;
|
||||
|
||||
const category = detectCategory(ff.title);
|
||||
if (category === "other") return null; // filter low-relevance events
|
||||
|
||||
const { groupKey, isGroupParent, isGroupChild } = resolveGrouping(ff, category);
|
||||
|
||||
const actual = ff.actual?.trim() || null;
|
||||
const forecast = ff.forecast?.trim() || null;
|
||||
const previous = ff.previous?.trim() || null;
|
||||
|
||||
return {
|
||||
id: `${ff.country}_${ff.title}_${ff.date}`.replace(/\s+/g, "_"),
|
||||
date: ff.date,
|
||||
currency: ff.country as Currency,
|
||||
category,
|
||||
title: displayTitle(ff.title, ff.country),
|
||||
rawTitle: ff.title,
|
||||
impact: detectImpact(ff, category),
|
||||
actual: actual,
|
||||
forecast: forecast,
|
||||
previous: previous,
|
||||
isPublished: actual !== null && actual !== "",
|
||||
week: "current" as const, // overridden in GET()
|
||||
source: "ff" as const,
|
||||
groupKey,
|
||||
isGroupParent,
|
||||
isGroupChild,
|
||||
};
|
||||
}
|
||||
|
||||
// ── FRED release calendar ──────────────────────────────────────────────────────
|
||||
// Complément fiable pour les semaines où ForexFactory n'est pas disponible
|
||||
// (samedi/dimanche) ou pour la semaine +2 (non couverte par FF).
|
||||
|
||||
interface FREDReleaseDef {
|
||||
title: string;
|
||||
currency: Currency;
|
||||
category: EventCategory;
|
||||
impact: "high" | "medium" | "low";
|
||||
utcHour: number; // heure UTC approximative de la publication
|
||||
}
|
||||
|
||||
const FRED_KEY_RELEASES: Record<number, FREDReleaseDef> = {
|
||||
// USD — publications majeures BLS/BEA/Census
|
||||
50: { title: "NFP & Taux de chômage (Employment Situation)", currency: "USD", category: "employment", impact: "high", utcHour: 13 },
|
||||
10: { title: "IPC (CPI) — USD", currency: "USD", category: "inflation", impact: "high", utcHour: 13 },
|
||||
9: { title: "Ventes au détail — USD", currency: "USD", category: "retail_sales", impact: "high", utcHour: 13 },
|
||||
194: { title: "ADP National Employment — USD", currency: "USD", category: "employment", impact: "medium", utcHour: 13 },
|
||||
180: { title: "Demandes allocations chômage hebdo — USD", currency: "USD", category: "employment", impact: "medium", utcHour: 13 },
|
||||
13: { title: "Production industrielle — USD", currency: "USD", category: "gdp", impact: "medium", utcHour: 14 },
|
||||
321: { title: "Empire State Manufacturing Survey — USD", currency: "USD", category: "pmi", impact: "medium", utcHour: 13 },
|
||||
// EUR — publications Eurostat/ECB
|
||||
267: { title: "PIB Zone Euro — Eurostat Flash", currency: "EUR", category: "gdp", impact: "high", utcHour: 10 },
|
||||
251: { title: "HICP / IPC Zone Euro", currency: "EUR", category: "inflation", impact: "high", utcHour: 10 },
|
||||
// JPY
|
||||
269: { title: "PIB Japon — Comptes nationaux", currency: "JPY", category: "gdp", impact: "high", utcHour: 1 },
|
||||
266: { title: "Comptes BoJ (Bank of Japan)", currency: "JPY", category: "policy_rate", impact: "medium", utcHour: 2 },
|
||||
};
|
||||
|
||||
// CB_MEETINGS_2026 supprimé — dates désormais dynamiques via lib/rateprobability.ts
|
||||
// (rateprobability.com OIS futures, mis à jour en temps réel, dates toujours exactes)
|
||||
|
||||
async function fetchFREDCalendar(
|
||||
fredKey: string,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const url = [
|
||||
"https://api.stlouisfed.org/fred/releases/dates",
|
||||
`?api_key=${fredKey}`,
|
||||
"&file_type=json",
|
||||
`&realtime_start=${fromDate}`,
|
||||
`&realtime_end=${toDate}`,
|
||||
"&include_release_dates_with_no_data=true",
|
||||
"&limit=500",
|
||||
].join("");
|
||||
const res = await fetch(url, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const releaseDates: Array<{ release_id: number; date: string; release_name: string }> =
|
||||
data.release_dates ?? [];
|
||||
|
||||
const events: CalendarEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rd of releaseDates) {
|
||||
const def = FRED_KEY_RELEASES[rd.release_id];
|
||||
if (!def) continue;
|
||||
const key = `${rd.release_id}_${rd.date}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
// Construire une date ISO avec l'heure UTC approximative
|
||||
const isoDate = `${rd.date}T${String(def.utcHour).padStart(2, "0")}:30:00Z`;
|
||||
const evDate = new Date(isoDate);
|
||||
|
||||
events.push({
|
||||
id: `fred_${rd.release_id}_${rd.date}`,
|
||||
date: isoDate,
|
||||
currency: def.currency,
|
||||
category: def.category,
|
||||
title: def.title,
|
||||
rawTitle: def.title,
|
||||
impact: def.impact,
|
||||
actual: null,
|
||||
forecast: null,
|
||||
previous: null,
|
||||
isPublished: evDate < new Date(),
|
||||
week: "current", // recalculé dans GET()
|
||||
source: "fred",
|
||||
groupKey: null,
|
||||
isGroupParent: false,
|
||||
isGroupChild: false,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── Dedup key ─────────────────────────────────────────────────────────────────
|
||||
// Clé déterministe pour identifier un doublon entre TE et Investing.
|
||||
// PMI et discours BC peuvent avoir plusieurs events dans la même journée →
|
||||
// on affine à l'heure UTC pour les distinguer.
|
||||
// Toutes les autres catégories sont uniques par (devise, catégorie, jour).
|
||||
|
||||
function dedupeKey(currency: string, category: EventCategory, isoDate: string): string {
|
||||
if (category === "pmi" || category === "cb_speech") {
|
||||
return `${currency}_${category}_${isoDate.slice(0, 13)}`; // YYYY-MM-DDTHH
|
||||
}
|
||||
return `${currency}_${category}_${isoDate.slice(0, 10)}`; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
// ── GET ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Calcule les bornes des 3 semaines (cette semaine, prochaine, +2)
|
||||
function getWeekBounds(): { nextMonday: Date; next2Monday: Date } {
|
||||
const now = new Date();
|
||||
const day = now.getDay(); // 0=dim
|
||||
const daysToNext = day === 0 ? 1 : 8 - day;
|
||||
const nextMonday = new Date(now);
|
||||
nextMonday.setDate(now.getDate() + daysToNext);
|
||||
nextMonday.setHours(0, 0, 0, 0);
|
||||
const next2Monday = new Date(nextMonday);
|
||||
next2Monday.setDate(nextMonday.getDate() + 7);
|
||||
return { nextMonday, next2Monday };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const fredKey = process.env.FRED_API_KEY;
|
||||
const { nextMonday, next2Monday } = getWeekBounds();
|
||||
|
||||
const fromDate = new Date().toISOString().slice(0, 10);
|
||||
const toDateObj = new Date();
|
||||
toDateObj.setDate(toDateObj.getDate() + 14);
|
||||
const toDate = toDateObj.toISOString().slice(0, 10);
|
||||
|
||||
// Fetch TE HTML + Investing + CB paths toujours en parallèle
|
||||
// FF+FRED uniquement si les deux scraping tombent à vide
|
||||
const [teEvents, invEvents, cbPaths] = await Promise.all([
|
||||
fetchTECalendarHTML(fromDate, toDate),
|
||||
fetchInvestingCalendar(fromDate, toDate),
|
||||
fetchAllCBPaths(),
|
||||
]);
|
||||
|
||||
const useScraping = teEvents.length > 0 || invEvents.length > 0;
|
||||
|
||||
// Fetch FF+FRED en secours seulement si les deux scrapers ont échoué
|
||||
const [ffEvents, fredEvents] = useScraping
|
||||
? [[], []]
|
||||
: await Promise.all([
|
||||
fetchFFEvents(),
|
||||
fredKey ? fetchFREDCalendar(fredKey, fromDate, toDate) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
const weekOf = (date: Date): "current" | "next" | "next2" =>
|
||||
date >= next2Monday ? "next2" :
|
||||
date >= nextMonday ? "next" : "current";
|
||||
|
||||
if (useScraping) {
|
||||
// ── BASE : TE HTML ────────────────────────────────────────────────────────
|
||||
// Index de dédupe : clé → index dans events[]
|
||||
const dedupeIndex = new Map<string, number>();
|
||||
|
||||
for (const te of teEvents) {
|
||||
if (te.category === "other") continue; // filtre les events sans catégorie pertinente
|
||||
const evDate = new Date(te.date);
|
||||
const key = dedupeKey(te.currency, te.category, te.date);
|
||||
dedupeIndex.set(key, events.length);
|
||||
events.push({
|
||||
id: te.id,
|
||||
date: te.date,
|
||||
currency: te.currency,
|
||||
category: te.category,
|
||||
title: te.title,
|
||||
rawTitle: te.title,
|
||||
impact: te.impact,
|
||||
actual: te.actual,
|
||||
forecast: te.forecast,
|
||||
previous: te.previous,
|
||||
isPublished: te.isPublished,
|
||||
week: weekOf(evDate),
|
||||
source: "fred",
|
||||
groupKey: null,
|
||||
isGroupParent: false,
|
||||
isGroupChild: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── COMPLÉMENT : Investing.com ────────────────────────────────────────────
|
||||
// Dédupe immédiat via dedupeIndex : même clé = doublon, on enrichit seulement.
|
||||
// Absent de TE = on ajoute l'event Investing directement.
|
||||
for (const inv of invEvents) {
|
||||
if (inv.category === "other") continue;
|
||||
const key = dedupeKey(inv.currency, inv.category, inv.date);
|
||||
const existingIdx = dedupeIndex.get(key);
|
||||
if (existingIdx !== undefined) {
|
||||
// Doublon — enrichir avec les valeurs manquantes d'Investing
|
||||
const ev = events[existingIdx];
|
||||
if (!ev.actual && inv.actual) ev.actual = inv.actual;
|
||||
if (!ev.forecast && inv.forecast) ev.forecast = inv.forecast;
|
||||
if (!ev.previous && inv.previous) ev.previous = inv.previous;
|
||||
} else {
|
||||
// Présent sur Investing mais absent de TE — on l'ajoute
|
||||
const evDate = new Date(inv.date);
|
||||
dedupeIndex.set(key, events.length);
|
||||
events.push({
|
||||
id: inv.id,
|
||||
date: inv.date,
|
||||
currency: inv.currency,
|
||||
category: inv.category,
|
||||
title: inv.title,
|
||||
rawTitle: inv.title,
|
||||
impact: inv.impact,
|
||||
actual: inv.actual,
|
||||
forecast: inv.forecast,
|
||||
previous: inv.previous,
|
||||
isPublished: inv.isPublished,
|
||||
week: weekOf(evDate),
|
||||
source: "fred",
|
||||
groupKey: null,
|
||||
isGroupParent: false,
|
||||
isGroupChild: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── SECOURS : ForexFactory + FRED ─────────────────────────────────────────
|
||||
const ffKeys = new Set<string>();
|
||||
for (const ff of ffEvents as FFEvent[]) {
|
||||
const ev = mapEvent(ff);
|
||||
if (!ev) continue;
|
||||
ev.week = weekOf(new Date(ff.date));
|
||||
events.push(ev);
|
||||
ffKeys.add(`${ev.currency}_${ev.category}_${ff.date.slice(0, 10)}`);
|
||||
}
|
||||
for (const fredEv of fredEvents as CalendarEvent[]) {
|
||||
const key = `${fredEv.currency}_${fredEv.category}_${fredEv.date.slice(0, 10)}`;
|
||||
if (ffKeys.has(key)) continue;
|
||||
fredEv.week = weekOf(new Date(fredEv.date));
|
||||
events.push(fredEv);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Réunions CB + probabilités OIS (rateprobability.com / InvestingLive) ───
|
||||
const cbMeetings = extractMeetingEvents(cbPaths, fromDate);
|
||||
|
||||
// Cherche un event policy_rate existant dans une fenêtre ±1 jour
|
||||
// (nécessaire car certains providers décalent d'un jour : TE ≠ rateprobability pour le SNB)
|
||||
function findExistingRateDecision(currency: Currency, dateIso: string): CalendarEvent | undefined {
|
||||
const target = new Date(dateIso).getTime();
|
||||
return events.find(e =>
|
||||
e.currency === currency &&
|
||||
e.category === "policy_rate" &&
|
||||
Math.abs(new Date(e.date).getTime() - target) <= 86_400_000 // ±1 jour
|
||||
);
|
||||
}
|
||||
|
||||
const cbDedup = new Set(
|
||||
events
|
||||
.filter(e => e.category === "policy_rate")
|
||||
.map(e => `${e.currency}_${e.date.slice(0, 10)}`)
|
||||
);
|
||||
|
||||
for (const meeting of cbMeetings) {
|
||||
const existing = findExistingRateDecision(meeting.currency, meeting.dateIso);
|
||||
if (existing) {
|
||||
// Enrichir l'event existant avec la probabilité OIS
|
||||
if (meeting.probMovePct > 0) {
|
||||
const probStr = `${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼" : "▲"}`;
|
||||
if (!existing.forecast) existing.forecast = probStr;
|
||||
if (meeting.probMovePct > 50 && !existing.title.includes("▼") && !existing.title.includes("▲")) {
|
||||
existing.title += ` · ${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼ baisse" : "▲ hausse"}`;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Vérifier aussi par la clé jour (pour ne pas dupliquer si le meeting IL tombe le même jour qu'un event TE)
|
||||
const dayKey2 = `${meeting.currency}_${meeting.dateIso}`;
|
||||
if (cbDedup.has(dayKey2)) continue;
|
||||
cbDedup.add(dayKey2);
|
||||
|
||||
const isoDate = `${meeting.dateIso}T${String(meeting.utcHour).padStart(2, "0")}:30:00Z`;
|
||||
const evDate = new Date(isoDate);
|
||||
const probLabel = meeting.probMovePct > 50
|
||||
? ` · ${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼ baisse" : "▲ hausse"}`
|
||||
: "";
|
||||
events.push({
|
||||
id: `cb_${meeting.currency}_${meeting.dateIso}`,
|
||||
date: isoDate,
|
||||
currency: meeting.currency,
|
||||
category: "policy_rate",
|
||||
title: meeting.title + probLabel,
|
||||
rawTitle: meeting.title,
|
||||
impact: "high",
|
||||
actual: null,
|
||||
forecast: meeting.probMovePct > 0 ? `${meeting.probMovePct.toFixed(0)}% ${meeting.probIsCut ? "▼" : "▲"}` : null,
|
||||
previous: null,
|
||||
isPublished: evDate < new Date(),
|
||||
week: weekOf(evDate),
|
||||
source: "fred",
|
||||
groupKey: null,
|
||||
isGroupParent: false,
|
||||
isGroupChild: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Remplissage previous pour décisions de taux sans valeur ────────────────
|
||||
// Les events futurs n'ont pas de previous dans TE/Investing ; on utilise
|
||||
// rate_decisions.json (taux directeur actuel) comme valeur de référence.
|
||||
// On exclut : discours, minutes, projections, votes, Beige Book, press conf.
|
||||
const SKIP_RATE_PREV = /speech|speaks?|testimony|press\s+conf|minutes|projection|beige\s+book|vote\s+(cut|hike|unchanged)|balance\s+sheet|chart\s+pack|payments\s+system/i;
|
||||
const rateDecEntry = (rateDecisionsData as Array<{ decisions: Record<string, { current: number }> }>)[0];
|
||||
const currentRates: Record<string, string> = {};
|
||||
if (rateDecEntry?.decisions) {
|
||||
for (const [ccy, d] of Object.entries(rateDecEntry.decisions)) {
|
||||
currentRates[ccy] = `${d.current}%`;
|
||||
}
|
||||
}
|
||||
for (const ev of events) {
|
||||
if (ev.category === "policy_rate" && !ev.previous && !SKIP_RATE_PREV.test(ev.rawTitle)) {
|
||||
ev.previous = currentRates[ev.currency] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const sourceLabel = useScraping
|
||||
? `tradingeconomics-html(${teEvents.length})+investing(${invEvents.length})+rateprobability`
|
||||
: (fredKey ? "forexfactory+fred+rateprobability" : "forexfactory+rateprobability");
|
||||
|
||||
const result: CalendarResponse = {
|
||||
events,
|
||||
nextWeekAvail: useScraping ? true : nextWeekAvailable(),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
source: sourceLabel,
|
||||
};
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "s-maxage=1800, stale-while-revalidate=3600" },
|
||||
});
|
||||
}
|
||||
+70
-32
@@ -1,12 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchTEBondYields } from "@/lib/tebonds";
|
||||
|
||||
// ── FRED (macro + taux + spread) ──────────────────────────────────────────────
|
||||
// limit=1 → valeur seule. limit=10 → filtre les "." pour trouver la dernière valeur valide.
|
||||
// ── FRED (spreads crédit + rendements) ───────────────────────────────────────
|
||||
// VIX et S&P500 ne passent plus par FRED (lag 1-2j) → Yahoo Finance (temps réel)
|
||||
|
||||
async function fredObs(series: string, apiKey: string): Promise<number | null> {
|
||||
try {
|
||||
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=1`;
|
||||
const res = await fetch(url, { next: { revalidate: 86400 } });
|
||||
const res = await fetch(url, { next: { revalidate: 3600 } }); // 1h — FRED publie 1x/jour mais réduit le délai d'affichage
|
||||
if (!res.ok) return null;
|
||||
const obs = ((await res.json())?.observations ?? []).find(
|
||||
(o: { value: string }) => o.value !== "."
|
||||
@@ -17,22 +18,32 @@ async function fredObs(series: string, apiKey: string): Promise<number | null> {
|
||||
|
||||
type FredResult = { value: number | null; delta: number | null; deltaPct: number | null };
|
||||
|
||||
/** Fetche les 10 dernières obs pour calculer valeur + delta vs session précédente */
|
||||
async function fredObsDelta(series: string, apiKey: string): Promise<FredResult> {
|
||||
// ── Yahoo Finance (VIX, S&P 500 — temps réel via regularMarketPrice) ──────────
|
||||
// Query v8 chart API : meta.regularMarketPrice + meta.chartPreviousClose
|
||||
// Cache 5 min → données fraîches pendant la séance boursière
|
||||
|
||||
async function yahooQuote(symbol: string): Promise<FredResult> {
|
||||
const empty: FredResult = { value: null, delta: null, deltaPct: null };
|
||||
try {
|
||||
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=10`;
|
||||
const res = await fetch(url, { next: { revalidate: 86400 } });
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=2d`;
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate: 300 }, // cache 5 min
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const obs: number[] = ((await res.json())?.observations ?? [])
|
||||
.filter((o: { value: string }) => o.value !== ".")
|
||||
.map((o: { value: string }) => parseFloat(o.value));
|
||||
if (!obs.length) return empty;
|
||||
const value = obs[0];
|
||||
const prev = obs[1] ?? null;
|
||||
const delta = prev !== null ? parseFloat((value - prev).toFixed(2)) : null;
|
||||
const deltaPct = prev !== null ? parseFloat(((value - prev) / prev * 100).toFixed(2)) : null;
|
||||
return { value, delta, deltaPct };
|
||||
const data = await res.json();
|
||||
const meta = data?.chart?.result?.[0]?.meta as {
|
||||
regularMarketPrice?: number;
|
||||
chartPreviousClose?: number;
|
||||
} | undefined;
|
||||
const current = meta?.regularMarketPrice ?? null;
|
||||
const prevClose = meta?.chartPreviousClose ?? null;
|
||||
if (current === null) return empty;
|
||||
const delta = prevClose !== null ? parseFloat((current - prevClose).toFixed(2)) : null;
|
||||
const deltaPct = prevClose !== null && prevClose > 0
|
||||
? parseFloat(((current - prevClose) / prevClose * 100).toFixed(2))
|
||||
: null;
|
||||
return { value: parseFloat(current.toFixed(2)), delta, deltaPct };
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
@@ -65,16 +76,18 @@ async function stooqMetal(symbol: string): Promise<FredResult> {
|
||||
}
|
||||
|
||||
// ── Binance (Bitcoin — gratuit, sans clé, temps réel) ────────────────────────
|
||||
|
||||
// ticker/price = prix spot instantané (plus précis que ticker/24hr lastPrice)
|
||||
// ticker/24hr = stats 24h pour le % de variation
|
||||
async function binanceBTC(): Promise<{ value: number | null; change24h: number | null }> {
|
||||
try {
|
||||
const res = await fetch("https://api.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT", { cache: "no-store" });
|
||||
if (!res.ok) return { value: null, change24h: null };
|
||||
const d = await res.json();
|
||||
const price = parseFloat(d.lastPrice);
|
||||
const pctChg = parseFloat(d.priceChangePercent);
|
||||
const [priceRes, statsRes] = await Promise.all([
|
||||
fetch("https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT", { cache: "no-store" }),
|
||||
fetch("https://api.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT", { cache: "no-store" }),
|
||||
]);
|
||||
const price = priceRes.ok ? parseFloat((await priceRes.json()).price) : NaN;
|
||||
const pctChg = statsRes.ok ? parseFloat((await statsRes.json()).priceChangePercent) : NaN;
|
||||
return {
|
||||
value: isNaN(price) ? null : Math.round(price),
|
||||
value: isNaN(price) ? null : parseFloat(price.toFixed(2)),
|
||||
change24h: isNaN(pctChg) ? null : parseFloat(pctChg.toFixed(2)),
|
||||
};
|
||||
} catch { return { value: null, change24h: null }; }
|
||||
@@ -103,11 +116,11 @@ export async function GET() {
|
||||
const fredKey = process.env.FRED_API_KEY;
|
||||
if (!fredKey) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
|
||||
|
||||
// 1. Marchés indices — FRED (données fin de journée, cache 24h)
|
||||
// VIXCLS = VIX clôture CBOE | SP500 = S&P 500
|
||||
// 1. Indices — Yahoo Finance (temps réel, cache 5 min)
|
||||
// ^VIX = CBOE Volatility Index | ^GSPC = S&P 500
|
||||
const [vixQ, sp500Q] = await Promise.all([
|
||||
fredObsDelta("VIXCLS", fredKey),
|
||||
fredObsDelta("SP500", fredKey),
|
||||
yahooQuote("^VIX"),
|
||||
yahooQuote("^GSPC"),
|
||||
]);
|
||||
|
||||
// Pétrole — Stooq futures (quasi temps réel, cache 5 min, sans clé API)
|
||||
@@ -128,14 +141,18 @@ export async function GET() {
|
||||
const btcBin = await binanceBTC();
|
||||
const btcCg = btcBin.value === null ? await coingeckoBTC() : { value: null, change24h: null };
|
||||
|
||||
// 4. FRED — spreads crédit + taux directeurs (cache 24h)
|
||||
const [hyRaw, igRaw, us10y, us2y] = await Promise.all([
|
||||
// 4. FRED — spreads crédit (cache 1h)
|
||||
// Yields 10Y — TE bonds (cache 1h, données du jour)
|
||||
// US 2Y — FRED DGS2 (garde pour la courbe 2-10)
|
||||
const [hyRaw, igRaw, us2y, bondYields] = await Promise.all([
|
||||
fredObs("BAMLH0A0HYM2", fredKey),
|
||||
fredObs("BAMLC0A0CM", fredKey),
|
||||
fredObs("DGS10", fredKey),
|
||||
fredObs("DGS2", fredKey),
|
||||
fetchTEBondYields(),
|
||||
]);
|
||||
|
||||
const us10y = bondYields["USD"]?.yield10y ?? null;
|
||||
|
||||
return NextResponse.json({
|
||||
// Sentiment / Risk-On
|
||||
vix: vixQ.value,
|
||||
@@ -145,10 +162,31 @@ export async function GET() {
|
||||
sp500ChangePct: sp500Q.deltaPct,
|
||||
btc: btcBin.value ?? btcCg.value,
|
||||
btcChange24h: btcBin.change24h ?? btcCg.change24h,
|
||||
// Crédit (FRED, % × 100 = bps)
|
||||
// Crédit (FRED, bps)
|
||||
hySpread: hyRaw != null ? Math.round(hyRaw * 100) : null,
|
||||
igSpread: igRaw != null ? Math.round(igRaw * 100) : null,
|
||||
// Taux (DXY injecté par page.tsx depuis /api/fx)
|
||||
// Yields 10Y par devise — TE bonds (temps réel, cache 1h)
|
||||
bondYields10y: {
|
||||
USD: bondYields["USD"]?.yield10y ?? null,
|
||||
EUR: bondYields["EUR"]?.yield10y ?? null,
|
||||
GBP: bondYields["GBP"]?.yield10y ?? null,
|
||||
JPY: bondYields["JPY"]?.yield10y ?? null,
|
||||
AUD: bondYields["AUD"]?.yield10y ?? null,
|
||||
CAD: bondYields["CAD"]?.yield10y ?? null,
|
||||
CHF: bondYields["CHF"]?.yield10y ?? null,
|
||||
NZD: bondYields["NZD"]?.yield10y ?? null,
|
||||
},
|
||||
bondYieldsDay: {
|
||||
USD: bondYields["USD"]?.dayDelta ?? null,
|
||||
EUR: bondYields["EUR"]?.dayDelta ?? null,
|
||||
GBP: bondYields["GBP"]?.dayDelta ?? null,
|
||||
JPY: bondYields["JPY"]?.dayDelta ?? null,
|
||||
AUD: bondYields["AUD"]?.dayDelta ?? null,
|
||||
CAD: bondYields["CAD"]?.dayDelta ?? null,
|
||||
CHF: bondYields["CHF"]?.dayDelta ?? null,
|
||||
NZD: bondYields["NZD"]?.dayDelta ?? null,
|
||||
},
|
||||
// Courbe USD (2Y de FRED, 10Y de TE)
|
||||
us10y,
|
||||
us2y,
|
||||
curveSlope: us10y !== null && us2y !== null ? Math.round((us10y - us2y) * 100) : null,
|
||||
|
||||
@@ -2,15 +2,252 @@ import { NextResponse } from "next/server";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
export async function GET() {
|
||||
const AUTHOR_URL = "https://investinglive.com/author/giuseppe-dellamotta/";
|
||||
const CATEGORY_URL = "https://investinglive.com/CentralBanks";
|
||||
const RSS_URLS = [
|
||||
"https://investinglive.com/CentralBanks/feed/",
|
||||
"https://investinglive.com/feed/",
|
||||
];
|
||||
const KEYWORDS = ["rate hikes by year-end", "interest rate expectations", "rate cuts by year-end"];
|
||||
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
|
||||
const CB_NAMES: Record<string, string> = {
|
||||
fed: "Fed (USD)",
|
||||
ecb: "ECB (EUR)",
|
||||
boe: "BoE (GBP)",
|
||||
boj: "BoJ (JPY)",
|
||||
snb: "SNB (CHF)",
|
||||
boc: "BoC (CAD)",
|
||||
rba: "RBA (AUD)",
|
||||
rbnz: "RBNZ (NZD)",
|
||||
};
|
||||
|
||||
function normalizeCb(raw: string): string {
|
||||
const key = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
return CB_NAMES[key] ?? raw.trim().replace(/[:*]/g, "");
|
||||
}
|
||||
|
||||
function dateFromUrl(url: string): string {
|
||||
// YYYYMMDD dans le slug
|
||||
const m1 = url.match(/(\d{8})/);
|
||||
if (m1) return m1[1];
|
||||
// Format WordPress /YYYY/MM/DD/
|
||||
const m2 = url.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//);
|
||||
if (m2) return `${m2[1]}${m2[2]}${m2[3]}`;
|
||||
return "00000000";
|
||||
}
|
||||
|
||||
function toText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, "\n")
|
||||
.replace(/\n+/g, "\n")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractArticleData(rawHtml: string, url: string) {
|
||||
const text = toText(rawHtml);
|
||||
if (!KEYWORDS.some((kw) => text.toLowerCase().includes(kw))) return null;
|
||||
|
||||
const result = {
|
||||
url,
|
||||
title: "Rate expectations",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
scraped_at: new Date().toISOString(),
|
||||
rate_cuts: [] as Array<{ cb: string; bps: number; prob_pct: number; prob_desc: string; direction: string }>,
|
||||
rate_hikes: [] as Array<{ cb: string; bps: number; prob_pct: number; prob_desc: string; direction: string }>,
|
||||
};
|
||||
|
||||
const titleMatch = rawHtml.match(/<h1[^>]*>(.*?)<\/h1>/i);
|
||||
if (titleMatch) result.title = titleMatch[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const dateMatch = url.match(/(\d{8})/);
|
||||
if (dateMatch) {
|
||||
const d = dateMatch[1];
|
||||
result.date = `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
|
||||
} else {
|
||||
const d2 = url.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//);
|
||||
if (d2) result.date = `${d2[1]}-${d2[2]}-${d2[3]}`;
|
||||
}
|
||||
|
||||
let currentSection: "cuts" | "hikes" | null = null;
|
||||
const lines = rawHtml
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "\n")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "\n")
|
||||
.split(/\n+/)
|
||||
.map((l) => l.replace(/ /g, " ").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes("rate cuts by year-end")) { currentSection = "cuts"; continue; }
|
||||
if (lower.includes("rate hikes by year-end")) { currentSection = "hikes"; continue; }
|
||||
|
||||
if (currentSection && line.length < 250) {
|
||||
const match = line.match(
|
||||
/([A-Za-z0-9\/\s\-]+?)\s*:\s*(\d+)\s*bps\s*\((\d+)%\s+probability\s+of\s+([^\)]+)\)/i
|
||||
);
|
||||
if (match) {
|
||||
const entry = {
|
||||
cb: normalizeCb(match[1]),
|
||||
bps: Number(match[2]),
|
||||
prob_pct: Number(match[3]),
|
||||
prob_desc: match[4].trim(),
|
||||
direction: currentSection === "cuts" ? "cut" : "hike",
|
||||
};
|
||||
if (currentSection === "cuts") result.rate_cuts.push(entry);
|
||||
else result.rate_hikes.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.rate_cuts.length === 0 && result.rate_hikes.length === 0) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchHtml(url: string): Promise<string | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch { return null; }
|
||||
finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
// ── Étape 1 : RSS feeds (plus stables, moins bloqués que les pages HTML) ─────
|
||||
async function candidatesFromRss(): Promise<string[]> {
|
||||
const candidates: string[] = [];
|
||||
for (const rssUrl of RSS_URLS) {
|
||||
const xml = await fetchHtml(rssUrl);
|
||||
if (!xml) continue;
|
||||
|
||||
// Extraire les <item> individuellement pour éviter de confondre avec <channel><link>
|
||||
const items = Array.from(xml.matchAll(/<item>([\s\S]*?)<\/item>/gi));
|
||||
for (const [, itemXml] of items) {
|
||||
const titleMatch = itemXml.match(/<title[^>]*>(?:<!\[CDATA\[)?([^\]<]+)/i);
|
||||
const creatorMatch = itemXml.match(/<dc:creator[^>]*>(?:<!\[CDATA\[)?([^\]<]+)/i);
|
||||
const linkMatch = itemXml.match(/<link>([^<]+)<\/link>/i)
|
||||
?? itemXml.match(/<guid[^>]*>([^<]+)<\/guid>/i);
|
||||
|
||||
if (!linkMatch) continue;
|
||||
const url = linkMatch[1].trim();
|
||||
const title = (titleMatch?.[1] ?? "").toLowerCase();
|
||||
const creator = (creatorMatch?.[1] ?? "").toLowerCase();
|
||||
|
||||
if (
|
||||
KEYWORDS.some((kw) => title.includes(kw)) ||
|
||||
creator.includes("dellamotta")
|
||||
) {
|
||||
candidates.push(url);
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) break;
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// ── Étape 2 : pages HTML auteur/catégorie avec regex flexible ─────────────────
|
||||
async function candidatesFromHtml(): Promise<string[]> {
|
||||
const seen = new Set<string>();
|
||||
const candidates: string[] = [];
|
||||
|
||||
for (const pageUrl of [AUTHOR_URL, CATEGORY_URL]) {
|
||||
const html = await fetchHtml(pageUrl);
|
||||
if (!html) continue;
|
||||
|
||||
// Pattern A : URL investinglive.com avec date YYYYMMDD dans le slug
|
||||
for (const m of Array.from(html.matchAll(/href=["']([^"']*investinglive\.com\/[^"']*\d{8}[^"']*)["']/gi))) {
|
||||
const url = m[1];
|
||||
if (!seen.has(url)) { seen.add(url); candidates.push(url); }
|
||||
}
|
||||
// Pattern B : URL relative avec date YYYYMMDD
|
||||
for (const m of Array.from(html.matchAll(/href=["'](\/[^"']*\d{8}[^"']*)["']/gi))) {
|
||||
const url = `https://investinglive.com${m[1]}`;
|
||||
if (!seen.has(url)) { seen.add(url); candidates.push(url); }
|
||||
}
|
||||
// Pattern C : URL WordPress /YYYY/MM/DD/
|
||||
for (const m of Array.from(html.matchAll(/href=["']([^"']*\/\d{4}\/\d{2}\/\d{2}\/[^"']*)["']/gi))) {
|
||||
const href = m[1];
|
||||
const url = href.startsWith("http") ? href : `https://investinglive.com${href.startsWith("/") ? href : `/${href}`}`;
|
||||
if (!seen.has(url)) { seen.add(url); candidates.push(url); }
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// ── Étape 0 : scan URL-date direct (le plus fiable, publié chaque semaine) ────
|
||||
// Pattern : /news/how-have-interest-rate-expectations-changed-after-this-weeks-event-YYYYMMDD/
|
||||
// On teste les 14 derniers jours (une publication par semaine environ)
|
||||
async function candidatesFromUrlScan(): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const now = Date.now();
|
||||
const checks = Array.from({ length: 14 }, (_, i) => {
|
||||
const d = new Date(now - i * 86400000);
|
||||
const s = d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
return `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${s}/`;
|
||||
});
|
||||
// HEAD requests en parallèle (rapide, peu de bande passante)
|
||||
const settled = await Promise.allSettled(
|
||||
checks.map(url =>
|
||||
fetch(url, { method: "HEAD", headers: { "User-Agent": USER_AGENT }, cache: "no-store" })
|
||||
.then(r => r.ok ? url : null)
|
||||
.catch(() => null)
|
||||
)
|
||||
);
|
||||
for (const r of settled) {
|
||||
if (r.status === "fulfilled" && r.value) results.push(r.value);
|
||||
}
|
||||
return results; // déjà triés du plus récent au plus ancien
|
||||
}
|
||||
|
||||
async function loadRemoteExpectation() {
|
||||
// 0. Scan direct URL-date (le plus fiable et précis)
|
||||
const urlScan = await candidatesFromUrlScan();
|
||||
// 1. RSS (stables, moins bloqués)
|
||||
const rss = await candidatesFromRss();
|
||||
// 2. HTML auteur/catégorie
|
||||
const html = await candidatesFromHtml();
|
||||
|
||||
// Fusion : URL-scan en priorité (le plus récent), puis RSS, puis HTML trié par date
|
||||
const htmlSorted = html.slice().sort((a, b) => dateFromUrl(b).localeCompare(dateFromUrl(a)));
|
||||
const candidates = [...urlScan, ...rss, ...htmlSorted].slice(0, 15);
|
||||
|
||||
for (const url of candidates) {
|
||||
const body = await fetchHtml(url);
|
||||
if (!body) continue;
|
||||
const parsed = extractArticleData(body, url);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadFallback() {
|
||||
try {
|
||||
const filePath = join(process.cwd(), "data", "rate_expectations.json");
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
// Return the most recent snapshot (first item)
|
||||
const latest = Array.isArray(data) ? data[0] : data;
|
||||
return NextResponse.json(latest);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "rate_expectations.json not found. Run the scraper first." }, { status: 404 });
|
||||
}
|
||||
return Array.isArray(data) ? data[0] : data;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const remote = await loadRemoteExpectation();
|
||||
if (remote) return NextResponse.json(remote);
|
||||
} catch (err) {
|
||||
console.warn("[expectations] scrape failed, fallback", err);
|
||||
}
|
||||
const fallback = loadFallback();
|
||||
if (fallback) return NextResponse.json(fallback);
|
||||
return NextResponse.json({ error: "Unable to load expectation data." }, { status: 404 });
|
||||
}
|
||||
|
||||
+597
-73
@@ -1,7 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { FRED_SERIES } from "@/lib/constants";
|
||||
import type { Currency } from "@/lib/types";
|
||||
import cpiOverridesRaw from "@/data/cpi_overrides.json";
|
||||
import cpiOverridesRaw from "@/data/cpi_overrides.json";
|
||||
import rateDecisionsRaw from "@/data/rate_decisions.json";
|
||||
import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
|
||||
import type { FFEvent } from "@/lib/forexfactory";
|
||||
|
||||
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
|
||||
const REVALIDATE = 86400; // cache 24h
|
||||
@@ -147,32 +150,176 @@ async function dbnomicsObs(provider: string, dataset: string, seriesCode: string
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── ForexFactory calendar (PMI primaire) ──────────────────────────────────────
|
||||
// ── ForexFactory — fetchFFThisWeek importé depuis @/lib/forexfactory ───────────
|
||||
|
||||
async function fetchFFPMI(currency: string): Promise<{
|
||||
mfg: { value: number; prev: number | null } | null;
|
||||
svc: { value: number; prev: number | null } | null;
|
||||
mfg: { value: number; prev: number | null } | null;
|
||||
svc: { value: number; prev: number | null } | null;
|
||||
composite: { value: number; prev: number | null } | null;
|
||||
}> {
|
||||
const empty = { mfg: null, svc: null };
|
||||
const empty = { mfg: null, svc: null, composite: null };
|
||||
try {
|
||||
const res = await fetch("https://nfs.faireconomy.media/ff_calendar_thisweek.json", {
|
||||
next: { revalidate: 3600 },
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const events = await res.json() as Array<{
|
||||
title: string; country: string; actual: string; previous: string;
|
||||
}>;
|
||||
const forCcy = events.filter((e) => e.country === currency && e.actual);
|
||||
const isMfg = (t: string) => /manufacturing\s+pmi|mfg\s+pmi/i.test(t);
|
||||
const isSvc = (t: string) => /services?\s+pmi|ism\s+non.manufactur|composite\s+pmi/i.test(t);
|
||||
const parse = (e: typeof forCcy[0] | undefined) => {
|
||||
const events = await fetchFFThisWeek();
|
||||
const forCcy = events.filter((e) => e.country === currency && e.actual);
|
||||
const isMfg = (t: string) => /manufacturing\s+pmi|mfg\s+pmi/i.test(t);
|
||||
const isComposite = (t: string) => /composite\s+pmi/i.test(t);
|
||||
const isSvc = (t: string) => /services?\s+pmi|ism\s+non.manufactur/i.test(t);
|
||||
const parse = (e: FFEvent | undefined) => {
|
||||
if (!e?.actual) return null;
|
||||
const val = parseFloat(e.actual);
|
||||
const prev = parseFloat(e.previous ?? "");
|
||||
return isNaN(val) ? null : { value: val, prev: isNaN(prev) ? null : prev };
|
||||
};
|
||||
return { mfg: parse(forCcy.find((e) => isMfg(e.title))), svc: parse(forCcy.find((e) => isSvc(e.title))) };
|
||||
return {
|
||||
mfg: parse(forCcy.find((e) => isMfg(e.title))),
|
||||
svc: parse(forCcy.find((e) => isSvc(e.title))),
|
||||
composite: parse(forCcy.find((e) => isComposite(e.title))),
|
||||
};
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── ForexFactory — consensus + surprise post-publication ─────────────────────
|
||||
//
|
||||
// Règle :
|
||||
// • Événement UPCOMING (forecast non vide, actual vide)
|
||||
// → afficher le consensus (forecast) dans la colonne "Cons."
|
||||
// • Événement RÉCENT (forecast + actual, release ≤ 5 jours)
|
||||
// → afficher la surprise = actual − forecast dans la colonne "Surpr."
|
||||
// • Événement ANCIEN (> 5 jours) ou sans forecast
|
||||
// → null (rien à afficher)
|
||||
|
||||
interface FFForecasts {
|
||||
cpi: number | null;
|
||||
cpiSurprise: number | null;
|
||||
unemployment: number | null;
|
||||
unemploymentSurprise: number | null;
|
||||
pmiMfg: number | null;
|
||||
pmiMfgSurprise: number | null;
|
||||
pmiSvc: number | null;
|
||||
pmiSvcSurprise: number | null;
|
||||
pmiComposite: number | null; // PMI Composite — séparé des Services
|
||||
pmiCompositeSurprise: number | null;
|
||||
retailSales: number | null;
|
||||
retailSalesSurprise: number | null;
|
||||
gdp: number | null;
|
||||
gdpSurprise: number | null;
|
||||
employment: number | null;
|
||||
employmentSurprise: number | null;
|
||||
}
|
||||
|
||||
async function fetchFFForecasts(currency: string): Promise<FFForecasts> {
|
||||
const empty: FFForecasts = {
|
||||
cpi: null, cpiSurprise: null,
|
||||
unemployment: null, unemploymentSurprise: null,
|
||||
pmiMfg: null, pmiMfgSurprise: null,
|
||||
pmiSvc: null, pmiSvcSurprise: null,
|
||||
pmiComposite: null, pmiCompositeSurprise: null,
|
||||
retailSales: null, retailSalesSurprise: null,
|
||||
gdp: null, gdpSurprise: null,
|
||||
employment: null, employmentSurprise: null,
|
||||
};
|
||||
try {
|
||||
const events = await fetchFFEvents(); // cette semaine + semaine prochaine
|
||||
// EUR : ForexFactory tague country="EUR" pour TOUS les pays de la zone (Italie, Espagne…).
|
||||
// On garde uniquement les événements dont le titre commence par "Euro Zone" ou "Euro Area"
|
||||
// pour éviter de matcher Italian Unemployment, Spanish CPI, etc. à la place des données agrégées.
|
||||
const forCcy = currency === "EUR"
|
||||
? events.filter((e) => e.country === "EUR" && /^euro(?:zone|[\s-](?:zone|area))/i.test(e.title))
|
||||
: events.filter((e) => e.country === currency);
|
||||
|
||||
const parseNum = (raw: string, min: number, max: number): number | null => {
|
||||
if (!raw) return null;
|
||||
const v = parseFloat(raw.replace(/[^0-9.-]/g, ""));
|
||||
return isNaN(v) || v < min || v > max ? null : v;
|
||||
};
|
||||
|
||||
/** Premier event dont le titre correspond au pattern ET qui a un forecast. */
|
||||
const find = (re: RegExp) => forCcy.find((e) => re.test(e.title) && e.forecast);
|
||||
|
||||
/** Calcul du nombre de jours depuis la date FF de l'événement. */
|
||||
const daysSince = (e: FFEvent): number => {
|
||||
const d = new Date(e.date);
|
||||
return Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retourne { forecast, surprise } pour un event FF :
|
||||
* - upcoming : forecast = consensus, surprise = null
|
||||
* - ≤5j après : forecast = null, surprise = actual − forecast
|
||||
* - >5j après : forecast = null, surprise = null
|
||||
*/
|
||||
const classify = (e: FFEvent | undefined, min: number, max: number): { forecast: number | null; surprise: number | null } => {
|
||||
if (!e) return { forecast: null, surprise: null };
|
||||
const fc = parseNum(e.forecast, min, max);
|
||||
if (fc === null) return { forecast: null, surprise: null };
|
||||
if (!e.actual) {
|
||||
// Upcoming : pas encore publié → afficher le consensus
|
||||
return { forecast: fc, surprise: null };
|
||||
}
|
||||
const ac = parseNum(e.actual, min, max);
|
||||
if (ac === null) return { forecast: null, surprise: null };
|
||||
if (daysSince(e) <= 5) {
|
||||
// Publié récemment : afficher la surprise (actual − forecast)
|
||||
return { forecast: null, surprise: parseFloat((ac - fc).toFixed(2)) };
|
||||
}
|
||||
return { forecast: null, surprise: null };
|
||||
};
|
||||
|
||||
// CPI y/y — pour JPY on exclut Tokyo/Flash (≠ CPI national)
|
||||
const cpiEvent = currency === "JPY"
|
||||
? find(/(?:national|all\s+items).*\bcpi\b.*y\s*\/\s*y|\bcpi\b.*(?:national|all\s+items).*y\s*\/\s*y/i)
|
||||
: find(/\bcpi\b.*y\s*\/\s*y|y\s*\/\s*y.*\bcpi\b/i);
|
||||
const cpiC = classify(cpiEvent, -5, 20);
|
||||
|
||||
// Chômage
|
||||
const uneEvent = currency === "EUR"
|
||||
? find(/unemployment\s+rate/i)
|
||||
: find(/unemployment\s+rate|jobless\s+rate/i);
|
||||
const uneC = classify(uneEvent, 0.5, 25);
|
||||
|
||||
// PMI Mfg
|
||||
const pmiMfgEvent = find(/manufacturing\s+pmi|flash\s+manufacturing\s+pmi|mfg\s+pmi/i);
|
||||
const pmiMfgC = classify(pmiMfgEvent, 20, 80);
|
||||
|
||||
// PMI Services (strict : exclut composite)
|
||||
const pmiSvcEvent = find(/services?\s+pmi|ism\s+non.manufactur/i);
|
||||
const pmiSvcC = classify(pmiSvcEvent, 20, 80);
|
||||
|
||||
// PMI Composite (séparé du Services)
|
||||
const pmiCompositeEvent = find(/composite\s+pmi/i);
|
||||
const pmiCompositeC = classify(pmiCompositeEvent, 20, 80);
|
||||
|
||||
// Retail Sales m/m
|
||||
const rsEvent = find(/retail\s+sales/i);
|
||||
const rsC = classify(rsEvent, -10, 10);
|
||||
|
||||
// PIB (GDP q/q%) : généralement entre -5% et +5%
|
||||
const gdpEvent = find(/\bgdp\b.*q\s*\/\s*q|q\s*\/\s*q.*\bgdp\b|gdp\s+growth/i);
|
||||
const gdpC = classify(gdpEvent, -5, 5);
|
||||
|
||||
// Emploi Δk — NFP, Employment Change, ADP : en milliers (-500k à +1000k)
|
||||
// ForexFactory affiche souvent les valeurs avec "K" ex: "185K"
|
||||
const empEvent = find(/non.?farm\s+payrolls|nfp\b|employment\s+change|adp\s+non.?farm/i);
|
||||
const empC = classify(empEvent, -500, 1000);
|
||||
|
||||
return {
|
||||
cpi: cpiC.forecast,
|
||||
cpiSurprise: cpiC.surprise,
|
||||
unemployment: uneC.forecast,
|
||||
unemploymentSurprise: uneC.surprise,
|
||||
pmiMfg: pmiMfgC.forecast,
|
||||
pmiMfgSurprise: pmiMfgC.surprise,
|
||||
pmiSvc: pmiSvcC.forecast,
|
||||
pmiSvcSurprise: pmiSvcC.surprise,
|
||||
pmiComposite: pmiCompositeC.forecast,
|
||||
pmiCompositeSurprise: pmiCompositeC.surprise,
|
||||
retailSales: rsC.forecast,
|
||||
retailSalesSurprise: rsC.surprise,
|
||||
gdp: gdpC.forecast,
|
||||
gdpSurprise: gdpC.surprise,
|
||||
employment: empC.forecast,
|
||||
employmentSurprise: empC.surprise,
|
||||
};
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
@@ -185,7 +332,7 @@ const TE_COUNTRY: Record<string, string> = {
|
||||
|
||||
async function scrapePMI(
|
||||
currency: string,
|
||||
indicator: "manufacturing-pmi" | "services-pmi",
|
||||
indicator: "manufacturing-pmi" | "services-pmi" | "composite-pmi",
|
||||
): Promise<{ value: number | null; prev: number | null }> {
|
||||
const country = TE_COUNTRY[currency];
|
||||
if (!country) return { value: null, prev: null };
|
||||
@@ -206,8 +353,8 @@ async function scrapePMI(
|
||||
if (!res.ok) return { value: null, prev: null };
|
||||
const html = await res.text();
|
||||
const metaMatch =
|
||||
html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i) ??
|
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);
|
||||
html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) ??
|
||||
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
if (!metaMatch) return { value: null, prev: null };
|
||||
const desc = metaMatch[1];
|
||||
const numRe = /(?:increased|decreased|declined|rose|fell|eased)\s+to\s*([\d.]+)\s+points?.+?from\s+([\d.]+)\s+points?/i;
|
||||
@@ -219,6 +366,135 @@ async function scrapePMI(
|
||||
} catch { return { value: null, prev: null }; }
|
||||
}
|
||||
|
||||
// ── Trading Economics — taux directeur officiel ───────────────────────────────
|
||||
// Scrape la meta description de la page interest-rate, ex :
|
||||
// "The benchmark interest rate in Japan was last recorded at 0.75 percent."
|
||||
|
||||
async function scrapeTeRate(country: string): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`https://tradingeconomics.com/${country}/interest-rate`, {
|
||||
next: { revalidate: 3600 },
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const html = await res.text();
|
||||
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
||||
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
if (!meta) return null;
|
||||
const m = meta[1].match(/at\s+([\d.]+)\s*percent/i);
|
||||
return m ? parseFloat(m[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Trading Economics — GDP QoQ% (pays dont FRED est stale) ─────────────────
|
||||
// Meta description : "GDP Growth Rate in X increased to 0.3 percent in Q4 2025 from 0.2 percent..."
|
||||
// Retourne { value, prev } directement depuis la description TE.
|
||||
|
||||
async function scrapeTeGdp(country: string): Promise<{ value: number | null; prev: number | null }> {
|
||||
const empty = { value: null, prev: null };
|
||||
try {
|
||||
// TE utilise "gdp-growth" (pas "gdp-growth-rate") pour le taux QoQ
|
||||
const res = await fetch(`https://tradingeconomics.com/${country}/gdp-growth`, {
|
||||
next: { revalidate: 3600 },
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const html = await res.text();
|
||||
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
||||
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
if (!meta) return empty;
|
||||
const desc = meta[1];
|
||||
// Format TE GDP : "expanded 0.50 percent in Q1 2026 over the previous quarter"
|
||||
// "contracted 0.1 percent in Q3 2025 from 0.9 percent"
|
||||
const m1 = desc.match(
|
||||
/(?:expanded|contracted|grew|grew by|declined|increased|decreased|rose|fell)\s+(?:by\s+)?([-\d.]+)\s*percent.{0,120}?from\s+([-\d.]+)\s*percent/i
|
||||
);
|
||||
if (m1) return { value: parseFloat(m1[1]), prev: parseFloat(m1[2]) };
|
||||
const m2 = desc.match(
|
||||
/(?:expanded|contracted|grew|declined|increased|decreased|rose|fell)\s+(?:by\s+)?([-\d.]+)\s*percent/i
|
||||
);
|
||||
if (m2) return { value: parseFloat(m2[1]), prev: null };
|
||||
return empty;
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── Trading Economics — Balance commerciale (niveau, normalisé en Milliards devise locale)
|
||||
// Format meta TE :
|
||||
// "X recorded a trade deficit/surplus of Y.YY EUR/USD/etc. Billion/Million in Month of Year"
|
||||
// Normalisation : Million → diviser par 1000 → Milliards
|
||||
// Signe : déficit = négatif, surplus = positif
|
||||
async function scrapeTeTradeBalance(country: string): Promise<{ value: number | null; prev: number | null }> {
|
||||
const empty = { value: null, prev: null };
|
||||
try {
|
||||
const res = await fetch(`https://tradingeconomics.com/${country}/balance-of-trade`, {
|
||||
next: { revalidate: 3600 },
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return empty;
|
||||
const html = await res.text();
|
||||
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
||||
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
if (!meta) return empty;
|
||||
const desc = meta[1];
|
||||
// "recorded a trade deficit of 60.31 USD Billion" / "recorded a trade surplus of 7820.60 EUR Million"
|
||||
const m = desc.match(
|
||||
/recorded a trade (deficit|surplus) of ([\d,.]+)\s+\w+\s+(Billion|Million)/i
|
||||
);
|
||||
if (!m) return empty;
|
||||
const rawVal = parseFloat(m[2].replace(/,/g, ""));
|
||||
const inBillion = m[3].toLowerCase() === "billion";
|
||||
const sign = m[1].toLowerCase() === "surplus" ? 1 : -1;
|
||||
const value = parseFloat((sign * (inBillion ? rawVal : rawVal / 1000)).toFixed(2));
|
||||
return { value, prev: null }; // TE meta ne donne pas le mois précédent
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
function buildGdpFromTe(te: { value: number; prev: number | null }, date: string): IndicatorResult {
|
||||
const surprise = te.prev !== null ? parseFloat((te.value - te.prev).toFixed(4)) : null;
|
||||
return {
|
||||
value: te.value,
|
||||
prev: te.prev,
|
||||
surprise,
|
||||
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
|
||||
lastUpdated: date,
|
||||
};
|
||||
}
|
||||
|
||||
// ── rate_decisions.json — taux officiels des CB (actuel + précédent) ──────────
|
||||
// Mettre à jour data/rate_decisions.json après chaque décision CB.
|
||||
|
||||
type RateDecision = { current: number; prev: number };
|
||||
|
||||
function getRateDecision(ccy: string): RateDecision | null {
|
||||
type RdRaw = [{ decisions: Record<string, RateDecision> }];
|
||||
const entry = (rateDecisionsRaw as unknown as RdRaw)[0];
|
||||
return entry?.decisions?.[ccy] ?? null;
|
||||
}
|
||||
|
||||
/** Construit un IndicatorResult à partir de la valeur TE + prev de l'override */
|
||||
function buildRateIndicator(current: number, prev: number, today: string): IndicatorResult {
|
||||
const surprise = parseFloat((current - prev).toFixed(4));
|
||||
return {
|
||||
value: current,
|
||||
prev,
|
||||
surprise,
|
||||
trend: surprise > 0 ? "up" : surprise < 0 ? "down" : "flat",
|
||||
lastUpdated: today,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
type Obs = { date: string; value: number };
|
||||
@@ -268,6 +544,54 @@ function toIndicatorPct(obs: Obs[]) {
|
||||
return toIndicator(pctObs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcul glissement annuel (Year-over-Year) depuis un indice niveau.
|
||||
* periods = 12 pour mensuel (M/M-12), 4 pour trimestriel (Q/Q-4).
|
||||
* obs[0] = plus récent, obs[periods] = même période an passé.
|
||||
* Si données insuffisantes, fallback sur toIndicatorPct (MoM/QoQ).
|
||||
*/
|
||||
function toIndicatorYoY(obs: Obs[], periods = 12): IndicatorResult {
|
||||
if (obs.length < periods + 1) return toIndicatorPct(obs); // fallback MoM si pas assez de données
|
||||
const curr = obs[0];
|
||||
const yearAgo = obs[periods];
|
||||
const yoy = parseFloat(((curr.value / yearAgo.value - 1) * 100).toFixed(2));
|
||||
|
||||
// Calcul du YoY précédent (mois/trimestre d'avant) pour surprise & trend
|
||||
const prevYoY = obs.length >= periods + 2
|
||||
? parseFloat(((obs[1].value / obs[periods + 1].value - 1) * 100).toFixed(2))
|
||||
: null;
|
||||
|
||||
const surprise = prevYoY !== null ? parseFloat((yoy - prevYoY).toFixed(4)) : null;
|
||||
return {
|
||||
value: yoy,
|
||||
prev: prevYoY,
|
||||
surprise,
|
||||
trend: surprise !== null ? (yoy > prevYoY! ? "up" : yoy < prevYoY! ? "down" : "flat") : null,
|
||||
lastUpdated: curr.date,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcul du changement absolu en milliers de personnes pour Employment Change.
|
||||
* personsToK = true si la série FRED est en personnes réelles (pas en milliers).
|
||||
* USD PAYEMS est déjà en milliers → personsToK=false.
|
||||
* AUD/CAD/JPY LFEMTTTT* sont en personnes → personsToK=true.
|
||||
*/
|
||||
function toIndicatorDeltaK(obs: Obs[], personsToK: boolean): IndicatorResult {
|
||||
if (obs.length < 2) return null;
|
||||
const raw = obs[0].value - obs[1].value;
|
||||
const valK = personsToK
|
||||
? parseFloat((raw / 1000).toFixed(1))
|
||||
: parseFloat(raw.toFixed(1));
|
||||
return {
|
||||
value: valK,
|
||||
prev: null,
|
||||
surprise: valK, // surprise = la valeur elle-même (signe = direction)
|
||||
trend: valK > 0 ? "up" : valK < 0 ? "down" : "flat",
|
||||
lastUpdated: obs[0].date,
|
||||
};
|
||||
}
|
||||
|
||||
function toPmiIndicator(raw: { value: number | null; prev: number | null }): IndicatorResult {
|
||||
if (raw.value === null) return null;
|
||||
const surprise = raw.prev !== null ? parseFloat((raw.value - raw.prev).toFixed(2)) : null;
|
||||
@@ -296,13 +620,22 @@ export async function GET(req: NextRequest) {
|
||||
const key = process.env.FRED_API_KEY;
|
||||
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
|
||||
|
||||
// policyRate / unemployment / retailSales → already % → toIndicator
|
||||
// cpiCore / gdp / employment → index/level → toIndicatorPct
|
||||
const PCT_FIELDS = new Set(["cpiCore", "gdp", "employment"]);
|
||||
// Limites de fetch par champ :
|
||||
// cpiCore → 14 obs (12 mois YoY + 1 pour prev YoY + 1 tampon)
|
||||
// cpiCore AUD/NZD (quarterly) → 6 obs (4 trimestres YoY + 1 prev + 1 tampon)
|
||||
// gdp / employment → 6 obs (QoQ/MoM avec prev)
|
||||
// autres → 5 obs (valeur + prev)
|
||||
const isQuarterlyCpi = (currency === "AUD" || currency === "NZD");
|
||||
const FIELD_LIMITS: Record<string, number> = {
|
||||
cpiCore: isQuarterlyCpi ? 7 : 16, // +2 tampon pour les valeurs "." filtrées par FRED
|
||||
gdp: 6,
|
||||
employment: 6,
|
||||
};
|
||||
|
||||
const fieldMap: Record<string, string | null> = {
|
||||
policyRate: series.policyRate,
|
||||
cpiCore: series.cpiCore,
|
||||
cpiHeadline: series.cpiHeadline, // headline pour MoM — peut être = cpiCore pour GBP
|
||||
gdp: series.gdp,
|
||||
retailSales: series.retailSales,
|
||||
unemployment: series.unemployment,
|
||||
@@ -310,24 +643,52 @@ export async function GET(req: NextRequest) {
|
||||
};
|
||||
|
||||
const fredFields = Object.entries(fieldMap).filter(([, id]) => id !== null) as [string, string][];
|
||||
const fredResults = await Promise.all(fredFields.map(([, id]) => fredObs(id, key)));
|
||||
const fredResults = await Promise.all(
|
||||
fredFields.map(([field, id]) => fredObs(id, key, FIELD_LIMITS[field] ?? 5))
|
||||
);
|
||||
const indicators: Record<string, IndicatorResult> = {};
|
||||
let _cpiCoreObs: Obs[] = []; // sauvegarde obs brutes pour fallback cpiMoM
|
||||
|
||||
fredFields.forEach(([field], i) => {
|
||||
indicators[field] = PCT_FIELDS.has(field)
|
||||
? toIndicatorPct(fredResults[i])
|
||||
: toIndicator(fredResults[i]);
|
||||
if (field === "cpiCore") {
|
||||
_cpiCoreObs = fredResults[i]; // sauvegarder pour le fallback
|
||||
// YoY : 12 périodes pour mensuel, 4 pour trimestriel (AUD/NZD)
|
||||
indicators[field] = toIndicatorYoY(fredResults[i], isQuarterlyCpi ? 4 : 12);
|
||||
} else if (field === "cpiHeadline") {
|
||||
// MoM% : variation mensuelle headline (ou QoQ pour AUD/NZD trimestriel)
|
||||
// Si la série headline FRED est identique à cpiCore (GBP) ou manquante (CHF/CAD) :
|
||||
// le fallback ci-dessous prendra le relais avec les obs cpiCore
|
||||
if (fredResults[i].length >= 2) indicators["cpiMoM"] = toIndicatorPct(fredResults[i]);
|
||||
} else if (field === "gdp") {
|
||||
// NZD NAEXKP01NZQ657S = déjà en QoQ% → toIndicator
|
||||
// Autres = indice niveau → toIndicatorPct (calcul QoQ)
|
||||
indicators[field] = currency === "NZD"
|
||||
? toIndicator(fredResults[i])
|
||||
: toIndicatorPct(fredResults[i]);
|
||||
} else if (field === "employment") {
|
||||
// Delta absolu en milliers. USD PAYEMS = déjà en milliers. Autres = personnes → /1000.
|
||||
const personsToK = currency !== "USD";
|
||||
indicators[field] = toIndicatorDeltaK(fredResults[i], personsToK);
|
||||
} else {
|
||||
indicators[field] = toIndicator(fredResults[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// ── EUR alternative sources ────────────────────────────────────────────────
|
||||
if (currency === "EUR") {
|
||||
if (!indicators.cpiCore) {
|
||||
// CP0000EZCCM086NEST indisponible → fallback Eurostat prc_hicp_midx (I15 index → MoM%)
|
||||
// prc_hicp_mmr (404 depuis 2025) remplacé par prc_hicp_midx + toIndicatorPct
|
||||
// CP0000EZCCM086NEST indisponible → fallback Eurostat prc_hicp_midx (I15 index → YoY%)
|
||||
const hicp = await eurostatSorted("prc_hicp_midx", {
|
||||
geo: "EA", coicop: "CP00", unit: "I15", freq: "M",
|
||||
}, 6);
|
||||
indicators.cpiCore = toIndicatorPct(hicp);
|
||||
}, 14);
|
||||
indicators.cpiCore = toIndicatorYoY(hicp, 12);
|
||||
// cpiMoM depuis mêmes obs Eurostat HICP
|
||||
if (!indicators.cpiMoM) indicators.cpiMoM = toIndicatorPct(hicp);
|
||||
} else if (!indicators.cpiMoM) {
|
||||
// cpiCore disponible depuis FRED → recalculer MoM depuis mêmes obs
|
||||
// On re-fetch avec limite réduite (2 obs suffisent pour MoM)
|
||||
const hicpMoM = await fredObs("CP0000EZCCM086NEST", key, 3);
|
||||
if (hicpMoM.length) indicators.cpiMoM = toIndicatorPct(hicpMoM);
|
||||
}
|
||||
if (!indicators.gdp) {
|
||||
// Essayer EA20 d'abord (données 2023-2025), puis EA19 (fallback automatique via eurostatSorted)
|
||||
@@ -353,25 +714,28 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// ── JPY CPI — IMF/IFS (DBnomics) ─────────────────────────────────────────
|
||||
// FRED n'a pas de série JPY CPI mensuelle récente.
|
||||
// M.JP.PCPI_PC_PP_PT = CPI All Items, % change previous period (MoM%), mensuel.
|
||||
// Dernière donnée disponible : 2025-06 (délai ~2 mois vs publication MIC).
|
||||
// La série est DÉJÀ en % → toIndicator (pas toIndicatorPct).
|
||||
// M.JP.PCPI_IX = CPI All Items, indice niveau mensuel → YoY% via toIndicatorYoY
|
||||
// Fallback : M.JP.PCPI_PC_PP_PT = MoM% déjà calculé (si index indisponible)
|
||||
if (currency === "JPY" && !indicators.cpiCore) {
|
||||
const obs = await dbnomicsObs("IMF", "IFS", "M.JP.PCPI_PC_PP_PT");
|
||||
if (obs.length) indicators.cpiCore = toIndicator(obs);
|
||||
const obsIdx = await dbnomicsObs("IMF", "IFS", "M.JP.PCPI_IX", 14);
|
||||
if (obsIdx.length >= 13) {
|
||||
indicators.cpiCore = toIndicatorYoY(obsIdx, 12);
|
||||
indicators.cpiMoM = toIndicatorPct(obsIdx); // MoM depuis mêmes obs
|
||||
} else {
|
||||
const obsMoM = await dbnomicsObs("IMF", "IFS", "M.JP.PCPI_PC_PP_PT");
|
||||
if (obsMoM.length) { indicators.cpiCore = toIndicator(obsMoM); indicators.cpiMoM = toIndicator(obsMoM); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── AUD/NZD CPI fallback — IMF/IFS (DBnomics) ────────────────────────────
|
||||
// FRED AUSCPIALLQINMEI / NZLCPIALLQINMEI = trimestriels index.
|
||||
// Si FRED échoue ou est absent, IMF/IFS fournit les données trimestrielles
|
||||
// via Q.AU.PCPI_IX / Q.NZ.PCPI_IX (index → QoQ% via toIndicatorPct).
|
||||
// Si FRED échoue, IMF/IFS Q.AU.PCPI_IX / Q.NZ.PCPI_IX → YoY% trimestriel
|
||||
if (currency === "AUD" && !indicators.cpiCore) {
|
||||
const obs = await dbnomicsObs("IMF", "IFS", "Q.AU.PCPI_IX");
|
||||
if (obs.length) indicators.cpiCore = toIndicatorPct(obs);
|
||||
const obs = await dbnomicsObs("IMF", "IFS", "Q.AU.PCPI_IX", 6);
|
||||
if (obs.length) indicators.cpiCore = toIndicatorYoY(obs, 4);
|
||||
}
|
||||
if (currency === "NZD" && !indicators.cpiCore) {
|
||||
const obs = await dbnomicsObs("IMF", "IFS", "Q.NZ.PCPI_IX");
|
||||
if (obs.length) indicators.cpiCore = toIndicatorPct(obs);
|
||||
const obs = await dbnomicsObs("IMF", "IFS", "Q.NZ.PCPI_IX", 6);
|
||||
if (obs.length) indicators.cpiCore = toIndicatorYoY(obs, 4);
|
||||
}
|
||||
|
||||
// ── GBP BoE policy rate ───────────────────────────────────────────────────
|
||||
@@ -398,43 +762,147 @@ export async function GET(req: NextRequest) {
|
||||
// • Banque du Canada Valet API : taux annoncé exact (V80691311)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// USD — DFEDTARU = borne haute de la cible Fed (quotidien, annonce FOMC)
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// USD — midpoint de la fourchette cible (DFEDTARU + DFEDTARL) / 2
|
||||
// Rateprobability.com / marchés quotent le midpoint (3.625%) pas l'upper bound (3.75%)
|
||||
// USD — TE scraping (taux Fed upper bound officiel = 3.75%)
|
||||
// Ancien calcul midpoint FRED (DFEDTARU+DFEDTARL)/2 = 3.625% → pas le taux annoncé
|
||||
if (currency === "USD") {
|
||||
const obs = await fredObs("DFEDTARU", key, 90);
|
||||
if (obs.length) indicators.policyRate = toIndicatorDeduped(obs);
|
||||
const te = await scrapeTeRate(TE_COUNTRY.USD);
|
||||
const ovr = getRateDecision("USD");
|
||||
if (te !== null && ovr) {
|
||||
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
||||
} else if (te !== null) {
|
||||
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
||||
}
|
||||
// Fallback FRED upper bound (jamais le midpoint)
|
||||
if (!indicators.policyRate) {
|
||||
const upper = await fredObs("DFEDTARU", key, 365);
|
||||
if (upper.length) {
|
||||
indicators.policyRate = toIndicatorDeduped(upper);
|
||||
if (indicators.policyRate && ovr)
|
||||
indicators.policyRate = buildRateIndicator(indicators.policyRate.value!, ovr.prev, today);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EUR — ECBDFR déjà utilisé mais mensuel → re-fetch 90j + dédupliqué
|
||||
// EUR — ECBDFR (365j) → on affiche le taux MRO = DFR + 0.15
|
||||
// Depuis sep 2024 : corridor ECB = DFR | MRO = DFR+15bps | MLF = DFR+40bps
|
||||
// Investing.com / Trading Economics affichent le MRO comme "taux BCE"
|
||||
if (currency === "EUR") {
|
||||
const obs = await fredObs("ECBDFR", key, 90);
|
||||
if (obs.length) indicators.policyRate = toIndicatorDeduped(obs);
|
||||
const obs = await fredObs("ECBDFR", key, 365);
|
||||
if (obs.length) {
|
||||
const base = toIndicatorDeduped(obs);
|
||||
if (base) {
|
||||
// Convertit DFR → MRO pour correspondre aux sources de référence
|
||||
const mro: typeof base = {
|
||||
...base,
|
||||
value: parseFloat((base.value + 0.15).toFixed(2)),
|
||||
prev: base.prev !== null ? parseFloat((base.prev + 0.15).toFixed(2)) : null,
|
||||
};
|
||||
indicators.policyRate = mro;
|
||||
if (mro.prev === null) {
|
||||
const ovr = getRateDecision("EUR");
|
||||
if (ovr) indicators.policyRate = buildRateIndicator(mro.value!, ovr.prev + 0.15, today);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JPY — IRSTCB01JPM156N (taux BoJ officiel, mis à jour depuis hausses 2024)
|
||||
// fallback IR3TIB01JPM156N (TIBOR 3M, trop élevé vs taux BoJ réel)
|
||||
// GBP — TE scraping en primaire (taux BoE officiel 3.75%)
|
||||
// IUDBEDR (BoE API) = taux SONIA effective, légèrement différent du taux annoncé
|
||||
// IR3TIB01GBM156N = LIBOR 3M UK → incorrect
|
||||
if (currency === "GBP") {
|
||||
const te = await scrapeTeRate(TE_COUNTRY.GBP);
|
||||
const ovr = getRateDecision("GBP");
|
||||
if (te !== null && ovr) {
|
||||
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
||||
} else if (te !== null) {
|
||||
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
||||
}
|
||||
// Fallback : BoE API puis FRED
|
||||
if (!indicators.policyRate) {
|
||||
const boe = await boeRate();
|
||||
if (boe.length) indicators.policyRate = toIndicator(boe);
|
||||
}
|
||||
if (!indicators.policyRate) {
|
||||
const obs = await fredObs("IR3TIB01GBM156N", key, 6);
|
||||
if (obs.length) indicators.policyRate = toIndicator(obs);
|
||||
}
|
||||
}
|
||||
|
||||
// JPY — TE scraping (taux BoJ officiel exact) + prev depuis rate_decisions.json
|
||||
// IRSTCB01JPM156N est stale (données arrêtées fin 2023 sur FRED)
|
||||
// IR3TIB01JPM156N = TIBOR 3M (~1.27%) ≠ taux BoJ réel (~0.75%)
|
||||
if (currency === "JPY") {
|
||||
const obs = await fredObsFreshest("IRSTCB01JPM156N", "IR3TIB01JPM156N", key);
|
||||
if (obs.length) indicators.policyRate = toIndicator(obs);
|
||||
const te = await scrapeTeRate(TE_COUNTRY.JPY);
|
||||
const ovr = getRateDecision("JPY");
|
||||
if (te !== null && ovr) {
|
||||
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
||||
} else if (te !== null) {
|
||||
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
||||
}
|
||||
// Fallback FRED si TE indisponible
|
||||
if (!indicators.policyRate) {
|
||||
const obs = await fredObsFreshest("IRSTCB01JPM156N", "IR3TIB01JPM156N", key);
|
||||
if (obs.length) indicators.policyRate = toIndicator(obs);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
+31
-80
@@ -1,95 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchTEBondYields } from "@/lib/tebonds";
|
||||
|
||||
// 10Y sovereign yields — mixed sources per CDC §6.4
|
||||
// USD: FRED DGS10 (daily)
|
||||
// EUR: ECB API (daily Bund)
|
||||
// GBP: BoE API IUDMNPY (daily)
|
||||
// Others: FRED monthly as fallback
|
||||
|
||||
const FRED_KEY = () => process.env.FRED_API_KEY ?? "";
|
||||
|
||||
async function fredObs(series: string): Promise<number | null> {
|
||||
try {
|
||||
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${FRED_KEY()}&file_type=json&sort_order=desc&limit=3`;
|
||||
const res = await fetch(url, { next: { revalidate: 86400 } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const val = (data?.observations ?? []).find((o: { value: string }) => o.value !== ".")?.value;
|
||||
return val ? parseFloat(val) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ecbBund10Y(): Promise<number | null> {
|
||||
try {
|
||||
const url = "https://data-api.ecb.europa.eu/service/data/YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y?format=jsondata&lastNObservations=1";
|
||||
const res = await fetch(url, { next: { revalidate: 86400 } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const obs = data?.dataSets?.[0]?.series?.["0:0:0:0:0:0:0"]?.observations;
|
||||
if (!obs) return null;
|
||||
const last = Object.values(obs).at(-1) as number[] | undefined;
|
||||
return last?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function boeGilt10Y(): Promise<number | null> {
|
||||
try {
|
||||
// BoE API series IUDMNPY = UK Nominal Par Yield 10Y
|
||||
const url = "https://www.bankofengland.co.uk/boeapps/database/_iadb-FromShowColumns.asp?csv.x=yes&Datefrom=01/Jan/2024&Dateto=now&SeriesCodes=IUDMNPY&CSVF=TN&UsingCodes=Y";
|
||||
const res = await fetch(url, { next: { revalidate: 86400 } });
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
const lines = text.trim().split("\n").filter((l) => l.trim());
|
||||
const last = lines.at(-1)?.split(",");
|
||||
const val = last?.at(-1)?.trim();
|
||||
return val ? parseFloat(val) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bocYield10Y(): Promise<number | null> {
|
||||
try {
|
||||
const url = "https://www.bankofcanada.ca/valet/observations/BD.CDN.10YR.DQ.YLD/json?recent=5";
|
||||
const res = await fetch(url, { next: { revalidate: 86400 } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const obs: { d: string; "BD.CDN.10YR.DQ.YLD": { v: string } }[] =
|
||||
data?.observations ?? [];
|
||||
const last = obs.findLast((o) => o["BD.CDN.10YR.DQ.YLD"]?.v);
|
||||
return last ? parseFloat(last["BD.CDN.10YR.DQ.YLD"].v) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 10Y sovereign yields — source unique : tradingeconomics.com/bonds (HTML statique)
|
||||
// Remplace les sources précédentes (FRED DGS10 + IRLTLT01XXM156N mensuel + ECB/BoE APIs)
|
||||
// qui avaient des décalages allant de 1 jour (FRED daily) à 1 mois (FRED monthly JPY/CHF/AUD/NZD).
|
||||
// TE bonds = données du jour pour les 8 devises, cache 1h.
|
||||
|
||||
export async function GET() {
|
||||
const [usd, eur, gbp, jpy, chf, cad, aud, nzd] = await Promise.all([
|
||||
fredObs("DGS10"),
|
||||
ecbBund10Y(),
|
||||
boeGilt10Y(),
|
||||
fredObs("IRLTLT01JPM156N"),
|
||||
fredObs("IRLTLT01CHM156N"),
|
||||
bocYield10Y(),
|
||||
fredObs("IRLTLT01AUM156N"),
|
||||
fredObs("IRLTLT01NZM156N"),
|
||||
]);
|
||||
const bondData = await fetchTEBondYields();
|
||||
|
||||
const yields = { USD: usd, EUR: eur, GBP: gbp, JPY: jpy, CHF: chf, CAD: cad, AUD: aud, NZD: nzd };
|
||||
const yields: Record<string, number | null> = {
|
||||
USD: bondData.USD?.yield10y ?? null,
|
||||
EUR: bondData.EUR?.yield10y ?? null,
|
||||
GBP: bondData.GBP?.yield10y ?? null,
|
||||
JPY: bondData.JPY?.yield10y ?? null,
|
||||
CHF: bondData.CHF?.yield10y ?? null,
|
||||
CAD: bondData.CAD?.yield10y ?? null,
|
||||
AUD: bondData.AUD?.yield10y ?? null,
|
||||
NZD: bondData.NZD?.yield10y ?? null,
|
||||
};
|
||||
|
||||
// Compute spreads vs USD
|
||||
const dayDeltas: Record<string, number | null> = {
|
||||
USD: bondData.USD?.dayDelta ?? null,
|
||||
EUR: bondData.EUR?.dayDelta ?? null,
|
||||
GBP: bondData.GBP?.dayDelta ?? null,
|
||||
JPY: bondData.JPY?.dayDelta ?? null,
|
||||
CHF: bondData.CHF?.dayDelta ?? null,
|
||||
CAD: bondData.CAD?.dayDelta ?? null,
|
||||
AUD: bondData.AUD?.dayDelta ?? null,
|
||||
NZD: bondData.NZD?.dayDelta ?? null,
|
||||
};
|
||||
|
||||
// Spread vs USD (bps)
|
||||
const usd = yields.USD;
|
||||
const spreads: Record<string, number | null> = {};
|
||||
for (const [ccy, yld] of Object.entries(yields)) {
|
||||
if (ccy === "USD" || yld === null || usd === null) {
|
||||
spreads[ccy] = null;
|
||||
} else {
|
||||
spreads[ccy] = Math.round((yld - usd) * 100); // bps
|
||||
spreads[ccy] = Math.round((yld - usd) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ yields, spreads, timestamp: Date.now() });
|
||||
return NextResponse.json({ yields, spreads, dayDeltas, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
+36
-11
@@ -129,7 +129,7 @@ export default function DriversBar({ drivers }: Props) {
|
||||
sp500, sp500ChangePct,
|
||||
btc, btcChange24h,
|
||||
hySpread, igSpread,
|
||||
us10y, curveSlope,
|
||||
us10y, us2y, curveSlope,
|
||||
gold, goldDelta,
|
||||
silver, silverDelta,
|
||||
brent, brentDelta,
|
||||
@@ -158,9 +158,18 @@ export default function DriversBar({ drivers }: Props) {
|
||||
<div className="flex items-start gap-4 overflow-x-auto pb-0.5">
|
||||
|
||||
{/* Sentiment / Risk-On */}
|
||||
<M label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1} />
|
||||
<M label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2} />
|
||||
<M label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2} />
|
||||
<M
|
||||
label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1}
|
||||
tooltip="Delta = clôture actuelle − clôture précédente issue de Yahoo Finance (métrique intraday/close de session)."
|
||||
/>
|
||||
<M
|
||||
label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2}
|
||||
tooltip="Changement = clôture actuelle − clôture précédente de Yahoo Finance. Le % est calculé sur la clôture précédente."
|
||||
/>
|
||||
<M
|
||||
label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2}
|
||||
tooltip="Variation 24h issue de Binance / CoinGecko via le ticker 24h (priceChangePercent)."
|
||||
/>
|
||||
|
||||
<VSep />
|
||||
|
||||
@@ -177,20 +186,36 @@ export default function DriversBar({ drivers }: Props) {
|
||||
<VSep />
|
||||
|
||||
{/* Taux & FX */}
|
||||
<M label="DXY" value={dxy} dec={2} />
|
||||
<M label="US 10Y" value={us10y} dec={2} unit="%" />
|
||||
<M
|
||||
label="DXY" value={dxy} dec={2}
|
||||
delta={(drivers as DriverData & { dxyDelta?: number | null }).dxyDelta ?? null}
|
||||
deltaDec={2}
|
||||
tooltip="ICE Dollar Index Futures (DX=F) via Yahoo Finance — temps réel, cache 5 min. Delta = clôture actuelle − clôture précédente."
|
||||
/>
|
||||
<M
|
||||
label="Crb 2-10" value={curveSlope} dec={0} unit=" bps"
|
||||
tooltip="Spread US 10Y − US 2Y. Positif = courbe normale. Négatif = courbe inversée (signal récessif historiquement, se matérialise ~12–18 mois après)."
|
||||
tooltip={`Spread US 10Y − US 2Y. Positif = courbe normale. Négatif = courbe inversée (signal récessif historiquement).\nUS 10Y: ${us10y != null ? us10y.toFixed(4) + "%" : "N/A"} | US 2Y: ${us2y != null ? us2y.toFixed(2) + "%" : "N/A"}`}
|
||||
/>
|
||||
|
||||
<VSep />
|
||||
|
||||
{/* Commodités */}
|
||||
<M label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1} />
|
||||
<M label="Argent $/oz" value={silver} dec={2} delta={silverDelta} deltaDec={2} />
|
||||
<M label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1} />
|
||||
<M label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1} />
|
||||
<M
|
||||
label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1}
|
||||
tooltip="Delta intraday = close − open issue de Stooq (métaux précieux)."
|
||||
/>
|
||||
<M
|
||||
label="Argent $/oz" value={silver} dec={2} delta={silverDelta} deltaDec={2}
|
||||
tooltip="Delta intraday = close − open issue de Stooq (métaux précieux)."
|
||||
/>
|
||||
<M
|
||||
label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
|
||||
tooltip="Delta intraday = close − open issue de Stooq (pétrole Brent)."
|
||||
/>
|
||||
<M
|
||||
label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
|
||||
tooltip="Delta intraday = close − open issue de Stooq (pétrole WTI)."
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
[
|
||||
{
|
||||
"updated_at": "2026-05-30",
|
||||
"note": "Taux directeurs officiels CB. current/prev = taux annoncés (pas interbancaires). EUR = MRO (DFR+15bps) pour cohérence avec investing.com/TE. Mettre à jour après chaque décision.",
|
||||
"decisions": {
|
||||
"USD": {
|
||||
"current": 3.75,
|
||||
"prev": 3.75,
|
||||
"source": "Fed funds target upper bound — Trading Economics / TE mai 2026 (fourchette 3.50-3.75%, upper=3.75%)"
|
||||
},
|
||||
"EUR": {
|
||||
"current": 2.15,
|
||||
"prev": 2.40,
|
||||
"source": "ECBDFR (FRED) + 0.15bps → MRO. DFR=2.00, MRO=DFR+15bps (corridor sep 2024)"
|
||||
},
|
||||
"GBP": {
|
||||
"current": 3.75,
|
||||
"prev": 4.00,
|
||||
"source": "BoE Official Bank Rate — Trading Economics mai 2026"
|
||||
},
|
||||
"JPY": {
|
||||
"current": 0.75,
|
||||
"prev": 0.50,
|
||||
"source": "BoJ — annonce jan 2026 (hausse 0.50% → 0.75%)"
|
||||
},
|
||||
"CHF": {
|
||||
"current": 0.00,
|
||||
"prev": 0.25,
|
||||
"source": "SNB — annonce déc 2025 (baisse 0.25% → 0.00%)"
|
||||
},
|
||||
"CAD": {
|
||||
"current": 2.25,
|
||||
"prev": 2.50,
|
||||
"source": "BoC — annonce avr 2026 (baisse 2.50% → 2.25%)"
|
||||
},
|
||||
"AUD": {
|
||||
"current": 4.35,
|
||||
"prev": 4.10,
|
||||
"source": "RBA — Trading Economics mai 2026 (Last=4.35, Previous=4.10)"
|
||||
},
|
||||
"NZD": {
|
||||
"current": 2.25,
|
||||
"prev": 2.50,
|
||||
"source": "RBNZ — annonce avr 2026 (baisse 2.50% → 2.25%)"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+40
-41
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// ── ForexFactory calendar — shared fetch utility ──────────────────────────────
|
||||
// Utilisé par /api/macro (PMI + forecasts) et /api/calendar (calendrier complet).
|
||||
//
|
||||
// Disponibilité :
|
||||
// • ff_calendar_thisweek.json : toujours disponible
|
||||
// • ff_calendar_nextweek.json : disponible du lundi au samedi matin environ
|
||||
// → Le samedi soir / dimanche matin le fichier est en 404 (FF ne l'a pas encore publié)
|
||||
|
||||
export interface FFEvent {
|
||||
title: string;
|
||||
country: string; // "USD", "EUR", "GBP", etc.
|
||||
date: string; // ISO string: "2026-05-29T08:30:00-04:00"
|
||||
impact: string; // "High", "Medium", "Low", "Holiday"
|
||||
actual: string;
|
||||
forecast: string;
|
||||
previous: string;
|
||||
}
|
||||
|
||||
// Cache séparé par semaine pour un retry rapide si nextweek est indisponible
|
||||
let _cacheThisWeek: { events: FFEvent[]; ts: number } | null = null;
|
||||
let _cacheNextWeek: { events: FFEvent[]; ts: number; ok: boolean } | null = null;
|
||||
|
||||
const TTL_OK = 3_600_000; // 1h si le JSON est dispo
|
||||
const TTL_RETRY = 300_000; // 5 min si le JSON était absent (réessaie fréquemment)
|
||||
|
||||
async function fetchFFWeek(week: "thisweek" | "nextweek"): Promise<FFEvent[]> {
|
||||
try {
|
||||
const url = `https://nfs.faireconomy.media/ff_calendar_${week}.json`;
|
||||
// cache: "no-store" pour éviter que Next.js mette en cache une 404
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const text = await res.text();
|
||||
const trimmed = text.trim();
|
||||
// Rejeter silencieusement les réponses HTML (404/503 pages)
|
||||
if (!trimmed.startsWith("[")) return [];
|
||||
return JSON.parse(trimmed) as FFEvent[];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Retourne les events ForexFactory de cette semaine + semaine prochaine (si dispo). */
|
||||
export async function fetchFFEvents(): Promise<FFEvent[]> {
|
||||
const now = Date.now();
|
||||
|
||||
// Fetch thisweek et nextweek en parallèle pour réduire la latence
|
||||
const [thisWeek, nextWeek] = await Promise.all([
|
||||
(async () => {
|
||||
if (_cacheThisWeek && now - _cacheThisWeek.ts <= TTL_OK) return _cacheThisWeek.events;
|
||||
const events = await fetchFFWeek("thisweek");
|
||||
_cacheThisWeek = { events, ts: now };
|
||||
return events;
|
||||
})(),
|
||||
(async () => {
|
||||
const ttl = _cacheNextWeek?.ok ? TTL_OK : TTL_RETRY;
|
||||
if (_cacheNextWeek && now - _cacheNextWeek.ts <= ttl) return _cacheNextWeek.events;
|
||||
const events = await fetchFFWeek("nextweek");
|
||||
_cacheNextWeek = { events, ts: now, ok: events.length > 0 };
|
||||
return events;
|
||||
})(),
|
||||
]);
|
||||
|
||||
return [...thisWeek, ...nextWeek];
|
||||
}
|
||||
|
||||
/** Events de cette semaine seulement (pour PMI/forecasts macro). */
|
||||
export async function fetchFFThisWeek(): Promise<FFEvent[]> {
|
||||
const all = await fetchFFEvents();
|
||||
const monday = new Date();
|
||||
monday.setDate(monday.getDate() - monday.getDay() + 1);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
const nextMonday = new Date(monday);
|
||||
nextMonday.setDate(monday.getDate() + 7);
|
||||
return all.filter((e) => {
|
||||
const d = new Date(e.date);
|
||||
return d >= monday && d < nextMonday;
|
||||
});
|
||||
}
|
||||
|
||||
/** Indique si les données de la semaine prochaine sont disponibles. */
|
||||
export function nextWeekAvailable(): boolean {
|
||||
return (_cacheNextWeek?.ok) ?? false;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// lib/investing.ts
|
||||
// Investing.com Economic Calendar — POST API (JSON avec HTML interne)
|
||||
// Endpoint : POST https://fr.investing.com/economic-calendar/Service/getCalendarFilteredData
|
||||
// Auth : aucune — Cloudflare peut rate-limiter en test mais pas en prod avec cache 30min
|
||||
// Timezone : 55 = UTC
|
||||
|
||||
import type { Currency } from "./types";
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
|
||||
// ── Country IDs (Investing.com internal) ─────────────────────────────────────
|
||||
|
||||
const INV_COUNTRY_IDS: Record<Currency, number> = {
|
||||
USD: 5,
|
||||
GBP: 4,
|
||||
AUD: 25,
|
||||
CAD: 6,
|
||||
EUR: 72, // Euro Area aggregate
|
||||
NZD: 43,
|
||||
JPY: 35,
|
||||
CHF: 12,
|
||||
};
|
||||
|
||||
// ── Currency code → Currency type ─────────────────────────────────────────────
|
||||
|
||||
const INV_CCY_MAP: Record<string, Currency> = {
|
||||
USD: "USD", GBP: "GBP", AUD: "AUD", CAD: "CAD",
|
||||
EUR: "EUR", NZD: "NZD", JPY: "JPY", CHF: "CHF",
|
||||
};
|
||||
|
||||
// ── Category detection (noms en français) ─────────────────────────────────────
|
||||
|
||||
function invCategory(title: string): EventCategory {
|
||||
const t = title.toLowerCase();
|
||||
if (/\bpmi\b|purchasing\s+managers/.test(t)) return "pmi";
|
||||
if (/taux d.intér|décision.*taux|politique monétaire|bank\s+rate/.test(t)) return "policy_rate";
|
||||
if (/discours|allocution|s.exprime|parole|communiqué|banque central/.test(t)) return "cb_speech";
|
||||
if (/\bipc\b|\bcpi\b|\bhicp\b|\bipch\b|inflation|prix à la conso|prix\s+consom/.test(t)) return "inflation";
|
||||
if (/\bpib\b|\bgdp\b|croissance économ/.test(t)) return "gdp";
|
||||
if (/ventes au détail|retail\s+sales/.test(t)) return "retail_sales";
|
||||
if (/balance commerc|balance des paiements|exportations|importations/.test(t)) return "trade_balance";
|
||||
if (/emploi|chômage|payrolls|nonfarm|chomage|travail|emplois créés/.test(t)) return "employment";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ── Importance (bull1/2/3 → impact) ───────────────────────────────────────────
|
||||
|
||||
function invImpact(bull: string): "high" | "medium" | "low" {
|
||||
if (bull === "bull3") return "high";
|
||||
if (bull === "bull2") return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
// ── Normalise valeurs numériques françaises ──────────────────────────────────
|
||||
// "0,6%" → "0.6%" | "52,3" → "52.3" | " " → 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 = /<tr\s+id="eventRowId_(\d+)"\s+class="js-event-item[^"]*"\s+event_attr_ID="(\d+)"\s+data-event-datetime="([^"]+)"[^>]*>([\s\S]*?)(?=<tr\s+id="eventRowId_|<\/tbody>)/g;
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ROW.exec(html)) !== null) {
|
||||
const [, rowId, , dateRaw, body] = m;
|
||||
|
||||
// Devise : " AUD" après le span ceFlags
|
||||
const ccyMatch = body.match(/class="ceFlags[^"]*"[^>]*> <\/span>\s*([A-Z]{2,4})/);
|
||||
if (!ccyMatch) continue;
|
||||
const ccy = INV_CCY_MAP[ccyMatch[1].trim()];
|
||||
if (!ccy) continue;
|
||||
|
||||
// DateTime UTC "2026/06/01 01:00:00" → "2026-06-01T01:00:00Z"
|
||||
const isoDate = dateRaw.replace(/\//g, "-").replace(" ", "T") + "Z";
|
||||
const evDate = new Date(isoDate);
|
||||
|
||||
// Importance : data-img_key="bull1/2/3"
|
||||
const bullMatch = body.match(/data-img_key="(bull\d)"/);
|
||||
const impact = bullMatch ? invImpact(bullMatch[1]) : "low";
|
||||
|
||||
// Nom de l'événement : texte dans <a href="/economic-calendar/...">
|
||||
const nameMatch = body.match(/<a\s+href="\/economic-calendar\/[^"]*"[^>]*>\s*([\s\S]*?)\s*<\/a>/);
|
||||
const title = nameMatch
|
||||
? nameMatch[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim()
|
||||
: "";
|
||||
if (!title) continue;
|
||||
|
||||
// Valeurs dans les td id="eventActual_XXX", eventForecast_XXX, eventPrevious_XXX
|
||||
const actualRaw = body.match(new RegExp(`id="eventActual_${rowId}"[^>]*>([^<]*)<`));
|
||||
const forecastRaw = body.match(new RegExp(`id="eventForecast_${rowId}"[^>]*>([^<]*)<`));
|
||||
const prevSpan = body.match(new RegExp(`id="eventPrevious_${rowId}"[^>]*>\\s*(?:<span[^>]*>)?([^<]*)(?:<\\/span>)?`));
|
||||
|
||||
const actual = normVal(actualRaw?.[1]);
|
||||
const forecast = normVal(forecastRaw?.[1]);
|
||||
const previous = normVal(prevSpan?.[1]);
|
||||
|
||||
events.push({
|
||||
id: `inv_${rowId}`,
|
||||
date: isoDate,
|
||||
currency: ccy,
|
||||
category: invCategory(title),
|
||||
title,
|
||||
impact,
|
||||
actual,
|
||||
forecast,
|
||||
previous,
|
||||
isPublished: actual !== null || evDate < now,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Fetch depuis l'API Investing.com ─────────────────────────────────────────
|
||||
|
||||
export async function fetchInvestingCalendar(
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
): Promise<InvCalendarEvent[]> {
|
||||
// Paramètres : toutes les devises + toutes les importances
|
||||
const countryParams = Object.values(INV_COUNTRY_IDS)
|
||||
.map(id => `country%5B%5D=${id}`)
|
||||
.join("&");
|
||||
|
||||
const body = [
|
||||
countryParams,
|
||||
"importance%5B%5D=1&importance%5B%5D=2&importance%5B%5D=3",
|
||||
"timeZone=55", // UTC
|
||||
"timeFilter=timeRemain",
|
||||
"currentTab=custom",
|
||||
`dateFrom=${fromDate}&dateTo=${toDate}`,
|
||||
"submitFilters=1",
|
||||
"limit_from=0",
|
||||
].join("&");
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://fr.investing.com/economic-calendar/Service/getCalendarFilteredData",
|
||||
{
|
||||
method: "POST",
|
||||
next: { revalidate: 1800 }, // cache 30 min
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": "https://fr.investing.com/economic-calendar/",
|
||||
"Origin": "https://fr.investing.com",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
},
|
||||
body,
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("[Investing] HTTP", res.status);
|
||||
return [];
|
||||
}
|
||||
const json = await res.json() as { data?: string };
|
||||
if (!json?.data) return [];
|
||||
return parseInvestingHTML(json.data);
|
||||
} catch (err) {
|
||||
console.warn("[Investing] fetch error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// lib/investinglive.ts
|
||||
// Scrapes Giuseppe Dellamotta's recurring rate-expectations article on investinglive.com
|
||||
// URL pattern: /news/how-have-interest-rate-expectations-changed-after-this-weeks-event-YYYYMMDD/
|
||||
// Published after major market events (typically on Fridays or post-CB-decision)
|
||||
//
|
||||
// Data format in articleBody JSON-LD:
|
||||
// "CB: XX bps (YY% probability of rate hike/no change at the next meeting)"
|
||||
// Covers all 8 CBs including SNB — the only free public source with CHF OIS-equivalent data.
|
||||
|
||||
import type { Currency } from "./types";
|
||||
|
||||
// ── CB name → Currency ────────────────────────────────────────────────────────
|
||||
|
||||
const CB_TO_CCY: Record<string, Currency> = {
|
||||
"fed": "USD",
|
||||
"fomc": "USD",
|
||||
"ecb": "EUR",
|
||||
"boe": "GBP",
|
||||
"boj": "JPY",
|
||||
"boc": "CAD",
|
||||
"rba": "AUD",
|
||||
"rbnz": "NZD",
|
||||
"snb": "CHF",
|
||||
};
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ILRateExpectation {
|
||||
currency: Currency;
|
||||
nextMeetingProbPct: number; // probability of change at next meeting (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<Record<Currency, ILRateExpectation>>;
|
||||
|
||||
// ── URL discovery ─────────────────────────────────────────────────────────────
|
||||
// Try the last 14 days to find the most recently published article.
|
||||
|
||||
async function findLatestArticleUrl(): Promise<{ url: string; dateStr: string } | null> {
|
||||
for (let daysAgo = 0; daysAgo <= 14; daysAgo++) {
|
||||
const d = new Date(Date.now() - daysAgo * 86400000);
|
||||
const yyyymmdd = d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const url = `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
|
||||
next: { revalidate: 21600 }, // re-check every 6h
|
||||
});
|
||||
if (res.ok) return { url, dateStr: `${yyyymmdd.slice(0,4)}-${yyyymmdd.slice(4,6)}-${yyyymmdd.slice(6,8)}` };
|
||||
} catch { /* try next day */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Article parser ─────────────────────────────────────────────────────────────
|
||||
// The structured data JSON-LD "articleBody" contains the raw text we need.
|
||||
|
||||
function parseArticleBody(text: string, publishedDate: string): ILExpectationsMap {
|
||||
const result: ILExpectationsMap = {};
|
||||
|
||||
// Matches: "RBNZ: 75 bps (79% probability of rate hike at the next meeting)"
|
||||
// "Fed: 13 bps (99% probability of no change at the next meeting)"
|
||||
const linePattern = /([A-Za-z]+)\s*:\s*(\d+)\s*bps\s*\(\s*(\d+)%\s*probability\s+of\s+(rate\s+(?:hike|cut)|no\s+change)\s+at\s+the\s+next\s+meeting\)/gi;
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linePattern.exec(text)) !== null) {
|
||||
const [, cbRaw, bpsStr, probStr, directionRaw] = m;
|
||||
const ccy = CB_TO_CCY[cbRaw.toLowerCase()];
|
||||
if (!ccy) continue;
|
||||
|
||||
const bpsYearEnd = parseInt(bpsStr);
|
||||
const probPct = parseInt(probStr);
|
||||
const direction = directionRaw.toLowerCase();
|
||||
const nextMeetingIsNoChange = direction === "no change";
|
||||
const nextMeetingIsHike = !nextMeetingIsNoChange && direction.includes("hike");
|
||||
// Probability of change = 100 - probNoChange OR directProb if it's a hike/cut
|
||||
const nextMeetingProbPct = nextMeetingIsNoChange ? 100 - probPct : probPct;
|
||||
|
||||
result[ccy] = {
|
||||
currency: ccy,
|
||||
nextMeetingProbPct,
|
||||
nextMeetingIsHike,
|
||||
nextMeetingIsNoChange,
|
||||
bpsYearEnd,
|
||||
publishedDate,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Main fetch ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchILExpectations(): Promise<ILExpectationsMap> {
|
||||
try {
|
||||
const found = await findLatestArticleUrl();
|
||||
if (!found) {
|
||||
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
|
||||
return {};
|
||||
}
|
||||
|
||||
const res = await fetch(found.url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
next: { revalidate: 21600 }, // cache 6h — new article only published after major events
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn("[IL] Fetch failed:", res.status);
|
||||
return {};
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
// Extract articleBody from JSON-LD structured data
|
||||
const jsonLdMatch = html.match(/"articleBody"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
||||
if (!jsonLdMatch) {
|
||||
console.warn("[IL] articleBody not found in JSON-LD");
|
||||
return {};
|
||||
}
|
||||
|
||||
const articleBody = jsonLdMatch[1]
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\u003c/g, "<")
|
||||
.replace(/\\u003e/g, ">");
|
||||
|
||||
const parsed = parseArticleBody(articleBody, found.dateStr);
|
||||
const count = Object.keys(parsed).length;
|
||||
console.log(`[IL] Parsed ${count} CBs from article dated ${found.dateStr}`);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error("[IL] error:", err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -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<Record<Currency, CBRatePath>>;
|
||||
|
||||
// ── Mapping CB → endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
const CB_KEYS: [Currency, string][] = [
|
||||
["USD", "fed"],
|
||||
["EUR", "ecb"],
|
||||
["GBP", "boe"],
|
||||
["JPY", "boj"],
|
||||
["CAD", "boc"],
|
||||
["AUD", "rba"],
|
||||
["NZD", "rbnz"],
|
||||
];
|
||||
|
||||
// Heures UTC approximatives des annonces
|
||||
const ANNOUNCE_UTC: Partial<Record<Currency, number>> = {
|
||||
USD: 18, EUR: 12, GBP: 11, JPY: 2, CAD: 14, AUD: 3, NZD: 2, CHF: 8,
|
||||
};
|
||||
|
||||
// Titres pour le calendrier
|
||||
const MEETING_TITLES: Partial<Record<Currency, string>> = {
|
||||
USD: "FOMC — Décision taux Fed",
|
||||
EUR: "BCE — Governing Council",
|
||||
GBP: "BoE MPC — Décision taux",
|
||||
JPY: "BoJ — Policy Board",
|
||||
CAD: "BoC — Décision taux",
|
||||
AUD: "RBA — Décision taux",
|
||||
NZD: "RBNZ — Décision taux",
|
||||
CHF: "SNB — Décision taux",
|
||||
};
|
||||
|
||||
// ── Extraction des champs (nommage hétérogène selon les CB) ───────────────────
|
||||
|
||||
function getCurrentRate(ccy: Currency, today: Record<string, unknown>): number {
|
||||
switch (ccy) {
|
||||
case "USD": return (today["midpoint"] as number) ?? 0;
|
||||
case "EUR": return (today["ecb_main_refinancing"] as number) ?? (today["ecb_deposit_facility"] as number) ?? 0;
|
||||
case "GBP": return (today["current_target"] as number) ?? 0;
|
||||
case "JPY": return (today["current_target"] as number) ?? 0;
|
||||
case "CAD": return (today["Overnight Rate Target"] as number) ?? 0;
|
||||
case "AUD": return (today["cash_rate_target"] as number) ?? 0;
|
||||
case "NZD": return (today["Official Cash Rate (OCR)"] as number) ?? (today["current_target"] as number) ?? 0;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getAsOf(today: Record<string, unknown>): string {
|
||||
const raw = String(today["As of"] ?? today["as_of"] ?? today["run_date"] ?? "");
|
||||
return raw.slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Fetch une CB ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchCBPath(ccy: Currency, slug: string): Promise<CBRatePath | null> {
|
||||
try {
|
||||
const res = await fetch(`https://rateprobability.com/api/${slug}/latest`, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Referer": `https://rateprobability.com/${slug}`,
|
||||
"Accept": "application/json, */*",
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const today = body["today"] as Record<string, unknown> | undefined;
|
||||
if (!today) return null;
|
||||
|
||||
const currentRate = getCurrentRate(ccy, today);
|
||||
const asOf = getAsOf(today);
|
||||
const nowIso = new Date().toISOString().slice(0, 10);
|
||||
const maxIso = new Date(Date.now() + 380 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const rawRows = (today["rows"] as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const meetings: RateProbMeeting[] = rawRows
|
||||
.filter(r =>
|
||||
typeof r["meeting_iso"] === "string" &&
|
||||
(r["meeting_iso"] as string) >= nowIso &&
|
||||
(r["meeting_iso"] as string) <= maxIso
|
||||
)
|
||||
.map(r => ({
|
||||
label: (r["meeting"] as string).slice(0, 6),
|
||||
dateIso: r["meeting_iso"] as string,
|
||||
impliedRate: parseFloat(String(r["implied_rate_post_meeting"] ?? currentRate)),
|
||||
probMovePct: parseFloat(String(r["prob_move_pct"] ?? 0)),
|
||||
probIsCut: Boolean(r["prob_is_cut"]),
|
||||
changeBps: parseFloat(String(r["change_bps"] ?? 0)),
|
||||
}));
|
||||
|
||||
const peakMeeting = meetings.length > 0
|
||||
? meetings.reduce((best, m) => m.probMovePct > best.probMovePct ? m : best, meetings[0])
|
||||
: null;
|
||||
|
||||
const yearEndImplied = meetings.length > 0
|
||||
? meetings[meetings.length - 1].impliedRate
|
||||
: null;
|
||||
|
||||
return { currency: ccy, asOf, currentRate, meetings, peakMeeting, yearEndImplied };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── SNB meeting dates (published par la SNB, trimestrielles) ─────────────────
|
||||
// Mise à jour annuelle : mars, juin, septembre, décembre
|
||||
|
||||
const SNB_MEETINGS: string[] = [
|
||||
// 2026
|
||||
"2026-06-19",
|
||||
"2026-09-25",
|
||||
"2026-12-11",
|
||||
// 2027
|
||||
"2027-03-18",
|
||||
"2027-06-17",
|
||||
"2027-09-23",
|
||||
"2027-12-09",
|
||||
];
|
||||
|
||||
// Construit un CBRatePath CHF depuis les données InvestingLive (probabilités OIS-équivalent)
|
||||
function buildSNBPath(il: ILExpectationsMap, currentRate: number): CBRatePath | null {
|
||||
const chfData = il["CHF"];
|
||||
if (!chfData) return null;
|
||||
|
||||
const nowIso = new Date().toISOString().slice(0, 10);
|
||||
const upcomingMeetings = SNB_MEETINGS.filter(d => d >= nowIso);
|
||||
if (upcomingMeetings.length === 0) return null;
|
||||
|
||||
// Direction par défaut : bpsYearEnd > 0 = hausse, < 0 = baisse
|
||||
const yearEndIsHike = chfData.bpsYearEnd >= 0;
|
||||
|
||||
const meetings: RateProbMeeting[] = upcomingMeetings.map((dateIso, i) => {
|
||||
const isNext = i === 0;
|
||||
const probMovePct = isNext ? chfData.nextMeetingProbPct : 0;
|
||||
// Si "no change" à la prochaine réunion, la direction vient du biais year-end
|
||||
const probIsCut = isNext
|
||||
? (chfData.nextMeetingIsNoChange ? !yearEndIsHike : !chfData.nextMeetingIsHike)
|
||||
: !yearEndIsHike;
|
||||
const changeBps = isNext ? (probIsCut ? -chfData.bpsYearEnd : chfData.bpsYearEnd) : 0;
|
||||
const impliedRate = isNext && probMovePct > 50
|
||||
? currentRate + (probIsCut ? -0.25 : 0.25)
|
||||
: currentRate;
|
||||
|
||||
return {
|
||||
label: dateIso.slice(0, 7), // "2026-06"
|
||||
dateIso,
|
||||
impliedRate,
|
||||
probMovePct,
|
||||
probIsCut,
|
||||
changeBps,
|
||||
};
|
||||
});
|
||||
|
||||
const peakMeeting = meetings.reduce((best, m) =>
|
||||
m.probMovePct > best.probMovePct ? m : best, meetings[0]
|
||||
);
|
||||
|
||||
return {
|
||||
currency: "CHF",
|
||||
asOf: chfData.publishedDate,
|
||||
currentRate,
|
||||
meetings,
|
||||
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
|
||||
yearEndImplied: meetings.at(-1)?.impliedRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fetch toutes les CB en parallèle ──────────────────────────────────────────
|
||||
|
||||
export async function fetchAllCBPaths(): Promise<RateProbData> {
|
||||
// rateprobability.com (7 CBs) + InvestingLive (tous CBs + CHF) en parallèle
|
||||
const [rpResults, ilData] = await Promise.all([
|
||||
Promise.allSettled(CB_KEYS.map(([ccy, slug]) => fetchCBPath(ccy, slug))),
|
||||
fetchILExpectations(),
|
||||
]);
|
||||
|
||||
const data: RateProbData = {};
|
||||
|
||||
// Intègre les données rateprobability.com
|
||||
for (let i = 0; i < CB_KEYS.length; i++) {
|
||||
const [ccy] = CB_KEYS[i];
|
||||
const r = rpResults[i];
|
||||
if (r.status === "fulfilled" && r.value) data[ccy] = r.value;
|
||||
}
|
||||
|
||||
// CHF/SNB : rateprobability ne couvre pas la SNB → InvestingLive est la seule source
|
||||
if (!data["CHF"] && ilData["CHF"]) {
|
||||
const snbPath = buildSNBPath(ilData, 0.00); // taux actuel SNB = 0%
|
||||
if (snbPath) data["CHF"] = snbPath;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Helper calendrier : dates de réunions extraites des paths ─────────────────
|
||||
|
||||
export interface CBMeetingEvent {
|
||||
currency: Currency;
|
||||
dateIso: string;
|
||||
utcHour: number;
|
||||
title: string;
|
||||
probMovePct: number;
|
||||
probIsCut: boolean;
|
||||
changeBps: number;
|
||||
}
|
||||
|
||||
export function extractMeetingEvents(data: RateProbData, fromDate: string): CBMeetingEvent[] {
|
||||
const events: CBMeetingEvent[] = [];
|
||||
for (const entry of Object.entries(data) as [Currency, CBRatePath][]) {
|
||||
const [ccy, path] = entry;
|
||||
const utcHour = ANNOUNCE_UTC[ccy] ?? 12;
|
||||
const title = MEETING_TITLES[ccy] ?? `Décision taux ${ccy}`;
|
||||
for (const m of path.meetings) {
|
||||
if (m.dateIso < fromDate) continue;
|
||||
events.push({ currency: ccy, dateIso: m.dateIso, utcHour, title, probMovePct: m.probMovePct, probIsCut: m.probIsCut, changeBps: m.changeBps });
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// lib/tebonds.ts
|
||||
// Scrape tradingeconomics.com/bonds pour les rendements 10Y souverains des 8 devises.
|
||||
// Source : HTML statique de la page (données du jour, pas de Socket.IO nécessaire)
|
||||
// EUR benchmark = Allemagne (Bund 10Y)
|
||||
// Cache : 1h (la page TE est mise à jour en continu mais le cache évite l'abus)
|
||||
|
||||
import type { Currency } from "./types";
|
||||
|
||||
const TE_BOND_COUNTRIES: Record<Currency, string> = {
|
||||
USD: "United States",
|
||||
EUR: "Germany",
|
||||
GBP: "United Kingdom",
|
||||
JPY: "Japan",
|
||||
AUD: "Australia",
|
||||
CAD: "Canada",
|
||||
CHF: "Switzerland",
|
||||
NZD: "New Zealand",
|
||||
};
|
||||
|
||||
export interface BondYield {
|
||||
yield10y: number; // rendement 10Y en %
|
||||
dayDelta: number; // variation journalière en points
|
||||
}
|
||||
|
||||
export type BondYields = Partial<Record<Currency, BondYield>>;
|
||||
|
||||
export async function fetchTEBondYields(): Promise<BondYields> {
|
||||
try {
|
||||
const res = await fetch("https://tradingeconomics.com/bonds", {
|
||||
next: { revalidate: 3600 },
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn("[tebonds] HTTP", res.status);
|
||||
return {};
|
||||
}
|
||||
const html = await res.text();
|
||||
return parseBondsHTML(html);
|
||||
} catch (err) {
|
||||
console.error("[tebonds] error:", err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseBondsHTML(html: string): BondYields {
|
||||
const result: BondYields = {};
|
||||
const seen = new Set<Currency>();
|
||||
|
||||
const rowPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = rowPattern.exec(html)) !== null) {
|
||||
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
for (const [ccy, country] of Object.entries(TE_BOND_COUNTRIES) as [Currency, string][]) {
|
||||
if (seen.has(ccy)) continue;
|
||||
if (!text.startsWith(country)) continue;
|
||||
|
||||
// Ligne format : "Country Yield DayDelta WeekPct ..."
|
||||
// On cherche les deux premiers nombres avec 3+ décimales
|
||||
const nums = text.match(/(\d+\.\d{3,4})/g);
|
||||
if (nums && nums.length >= 2) {
|
||||
result[ccy] = {
|
||||
yield10y: parseFloat(nums[0]),
|
||||
dayDelta: parseFloat(nums[1]),
|
||||
};
|
||||
seen.add(ccy);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (seen.size === 8) break; // toutes les devises trouvées
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// lib/tradingeconomics.ts
|
||||
// Trading Economics Economic Calendar
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Primary : TE paid API (TRADING_ECONOMICS_API_KEY in .env.local)
|
||||
// 2. Fallback : HTML scraping of https://tradingeconomics.com/calendar
|
||||
// → The HTML includes previous/consensus/forecast in static DOM.
|
||||
// Actual values are pushed via Socket.IO when released (real-time only),
|
||||
// so upcoming events get full forecast data; recently published events
|
||||
// may show null actual (supplemented by ForexFactory/FRED for key releases).
|
||||
//
|
||||
// Socket.IO details (for reference):
|
||||
// URL : https://live.tradingeconomics.com?key=sun
|
||||
// Auth : JWT token embedded in page (epoch+IP bound, refreshes each fetch)
|
||||
// Crypto : NaCl secretbox (XSalsa20-Poly1305) + pako inflate
|
||||
// → Not used here: Socket.IO is for real-time actuals, not the snapshot we need.
|
||||
|
||||
import type { Currency } from "./types";
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
|
||||
// ── Country → Currency ────────────────────────────────────────────────────────
|
||||
|
||||
const TE_COUNTRY_TO_CCY: Record<string, Currency> = {
|
||||
"united states": "USD",
|
||||
"euro area": "EUR",
|
||||
"united kingdom": "GBP",
|
||||
"japan": "JPY",
|
||||
"switzerland": "CHF",
|
||||
"canada": "CAD",
|
||||
"australia": "AUD",
|
||||
"new zealand": "NZD",
|
||||
};
|
||||
|
||||
// ── Category ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// eventName = data-event attribute (titre brut TE), utilisé pour détecter les discours
|
||||
// indépendamment du data-category (qui vaut souvent "interest rate" pour les speeches CB)
|
||||
function teCategory(cat: string, eventName?: string): EventCategory {
|
||||
const e = (eventName ?? "").toLowerCase();
|
||||
if (/\bspeech\b|speaks?\b|testimony|\bpress\s+conf/.test(e)) return "cb_speech";
|
||||
const c = cat.toLowerCase();
|
||||
if (/\bpmi\b|purchasing\s+managers/.test(c)) return "pmi";
|
||||
if (/interest\s+rate|monetary\s+policy|rate\s+decision|bank\s+rate/.test(c)) return "policy_rate";
|
||||
if (/speech|speaks?|testimony|press\s+conf|central\s+bank/.test(c)) return "cb_speech";
|
||||
if (/inflation|cpi|hicp|consumer\s+price|ppi/.test(c)) return "inflation";
|
||||
if (/\bgdp\b|gross\s+domestic|growth\s+rate/.test(c)) return "gdp";
|
||||
if (/retail\s+sales|core\s+retail/.test(c)) return "retail_sales";
|
||||
if (/trade\s+balance|current\s+account|balance\s+of\s+trade/.test(c)) return "trade_balance";
|
||||
if (/employment|payrolls|nonfarm|jobless|unemployment|job\s+creation/.test(c)) return "employment";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ── Importance (1/2/3) → impact ───────────────────────────────────────────────
|
||||
|
||||
function teImpact(n: number): "high" | "medium" | "low" {
|
||||
if (n >= 3) return "high";
|
||||
if (n >= 2) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
// ── 12h → ISO time ────────────────────────────────────────────────────────────
|
||||
// TE times are in UTC (default when no timezone cookie is set on the server)
|
||||
|
||||
function to24h(t: string): string {
|
||||
const m = t.trim().match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
||||
if (!m) return "12:00";
|
||||
let h = parseInt(m[1]);
|
||||
const pm = m[3].toUpperCase() === "PM";
|
||||
if (pm && h !== 12) h += 12;
|
||||
if (!pm && h === 12) h = 0;
|
||||
return `${String(h).padStart(2, "0")}:${m[2]}`;
|
||||
}
|
||||
|
||||
// ── Decode HTML entities ───────────────────────────────────────────────────────
|
||||
|
||||
function decodeHTMLEntities(s: string): string {
|
||||
return s
|
||||
.replace(/&/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 <span id='X'>VALUE</span> or <a id='X' ...>VALUE</a>
|
||||
const m = html.match(new RegExp(`<(?:span|a)[^>]+id=['"]${id}['"][^>]*>([^<]*)<`, "i"));
|
||||
const val = m?.[1]?.trim();
|
||||
return val && val.length > 0 ? decodeHTMLEntities(val) : null;
|
||||
}
|
||||
|
||||
// ── Public type ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TECalendarEvent {
|
||||
id: string;
|
||||
date: string; // ISO with UTC time
|
||||
currency: Currency;
|
||||
category: EventCategory;
|
||||
title: string;
|
||||
impact: "high" | "medium" | "low";
|
||||
actual: string | null;
|
||||
forecast: string | null; // consensus preferred over TE forecast
|
||||
previous: string | null;
|
||||
isPublished: boolean;
|
||||
teId: string;
|
||||
}
|
||||
|
||||
// ── HTML scraper ──────────────────────────────────────────────────────────────
|
||||
// La page /calendar couvre le G20 (USD/EUR/GBP/JPY/CAD/AUD/NZD) mais PAS la Suisse (CHF).
|
||||
// On fetche aussi /switzerland/calendar pour avoir les events CHF.
|
||||
|
||||
async function fetchOneTEPage(url: string): Promise<string> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate: 1800 },
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return "";
|
||||
return res.text();
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
// fromDate / toDate au format YYYY-MM-DD
|
||||
export async function fetchTECalendarHTML(fromDate?: string, toDate?: string): Promise<TECalendarEvent[]> {
|
||||
const qs = fromDate && toDate ? `?startDate=${fromDate}&endDate=${toDate}` : "";
|
||||
const pages = await Promise.all([
|
||||
fetchOneTEPage(`https://tradingeconomics.com/calendar${qs}`),
|
||||
fetchOneTEPage(`https://tradingeconomics.com/switzerland/calendar${qs}`), // CHF — hors G20
|
||||
]);
|
||||
|
||||
const allEvents: TECalendarEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const html of pages) {
|
||||
if (!html) continue;
|
||||
for (const ev of parseCalendarHTML(html)) {
|
||||
if (seen.has(ev.id)) continue;
|
||||
seen.add(ev.id);
|
||||
allEvents.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
allEvents.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
return allEvents;
|
||||
}
|
||||
|
||||
function parseCalendarHTML(html: string): TECalendarEvent[] {
|
||||
const events: TECalendarEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Split on each event row — each <tr data-url= ... data-id= ... data-country= ...>
|
||||
const rowPattern = /<tr\s+data-url="[^"]*"\s+data-id="(\d+)"\s+data-country="([^"]+)"\s+data-category="([^"]+)"\s+data-event="([^"]+)"[^>]*>([\s\S]*?)(?=<tr\s+data-url=|<\/tbody>)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = rowPattern.exec(html)) !== null) {
|
||||
const [, id, country, category, eventAttr, body] = match;
|
||||
|
||||
const ccy = TE_COUNTRY_TO_CCY[country.toLowerCase()];
|
||||
if (!ccy) continue; // only our 8 currencies
|
||||
|
||||
// Date: <td ... class=' 2026-06-01'>
|
||||
const dateMatch = body.match(/class=' (\d{4}-\d{2}-\d{2})'/);
|
||||
if (!dateMatch) continue;
|
||||
const dateStr = dateMatch[1];
|
||||
|
||||
// Time + importance: <span class="event-52 calendar-date-3"> 02:00 PM </span>
|
||||
const timeMatch = body.match(/calendar-date-(\d)[^"]*"[^>]*>\s*([\d:]+\s*[AP]M)\s*/i);
|
||||
const importance = timeMatch ? parseInt(timeMatch[1]) : 1;
|
||||
const timeStr = timeMatch ? timeMatch[2].trim() : "00:00 AM";
|
||||
const time24 = to24h(timeStr);
|
||||
const isoDate = `${dateStr}T${time24}:00Z`;
|
||||
const evDate = new Date(isoDate);
|
||||
|
||||
// Display title: <a class='calendar-event' ...>ISM Manufacturing PMI</a>
|
||||
const titleMatch = body.match(/<a\s+class='calendar-event'[^>]*>([^<]+)<\/a>/);
|
||||
const title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : decodeHTMLEntities(eventAttr);
|
||||
|
||||
// Reference period: <span class="calendar-reference">MAY</span>
|
||||
const refMatch = body.match(/class="calendar-reference"\s*>([^<]*)</);
|
||||
const ref = refMatch ? refMatch[1].trim() : "";
|
||||
|
||||
// Values — all live in id='' span/anchor elements
|
||||
const actual = extractText(body, "actual");
|
||||
const previous = extractText(body, "previous");
|
||||
const consensus = extractText(body, "consensus");
|
||||
const forecast = extractText(body, "forecast");
|
||||
|
||||
const displayTitle = ref ? `${title} ${ref}` : title;
|
||||
|
||||
events.push({
|
||||
id: `te_${id}`,
|
||||
date: isoDate,
|
||||
currency: ccy,
|
||||
category: teCategory(category, eventAttr),
|
||||
title: displayTitle,
|
||||
impact: teImpact(importance),
|
||||
actual,
|
||||
forecast: consensus ?? forecast, // TE consensus > TE proprietary forecast
|
||||
previous,
|
||||
isPublished: actual !== null || evDate < now,
|
||||
teId: id,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date ascending
|
||||
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Paid API (when TRADING_ECONOMICS_API_KEY is set) ─────────────────────────
|
||||
|
||||
export async function fetchTECalendar(
|
||||
apiKey: string,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
): Promise<TECalendarEvent[]> {
|
||||
const countries = [
|
||||
"united states", "euro area", "united kingdom", "japan",
|
||||
"switzerland", "canada", "australia", "new zealand",
|
||||
].join(",");
|
||||
|
||||
const url = [
|
||||
`https://api.tradingeconomics.com/calendar/country/${encodeURIComponent(countries)}`,
|
||||
`/${fromDate}/${toDate}`,
|
||||
`?c=${encodeURIComponent(apiKey)}&f=json`,
|
||||
].join("");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate: 1800 },
|
||||
headers: { "Accept": "application/json" },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const raw = await res.json() as Array<Record<string, unknown>>;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const now = new Date();
|
||||
return raw
|
||||
.map(ev => {
|
||||
const ccy = TE_COUNTRY_TO_CCY[(ev["Country"] as string)?.toLowerCase()];
|
||||
if (!ccy) return null;
|
||||
const dateRaw = ev["Date"] as string ?? "";
|
||||
const isoDate = dateRaw.endsWith("Z") ? dateRaw : dateRaw + "Z";
|
||||
const evDate = new Date(isoDate);
|
||||
const actual = (ev["Actual"] as string | null) || null;
|
||||
const forecast = (ev["Forecast"] as string | null) || (ev["TEForecast"] as string | null) || null;
|
||||
const previous = (ev["Previous"] as string | null) || null;
|
||||
return {
|
||||
id: `te_${ev["CalendarId"]}`,
|
||||
date: isoDate,
|
||||
currency: ccy,
|
||||
category: teCategory(ev["Category"] as string ?? ""),
|
||||
title: (ev["Event"] as string ?? ""),
|
||||
impact: teImpact(ev["Importance"] as number ?? 1),
|
||||
actual: actual !== "" ? actual : null,
|
||||
forecast: forecast !== "" ? forecast : null,
|
||||
previous: previous !== "" ? previous : null,
|
||||
isPublished: actual !== null || evDate < now,
|
||||
teId: String(ev["CalendarId"] ?? ""),
|
||||
} satisfies TECalendarEvent;
|
||||
})
|
||||
.filter((e): e is TECalendarEvent => e !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user