Files
forex-dashboard/lib/centralBankGovernance.ts
caty21 465a81a27c feat: RBNZ — automatise la découverte + lecture du Monetary Policy Statement
rbnz.govt.nz bloque bien ses pages génériques (index, listing) derrière un
challenge Cloudflare, comme déjà documenté — mais vérifié en direct que
sitemap.xml, les pages de contenu individuelles qu'il référence, et les PDF
sous /-/media/ répondent normalement (200, sans challenge). C'est exactement
ce qu'indexerait un moteur de recherche : aucun contournement, juste des
ressources publiques non protégées.

.github/scripts/fetch-rbnz-mps.mjs :
  1. Lit sitemap.xml pour trouver la dernière page MPS ("...-filtered-listing-
     page/{year}/{month}-{id}/monetary-policy-statement-{month}-{year}")
  2. En extrait le lien du PDF officiel
  3. Parse le PDF (pdf-parse, déjà utilisé pour BoJ) et extrait la Table 6.5
     "Summary of economic projections" (CPI, OCR moyenne annuelle, GDP par
     année "March year")
Note technique : le fetch() natif de Node (undici) se prend un 403 sur ces
mêmes URLs alors que curl passe (200, vérifié à la seconde près, même
User-Agent) — Cloudflare fingerprinte la pile TLS/HTTP2 du client, pas
seulement l'en-tête. Le script shell out donc vers curl (préinstallé sur les
runners GitHub Actions).

.github/workflows/fetch-rbnz-mps.yml : quotidien (le MPS n'est publié
qu'~4x/an mais découverte + parsing sont légers), + déclenchement manuel.

lib/centralBankGovernance.ts : fetchRbnzGovernance() expose désormais un vrai
reportPdfUrl/statementUrl (au lieu de null) et un CBForecast — la 8e et
dernière banque centrale de l'onglet Banques centrales à avoir ses
prévisions, malgré le blocage du reste du site RBNZ.

Vérifié en direct : PIB/inflation 2026-2029 extraits correctement (+0.7 à
+3.4% PIB, +3.1 à +2.0% inflation), card RBNZ affiche le bloc "Prévisions"
et un vrai bouton "Rapport PDF" fonctionnel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 23:46:31 +02:00

894 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 bloque tout fetch() non-navigateur sans header
// custom ; RBNZ bloque ses pages génériques mais pas son sitemap.xml ni les
// pages/PDF individuels qu'il référence — voir la section RBNZ plus bas).
import type { Currency } from "./types";
import rateDecisionsData from "@/data/rate_decisions.json";
import rbnzMpsData from "@/data/rbnz-mps.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(/&nbsp;/g, " ")
.replace(/&ndash;|&#8211;/g, "")
.replace(/&minus;|&#8722;/g, "-")
.replace(/&#8209;/g, "-")
.replace(/&amp;/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 XY 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 les pages génériques (index, listing) derrière un
// challenge Cloudflare interactif ("Verify you are human") — vérifié avec un
// vrai navigateur headless, pas seulement fetch(). On ne cherche pas à
// résoudre un captcha anti-bot / évader cette protection délibérée.
//
// MAIS : sitemap.xml, les pages de contenu individuelles qu'il référence
// (ex. la page d'une Monetary Policy Statement précise), et les PDF sous
// /-/media/ répondent normalement (200, pas de challenge) — voir
// .github/scripts/fetch-rbnz-mps.mjs, qui découvre et lit le dernier MPS pour
// en extraire les prévisions (Table 6.5), exactement comme le ferait un
// moteur de recherche indexant le site. Repli pour le reste (aucune requête
// vers les pages bloquées) :
// - 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 */ }
// Prévisions (Table 6.5 du Monetary Policy Statement) — maintenues par
// .github/workflows/fetch-rbnz-mps.yml. Découvertes via sitemap.xml + PDF
// public (pas la page d'index générique qui, elle, reste bloquée) : voir
// le script pour le détail. Premier CB de ce dashboard où l'on obtient une
// vraie prévision RBNZ malgré le blocage du reste du site.
const mps = rbnzMpsData as {
sourcePageUrl?: string; sourcePdfUrl?: string; mpsMonth?: number; mpsYear?: number;
years?: string[]; gdp?: Record<string, number>; inflation?: Record<string, number>;
};
const forecast: CBForecast | null = mps?.years?.length ? {
asOf: `${mps.mpsYear}-${String(mps.mpsMonth).padStart(2, "0")}`,
years: mps.years,
gdp: mps.gdp ?? {},
inflation: mps.inflation ?? {},
label: "RBNZ — Monetary Policy Statement (Table 6.5, année se terminant en mars)",
sourceUrl: mps.sourcePdfUrl ?? null,
} : null;
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: mps?.sourcePageUrl ?? null,
reportPdfUrl: mps?.sourcePdfUrl ?? null,
reportLabel: mps?.sourcePdfUrl ? "Monetary Policy Statement" : null,
forecast,
fetchError: "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) sur ses pages génériques — taux (rate_decisions.json), date de réunion (Trading Economics) et prévisions (PDF public du MPS, non bloqué) 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 };
}