mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
feat: banques centrales (vote+dot plot+previsions), calendrier elargi, fix InvestingLive, M3 auto
Central bank governance (nouvel onglet Banques centrales) : - lib/centralBankGovernance.ts + app/api/central-bank-sources : scraping live du vote de la derniere reunion, dot plot Fed (SEP), et desormais les previsions macro (PIB + inflation) publiees par chaque BC elle-meme (Fed SEP, Eurosystem staff projections, BoJ Outlook Report PDF, SNB conditional forecast, BoC MPR, RBA SMP). GBP/NZD laisses honnetement vides quand aucune source chiffree fiable n'est accessible (RBNZ bloque par Cloudflare). - components/CentralBankSourcesTab.tsx : nouvel onglet avec cards par banque. Taux directeurs + Money Supply M3 : - data/rate_decisions.json corrige (JPY, EUR, NZD etc. etaient perimes d'1-2 decisions) et desormais auto-maintenu : .github/workflows/update-rate-decisions.yml (horaire) detecte les changements de taux via Trading Economics et fait glisser current -> prev sans perte de donnee. - data/money-supply-m3.json (nouveau) + .github/workflows/fetch-money-supply.yml (hebdo) : M3 par devise (proxy M2 pour l'USD, la Fed ne publiant plus M3 depuis 2006), affiche dans CurrencyCard. Calendrier economique elargi : - lib/calendar-countries.ts, lib/calendar-taxonomy.ts, lib/fxstreetCalendar.ts : couverture pays elargie + classification des evenements + source FXStreet. Fix InvestingLive : - lib/investinglive.ts : l'ancienne API WordPress (wp-json) renvoyait 404 depuis leur migration Nuxt.js -> reecrit vers api.investinglive.com/api/homepage/articles, + fix crash silencieux (Tldr pas toujours un tableau). Divers : - .vercel/ ajoute au .gitignore (ne doit jamais etre commite, cf. son propre README). - scripts/ (lancement PWA, push env Vercel), captures d'ecran, cache InvestingLive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
// lib/calendar-countries.ts
|
||||
// Univers de pays pour le calendrier économique — restreint le 2026-07-08 à la
|
||||
// demande explicite de l'utilisateur : les 8 devises majeures tradées par le
|
||||
// dashboard + la France en complément (grosse économie de la zone euro, dont
|
||||
// les publications (Ifo-like, PMI, CPI...) précèdent souvent l'agrégat EMU).
|
||||
//
|
||||
// Sert de table de correspondance commune entre :
|
||||
// - Trading Economics : slug URL (/xxx/calendar) + nom affiché en data-country
|
||||
// - investingLive (widget FXStreet calendar.fxstreet.com) : code pays + data-countryname
|
||||
//
|
||||
// currency = devise ISO 4217 associée (EMU et FR partagent EUR).
|
||||
|
||||
export interface CalendarCountry {
|
||||
code: string; // code FXStreet (aussi utilisé comme identifiant canonique)
|
||||
teSlug: string; // slug Trading Economics : https://tradingeconomics.com/{slug}/calendar
|
||||
teName: string; // valeur data-country de TE (lowercase)
|
||||
fxName: string; // valeur data-countryname du widget FXStreet
|
||||
currency: string; // ISO 4217
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export const CALENDAR_COUNTRIES: CalendarCountry[] = [
|
||||
{ code: "EMU", teSlug: "euro-area", teName: "euro area", fxName: "Eurozone", currency: "EUR", displayName: "Zone Euro" },
|
||||
{ code: "FR", teSlug: "france", teName: "france", fxName: "France", currency: "EUR", displayName: "France" },
|
||||
{ code: "US", teSlug: "united-states", teName: "united states", fxName: "United States", currency: "USD", displayName: "États-Unis" },
|
||||
{ code: "CA", teSlug: "canada", teName: "canada", fxName: "Canada", currency: "CAD", displayName: "Canada" },
|
||||
{ code: "UK", teSlug: "united-kingdom", teName: "united kingdom", fxName: "United Kingdom", currency: "GBP", displayName: "Royaume-Uni" },
|
||||
{ code: "CH", teSlug: "switzerland", teName: "switzerland", fxName: "Switzerland", currency: "CHF", displayName: "Suisse" },
|
||||
{ code: "JP", teSlug: "japan", teName: "japan", fxName: "Japan", currency: "JPY", displayName: "Japon" },
|
||||
{ code: "AU", teSlug: "australia", teName: "australia", fxName: "Australia", currency: "AUD", displayName: "Australie" },
|
||||
{ code: "NZ", teSlug: "new-zealand", teName: "new zealand", fxName: "New Zealand", currency: "NZD", displayName: "Nouvelle-Zélande" },
|
||||
];
|
||||
|
||||
export const TE_NAME_TO_CURRENCY: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.teName, c.currency])
|
||||
);
|
||||
|
||||
export const FX_NAME_TO_CURRENCY: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.fxName, c.currency])
|
||||
);
|
||||
|
||||
export const TE_NAME_TO_CODE: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.teName, c.code])
|
||||
);
|
||||
|
||||
export const FX_NAME_TO_CODE: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.fxName, c.code])
|
||||
);
|
||||
|
||||
export const FXSTREET_COUNTRYCODES = CALENDAR_COUNTRIES.map(c => c.code).join(",");
|
||||
@@ -0,0 +1,79 @@
|
||||
// lib/calendar-taxonomy.ts
|
||||
// Règles de tri du fourre-tout "other" du calendrier économique, décidées avec
|
||||
// l'utilisateur devise par devise (session du 2026-07-08) :
|
||||
// - Confiance/sentiment, immobilier (prix uniquement), M3 (EUR uniquement),
|
||||
// commerce extérieur détaillé, Fed régionale (Philly uniquement), flux de
|
||||
// portefeuille étrangers, finances publiques, jours fériés → catégories
|
||||
// dédiées (visibles, triables séparément).
|
||||
// - Adjudications obligataires, production industrielle, énergie hebdo US,
|
||||
// hypothécaires hebdo US, CPI infranational, réunions institutionnelles
|
||||
// sans chiffre → exclus du calendrier (bruit, cf. décisions utilisateur).
|
||||
// Tout le reste (non couvert par une règle ci-dessous) continue d'apparaître
|
||||
// sous "other" / "Autre".
|
||||
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
|
||||
const SENTIMENT_RE = /\bifo\b|\bzew\b|\bgfk\b|consumer confidence|business confidence|business climate|economic sentiment|\bsentix\b|westpac consumer confidence|anz.*consumer confidence|anz business confidence|anz activity outlook|nab business confidence|cbi business optimism|cbi distributive trades|cbi industrial trends|eco watchers survey|reuters tankan/i;
|
||||
|
||||
const HOUSING_PRICE_RE = /house price|housing price|nationwide housing|case-shiller|rightmove|rics house price|cotality dwelling|residential property price/i;
|
||||
|
||||
const MONEY_SUPPLY_RE = /m3 money supply/i;
|
||||
|
||||
const TRADE_DETAIL_RE = /^exports?\b|^imports?\b|export price|import price/i;
|
||||
|
||||
const REGIONAL_FED_RE = /philly fed|philadelphia fed/i;
|
||||
|
||||
const PORTFOLIO_FLOWS_RE = /tic flows|net capital flows|foreign bond investment|stock investment by foreigners|cftc \w+ nc net positions/i;
|
||||
|
||||
const PUBLIC_FINANCE_RE = /budget balance|monthly budget statement|public sector net borrowing/i;
|
||||
|
||||
const HOLIDAY_RE = /\bholiday\b|\b(independence|canada|marine|labor|labour|thanksgiving|christmas|boxing|memorial|veterans|presidents?|columbus|mlk|good friday|easter monday)\s+day\b/i;
|
||||
|
||||
// ── Exclusions (bruit écarté du calendrier, décision utilisateur) ────────────
|
||||
|
||||
const AUCTION_RE = /\bauction\b|\btender\b|conventional gilt|index-linked gilt/i;
|
||||
const INDUSTRIAL_PROD_RE = /industrial production|industrial output|manufacturing production|capacity utilization|tertiary industry index/i;
|
||||
const US_ENERGY_WEEKLY_RE = /\beia\b|\bapi\b crude|baker hughes|crude oil stock|natural gas stock|gasoline stock|distillate|heating oil stock|refinery crude/i;
|
||||
const US_MORTGAGE_WEEKLY_RE = /\bmba\b|mortgage application|mortgage market index|mortgage refinance index|mba purchase index/i;
|
||||
const SUBNATIONAL_CPI_RE = /baden wuerttemberg cpi|bavaria cpi|brandenburg cpi|hesse cpi|north rhine westphalia cpi|saxony cpi|tokyo cpi|tokyo core cpi/i;
|
||||
const INSTITUTIONAL_MEETING_RE = /ecofin meeting|eurogroup meeting|iea oil market report|wasde report|nopa crush report/i;
|
||||
|
||||
export function classifyOtherTitle(title: string): EventCategory {
|
||||
if (SENTIMENT_RE.test(title)) return "sentiment";
|
||||
if (HOUSING_PRICE_RE.test(title)) return "housing";
|
||||
if (MONEY_SUPPLY_RE.test(title)) return "money_supply";
|
||||
if (TRADE_DETAIL_RE.test(title)) return "trade_detail";
|
||||
if (REGIONAL_FED_RE.test(title)) return "regional_fed";
|
||||
if (PORTFOLIO_FLOWS_RE.test(title)) return "portfolio_flows";
|
||||
if (PUBLIC_FINANCE_RE.test(title)) return "public_finance";
|
||||
if (HOLIDAY_RE.test(title)) return "holiday";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function isExcludedEventTitle(title: string): boolean {
|
||||
return (
|
||||
AUCTION_RE.test(title) ||
|
||||
INDUSTRIAL_PROD_RE.test(title) ||
|
||||
US_ENERGY_WEEKLY_RE.test(title) ||
|
||||
US_MORTGAGE_WEEKLY_RE.test(title) ||
|
||||
SUBNATIONAL_CPI_RE.test(title) ||
|
||||
INSTITUTIONAL_MEETING_RE.test(title)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plancher d'impact (demande explicite : PPI, Trade Balance, Construction PMI
|
||||
// ne doivent jamais rester masqués par le filtre "Impact faible" par défaut) ──
|
||||
// TE/investingLive taggent souvent ces indicateurs "low" par pays (surtout hors
|
||||
// USD), ce qui les cache dans le calendrier tant que la case "Impact faible"
|
||||
// n'est pas cochée. On relève le plancher à "medium" sans jamais rétrograder
|
||||
// un impact déjà "high" décidé par la source (ex. PPI MoM US, Balance of Trade JP).
|
||||
|
||||
const IMPACT_FLOOR_RE = /construction pmi|\bppi\b|producer price|balance of trade|trade balance|goods trade balance|foreign trade balance/i;
|
||||
|
||||
export function applyImpactFloor(
|
||||
title: string,
|
||||
impact: "high" | "medium" | "low"
|
||||
): "high" | "medium" | "low" {
|
||||
if (impact === "low" && IMPACT_FLOOR_RE.test(title)) return "medium";
|
||||
return impact;
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
// lib/centralBankGovernance.ts
|
||||
// Données de gouvernance des banques centrales : vote de la dernière réunion,
|
||||
// lien vers le dernier rapport de politique monétaire (PDF), lien vers le site
|
||||
// officiel, et pour la Fed le dot plot (Summary of Economic Projections).
|
||||
//
|
||||
// Sources (HTML public, sans clé API) :
|
||||
// Fed : federalreserve.gov — statement (vote) + SEP "accessible version" (dot plot)
|
||||
// BoE : bankofengland.co.uk — Monetary Policy Summary and Minutes (vote)
|
||||
// Les autres banques (ECB, BoJ, SNB, BoC, RBA, RBNZ) exposent au minimum les
|
||||
// liens statiques (site officiel + dernier rapport connu) ; scraping vote/PDF
|
||||
// ajouté au fur et à mesure de ce qui est effectivement accessible sans
|
||||
// contournement de WAF (RBA/RBNZ bloquent tout fetch() non-navigateur).
|
||||
|
||||
import type { Currency } from "./types";
|
||||
import rateDecisionsData from "@/data/rate_decisions.json";
|
||||
import { fetchTECalendarForCountry } from "./tradingeconomics";
|
||||
|
||||
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
||||
|
||||
async function fetchText(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": UA, "Accept-Language": "en-US,en;q=0.9" },
|
||||
next: { revalidate: 6 * 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function htmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/g, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/g, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/–|–/g, "–")
|
||||
.replace(/−|−/g, "-")
|
||||
.replace(/‑/g, "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ── Types publics ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FedDotYear {
|
||||
year: string; // "2026" | "2027" | "2028" | "Longer run"
|
||||
median: number | null;
|
||||
}
|
||||
|
||||
export interface FedDot {
|
||||
year: string;
|
||||
rate: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FedSepHistoryPoint {
|
||||
date: string; // ISO de la réunion SEP
|
||||
medianByYear: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface FedDotPlot {
|
||||
asOfDate: string;
|
||||
years: string[];
|
||||
medianByYear: Record<string, number>;
|
||||
prevMedianByYear: Record<string, number> | null;
|
||||
prevLabel: string | null; // ex. "March projection"
|
||||
dots: FedDot[];
|
||||
history: FedSepHistoryPoint[]; // dernières réunions SEP, la plus récente en dernier
|
||||
gdpMedian: Record<string, number> | null;
|
||||
pceMedian: Record<string, number> | null;
|
||||
sepHtmlUrl: string;
|
||||
sepPdfUrl: string;
|
||||
}
|
||||
|
||||
// Prévisions macro publiées par la BC elle-même (comment elle perçoit sa propre
|
||||
// économie) : croissance PIB + inflation, par année. gdp/inflation sont indexés
|
||||
// par année ("2026", "2027"…) → valeur en %, null si non disponible pour cette
|
||||
// année précise. isProxy = valeur de substitution (ex. consensus Trading
|
||||
// Economics) quand la BC elle-même n'est pas accessible en scraping (RBNZ).
|
||||
export interface CBForecast {
|
||||
asOf: string; // date de publication de la prévision
|
||||
years: string[]; // ordre d'affichage
|
||||
gdp: Record<string, number | null>;
|
||||
inflation: Record<string, number | null>;
|
||||
label: string; // ex. "Eurosystem staff projections — juin 2026"
|
||||
sourceUrl: string | null;
|
||||
isProxy?: boolean;
|
||||
proxyLabel?: string;
|
||||
}
|
||||
|
||||
export interface CBGovernance {
|
||||
currency: Currency;
|
||||
bankName: string;
|
||||
countryLabel: string;
|
||||
officialSiteUrl: string;
|
||||
policyPageUrl: string;
|
||||
meetingDate: string | null;
|
||||
rateLevel: string | null;
|
||||
voteSummary: string | null; // "12 – 0", "7 – 2", ou "Consensus (pas de vote publié)"
|
||||
voteDetail: string | null;
|
||||
statementUrl: string | null;
|
||||
reportPdfUrl: string | null;
|
||||
reportLabel: string | null;
|
||||
dotPlot?: FedDotPlot;
|
||||
forecast?: CBForecast | null;
|
||||
fetchError?: string;
|
||||
}
|
||||
|
||||
// ── Métadonnées statiques (toujours disponibles, même si le scraping échoue) ──
|
||||
|
||||
export const CB_STATIC_INFO: Record<Currency, { bankName: string; countryLabel: string; officialSiteUrl: string; policyPageUrl: string }> = {
|
||||
USD: { bankName: "Federal Reserve (Fed)", countryLabel: "États-Unis", officialSiteUrl: "https://www.federalreserve.gov", policyPageUrl: "https://www.federalreserve.gov/monetarypolicy.htm" },
|
||||
EUR: { bankName: "Banque Centrale Européenne", countryLabel: "Zone Euro", officialSiteUrl: "https://www.ecb.europa.eu", policyPageUrl: "https://www.ecb.europa.eu/press/govcdec/mopo/html/index.en.html" },
|
||||
GBP: { bankName: "Bank of England (BoE)", countryLabel: "Royaume-Uni", officialSiteUrl: "https://www.bankofengland.co.uk", policyPageUrl: "https://www.bankofengland.co.uk/monetary-policy" },
|
||||
JPY: { bankName: "Bank of Japan (BoJ)", countryLabel: "Japon", officialSiteUrl: "https://www.boj.or.jp/en", policyPageUrl: "https://www.boj.or.jp/en/mopo/index.htm" },
|
||||
CHF: { bankName: "Swiss National Bank (SNB)", countryLabel: "Suisse", officialSiteUrl: "https://www.snb.ch", policyPageUrl: "https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy" },
|
||||
CAD: { bankName: "Bank of Canada (BoC)", countryLabel: "Canada", officialSiteUrl: "https://www.bankofcanada.ca", policyPageUrl: "https://www.bankofcanada.ca/core-functions/monetary-policy/" },
|
||||
AUD: { bankName: "Reserve Bank of Australia (RBA)", countryLabel: "Australie", officialSiteUrl: "https://www.rba.gov.au", policyPageUrl: "https://www.rba.gov.au/monetary-policy/" },
|
||||
NZD: { bankName: "Reserve Bank of New Zealand (RBNZ)", countryLabel: "Nouvelle-Zélande", officialSiteUrl: "https://www.rbnz.govt.nz", policyPageUrl: "https://www.rbnz.govt.nz/monetary-policy" },
|
||||
};
|
||||
|
||||
function staticFallback(ccy: Currency, error: string): CBGovernance {
|
||||
const info = CB_STATIC_INFO[ccy];
|
||||
return {
|
||||
currency: ccy, ...info,
|
||||
meetingDate: null, rateLevel: null, voteSummary: null, voteDetail: null,
|
||||
statementUrl: null, reportPdfUrl: null, reportLabel: null,
|
||||
fetchError: error,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fed (USD) ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFedFraction(s: string): number {
|
||||
const m = s.match(/^(\d+)(?:-(\d+)\/(\d+))?$/);
|
||||
if (!m) return NaN;
|
||||
const whole = parseFloat(m[1]);
|
||||
return m[2] ? whole + parseFloat(m[2]) / parseFloat(m[3]) : whole;
|
||||
}
|
||||
|
||||
async function findLatestFedDates(): Promise<{ statementDates: string[]; sepDates: string[] } | null> {
|
||||
const html = await fetchText("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm");
|
||||
if (!html) return null;
|
||||
const todayCompact = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const statementDates = Array.from(new Set(Array.from(html.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g), m => m[1])))
|
||||
.filter(d => d <= todayCompact).sort();
|
||||
const sepDates = Array.from(new Set(Array.from(html.matchAll(/\/monetarypolicy\/fomcprojtabl[e]?(\d{8})\.htm/g), m => m[1])))
|
||||
.filter(d => d <= todayCompact).sort();
|
||||
return { statementDates, sepDates };
|
||||
}
|
||||
|
||||
async function fetchFedStatement(dateCompact: string): Promise<{ rateLevel: string; voteSummary: string; voteDetail: string | null; statementUrl: string } | null> {
|
||||
const url = `https://www.federalreserve.gov/newsevents/pressreleases/monetary${dateCompact}a.htm`;
|
||||
const html = await fetchText(url);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const rateMatch = text.match(/target range for the federal funds rate[\s\S]{0,60}?(\d+(?:-\d\/\d)?)\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i);
|
||||
const lower = rateMatch ? parseFedFraction(rateMatch[1]) : NaN;
|
||||
const upper = rateMatch ? parseFedFraction(rateMatch[2]) : NaN;
|
||||
const rateLevel = !isNaN(lower) && !isNaN(upper) ? `${lower}–${upper}%` : null;
|
||||
|
||||
const voteMatch = text.match(/by a\s+(\d+)\s*[–—-]\s*(\d+)\s+vote/i);
|
||||
const voteSummary = voteMatch ? `${voteMatch[1]} – ${voteMatch[2]}` : null;
|
||||
|
||||
const dissentMatch = text.match(/Voting against[^:]*:\s*([^.]+)\./i);
|
||||
const voteDetail = dissentMatch ? dissentMatch[1].trim() : null;
|
||||
|
||||
if (!rateLevel || !voteSummary) return null;
|
||||
return { rateLevel, voteSummary, voteDetail, statementUrl: url };
|
||||
}
|
||||
|
||||
// Table 1 : médiane Fed funds rate (trimestre courant + trimestre précédent, si présent)
|
||||
// + médianes GDP/PCE inflation (même table, mêmes 4 colonnes années) pour le bloc
|
||||
// "prévisions" générique (comment le FOMC voit sa propre économie).
|
||||
function parseSepTable1(text: string): {
|
||||
years: string[]; median: Record<string, number>; prevMedian: Record<string, number> | null; prevLabel: string | null;
|
||||
gdpMedian: Record<string, number> | null; pceMedian: Record<string, number> | null;
|
||||
} | null {
|
||||
// Les 3 années couvertes glissent d'une publication SEP à l'autre (ex. déc. 2025
|
||||
// couvre 2025-2028, juin 2026 couvre 2026-2028+LR) — on les lit depuis l'en-tête
|
||||
// de Table 1 plutôt que de les figer, sinon l'historique se retrouve mal étiqueté.
|
||||
const headerM = text.match(/(\d{4})\s+(\d{4})\s+(\d{4})\s+Longer run/);
|
||||
if (!headerM) return null;
|
||||
const years = [headerM[1], headerM[2], headerM[3], "Longer run"];
|
||||
|
||||
const start = text.indexOf("Federal funds rate");
|
||||
if (start === -1) return null;
|
||||
const noteIdx = text.indexOf("Note:", start);
|
||||
const block = noteIdx === -1 ? text.slice(start) : text.slice(start, noteIdx);
|
||||
|
||||
const medM = block.match(/^Federal funds rate\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
if (!medM) return null;
|
||||
|
||||
const prevM = block.match(/(January|March|April|June|July|September|December)\s+projection\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i);
|
||||
|
||||
// "Change in real GDP X X X X ..." / "PCE inflation X X X X ..." — même format
|
||||
// de ligne que "Federal funds rate", ailleurs dans Table 1 (pas dans `block`,
|
||||
// qui commence après ces lignes, donc on cherche dans `text` en entier).
|
||||
const toYearRecord = (vals: [string, string, string, string] | null) =>
|
||||
vals ? { [years[0]]: parseFloat(vals[0]), [years[1]]: parseFloat(vals[1]), [years[2]]: parseFloat(vals[2]), [years[3]]: parseFloat(vals[3]) } : null;
|
||||
const gdpM = text.match(/Change in real GDP\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
const pceM = text.match(/(?<!Core )PCE inflation\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
|
||||
return {
|
||||
years,
|
||||
median: { [years[0]]: parseFloat(medM[1]), [years[1]]: parseFloat(medM[2]), [years[2]]: parseFloat(medM[3]), [years[3]]: parseFloat(medM[4]) },
|
||||
prevMedian: prevM ? { [years[0]]: parseFloat(prevM[2]), [years[1]]: parseFloat(prevM[3]), [years[2]]: parseFloat(prevM[4]), [years[3]]: parseFloat(prevM[5]) } : null,
|
||||
prevLabel: prevM ? `${prevM[1]} projection` : null,
|
||||
gdpMedian: gdpM ? toYearRecord([gdpM[1], gdpM[2], gdpM[3], gdpM[4]]) : null,
|
||||
pceMedian: pceM ? toYearRecord([pceM[1], pceM[2], pceM[3], pceM[4]]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Figure 2 : dots individuels — table HTML sémantique (th.stub = niveau de taux,
|
||||
// td.data|td.emptydata = nb de participants par colonne année), bien plus fiable
|
||||
// qu'un parsing du texte aplati (cellules vides ambiguës une fois le texte collapsé).
|
||||
function parseSepFigure2(html: string): FedDot[] {
|
||||
const dots: FedDot[] = [];
|
||||
const figIdx = html.indexOf("Figure 2");
|
||||
if (figIdx === -1) return dots;
|
||||
const tableStart = html.indexOf("<table", figIdx);
|
||||
const tableEnd = html.indexOf("</table>", tableStart);
|
||||
if (tableStart === -1 || tableEnd === -1) return dots;
|
||||
const tableHtml = html.slice(tableStart, tableEnd);
|
||||
|
||||
const headerM = tableHtml.match(/<thead>([\s\S]*?)<\/thead>/);
|
||||
const years = headerM
|
||||
? Array.from(headerM[1].matchAll(/<th[^>]*>([^<]+)<\/th>/g), m => m[1].trim()).filter(y => !/midpoint/i.test(y))
|
||||
: ["2026", "2027", "2028", "Longer run"];
|
||||
|
||||
const rowRe = /<tr>\s*<th class="stub"[^>]*>([\d.]+)<\/th>([\s\S]*?)<\/tr>/g;
|
||||
let rm: RegExpExecArray | null;
|
||||
while ((rm = rowRe.exec(tableHtml)) !== null) {
|
||||
const rate = parseFloat(rm[1]);
|
||||
const cells = Array.from(rm[2].matchAll(/<td class="(data|emptydata)"[^>]*>([^<]*)<\/td>/g));
|
||||
cells.forEach((c, i) => {
|
||||
if (c[1] === "data") {
|
||||
const count = parseInt(c[2].trim(), 10);
|
||||
if (!isNaN(count) && count > 0 && years[i]) dots.push({ year: years[i], rate, count });
|
||||
}
|
||||
});
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
async function fetchFedDotPlot(sepDates: string[]): Promise<FedDotPlot | null> {
|
||||
if (!sepDates.length) return null;
|
||||
const latest = sepDates.at(-1)!;
|
||||
const htmlUrl = `https://www.federalreserve.gov/monetarypolicy/fomcprojtabl${latest}.htm`;
|
||||
const html = await fetchText(htmlUrl);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const table1 = parseSepTable1(text);
|
||||
if (!table1) return null;
|
||||
const dots = parseSepFigure2(html);
|
||||
|
||||
// Historique : jusqu'à 6 dernières publications SEP (trimestrielles) pour montrer l'évolution
|
||||
const histDates = sepDates.slice(-6);
|
||||
const history: FedSepHistoryPoint[] = [];
|
||||
const histResults = await Promise.all(histDates.map(async d => {
|
||||
if (d === latest) return { date: d, medianByYear: table1.median };
|
||||
const h = await fetchText(`https://www.federalreserve.gov/monetarypolicy/fomcprojtabl${d}.htm`);
|
||||
if (!h) return null;
|
||||
const t1 = parseSepTable1(htmlToText(h));
|
||||
return t1 ? { date: d, medianByYear: t1.median } : null;
|
||||
}));
|
||||
for (const h of histResults) if (h) history.push({ date: `${h.date.slice(0,4)}-${h.date.slice(4,6)}-${h.date.slice(6,8)}`, medianByYear: h.medianByYear });
|
||||
|
||||
return {
|
||||
asOfDate: `${latest.slice(0,4)}-${latest.slice(4,6)}-${latest.slice(6,8)}`,
|
||||
years: table1.years,
|
||||
medianByYear: table1.median,
|
||||
prevMedianByYear: table1.prevMedian,
|
||||
prevLabel: table1.prevLabel,
|
||||
dots,
|
||||
history,
|
||||
gdpMedian: table1.gdpMedian,
|
||||
pceMedian: table1.pceMedian,
|
||||
sepHtmlUrl: htmlUrl,
|
||||
sepPdfUrl: `https://www.federalreserve.gov/monetarypolicy/files/fomcprojtabl${latest}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchFedGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.USD;
|
||||
const dates = await findLatestFedDates();
|
||||
if (!dates || !dates.statementDates.length) return staticFallback("USD", "Impossible de lire le calendrier FOMC");
|
||||
|
||||
const latestStatementDate = dates.statementDates.at(-1)!;
|
||||
const [statement, dotPlot] = await Promise.all([
|
||||
fetchFedStatement(latestStatementDate),
|
||||
fetchFedDotPlot(dates.sepDates),
|
||||
]);
|
||||
|
||||
if (!statement) return staticFallback("USD", "Communiqué FOMC illisible");
|
||||
|
||||
const forecast: CBForecast | null = dotPlot && (dotPlot.gdpMedian || dotPlot.pceMedian)
|
||||
? {
|
||||
asOf: dotPlot.asOfDate,
|
||||
years: dotPlot.years.filter(y => y !== "Longer run"),
|
||||
gdp: dotPlot.gdpMedian ?? {},
|
||||
inflation: dotPlot.pceMedian ?? {},
|
||||
label: `Fed — Summary of Economic Projections (médianes) — ${dotPlot.asOfDate}`,
|
||||
sourceUrl: dotPlot.sepHtmlUrl,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
currency: "USD", ...info,
|
||||
meetingDate: `${latestStatementDate.slice(0,4)}-${latestStatementDate.slice(4,6)}-${latestStatementDate.slice(6,8)}`,
|
||||
rateLevel: statement.rateLevel,
|
||||
voteSummary: statement.voteSummary,
|
||||
voteDetail: statement.voteDetail,
|
||||
statementUrl: statement.statementUrl,
|
||||
reportPdfUrl: dotPlot?.sepPdfUrl ?? null,
|
||||
reportLabel: dotPlot ? `Summary of Economic Projections — ${dotPlot.asOfDate}` : null,
|
||||
...(dotPlot ? { dotPlot } : {}),
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// ── BoE (GBP) ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOE_MONTHS = ["january","february","march","april","may","june","july","august","september","october","november","december"];
|
||||
|
||||
interface BoeParsed { rateLevel: string; voteSummary: string; meetingDate: string | null; voteDetail: string | null; }
|
||||
|
||||
// "voted by a majority of X–Y to maintain/raise/lower Bank Rate at Z%" ou, si
|
||||
// unanime, "voted unanimously to maintain/raise/lower Bank Rate at Z%".
|
||||
function parseBoeVote(text: string): BoeParsed | null {
|
||||
const majM = text.match(/voted by a majority of\s+(\d+)\s*[–—-]\s*(\d+)\s+to\s+(?:maintain|reduce|increase|raise|cut)[^.]*?Bank Rate at\s+([\d.]+)%/i);
|
||||
const unanM = !majM && text.match(/voted unanimously to\s+(?:maintain|reduce|increase|raise|cut)[^.]*?Bank Rate at\s+([\d.]+)%/i);
|
||||
if (!majM && !unanM) return null;
|
||||
|
||||
const dateM = text.match(/At its meeting ending on\s+([^,]+),/i);
|
||||
// Périodes tolérées à l'intérieur (ex. "0.25 percentage points") : on ne coupe
|
||||
// que sur un point réellement suivi d'un espace (fin de phrase), pas un point décimal.
|
||||
const dissentM = text.match(/(\w+\s+members?\s+voted to(?:(?!\.\s)[\s\S])+)\.\s/i);
|
||||
|
||||
return majM
|
||||
? { rateLevel: `${majM[3]}%`, voteSummary: `${majM[1]} – ${majM[2]}`, meetingDate: dateM?.[1]?.trim() ?? null, voteDetail: dissentM?.[1]?.trim() ?? null }
|
||||
: { rateLevel: `${(unanM as RegExpMatchArray)[1]}%`, voteSummary: "Unanime", meetingDate: dateM?.[1]?.trim() ?? null, voteDetail: null };
|
||||
}
|
||||
|
||||
export async function fetchBoeGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.GBP;
|
||||
const now = new Date();
|
||||
|
||||
// Certaines pages du mois courant existent déjà en placeholder avant la réunion
|
||||
// ("to be published at Xpm") — on ne valide donc pas sur la simple présence de
|
||||
// la page, mais sur un vote réellement trouvé, et on recule au besoin.
|
||||
for (let back = 0; back < 5; back++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - back, 1);
|
||||
const monthName = BOE_MONTHS[d.getMonth()];
|
||||
const url = `https://www.bankofengland.co.uk/monetary-policy-summary-and-minutes/${d.getFullYear()}/${monthName}-${d.getFullYear()}`;
|
||||
const html = await fetchText(url);
|
||||
if (!html) continue;
|
||||
const parsed = parseBoeVote(htmlToText(html));
|
||||
if (!parsed) continue;
|
||||
|
||||
return {
|
||||
currency: "GBP", ...info,
|
||||
meetingDate: parsed.meetingDate,
|
||||
rateLevel: parsed.rateLevel,
|
||||
voteSummary: parsed.voteSummary,
|
||||
voteDetail: parsed.voteDetail,
|
||||
statementUrl: url,
|
||||
reportPdfUrl: `${url}.pdf`,
|
||||
reportLabel: "Monetary Policy Summary and Minutes",
|
||||
};
|
||||
}
|
||||
return staticFallback("GBP", "Vote MPC introuvable sur les 5 derniers mois");
|
||||
}
|
||||
|
||||
// ── RBA (AUD) ─────────────────────────────────────────────────────────────────
|
||||
// IMPORTANT : rba.gov.au renvoie 403 (Akamai) dès qu'un User-Agent de navigateur
|
||||
// est envoyé — la demande "brute" (aucun header custom) passe en revanche très
|
||||
// bien. Ne PAS utiliser fetchText() ici (il pose un User-Agent Chrome).
|
||||
|
||||
async function fetchPlain(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, { next: { revalidate: 6 * 3600 } });
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Prévisions semestrielles (Table 3.2 "Detailed Baseline Forecast Table") de la
|
||||
// dernière Statement on Monetary Policy — colonnes Dec/Jun (pas des années
|
||||
// calendaires pleines, la RBA prévoit par semestre).
|
||||
async function fetchRbaForecast(): Promise<CBForecast | null> {
|
||||
const indexHtml = await fetchPlain("https://www.rba.gov.au/publications/smp/");
|
||||
if (!indexHtml) return null;
|
||||
const linkM = indexHtml.match(/href="(\/publications\/smp\/\d{4}\/[a-z]+\/)"/);
|
||||
if (!linkM) return null;
|
||||
const pageUrl = `https://www.rba.gov.au${linkM[1]}outlook.html`;
|
||||
|
||||
const html = await fetchPlain(pageUrl);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const tblIdx = text.indexOf("Table 3.2");
|
||||
if (tblIdx === -1) return null;
|
||||
const endIdx = text.indexOf("Forecasts finalised", tblIdx);
|
||||
const win = text.slice(tblIdx, endIdx === -1 ? tblIdx + 6000 : endIdx);
|
||||
|
||||
const headerM = win.match(/(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})/);
|
||||
if (!headerM) return null;
|
||||
const years = [0, 2, 4, 6, 8, 10].map(i => `${headerM[i + 2]}-${headerM[i + 1] === "Dec" ? "12" : "06"}`);
|
||||
|
||||
const numRe = "(-?[\\d.]+)";
|
||||
const seriesRe = `${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}`;
|
||||
const gdpM = win.match(new RegExp(`Gross domestic product\\s+${seriesRe}`));
|
||||
const cpiM = win.match(new RegExp(`Consumer Price Index\\s+${seriesRe}`));
|
||||
if (!gdpM && !cpiM) return null;
|
||||
|
||||
const toRecord = (m: RegExpMatchArray | null) =>
|
||||
m ? Object.fromEntries(years.map((y, i) => [y, parseFloat(m[i + 1])])) : {};
|
||||
|
||||
return {
|
||||
asOf: new Date().toISOString().slice(0, 10),
|
||||
years,
|
||||
gdp: toRecord(gdpM),
|
||||
inflation: toRecord(cpiM),
|
||||
label: "RBA — Statement on Monetary Policy, prévisions semestrielles",
|
||||
sourceUrl: pageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRbaGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.AUD;
|
||||
const [indexHtml, forecast] = await Promise.all([
|
||||
fetchPlain("https://www.rba.gov.au/monetary-policy/int-rate-decisions/"),
|
||||
fetchRbaForecast().catch(() => null),
|
||||
]);
|
||||
if (!indexHtml) return staticFallback("AUD", "Index des décisions introuvable");
|
||||
|
||||
const links = Array.from(new Set(Array.from(indexHtml.matchAll(/\/media-releases\/(\d{4})\/mr-(\d{2})-(\d{2})\.html/g), m => m[0])));
|
||||
if (!links.length) return staticFallback("AUD", "Aucune décision trouvée sur l'index");
|
||||
// Le numéro de séquence (mr-YY-NN) croît avec le temps mais n'est pas daté — on
|
||||
// trie par (année, numéro) pour prendre la plus récente.
|
||||
links.sort((a, b) => {
|
||||
const pa = a.match(/mr-(\d{2})-(\d{2})/)!, pb = b.match(/mr-(\d{2})-(\d{2})/)!;
|
||||
return pa[1] !== pb[1] ? +pa[1] - +pb[1] : +pa[2] - +pb[2];
|
||||
});
|
||||
const url = `https://www.rba.gov.au${links.at(-1)}`;
|
||||
|
||||
const html = await fetchPlain(url);
|
||||
if (!html) return staticFallback("AUD", "Communiqué RBA illisible");
|
||||
const text = htmlToText(html);
|
||||
|
||||
const dateM = text.match(/\bDate\s+(\d{1,2}\s+\w+\s+\d{4})/);
|
||||
const majM = text.match(/made by majority:\s*([a-z]+)\s+members? voted to\s+(increase|decrease|lower|leave)[^;]*?(?:to\s+([\d.]+)\s+per cent)?;\s*([a-z]+)\s+members? voted to\s+(increase|decrease|lower|leave)[^.]*?(?:at\s+([\d.]+)\s+per cent)?/i);
|
||||
const unanRateM = text.match(/Board decided to\s+(?:leave the cash rate target unchanged at|increase the cash rate target(?:\s+by[^t]*)?\s*to|lower the cash rate target(?:\s+by[^t]*)?\s*to)\s+([\d.]+)\s+per cent/i);
|
||||
|
||||
const WORDNUM: Record<string, number> = { one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9 };
|
||||
|
||||
let voteSummary: string, rateLevel: string | null, voteDetail: string | null;
|
||||
if (majM) {
|
||||
const n1 = WORDNUM[majM[1].toLowerCase()] ?? NaN;
|
||||
const n2 = WORDNUM[majM[4].toLowerCase()] ?? NaN;
|
||||
voteSummary = !isNaN(n1) && !isNaN(n2) ? `${Math.max(n1,n2)} – ${Math.min(n1,n2)}` : "Majorité (détail ci-dessous)";
|
||||
rateLevel = (majM[3] ?? majM[6]) ? `${majM[3] ?? majM[6]}%` : null;
|
||||
voteDetail = majM[0];
|
||||
} else {
|
||||
voteSummary = "Unanime";
|
||||
rateLevel = unanRateM ? `${unanRateM[1]}%` : null;
|
||||
voteDetail = null;
|
||||
}
|
||||
|
||||
return {
|
||||
currency: "AUD", ...info,
|
||||
meetingDate: dateM ? dateM[1] : null,
|
||||
rateLevel,
|
||||
voteSummary,
|
||||
voteDetail,
|
||||
statementUrl: url,
|
||||
reportPdfUrl: null, // Statement on Monetary Policy = trimestriel, pas à chaque réunion
|
||||
reportLabel: "Statement by the Monetary Policy Board",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// ── SNB (CHF) ─────────────────────────────────────────────────────────────────
|
||||
// Décision collégiale (Direction générale à 3 membres) — aucun vote publié.
|
||||
|
||||
export async function fetchSnbGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.CHF;
|
||||
const listHtml = await fetchText("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions");
|
||||
const linkM = listHtml?.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_(\d{8})[^"]*)"/);
|
||||
if (!linkM) return staticFallback("CHF", "Décision SNB introuvable");
|
||||
const dateCompact = linkM[2];
|
||||
const dateIso = `${dateCompact.slice(0,4)}-${dateCompact.slice(4,6)}-${dateCompact.slice(6,8)}`;
|
||||
|
||||
const prHtml = await fetchText(`https://www.snb.ch${linkM[1]}`);
|
||||
if (!prHtml) return staticFallback("CHF", "Communiqué SNB illisible");
|
||||
const prText = htmlToText(prHtml);
|
||||
const rateM = prText.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i);
|
||||
|
||||
// "Conditional inflation forecast" — pas de prévision de croissance chiffrée
|
||||
// publiée par la SNB dans ce même communiqué, seulement l'inflation.
|
||||
const inflM = prText.match(/average annual inflation at\s+(-?[\d.]+)%\s+for\s+(\d{4}),\s+(-?[\d.]+)%\s+for\s+(\d{4})\s+and\s+(-?[\d.]+)%\s+for\s+(\d{4})/i);
|
||||
const forecast: CBForecast | null = inflM ? {
|
||||
asOf: dateIso,
|
||||
years: [inflM[2], inflM[4], inflM[6]],
|
||||
gdp: {},
|
||||
inflation: { [inflM[2]]: parseFloat(inflM[1]), [inflM[4]]: parseFloat(inflM[3]), [inflM[6]]: parseFloat(inflM[5]) },
|
||||
label: "SNB — prévision conditionnelle d'inflation",
|
||||
sourceUrl: `https://www.snb.ch${linkM[1]}`,
|
||||
} : null;
|
||||
|
||||
return {
|
||||
currency: "CHF", ...info,
|
||||
meetingDate: dateIso,
|
||||
rateLevel: rateM ? `${rateM[1]}%` : null,
|
||||
voteSummary: "Décision collégiale (3 membres)",
|
||||
voteDetail: null,
|
||||
statementUrl: `https://www.snb.ch${linkM[1]}`,
|
||||
reportPdfUrl: `https://www.snb.ch/public/asset/en/www-snb-ch/publications/communication/press-releases-restricted/pre_${dateCompact}/publications0_en/pre_${dateCompact}.en.pdf`,
|
||||
reportLabel: "Monetary Policy Assessment",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// Projections du dernier Monetary Policy Report — Table 2 ("Contributions to
|
||||
// average annual real GDP growth") donne les lignes annuelles GDP et CPI
|
||||
// inflation ; Table 3 (juste après) redonne un CPI trimestriel qu'on veut
|
||||
// éviter de capter par erreur, d'où la fenêtre de recherche bornée à
|
||||
// [Table 2: … Table 3:].
|
||||
async function fetchBocForecast(): Promise<CBForecast | null> {
|
||||
const indexHtml = await fetchText("https://www.bankofcanada.ca/publications/mpr/");
|
||||
if (!indexHtml) return null;
|
||||
const mprM = indexHtml.match(/href="(https:\/\/www\.bankofcanada\.ca\/publications\/mpr\/mpr-\d{4}-\d{2}-\d{2}\/)"/);
|
||||
if (!mprM) return null;
|
||||
const projUrl = `${mprM[1]}projections/`;
|
||||
|
||||
const html = await fetchText(projUrl);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const t2Start = text.indexOf("Table 2:");
|
||||
const t3Start = text.indexOf("Table 3:", t2Start);
|
||||
if (t2Start === -1 || t3Start === -1) return null;
|
||||
const window = text.slice(t2Start, t3Start);
|
||||
|
||||
const yearsM = window.match(/(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})/);
|
||||
const numRe = "(-?[\\d.]+)(?:\\s*\\([^)]*\\))?";
|
||||
const gdpM = window.match(new RegExp(`\\bGDP\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}`));
|
||||
const cpiM = window.match(new RegExp(`\\bCPI inflation\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}`));
|
||||
if (!yearsM || (!gdpM && !cpiM)) return null;
|
||||
|
||||
const years = [yearsM[1], yearsM[2], yearsM[3], yearsM[4]];
|
||||
const toRecord = (m: RegExpMatchArray | null) =>
|
||||
m ? { [years[0]]: parseFloat(m[1]), [years[1]]: parseFloat(m[2]), [years[2]]: parseFloat(m[3]), [years[3]]: parseFloat(m[4]) } : {};
|
||||
|
||||
return {
|
||||
asOf: new Date().toISOString().slice(0, 10),
|
||||
years,
|
||||
gdp: toRecord(gdpM),
|
||||
inflation: toRecord(cpiM),
|
||||
label: "BoC — Monetary Policy Report, projections",
|
||||
sourceUrl: projUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ── BoC (CAD) ─────────────────────────────────────────────────────────────────
|
||||
// Décision par consensus — aucun vote publié.
|
||||
|
||||
export async function fetchBocGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.CAD;
|
||||
const [html, forecast] = await Promise.all([
|
||||
fetchText("https://www.bankofcanada.ca/core-functions/monetary-policy/key-interest-rate/"),
|
||||
fetchBocForecast().catch(() => null),
|
||||
]);
|
||||
if (!html) return staticFallback("CAD", "Page taux directeur introuvable");
|
||||
const text = htmlToText(html);
|
||||
|
||||
// Tableau "Date* Target (%) Change (%) <date la plus récente> <taux> ..." —
|
||||
// la première ligne du tableau est toujours la décision la plus récente.
|
||||
const rowM = text.match(/Target \(%\)\s+Change \(%\)\s+([A-Z][a-z]+ \d{1,2},\s*\d{4})\s+([\d.]+)/);
|
||||
|
||||
return {
|
||||
currency: "CAD", ...info,
|
||||
meetingDate: rowM ? rowM[1] : null,
|
||||
rateLevel: rowM ? `${rowM[2]}%` : null,
|
||||
voteSummary: "Décision par consensus",
|
||||
voteDetail: null,
|
||||
statementUrl: "https://www.bankofcanada.ca/core-functions/monetary-policy/key-interest-rate/",
|
||||
reportPdfUrl: null,
|
||||
reportLabel: "Monetary Policy Report",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// Extrait les projections macroéconomiques (staff projections) du même
|
||||
// communiqué de décision déjà fetché — phrasé quasi-identique à chaque
|
||||
// publication trimestrielle : "headline inflation is expected to average X%
|
||||
// in Y1, Y% in Y2 and Z% in Y3" / "economic growth at an average of X% in Y1…".
|
||||
function parseEcbForecast(text: string, sourceUrl: string): CBForecast | null {
|
||||
const inflM = text.match(/(?:headline )?inflation is expected to average\s+([\d.]+)%\s+in\s+(\d{4}),\s+([\d.]+)%\s+in\s+(\d{4})\s+and\s+([\d.]+)%\s+in\s+(\d{4})/i);
|
||||
const gdpM = text.match(/(?:economic growth|GDP growth) at an average of\s+([\d.]+)%\s+in\s+(\d{4}),\s+([\d.]+)%\s+in\s+(\d{4})\s+and\s+([\d.]+)%\s+in\s+(\d{4})/i);
|
||||
if (!inflM && !gdpM) return null;
|
||||
|
||||
const years = Array.from(new Set([inflM?.[2], inflM?.[4], inflM?.[6], gdpM?.[2], gdpM?.[4], gdpM?.[6]].filter((y): y is string => !!y))).sort();
|
||||
|
||||
return {
|
||||
asOf: new Date().toISOString().slice(0, 10),
|
||||
years,
|
||||
gdp: gdpM ? { [gdpM[2]]: parseFloat(gdpM[1]), [gdpM[4]]: parseFloat(gdpM[3]), [gdpM[6]]: parseFloat(gdpM[5]) } : {},
|
||||
inflation: inflM ? { [inflM[2]]: parseFloat(inflM[1]), [inflM[4]]: parseFloat(inflM[3]), [inflM[6]]: parseFloat(inflM[5]) } : {},
|
||||
label: "Eurosystem staff macroeconomic projections",
|
||||
sourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ECB (EUR) ─────────────────────────────────────────────────────────────────
|
||||
// Décision par consensus — aucun vote publié. Découverte du dernier communiqué
|
||||
// via le flux RSS (les URLs de communiqués contiennent un hash non prévisible).
|
||||
|
||||
export async function fetchEcbGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.EUR;
|
||||
// Le site ECB charge sa liste de décisions côté client (pas de lien statique
|
||||
// fiable vers le dernier communiqué — hash d'URL non prévisible depuis la date).
|
||||
// Le flux RSS général capte la décision si elle est encore dans sa fenêtre
|
||||
// glissante ; sinon on retombe sur data/rate_decisions.json (déjà tenu à jour
|
||||
// manuellement ailleurs dans l'app) pour au moins afficher le taux courant.
|
||||
const rss = await fetchText("https://www.ecb.europa.eu/rss/press.html");
|
||||
const itemM = rss ? Array.from(rss.matchAll(/<item>([\s\S]*?)<\/item>/g)).map(m => m[1]).find(item => /ecb\.mp\d{6}~/.test(item)) : undefined;
|
||||
|
||||
if (itemM) {
|
||||
const urlM = itemM.match(/<link>([^<]+)<\/link>/);
|
||||
const dateM = itemM.match(/<pubDate>([^<]+)<\/pubDate>/);
|
||||
if (urlM) {
|
||||
const html = await fetchText(urlM[1]);
|
||||
const text = html ? htmlToText(html) : "";
|
||||
const rateM = text.match(/deposit facility rate[^.]*?to\s+([\d.]+)%/i);
|
||||
if (rateM || text) {
|
||||
return {
|
||||
currency: "EUR", ...info,
|
||||
meetingDate: dateM ? new Date(dateM[1]).toISOString().slice(0, 10) : null,
|
||||
rateLevel: rateM?.[1] ? `${rateM[1]}%` : null,
|
||||
voteSummary: "Décision par consensus",
|
||||
voteDetail: null,
|
||||
statementUrl: urlM[1],
|
||||
reportPdfUrl: null,
|
||||
reportLabel: "Monetary Policy Decision",
|
||||
forecast: parseEcbForecast(text, urlM[1]),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Repli : taux courant connu (data/rate_decisions.json), pas de date/URL de communiqué.
|
||||
const rateDecEntry = (rateDecisionsData as Array<{ decisions: Record<string, { current: number; source?: string }> }>)[0];
|
||||
const eurDecision = rateDecEntry?.decisions?.EUR;
|
||||
if (eurDecision === undefined) return staticFallback("EUR", "Communiqué introuvable et rate_decisions.json indisponible");
|
||||
|
||||
// Le champ "source" de rate_decisions.json contient parfois l'URL du
|
||||
// communiqué (écrite manuellement lors de la dernière correction) — on la
|
||||
// réutilise pour tenter d'en tirer les projections, à défaut de RSS.
|
||||
const fallbackUrlM = eurDecision.source?.match(/https:\/\/www\.ecb\.europa\.eu\/press\/pr\/\S+?\.html/);
|
||||
const fallbackHtml = fallbackUrlM ? await fetchText(fallbackUrlM[0]) : null;
|
||||
const fallbackForecast = fallbackHtml ? parseEcbForecast(htmlToText(fallbackHtml), fallbackUrlM![0]) : null;
|
||||
|
||||
return {
|
||||
currency: "EUR", ...info,
|
||||
meetingDate: null,
|
||||
rateLevel: `${eurDecision.current}%`,
|
||||
voteSummary: "Décision par consensus",
|
||||
voteDetail: null,
|
||||
statementUrl: fallbackUrlM?.[0] ?? null,
|
||||
reportPdfUrl: null,
|
||||
reportLabel: null,
|
||||
forecast: fallbackForecast,
|
||||
fetchError: "Dernier communiqué hors de la fenêtre RSS — taux affiché depuis rate_decisions.json",
|
||||
};
|
||||
}
|
||||
|
||||
// ── BoJ (JPY) ─────────────────────────────────────────────────────────────────
|
||||
// Vote numérique publié (ex. "7-1 majority vote"). Statement PDF-only en 2026 ;
|
||||
// date de réunion découverte par recherche en arrière (par lots concurrents,
|
||||
// même technique que lib/investinglive.ts) faute de calendrier discret exploitable.
|
||||
|
||||
async function tryBojDate(dateCompact: string): Promise<{ text: string; url: string } | null> {
|
||||
const url = `https://www.boj.or.jp/en/mopo/mpmdeci/mpr_2026/k${dateCompact}a.pdf`;
|
||||
try {
|
||||
// Pas de next:{revalidate} ici : le cache fetch de Next.js sur une réponse
|
||||
// binaire (PDF) interfère avec la lecture arrayBuffer() qui suit (échec
|
||||
// silencieux observé en pratique — un fetch "nu" comme pour RBA fonctionne).
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const buf = await res.arrayBuffer();
|
||||
const { PDFParse } = await import("pdf-parse");
|
||||
const parser = new PDFParse({ data: Buffer.from(buf) });
|
||||
const result = await parser.getText();
|
||||
await parser.destroy();
|
||||
return { text: result.text, url };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Outlook for Economic Activity and Prices ("Outlook Report") — publié 4x/an
|
||||
// (env. jan/avr/jul/oct), pas à chaque réunion. Table "Forecasts of the
|
||||
// Majority of the Policy Board Members" : pour chaque exercice fiscal, la
|
||||
// médiane est entre crochets ; on ignore les lignes "Forecasts made in [mois]"
|
||||
// qui sont l'ancienne prévision de comparaison, pas la prévision actuelle.
|
||||
async function fetchBojOutlookPdfText(year: number, month: number): Promise<string | null> {
|
||||
const yy = String(year % 100).padStart(2, "0");
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const url = `https://www.boj.or.jp/en/mopo/outlook/gor${yy}${mm}a.pdf`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const buf = await res.arrayBuffer();
|
||||
const { PDFParse } = await import("pdf-parse");
|
||||
const parser = new PDFParse({ data: Buffer.from(buf) });
|
||||
const result = await parser.getText();
|
||||
await parser.destroy();
|
||||
return result.text;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function fetchBojForecast(): Promise<CBForecast | null> {
|
||||
let y = new Date().getFullYear();
|
||||
let m = new Date().getMonth() + 1;
|
||||
const quarterMonths = [1, 4, 7, 10];
|
||||
|
||||
for (let tries = 0; tries < 8; tries++) {
|
||||
if (quarterMonths.includes(m)) {
|
||||
const text = await fetchBojOutlookPdfText(y, m);
|
||||
if (text) {
|
||||
const flat = text.replace(/\s+/g, " ");
|
||||
const rows = Array.from(flat.matchAll(
|
||||
/Fiscal (20\d{2})\s+[+-][\d.]+\s+to\s+[+-][\d.]+\s+\[([+-][\d.]+)\]\s+([+-][\d.]+)(?:\s+to\s+[+-][\d.]+\s+\[([+-][\d.]+)\])?/g
|
||||
));
|
||||
if (rows.length) {
|
||||
const years: string[] = [];
|
||||
const gdp: Record<string, number> = {};
|
||||
const inflation: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
const [, fy, gdpMedian, cpiValue, cpiMedian] = r;
|
||||
years.push(fy);
|
||||
gdp[fy] = parseFloat(gdpMedian);
|
||||
inflation[fy] = parseFloat(cpiMedian ?? cpiValue);
|
||||
}
|
||||
return {
|
||||
asOf: `${y}-${String(m).padStart(2, "0")}`,
|
||||
years,
|
||||
gdp,
|
||||
inflation,
|
||||
label: "BoJ — Outlook for Economic Activity and Prices (médianes)",
|
||||
sourceUrl: `https://www.boj.or.jp/en/mopo/outlook/gor${String(y % 100).padStart(2, "0")}${String(m).padStart(2, "0")}a.pdf`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
m--; if (m === 0) { m = 12; y--; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchBojGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.JPY;
|
||||
const now = new Date();
|
||||
const forecast = await fetchBojForecast().catch(() => null);
|
||||
|
||||
for (let batchStart = 0; batchStart <= 56; batchStart += 7) {
|
||||
const days = Array.from({ length: 7 }, (_, i) => batchStart + i);
|
||||
const results = await Promise.all(days.map(async d => {
|
||||
const dt = new Date(now); dt.setDate(now.getDate() - d);
|
||||
const compact = dt.toISOString().slice(0, 10).replace(/-/g, "").slice(2); // YYMMDD
|
||||
const r = await tryBojDate(compact);
|
||||
return r ? { ...r, daysAgo: d, dateIso: dt.toISOString().slice(0, 10) } : null;
|
||||
}));
|
||||
const found = results.filter((r): r is NonNullable<typeof r> => r !== null).sort((a, b) => a.daysAgo - b.daysAgo)[0];
|
||||
if (found) {
|
||||
const voteM = found.text.match(/by an?\s+(\d+)-(\d+)\s+majority vote/i);
|
||||
// Ex. "encourage the uncollateralized overnight call rate to remain at around 1.0 percent"
|
||||
const rateM = found.text.match(/(?:overnight call rate|policy rate)[^.]*?around\s+(-?[\d.]+)\s*percent/i);
|
||||
const namesM = found.text.match(/Voting against[^:]*:\s*([^.]+)\./i);
|
||||
return {
|
||||
currency: "JPY", ...info,
|
||||
meetingDate: found.dateIso,
|
||||
rateLevel: rateM ? `${rateM[1]}%` : null,
|
||||
voteSummary: voteM ? `${voteM[1]} – ${voteM[2]}` : "Unanime",
|
||||
voteDetail: namesM ? namesM[1].trim() : null,
|
||||
statementUrl: found.url,
|
||||
reportPdfUrl: found.url,
|
||||
reportLabel: "Statement on Monetary Policy",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
}
|
||||
return staticFallback("JPY", "Communiqué BoJ introuvable sur les 8 dernières semaines");
|
||||
}
|
||||
|
||||
// ── RBNZ (NZD) ────────────────────────────────────────────────────────────────
|
||||
// rbnz.govt.nz bloque toute requête automatisée derrière un challenge Cloudflare
|
||||
// interactif ("Verify you are human") — vérifié avec un vrai navigateur headless,
|
||||
// pas seulement fetch(). Ce n'est pas contournable proprement (et on ne cherche
|
||||
// pas à résoudre un captcha anti-bot / évader une protection délibérée).
|
||||
//
|
||||
// Repli légitime (aucune requête vers rbnz.govt.nz) :
|
||||
// - Taux OCR courant : data/rate_decisions.json (auto-maintenu par
|
||||
// update-rate-decisions.yml).
|
||||
// - Date de la dernière réunion : calendrier économique Trading Economics
|
||||
// (lib/tradingeconomics.ts, déjà utilisé ailleurs dans l'app pour le
|
||||
// calendrier), qui couvre la Nouvelle-Zélande — pas de scraping du site
|
||||
// RBNZ lui-même.
|
||||
// - Vote : la RBNZ ne publie de toute façon pas de décompte individuel des
|
||||
// votes (confirmé via TE/interest.co.nz — décision par consensus du
|
||||
// comité, contrairement à la Fed/BoE), donc "non publié" est correct sur
|
||||
// le fond, pas seulement une conséquence du blocage.
|
||||
|
||||
export async function fetchRbnzGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.NZD;
|
||||
const rateDecEntry = (rateDecisionsData as Array<{ decisions: Record<string, { current: number }> }>)[0];
|
||||
const nzdRate = rateDecEntry?.decisions?.NZD?.current;
|
||||
if (nzdRate === undefined) return staticFallback("NZD", "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) et rate_decisions.json indisponible");
|
||||
|
||||
let meetingDate: string | null = null;
|
||||
try {
|
||||
const to = new Date();
|
||||
const from = new Date(to); from.setDate(from.getDate() - 70);
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
// "new-zealand" n'est pas dans le G20 → absent de fetchTECalendarHTML (page
|
||||
// /calendar générique), d'où l'usage du fetcher mono-pays dédié.
|
||||
const events = await fetchTECalendarForCountry("new-zealand", iso(from), iso(to));
|
||||
const decisions = events
|
||||
.filter(e => e.category === "policy_rate" && e.isPublished)
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
if (decisions[0]) meetingDate = decisions[0].date.slice(0, 10);
|
||||
} catch { /* meetingDate reste null, pas bloquant */ }
|
||||
|
||||
return {
|
||||
currency: "NZD", ...info,
|
||||
meetingDate,
|
||||
rateLevel: `${nzdRate}%`,
|
||||
voteSummary: "Non publié",
|
||||
voteDetail: "La RBNZ ne publie pas de décompte de vote (décision par consensus du comité) ; communiqué détaillé indisponible — rbnz.govt.nz bloque le scraping automatisé (challenge Cloudflare).",
|
||||
statementUrl: null,
|
||||
reportPdfUrl: null,
|
||||
reportLabel: null,
|
||||
fetchError: "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) — taux (rate_decisions.json) et date de réunion (Trading Economics) affichés en repli.",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Agrégateur ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAllCBGovernance(): Promise<Record<Currency, CBGovernance>> {
|
||||
const [usd, gbp, eur, jpy, chf, cad, aud, nzd] = await Promise.all([
|
||||
fetchFedGovernance().catch(() => staticFallback("USD", "Erreur de scraping")),
|
||||
fetchBoeGovernance().catch(() => staticFallback("GBP", "Erreur de scraping")),
|
||||
fetchEcbGovernance().catch(() => staticFallback("EUR", "Erreur de scraping")),
|
||||
fetchBojGovernance().catch(() => staticFallback("JPY", "Erreur de scraping")),
|
||||
fetchSnbGovernance().catch(() => staticFallback("CHF", "Erreur de scraping")),
|
||||
fetchBocGovernance().catch(() => staticFallback("CAD", "Erreur de scraping")),
|
||||
fetchRbaGovernance().catch(() => staticFallback("AUD", "Erreur de scraping")),
|
||||
fetchRbnzGovernance().catch(() => staticFallback("NZD", "Erreur de scraping")),
|
||||
]);
|
||||
return { USD: usd, GBP: gbp, EUR: eur, JPY: jpy, CHF: chf, CAD: cad, AUD: aud, NZD: nzd };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// lib/fxstreetCalendar.ts
|
||||
// Calendrier économique investingLive (investinglive.com/EconomicCalendar).
|
||||
//
|
||||
// investingLive n'a pas d'API JSON publique : la page embarque le widget
|
||||
// FXStreet (calendar.fxstreet.com), chargé via /fxstreet-calendar.js sur
|
||||
// investinglive.com. On appelle directement l'endpoint HTML de ce widget —
|
||||
// c'est le même appel que fait le navigateur quand on visite
|
||||
// investinglive.com/EconomicCalendar (Referer investingLive requis).
|
||||
//
|
||||
// Endpoint : GET https://calendar.fxstreet.com/EventDateWidget/GetMain
|
||||
// ?culture=en-US&timezone=UTC&start=YYYYMMDD&end=YYYYMMDD&view=range
|
||||
// &rows=5000&countrycode=US,UK,EMU,JP,...
|
||||
// → retourne un fragment HTML (table <tr class="fxst-dateRow"> + <tr ...fxit-eventrow>)
|
||||
// couvrant les 45 pays de CALENDAR_COUNTRIES (bien au-delà des 8 devises majeures).
|
||||
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
import { FXSTREET_COUNTRYCODES, FX_NAME_TO_CURRENCY, FX_NAME_TO_CODE } from "./calendar-countries";
|
||||
import { classifyOtherTitle } from "./calendar-taxonomy";
|
||||
|
||||
export interface WideCalendarEvent {
|
||||
id: string;
|
||||
date: string; // ISO UTC
|
||||
currency: string; // ISO 4217 (univers élargi, pas seulement les 8 majeures)
|
||||
countryCode: string; // code pays (ex. "US", "EMU", "CN")
|
||||
category: EventCategory;
|
||||
title: string;
|
||||
impact: "high" | "medium" | "low";
|
||||
actual: string | null;
|
||||
forecast: string | null;
|
||||
previous: string | null;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
// ── Catégorie (mêmes heuristiques que teCategory/invCategory) ────────────────
|
||||
|
||||
function fxCategory(title: string): EventCategory {
|
||||
const t = title.toLowerCase();
|
||||
if (/\bspeech\b|speaks?\b|testimony|\bpress\s+conf/.test(t)) return "cb_speech";
|
||||
if (/\bpmi\b|purchasing\s+managers/.test(t)) return "pmi";
|
||||
if (/interest\s+rate|monetary\s+policy|rate\s+decision|bank\s+rate/.test(t)) return "policy_rate";
|
||||
if (/inflation|\bcpi\b|\bhicp\b|consumer\s+price|\bppi\b|producer\s+price/.test(t)) return "inflation";
|
||||
if (/\bgdp\b|gross\s+domestic|growth\s+rate/.test(t)) return "gdp";
|
||||
if (/retail\s+sales|core\s+retail|household\s+spending/.test(t)) return "retail_sales";
|
||||
if (/trade\s+balance|current\s+account|balance\s+of\s+trade/.test(t)) return "trade_balance";
|
||||
if (/employment|payrolls|nonfarm|jobless|unemployment|job\s+creation|\badp\b|jolts|job\s+openings|job\s+quits|ism\s+\w+\s+employ/.test(t)) return "employment";
|
||||
return classifyOtherTitle(title);
|
||||
}
|
||||
|
||||
function impactFromVolatility(n: number): "high" | "medium" | "low" {
|
||||
if (n >= 3) return "high";
|
||||
if (n >= 2) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function decodeHTMLEntities(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripTags(s: string): string {
|
||||
return decodeHTMLEntities(s.replace(/<[^>]*>/g, " ").replace(/\s+/g, " "));
|
||||
}
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
|
||||
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
||||
};
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFXStreetHTML(html: string, fromDate: string): WideCalendarEvent[] {
|
||||
const events: WideCalendarEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
let currentYear = parseInt(fromDate.slice(0, 4), 10);
|
||||
let lastMonth = -1;
|
||||
let currentDateStr: string | null = null; // YYYY-MM-DD
|
||||
|
||||
const rowRe =
|
||||
/<tr class="fxst-dateRow">\s*<td colspan="9">([^<]+)<\/td>\s*<\/tr>|<tr class="fxst-tr-event[^"]*"\s+data-eventdateid="([^"]*)"\s+data-actual="([^"]*)"\s+data-unit="([^"]*)"\s+data-precision="([^"]*)"\s+data-pot="([^"]*)"\s+data-isbetter="([^"]*)"\s+data-countryname="([^"]+)">([\s\S]*?)<\/tr>/g;
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rowRe.exec(html)) !== null) {
|
||||
if (m[1] !== undefined) {
|
||||
// ── Ligne séparateur de date : "Wednesday, Jul 01" ──────────────────
|
||||
const dm = m[1].match(/([A-Za-z]+)\s+(\d{1,2})/);
|
||||
if (!dm) continue;
|
||||
const monIdx = MONTHS[dm[1].slice(0, 3).toLowerCase()];
|
||||
if (monIdx === undefined) continue;
|
||||
const day = parseInt(dm[2], 10);
|
||||
if (lastMonth !== -1 && monIdx < lastMonth) currentYear += 1; // rollover déc → jan
|
||||
lastMonth = monIdx;
|
||||
currentDateStr = `${currentYear}-${String(monIdx + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Ligne événement ────────────────────────────────────────────────────
|
||||
const [, , eventDateId, , , , , , countryName, body] = m;
|
||||
if (!currentDateStr) continue;
|
||||
|
||||
const ccy = FX_NAME_TO_CURRENCY[countryName];
|
||||
const code = FX_NAME_TO_CODE[countryName];
|
||||
if (!ccy || !code) continue;
|
||||
|
||||
const cells = Array.from(body.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g), c => c[1]);
|
||||
if (cells.length < 7) continue;
|
||||
|
||||
const timeM = cells[0].match(/(\d{1,2}):(\d{2})/);
|
||||
const time24 = timeM ? `${timeM[1].padStart(2, "0")}:${timeM[2]}` : "00:00";
|
||||
const isoDate = `${currentDateStr}T${time24}:00Z`;
|
||||
const evDate = new Date(isoDate);
|
||||
|
||||
const title = stripTags(cells[2]);
|
||||
if (!title) continue;
|
||||
|
||||
const volM = stripTags(cells[3]).match(/\d+/);
|
||||
const impact = impactFromVolatility(volM ? parseInt(volM[0], 10) : 0);
|
||||
|
||||
const actual = stripTags(cells[4]) || null;
|
||||
const forecast = stripTags(cells[5]) || null;
|
||||
const previous = stripTags(cells[6]) || null;
|
||||
|
||||
events.push({
|
||||
id: `il_${eventDateId}`,
|
||||
date: isoDate,
|
||||
currency: ccy,
|
||||
countryCode: code,
|
||||
category: fxCategory(title),
|
||||
title,
|
||||
impact,
|
||||
actual,
|
||||
forecast,
|
||||
previous,
|
||||
isPublished: actual !== null || evDate < now,
|
||||
});
|
||||
}
|
||||
|
||||
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
||||
// fromDate / toDate au format YYYY-MM-DD
|
||||
|
||||
export async function fetchFXStreetCalendar(fromDate: string, toDate: string): Promise<WideCalendarEvent[]> {
|
||||
const start = fromDate.replace(/-/g, "");
|
||||
const end = toDate.replace(/-/g, "");
|
||||
|
||||
const url =
|
||||
`https://calendar.fxstreet.com/EventDateWidget/GetMain` +
|
||||
`?culture=en-US&timezone=UTC&start=${start}&end=${end}&view=range&rows=5000` +
|
||||
`&countrycode=${FXSTREET_COUNTRYCODES}`;
|
||||
|
||||
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-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://investinglive.com/EconomicCalendar",
|
||||
},
|
||||
});
|
||||
if (!res.ok) { console.warn("[fxstreet] HTTP", res.status); return []; }
|
||||
const html = await res.text();
|
||||
return parseFXStreetHTML(html, fromDate);
|
||||
} catch (err) {
|
||||
console.error("[fxstreet] error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+24
-16
@@ -46,50 +46,58 @@ export interface ILExpectationsWithHistory {
|
||||
|
||||
interface ArticleRef { url: string; dateStr: string; daysAgo: number; }
|
||||
|
||||
// Teste les 4 variantes d'URL (/centralbank/ ou /news/, events pluriel ou singulier)
|
||||
// en parallèle pour un jour donné — un seul aller-retour réseau au lieu de 4 séquentiels.
|
||||
async function tryUrl(daysAgo: number): Promise<ArticleRef | null> {
|
||||
const d = new Date(Date.now() - daysAgo * 86_400_000);
|
||||
const yyyymmdd = d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const dateStr = `${yyyymmdd.slice(0,4)}-${yyyymmdd.slice(4,6)}-${yyyymmdd.slice(6,8)}`;
|
||||
// Tester les 4 variantes : /centralbank/ ou /news/, events (pluriel) ou event (singulier)
|
||||
const candidates = [
|
||||
`https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`,
|
||||
`https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`,
|
||||
`https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`,
|
||||
`https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`,
|
||||
];
|
||||
for (const url of candidates) {
|
||||
const hits = await Promise.all(candidates.map(async (url) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) { res.body?.cancel().catch(() => {}); continue; }
|
||||
res.body?.cancel().catch(() => {});
|
||||
return { url, dateStr, daysAgo };
|
||||
} catch { continue; }
|
||||
return res.ok ? url : null;
|
||||
} catch { return null; }
|
||||
}));
|
||||
const found = hits.find((u): u is string => u !== null);
|
||||
return found ? { url: found, dateStr, daysAgo } : null;
|
||||
}
|
||||
|
||||
// Cherche le jour le plus récent (daysAgo le plus petit) avec un article dans [start, end],
|
||||
// par lots concurrents plutôt que séquentiellement — évite jusqu'à ~150 aller-retours
|
||||
// réseau en série (risque de timeout sur les fonctions serverless Vercel).
|
||||
async function findDayInRange(start: number, end: number, batchSize = 6): Promise<ArticleRef | null> {
|
||||
for (let batchStart = start; batchStart <= end; batchStart += batchSize) {
|
||||
const batchEnd = Math.min(batchStart + batchSize - 1, end);
|
||||
const days = Array.from({ length: batchEnd - batchStart + 1 }, (_, i) => batchStart + i);
|
||||
const results = await Promise.all(days.map(tryUrl));
|
||||
const found = results
|
||||
.filter((r): r is ArticleRef => r !== null)
|
||||
.sort((a, b) => a.daysAgo - b.daysAgo)[0];
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findArticleRefs(): Promise<{ current: ArticleRef | null; previous: ArticleRef | null }> {
|
||||
let current: ArticleRef | null = null;
|
||||
|
||||
for (let d = 0; d <= 14; d++) {
|
||||
const found = await tryUrl(d);
|
||||
if (found) { current = found; break; }
|
||||
}
|
||||
const current = await findDayInRange(0, 14);
|
||||
|
||||
console.log(`[IL] article courant : ${current ? `day=${current.daysAgo} url=${current.url}` : "introuvable (0–14 jours)"}`);
|
||||
if (!current) return { current: null, previous: null };
|
||||
|
||||
let previous: ArticleRef | null = null;
|
||||
const searchFrom = current.daysAgo + 1;
|
||||
const searchTo = current.daysAgo + 28;
|
||||
for (let d = searchFrom; d <= searchTo; d++) {
|
||||
const found = await tryUrl(d);
|
||||
if (found) { previous = found; break; }
|
||||
}
|
||||
const previous = await findDayInRange(searchFrom, searchTo);
|
||||
|
||||
console.log(`[IL] article précédent : ${previous ? `day=${previous.daysAgo} url=${previous.url}` : `introuvable (day ${searchFrom}–${searchTo})`}`);
|
||||
return { current, previous };
|
||||
|
||||
+24
-18
@@ -620,42 +620,48 @@ async function fetchRssFeed(url: string, source: string): Promise<NewsItem[]> {
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── Source 1 : InvestingLive via WordPress REST API ───────────────────────────
|
||||
// Beaucoup plus fiable que le scraping HTML — retourne du JSON structuré
|
||||
// ── Source 1 : InvestingLive via leur API interne (Nuxt/api.investinglive.com) ─
|
||||
// L'ancien endpoint WordPress (wp-json) renvoie 404 depuis la migration du site
|
||||
// vers Nuxt.js (rebrand ForexLive → investingLive) — remplacé par l'API JSON
|
||||
// qui alimente leur propre page d'accueil (aucune clé requise, publique).
|
||||
|
||||
async function fetchInvestingLiveNews(): Promise<NewsItem[]> {
|
||||
try {
|
||||
// WordPress REST API : liste des posts récents, filtrée sur catégorie forex si dispo
|
||||
const res = await fetch(
|
||||
"https://investinglive.com/wp-json/wp/v2/posts?per_page=20&orderby=date&order=desc&_fields=id,title,link,date,excerpt,categories",
|
||||
"https://api.investinglive.com/api/homepage/articles",
|
||||
{
|
||||
next: { revalidate: 1800 },
|
||||
next: { revalidate: 900 },
|
||||
headers: { ...TE_HEADERS, "Accept": "application/json" },
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const posts = await res.json() as Array<{
|
||||
id: number;
|
||||
title: { rendered: string };
|
||||
link: string;
|
||||
date: string;
|
||||
excerpt: { rendered: string };
|
||||
}>;
|
||||
|
||||
const data = await res.json() as {
|
||||
LatestArticles?: Array<{
|
||||
Id: string;
|
||||
Title: string;
|
||||
Slug: string;
|
||||
Tldr?: string[] | string | null; // schéma incohérent selon le type d'article
|
||||
PublishedOn: string;
|
||||
Category?: { Name: string; Slug: string };
|
||||
}>;
|
||||
};
|
||||
const posts = data.LatestArticles;
|
||||
if (!Array.isArray(posts)) return [];
|
||||
|
||||
return posts.slice(0, 20).map(post => {
|
||||
const title = post.title?.rendered?.replace(/<[^>]+>/g, "").replace(/’/g, "'").replace(/“/g, '"').replace(/”/g, '"').trim() ?? "";
|
||||
const summary = post.excerpt?.rendered?.replace(/<[^>]+>/g, "").trim().slice(0, 250);
|
||||
const title = post.Title?.trim() ?? "";
|
||||
const summary = (Array.isArray(post.Tldr) ? post.Tldr.join(" ") : post.Tldr ?? "").slice(0, 250);
|
||||
const catSlug = post.Category?.Slug ?? "news";
|
||||
const url = `https://investinglive.com/${catSlug}/${post.Slug}/`;
|
||||
const combined = `${title} ${summary ?? ""}`;
|
||||
const { impacts, categories } = applyRules(combined);
|
||||
return {
|
||||
id: `il-${post.id}`,
|
||||
id: `il-${post.Id}`,
|
||||
title,
|
||||
url: post.link,
|
||||
url,
|
||||
source: "InvestingLive",
|
||||
publishedAt: parseDate(post.date),
|
||||
publishedAt: parseDate(post.PublishedOn),
|
||||
summary,
|
||||
impacts,
|
||||
categories,
|
||||
|
||||
+90
-99
@@ -99,15 +99,21 @@ const MEETING_TITLES: Partial<Record<Currency, string>> = {
|
||||
|
||||
// ── Extraction des champs (nommage hétérogène selon les CB) ───────────────────
|
||||
|
||||
// .github/scripts/fetch-rate-data.mjs écrit toujours "midpoint", quelle que soit
|
||||
// la devise (CME/Investing.com/InvestingLive écrivent tous le même schéma). Les
|
||||
// noms par devise ci-dessous ("current_target", "cash_rate_target"…) datent de
|
||||
// l'ancienne source rateprobability.com (remplacée par le commit 2673686) et ne
|
||||
// sont plus jamais écrits par le pipeline actuel — d'où le "0.00%" affiché pour
|
||||
// toute devise autre que USD/NZD (les deux seules à retomber sur "midpoint").
|
||||
function getCurrentRate(ccy: Currency, today: Record<string, unknown>): number {
|
||||
if (typeof today["midpoint"] === "number") return today["midpoint"] as 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) ?? (today["midpoint"] as number) ?? 0;
|
||||
case "NZD": return (today["Official Cash Rate (OCR)"] as number) ?? (today["current_target"] as number) ?? 0;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
@@ -118,56 +124,79 @@ function getAsOf(today: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
|
||||
// ── SNB meeting dates (published par la SNB, trimestrielles) ─────────────────
|
||||
// Mise à jour annuelle : mars, juin, septembre, décembre
|
||||
// ── Calendriers officiels par banque centrale ─────────────────────────────────
|
||||
// Utilisés pour les CB sans page Investing.com dédiée (ou dont le fallback IL
|
||||
// n'a qu'un seul point "year-end") : on construit une vraie courbe multi-réunions
|
||||
// à partir des dates réelles + de l'estimation InvestingLive (proba/direction).
|
||||
// Sources officielles, à mettre à jour quand chaque banque publie son calendrier
|
||||
// suivant (généralement 1x/an) :
|
||||
// SNB : snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions
|
||||
// RBNZ : rbnz.govt.nz
|
||||
// ECB : ecb.europa.eu/press/calendars/mgcgc (jour 2 = décision + conf. presse)
|
||||
// BoE : bankofengland.co.uk/monetary-policy/upcoming-mpc-dates
|
||||
// BoJ : boj.or.jp/en/mopo/mpmsche_minu (2027 pas encore publié à l'écriture de ceci)
|
||||
// BoC : bankofcanada.ca (annonce annuelle du calendrier, pas encore publié pour 2027)
|
||||
// RBA : rba.gov.au/schedules-events/board-meeting-schedules.html (jour 2 = décision)
|
||||
|
||||
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",
|
||||
"2026-06-19", "2026-09-25", "2026-12-11",
|
||||
"2027-03-18", "2027-06-17", "2027-09-23", "2027-12-09",
|
||||
];
|
||||
|
||||
// ── RBNZ meeting dates (7 par an) ─────────────────────────────────────────────
|
||||
// Source officielle : rbnz.govt.nz — mise à jour annuelle
|
||||
|
||||
const RBNZ_MEETINGS: string[] = [
|
||||
// 2026
|
||||
"2026-07-09",
|
||||
"2026-08-19",
|
||||
"2026-10-14",
|
||||
"2026-11-25",
|
||||
// 2027
|
||||
"2027-02-24",
|
||||
"2027-04-09",
|
||||
"2027-05-26",
|
||||
"2027-07-14",
|
||||
"2027-08-18",
|
||||
"2027-10-13",
|
||||
"2027-11-24",
|
||||
"2026-07-09", "2026-08-19", "2026-10-14", "2026-11-25",
|
||||
"2027-02-24", "2027-04-09", "2027-05-26", "2027-07-14",
|
||||
"2027-08-18", "2027-10-13", "2027-11-24",
|
||||
];
|
||||
|
||||
// Construit un CBRatePath NZD depuis InvestingLive + calendrier RBNZ officiel
|
||||
function buildRBNZPath(il: ILExpectationsMap, currentRate: number): CBRatePath | null {
|
||||
const nzdData = il["NZD"];
|
||||
if (!nzdData) return null;
|
||||
const ECB_MEETINGS: string[] = [
|
||||
"2026-07-23", "2026-09-10", "2026-10-29", "2026-12-17",
|
||||
"2027-02-04", "2027-03-18", "2027-04-29", "2027-06-10",
|
||||
"2027-07-22", "2027-09-09", "2027-10-28", "2027-12-16",
|
||||
];
|
||||
|
||||
const BOE_MEETINGS: string[] = [
|
||||
"2026-07-30", "2026-09-17", "2026-11-05", "2026-12-17",
|
||||
"2027-02-04", "2027-03-18", "2027-04-29", "2027-06-17",
|
||||
"2027-07-29", "2027-09-16", "2027-11-04", "2027-12-16",
|
||||
];
|
||||
|
||||
const BOJ_MEETINGS: string[] = [
|
||||
"2026-07-31", "2026-09-18", "2026-10-30", "2026-12-18",
|
||||
];
|
||||
|
||||
const BOC_MEETINGS: string[] = [
|
||||
"2026-07-15", "2026-09-02", "2026-10-28", "2026-12-09",
|
||||
];
|
||||
|
||||
const RBA_MEETINGS: string[] = [
|
||||
"2026-08-11", "2026-09-29", "2026-11-03", "2026-12-08",
|
||||
"2027-02-09", "2027-03-23", "2027-05-04", "2027-06-22",
|
||||
"2027-08-10", "2027-09-28", "2027-11-02", "2027-12-14",
|
||||
];
|
||||
|
||||
// Construit un CBRatePath depuis un calendrier officiel + l'estimation InvestingLive
|
||||
// (proba/direction de la prochaine réunion, biais year-end pour les suivantes).
|
||||
function buildOfficialCalendarPath(
|
||||
currency: Currency,
|
||||
officialMeetings: string[],
|
||||
il: ILExpectationsMap,
|
||||
currentRate: number,
|
||||
): CBRatePath | null {
|
||||
const ilData = il[currency];
|
||||
if (!ilData) return null;
|
||||
|
||||
const nowIso = new Date().toISOString().slice(0, 10);
|
||||
const upcomingMeetings = RBNZ_MEETINGS.filter(d => d >= nowIso);
|
||||
const upcomingMeetings = officialMeetings.filter(d => d >= nowIso);
|
||||
if (upcomingMeetings.length === 0) return null;
|
||||
|
||||
const yearEndIsCut = nzdData.bpsYearEnd < 0;
|
||||
const yearEndIsCut = ilData.bpsYearEnd < 0;
|
||||
|
||||
const meetings: RateProbMeeting[] = upcomingMeetings.map((dateIso, i) => {
|
||||
const isNext = i === 0;
|
||||
const probMovePct = isNext ? nzdData.nextMeetingProbPct : 0;
|
||||
const probMovePct = isNext ? ilData.nextMeetingProbPct : 0;
|
||||
const probIsCut = isNext
|
||||
? (nzdData.nextMeetingIsNoChange ? yearEndIsCut : !nzdData.nextMeetingIsHike)
|
||||
? (ilData.nextMeetingIsNoChange ? yearEndIsCut : !ilData.nextMeetingIsHike)
|
||||
: yearEndIsCut;
|
||||
const changeBps = isNext ? (probMovePct > 50 ? (probIsCut ? -25 : 25) : 0) : 0;
|
||||
const impliedRate = isNext && probMovePct > 50
|
||||
@@ -182,56 +211,8 @@ function buildRBNZPath(il: ILExpectationsMap, currentRate: number): CBRatePath |
|
||||
);
|
||||
|
||||
return {
|
||||
currency: "NZD",
|
||||
asOf: nzdData.publishedDate,
|
||||
currentRate,
|
||||
meetings,
|
||||
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
|
||||
yearEndImplied: meetings.at(-1)?.impliedRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
currency,
|
||||
asOf: ilData.publishedDate,
|
||||
currentRate,
|
||||
meetings,
|
||||
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
|
||||
@@ -362,18 +343,28 @@ export async function fetchAllCBPaths(): Promise<RateProbData> {
|
||||
}
|
||||
}
|
||||
|
||||
// CHF/SNB : Investing.com n'a pas de page SNB → InvestingLive seule source.
|
||||
if (!data["CHF"] && ilData["CHF"]) {
|
||||
const snbPath = buildSNBPath(ilData, 0.00);
|
||||
if (snbPath) data["CHF"] = snbPath;
|
||||
}
|
||||
|
||||
// NZD/RBNZ : Investing.com n'a pas de page RBNZ → InvestingLive + calendrier RBNZ.
|
||||
// Si les données JSON ont ≤ 1 réunion (buildILFallback n'en met qu'une) → reconstruire.
|
||||
if ((!data["NZD"] || data["NZD"].meetings.length <= 1) && ilData["NZD"]) {
|
||||
const rate = data["NZD"]?.currentRate || 2.25; // RBNZ OCR juin 2026
|
||||
const rbnzPath = buildRBNZPath(ilData, rate);
|
||||
if (rbnzPath) data["NZD"] = rbnzPath;
|
||||
// Investing.com n'a de Rate Monitor que pour la Fed (USD) — toutes les autres
|
||||
// devises retombent sur le fallback InvestingLive, qui n'a qu'un seul point
|
||||
// "year-end" (buildILFallback dans fetch-rate-data.mjs). On reconstruit ici une
|
||||
// vraie courbe multi-réunions à partir du calendrier officiel de chaque CB +
|
||||
// de l'estimation InvestingLive (proba/direction), comme déjà fait pour NZD/CHF.
|
||||
const OFFICIAL_CALENDARS: Partial<Record<Currency, { meetings: string[]; fallbackRate: number }>> = {
|
||||
CHF: { meetings: SNB_MEETINGS, fallbackRate: 0.00 },
|
||||
NZD: { meetings: RBNZ_MEETINGS, fallbackRate: 2.25 },
|
||||
EUR: { meetings: ECB_MEETINGS, fallbackRate: 2.15 },
|
||||
GBP: { meetings: BOE_MEETINGS, fallbackRate: 3.75 },
|
||||
JPY: { meetings: BOJ_MEETINGS, fallbackRate: 0.75 },
|
||||
CAD: { meetings: BOC_MEETINGS, fallbackRate: 2.25 },
|
||||
AUD: { meetings: RBA_MEETINGS, fallbackRate: 4.35 },
|
||||
};
|
||||
for (const [ccyStr, cal] of Object.entries(OFFICIAL_CALENDARS)) {
|
||||
const ccy = ccyStr as Currency;
|
||||
if (!cal) continue;
|
||||
// Reconstruit si aucune donnée, ou si le fallback IL n'a mis qu'un seul point.
|
||||
if ((data[ccy] && data[ccy]!.meetings.length > 1) || !ilData[ccy]) continue;
|
||||
const rate = data[ccy]?.currentRate || cal.fallbackRate;
|
||||
const path = buildOfficialCalendarPath(ccy, cal.meetings, ilData, rate);
|
||||
if (path) data[ccy] = path;
|
||||
}
|
||||
|
||||
// Enrichissement IL : fusion proba première réunion + deltas hebdo
|
||||
|
||||
+96
-1
@@ -17,6 +17,9 @@
|
||||
|
||||
import type { Currency } from "./types";
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
import { CALENDAR_COUNTRIES, TE_NAME_TO_CURRENCY, TE_NAME_TO_CODE } from "./calendar-countries";
|
||||
import type { WideCalendarEvent } from "./fxstreetCalendar";
|
||||
import { classifyOtherTitle } from "./calendar-taxonomy";
|
||||
|
||||
// ── Country → Currency ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,7 +50,7 @@ function teCategory(cat: string, eventName?: string): EventCategory {
|
||||
if (/retail\s+sales|core\s+retail|household\s+spending/.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|\badp\b|jolts|job\s+openings|job\s+quits|ism\s+\w+\s+employ/.test(c)) return "employment";
|
||||
return "other";
|
||||
return classifyOtherTitle(eventName ?? "");
|
||||
}
|
||||
|
||||
// ── Importance (1/2/3) → impact ───────────────────────────────────────────────
|
||||
@@ -267,6 +270,98 @@ export async function fetchTEInflationForecasts(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Fetch élargi (45 pays) ─────────────────────────────────────────────────
|
||||
// Contrairement à fetchTECalendarHTML (limité aux 8 devises majeures pour
|
||||
// les besoins de macro/route.ts), cette variante couvre tout CALENDAR_COUNTRIES
|
||||
// en fetchant chaque page /{slug}/calendar en parallèle (même pattern que le
|
||||
// cas spécial Suisse ci-dessus, généralisé). Utilisée par le calendrier économique
|
||||
// pour se rapprocher de la couverture de tradingeconomics.com/calendar.
|
||||
|
||||
function parseCalendarHTMLWide(html: string): WideCalendarEvent[] {
|
||||
const events: WideCalendarEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
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_NAME_TO_CURRENCY[country.toLowerCase()];
|
||||
const code = TE_NAME_TO_CODE[country.toLowerCase()];
|
||||
if (!ccy || !code) continue; // pays hors CALENDAR_COUNTRIES
|
||||
|
||||
const dateMatch = body.match(/class=' (\d{4}-\d{2}-\d{2})'/);
|
||||
if (!dateMatch) continue;
|
||||
const dateStr = dateMatch[1];
|
||||
|
||||
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);
|
||||
|
||||
const titleMatch = body.match(/<a\s+class='calendar-event'[^>]*>([^<]+)<\/a>/);
|
||||
const title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : decodeHTMLEntities(eventAttr);
|
||||
|
||||
const refMatch = body.match(/class="calendar-reference"\s*>([^<]*)</);
|
||||
const ref = refMatch ? refMatch[1].trim() : "";
|
||||
|
||||
const actual = extractText(body, "actual");
|
||||
const previous = extractText(body, "previous");
|
||||
const consensus = extractText(body, "consensus");
|
||||
const forecast = extractText(body, "forecast");
|
||||
|
||||
events.push({
|
||||
id: `te_${id}`,
|
||||
date: isoDate,
|
||||
currency: ccy,
|
||||
countryCode: code,
|
||||
category: teCategory(category, eventAttr),
|
||||
title: ref ? `${title} ${ref}` : title,
|
||||
impact: teImpact(importance),
|
||||
actual,
|
||||
forecast: consensus ?? forecast,
|
||||
previous,
|
||||
isPublished: actual !== null || evDate < now,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Variante mono-pays : évite de fetcher les ~45 pages de fetchTECalendarWide
|
||||
// quand on n'a besoin que d'un seul pays (ex. new-zealand, absent du G20 donc
|
||||
// absent de fetchTECalendarHTML — cf. centralBankGovernance.ts RBNZ).
|
||||
export async function fetchTECalendarForCountry(teSlug: string, fromDate?: string, toDate?: string): Promise<WideCalendarEvent[]> {
|
||||
const qs = fromDate && toDate ? `?startDate=${fromDate}&endDate=${toDate}` : "";
|
||||
const html = await fetchOneTEPage(`https://tradingeconomics.com/${teSlug}/calendar${qs}`);
|
||||
if (!html) return [];
|
||||
return parseCalendarHTMLWide(html);
|
||||
}
|
||||
|
||||
export async function fetchTECalendarWide(fromDate?: string, toDate?: string): Promise<WideCalendarEvent[]> {
|
||||
const qs = fromDate && toDate ? `?startDate=${fromDate}&endDate=${toDate}` : "";
|
||||
const pages = await Promise.all(
|
||||
CALENDAR_COUNTRIES.map(c => fetchOneTEPage(`https://tradingeconomics.com/${c.teSlug}/calendar${qs}`))
|
||||
);
|
||||
|
||||
const allEvents: WideCalendarEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const html of pages) {
|
||||
if (!html) continue;
|
||||
for (const ev of parseCalendarHTMLWide(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;
|
||||
}
|
||||
|
||||
// ── Paid API (when TRADING_ECONOMICS_API_KEY is set) ─────────────────────────
|
||||
|
||||
export async function fetchTECalendar(
|
||||
|
||||
Reference in New Issue
Block a user