Files
forex-dashboard/.github/scripts/fetch-rate-data.mjs
T
caty21 3a384eb820 fix: OIS indisponible sur toutes les cards + transparence sur l'instrument utilisé
Le fetch /api/rate-probabilities n'avait aucun fallback cache côté client
(contrairement aux autres widgets) et pouvait planter en bloc si InvestingLive
timeoutait (fetch sans AbortController) - une seule panne coupait les 8 devises
d'un coup au lieu de dégrader devise par devise.

- route.ts : try/catch pour toujours renvoyer un JSON valide
- page.tsx : fallback localStorage si le fetch échoue
- investinglive.ts : timeout 8s sur les fetches vers investinglive.com
- fetch-rate-data.mjs / rateprobability.ts : le pipeline écrit maintenant la
  source réelle utilisée par devise (Rate Monitor USD, futures Euribor/SONIA
  pour EUR/GBP, InvestingLive pour les 5 autres) et la carte l'affiche
- CurrencyCard.tsx : SourcesPopup corrigé (CME FedWatch retiré, plus de fausse
  mention "Rate Monitor" pour les devises qui n'en ont pas)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:09:50 +02:00

669 lines
30 KiB
JavaScript
Raw 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.
// Sources (in priority order):
// 1. Investing.com Fed Rate Monitor — USD seul (seule CB pour laquelle Investing.com
// publie cet outil ; les autres slugs *-rate-monitor n'existent pas → 404 attendus)
// 2. InvestingLive fallback — articles Giuseppe Dellamotta, hebdo, toutes CBs
//
// (CME FedWatch retiré : endpoint derrière le WAF Akamai de cmegroup.com, 403/404
// systématique, et de toute façon redondant avec la source 1 pour l'USD.)
import { writeFileSync, mkdirSync, readFileSync } from "fs";
// ── Helpers ───────────────────────────────────────────────────────────────────
const MONTHS = { Jan:"01",Feb:"02",Mar:"03",Apr:"04",May:"05",Jun:"06",
Jul:"07",Aug:"08",Sep:"09",Oct:"10",Nov:"11",Dec:"12" };
function normalizeDate(raw) {
if (!raw) return null;
if (/^\d{8}$/.test(raw))
return `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
const m = raw.match(/([A-Z][a-z]{2})\s+(\d{1,2}),?\s+(\d{4})/);
if (m) return `${m[3]}-${MONTHS[m[1]]??'01'}-${m[2].padStart(2,'0')}`;
return null;
}
const CHROME_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
};
// ── 1. Investing.com Rate Monitors (Fed only — voir note en tête de fichier) ──
const IC_SLUGS = {
USD: "fed-rate-monitor",
EUR: "ecb-rate-monitor",
GBP: "boe-rate-monitor",
JPY: "boj-rate-monitor",
CAD: "boc-rate-monitor",
AUD: "rba-rate-monitor",
NZD: "rbnz-rate-monitor",
CHF: "snb-rate-monitor",
};
// Taux directeurs actuels — lus depuis data/rate_decisions.json (source unique,
// maintenue manuellement "après chaque décision") pour éviter qu'une copie
// codée en dur ici ne dérive silencieusement de la réalité au fil des mois.
const rateDecisions = JSON.parse(readFileSync("data/rate_decisions.json", "utf8"))[0]?.decisions ?? {};
const FALLBACK_RATES = Object.fromEntries(
Object.entries(rateDecisions).map(([ccy, d]) => [ccy, d.current])
);
// ── SNB officiel (CHF) ─────────────────────────────────────────────────────────
// snb.ch publie ses décisions sous /press-releases-restricted/pre_YYYYMMDD.
// On découvre la plus récente depuis la page listing (1er lien du pattern),
// puis on lit le taux dans le titre : "leaves ... unchanged at X%" / "lowers/raises ... to X%".
async function fetchSnbRate() {
try {
const listRes = await fetch("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions", { headers: CHROME_HEADERS });
if (!listRes.ok) return null;
const listHtml = await listRes.text();
const linkMatch = listHtml.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_(\d{8})[^"]*)"/);
if (!linkMatch) return null;
const dateIso = `${linkMatch[2].slice(0,4)}-${linkMatch[2].slice(4,6)}-${linkMatch[2].slice(6,8)}`;
const prRes = await fetch(`https://www.snb.ch${linkMatch[1]}`, { headers: CHROME_HEADERS });
if (!prRes.ok) return null;
const prHtml = await prRes.text();
const rateMatch = prHtml.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i);
if (!rateMatch) return null;
console.log(`[SNB] official: ${rateMatch[1]}% (decision ${dateIso})`);
return parseFloat(rateMatch[1]);
} catch (e) {
console.error(`[SNB] error: ${e.message}`);
return null;
}
}
// ── Fed officiel (USD) ──────────────────────────────────────────────────────────
// federalreserve.gov/monetarypolicy/fomccalendars.htm liste, pour chaque réunion,
// un lien de statement /newsevents/pressreleases/monetary(YYYYMMDD)a.htm. On prend
// la réunion passée la plus récente, puis on lit la fourchette officielle dans le
// texte : "target range for the federal funds rate at/to X to Y percent" (X/Y en
// fractions type "3-1/2"). On retourne le haut de fourchette (convention "upper
// bound" déjà utilisée ailleurs dans ce repo pour l'USD).
function parseFedFraction(s) {
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 fetchFedRate() {
try {
const calRes = await fetch("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", { headers: CHROME_HEADERS });
if (!calRes.ok) return null;
const calHtml = await calRes.text();
const todayCompact = new Date().toISOString().slice(0,10).replace(/-/g,"");
const dates = new Set();
for (const m of calHtml.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g)) dates.add(m[1]);
const latestPast = [...dates].filter(d => d <= todayCompact).sort().reverse()[0];
if (!latestPast) return null;
const dateIso = `${latestPast.slice(0,4)}-${latestPast.slice(4,6)}-${latestPast.slice(6,8)}`;
const stRes = await fetch(`https://www.federalreserve.gov/newsevents/pressreleases/monetary${latestPast}a.htm`, { headers: CHROME_HEADERS });
if (!stRes.ok) return null;
const stHtml = await stRes.text();
const rateMatch = stHtml.match(/target range for the federal funds rate[\s\S]{0,60}?(\d+(?:-\d\/\d)?)\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i);
if (!rateMatch) return null;
const upper = parseFedFraction(rateMatch[2]);
if (isNaN(upper)) return null;
console.log(`[FED] official: ${rateMatch[1]}-${rateMatch[2]}% → upper=${upper}% (decision ${dateIso})`);
return upper;
} catch (e) {
console.error(`[FED] error: ${e.message}`);
return null;
}
}
async function fetchInvestingCom(ccy) {
const slug = IC_SLUGS[ccy];
const url = `https://www.investing.com/central-banks/${slug}`;
try {
const res = await fetch(url, {
headers: { ...CHROME_HEADERS, "Referer": "https://www.investing.com/central-banks/" }
});
console.log(`[IC/${ccy}] ${res.status}`);
if (!res.ok) return null;
const html = await res.text();
console.log(`[IC/${ccy}] html ${html.length} chars`);
if (/just a moment|cf-browser-verification|enable javascript/i.test(html)) {
console.error(`[IC/${ccy}] Cloudflare challenge`);
return null;
}
return parseInvestingComHtml(ccy, html);
} catch (e) {
console.error(`[IC/${ccy}] error: ${e.message}`);
return null;
}
}
function parseInvestingComHtml(ccy, html) {
// ── Attempt A: __NEXT_DATA__ (Next.js SSR) ────────────────────────────────
const ndMatch = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (ndMatch) {
try {
const nd = JSON.parse(ndMatch[1]);
console.log(`[IC/${ccy}] __NEXT_DATA__ found`);
const result = parseNextData(ccy, nd);
if (result) return result;
} catch (e) { console.error(`[IC/${ccy}] nextData parse: ${e.message}`); }
}
// ── Attempt B: JSON-LD / embedded JSON with "meetingDate" or "probability" ─
const scripts = [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)];
for (const [, content] of scripts) {
if (!content.includes("meetingDate") && !content.includes("probability")) continue;
try {
const json = JSON.parse(content.trim());
console.log(`[IC/${ccy}] embedded JSON found`);
const result = parseJsonData(ccy, json);
if (result) return result;
} catch {}
}
// ── Attempt C: HTML table ──────────────────────────────────────────────────
return parseRateMonitorTable(ccy, html);
}
function parseNextData(ccy, nd) {
const pageProps = nd?.props?.pageProps ?? nd?.props ?? {};
console.log(`[IC/${ccy}] pageProps keys: ${Object.keys(pageProps).slice(0,10).join(", ")}`);
// Navigate common paths where rate monitor data lives
const candidates = [
pageProps?.rateMonitorData,
pageProps?.data?.rateMonitor,
pageProps?.initialData,
pageProps?.dehydratedState?.queries?.[0]?.state?.data,
];
for (const c of candidates) {
if (!c) continue;
const result = parseJsonData(ccy, c);
if (result) return result;
}
// Log for debugging first run
console.log(`[IC/${ccy}] nextData pageProps preview: ${JSON.stringify(pageProps).slice(0, 400)}`);
return null;
}
function parseJsonData(ccy, json) {
// Try to find meetings array in various shapes
const meetings = json?.meetings ?? json?.data?.meetings ?? json?.rows ?? json?.items ?? null;
if (!Array.isArray(meetings) || !meetings.length) return null;
const nowIso = new Date().toISOString().slice(0,10);
const rows = [];
let currentRate = FALLBACK_RATES[ccy] ?? 0;
for (const m of meetings) {
const dateIso = normalizeDate(
m.meetingDate ?? m.date ?? m.meeting_date ?? m.dateIso ?? ""
);
if (!dateIso || dateIso < nowIso) continue;
// Probabilities can be in many shapes
const probCut = parseFloat(m.probCut ?? m.cut ?? m.decrease ?? m.minus25 ?? 0)
+ parseFloat(m.probCut50 ?? m.minus50 ?? 0);
const probHike = parseFloat(m.probHike ?? m.hike ?? m.increase ?? m.plus25 ?? 0)
+ parseFloat(m.probHike50 ?? m.plus50 ?? 0);
const probHold = parseFloat(m.probHold ?? m.hold ?? m.unchanged ?? 0);
const probIsCut = probCut >= probHike;
const probMovePct = Math.max(probCut, probHike, 0);
const impliedRate = parseFloat(
m.impliedRate ?? m.implied_rate ?? m.rate ?? currentRate
);
const changeBps = (impliedRate - currentRate) * 100;
const dateLabel = new Date(dateIso + "T12:00:00Z")
.toLocaleDateString("en-US", { month:"short", day:"numeric" });
rows.push({
meeting: dateLabel, meeting_iso: dateIso,
implied_rate_post_meeting: impliedRate,
prob_move_pct: probMovePct, prob_is_cut: probIsCut,
change_bps: changeBps, num_moves: changeBps / 25,
});
}
if (!rows.length) return null;
return { today: { midpoint: currentRate, rows } };
}
// Structure réelle de la page (vérifiée juillet 2026) : chaque réunion FOMC est un
// bloc "Meeting Time: <i>Jul 29, 2026 02:00PM ET</i>" suivi d'un <table class="fedRateTbl">
// dont les lignes sont des fourchettes de taux ("3.50 - 3.75") + probabilité courante.
// Il n'y a AUCUNE colonne date dans la table elle-même (elle vit dans le bloc parent) —
// c'est pourquoi l'ancienne heuristique par en-têtes de colonnes ne trouvait jamais rien.
function parseRateMonitorTable(ccy, html) {
const currentRate = FALLBACK_RATES[ccy] ?? 0;
const nowIso = new Date().toISOString().slice(0,10);
const blockRe = /Meeting Time:<\/span>\s*<i>([^<]+)<\/i>[\s\S]*?<table class="genTbl openTbl fedRateTbl">([\s\S]*?)<\/table>/g;
const rowRe = /<td class="left">\s*([\d.]+\s*-\s*[\d.]+)[\s\S]*?<\/td>\s*<td>\s*([\d.]+)%<\/td>/g;
const rows = [];
let bm;
while ((bm = blockRe.exec(html)) !== null) {
const dateIso = normalizeDate(bm[1].trim());
if (!dateIso || dateIso < nowIso) continue;
let rm, totalProb = 0, weightedRate = 0, probBelow = 0, probAbove = 0;
rowRe.lastIndex = 0;
while ((rm = rowRe.exec(bm[2])) !== null) {
const parts = rm[1].match(/([\d.]+)\s*-\s*([\d.]+)/);
if (!parts) continue;
const hi = parseFloat(parts[2]);
const prob = parseFloat(rm[2]);
totalProb += prob;
// currentRate suit la convention "upper bound" (ex: 3.75 pour la fourchette
// 3.50-3.75, cf. data/rate_decisions.json / app/api/macro) : chaque fourchette
// doit donc être représentée par SON haut (hi), pas son milieu — sinon la
// fourchette actuelle (mid=3.625) ne matche jamais currentRate (3.75), ce qui
// introduit un biais fantôme de ~-12.5bps même à 100% de probabilité de statu quo.
weightedRate += hi * prob;
// La fourchette dont le haut == currentRate EST la fourchette actuelle → ni cut ni hike.
if (Math.abs(hi - currentRate) < 0.01) continue;
if (hi < currentRate) probBelow += prob;
else probAbove += prob;
}
if (!totalProb) continue;
const impliedRate = parseFloat((weightedRate / totalProb).toFixed(4));
const probIsCut = probBelow > probAbove; // strict : à 0/0 (aucun biais), ne pas défaulter sur "cut"
const probMovePct = Math.round(Math.max(probBelow, probAbove));
const changeBps = (impliedRate - currentRate) * 100;
const dateLabel = new Date(dateIso + "T12:00:00Z")
.toLocaleDateString("en-US", { month:"short", day:"numeric" });
rows.push({
meeting: dateLabel, meeting_iso: dateIso,
implied_rate_post_meeting: impliedRate,
prob_move_pct: probMovePct, prob_is_cut: probIsCut,
change_bps: changeBps, num_moves: changeBps / 25,
});
}
if (!rows.length) { console.warn(`[IC/${ccy}] no parseable meeting block found`); return null; }
console.log(`[IC/${ccy}] ✓ table parsed: ${rows.length} meetings`);
return { today: { midpoint: currentRate, rows } };
}
// ── 1bis. Investing.com futures strips (EUR/GBP) ──────────────────────────────
// Investing.com n'a de Rate Monitor (probabilité pré-calculée par réunion) que
// pour la Fed (voir note en tête de fichier — ecb/boe-rate-monitor → 404). Mais
// Investing.com sert bien les prix (delayed) des futures Euribor 3M (Eurex) et
// Three-Month SONIA (ICE) — la référence que les praticiens utilisent pour lire
// les anticipations BCE/BoE en l'absence d'un outil équivalent au Fed Rate
// Monitor. Contrairement à ce dernier, il n'y a PAS de distribution de
// probabilité publiée par contrat : on déduit nous-mêmes une probabilité
// approximative (même convention bps/25 déjà utilisée pour le fallback
// InvestingLive plus bas), en mappant chaque échéance de future sur le
// calendrier réel de réunions (réunion → échéance la plus proche ≥ sa date).
// Au-delà de la dernière échéance réellement cotée, on prolonge à plat sur une
// fenêtre de grâce (FUTURES_GRACE_DAYS) ; au-delà, on s'arrête — pas de réunion
// affichée plutôt qu'une valeur inventée.
//
// Résolution intrinsèquement plus grossière que l'USD : Euribor cote ~mensuel
// (contrats vus : Jul/Aug/Sep/Oct 26), SONIA cote trimestriel (Jun/Sep 26
// seulement, 2 contrats dispo sur investing.com) — donc plusieurs réunions
// rapprochées peuvent partager la même valeur implicite.
//
// Calendriers dupliqués depuis lib/rateprobability.ts (ce script est un .mjs
// autonome sans accès aux imports TS de l'app) — à garder synchronisés, même
// cadence de maintenance qu'indiqué là-bas (~1x/an, à la publication de
// chaque banque centrale).
const ECB_MEETINGS = [
"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 = [
"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 FUTURES_STRIP_CONFIG = {
EUR: { overviewUrl: "https://www.investing.com/rates-bonds/euribor-futures", prefix: "FEU3c", meetings: ECB_MEETINGS },
GBP: { overviewUrl: "https://www.investing.com/rates-bonds/three-month-sonia-futures", prefix: "SON3c", meetings: BOE_MEETINGS },
};
const FUTURES_GRACE_DAYS = 60;
// La page overview liste les contrats liés dans un tableau "relative-table" —
// chaque ligne est un <a href="...?cid=NNNN" title="FEU3c1">. On (re)découvre
// cette liste à chaque run plutôt que de figer des cid en dur : les contrats
// expirent (mensuel/trimestriel) et le cid pertinent change avec le temps.
async function fetchInvestingComContractList(overviewUrl, prefix) {
try {
const res = await fetch(overviewUrl, { headers: CHROME_HEADERS });
if (!res.ok) { console.error(`[futures] ${overviewUrl}${res.status}`); return []; }
const html = await res.text();
if (/just a moment|cf-browser-verification|enable javascript/i.test(html)) {
console.error(`[futures] Cloudflare challenge on ${overviewUrl}`);
return [];
}
const re = /<a href="([^"]+)"\s+class="[^"]*"\s+title="([A-Za-z0-9]+)">/g;
const seen = new Map();
let m;
while ((m = re.exec(html)) !== null) {
const [, href, label] = m;
if (!label.startsWith(prefix) || seen.has(label)) continue;
seen.set(label, href);
}
return [...seen.entries()]
.map(([label, url]) => ({ label, url, n: parseInt(label.slice(prefix.length), 10) }))
.filter(c => Number.isFinite(c.n))
.sort((a, b) => a.n - b.n);
} catch (e) {
console.error(`[futures] ${overviewUrl} error: ${e.message}`);
return [];
}
}
// Chaque page contrat (?cid=NNNN) rend son propre prix + échéance server-side
// (vérifié : pas besoin d'exécuter le JS client). Convention Eurex/ICE "100
// taux", identique à Eurodollar/Fed Funds.
async function fetchContractQuote(url) {
try {
const res = await fetch(url, { headers: CHROME_HEADERS });
if (!res.ok) return null;
const html = await res.text();
const settlement = html.match(/data-test="settlement_date"[^>]*>(\d{2})\/(\d{2})\/(\d{4})</);
const price = html.match(/data-test="instrument-price-last"[^>]*>([\d.]+)</);
if (!settlement || !price) return null;
const [, mm, dd, yyyy] = settlement;
return { dateIso: `${yyyy}-${mm}-${dd}`, price: parseFloat(price[1]) };
} catch (e) {
console.error(`[futures] contract fetch error: ${e.message}`);
return null;
}
}
async function fetchFuturesStrip(ccy) {
const cfg = FUTURES_STRIP_CONFIG[ccy];
if (!cfg) return null;
const contracts = await fetchInvestingComContractList(cfg.overviewUrl, cfg.prefix);
if (contracts.length < 2) { console.warn(`[futures/${ccy}] only ${contracts.length} contract(s) listed`); return null; }
const points = [];
for (const c of contracts) {
const q = await fetchContractQuote(c.url);
if (q) points.push(q);
await new Promise(r => setTimeout(r, 400));
}
if (points.length < 2) { console.warn(`[futures/${ccy}] only ${points.length} contract(s) priced`); return null; }
points.sort((a, b) => a.dateIso.localeCompare(b.dateIso));
const currentRate = FALLBACK_RATES[ccy] ?? 0;
const nowIso = new Date().toISOString().slice(0, 10);
const graceIso = new Date(new Date(points.at(-1).dateIso).getTime() + FUTURES_GRACE_DAYS * 86400000)
.toISOString().slice(0, 10);
const rows = [];
for (const meetingIso of cfg.meetings) {
if (meetingIso < nowIso) continue;
if (meetingIso > graceIso) break; // hors horizon réel couvert par les futures : on s'arrête plutôt que d'inventer
const point = points.find(p => p.dateIso >= meetingIso) ?? points.at(-1);
const impliedRate = parseFloat((100 - point.price).toFixed(4));
const cumulBps = parseFloat(((impliedRate - currentRate) * 100).toFixed(2));
rows.push({
meeting: new Date(meetingIso + "T12:00:00Z").toLocaleDateString("en-US", { month: "short", day: "numeric" }),
meeting_iso: meetingIso,
implied_rate_post_meeting: impliedRate,
prob_move_pct: Math.min(100, Math.round(Math.abs(cumulBps) / 25 * 100)),
prob_is_cut: cumulBps < 0,
change_bps: cumulBps,
num_moves: parseFloat((cumulBps / 25).toFixed(3)),
});
}
if (!rows.length) return null;
console.log(`[futures/${ccy}] ✓ ${points.length} contrats réels (${points.map(p => p.dateIso).join(", ")}) → ${rows.length} réunions`);
return { today: { midpoint: currentRate, rows } };
}
// ── 2. InvestingLive fallback (Giuseppe Dellamotta, hebdomadaire) ─────────────
const IL_CB_MAP = {
fed: "USD", fomc: "USD",
ecb: "EUR",
boe: "GBP",
boj: "JPY",
boc: "CAD",
rba: "AUD",
rbnz: "NZD",
snb: "CHF",
};
async function fetchInvestingLive() {
for (let daysAgo = 0; daysAgo <= 14; daysAgo++) {
const d = new Date(Date.now() - daysAgo * 86_400_000);
const yyyymmdd = d.toISOString().slice(0,10).replace(/-/g,"");
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) {
try {
const res = await fetch(url, { headers: { "User-Agent": CHROME_HEADERS["User-Agent"] } });
if (!res.ok) continue;
const html = await res.text();
console.log(`[IL] found article ${yyyymmdd} at ${url}`);
return { data: parseILArticle(html), date: d.toISOString().slice(0,10) };
} catch {}
}
}
return { data: {}, date: null };
}
function parseILArticle(html) {
const results = {};
// Extract articleBody from JSON-LD
const jldMatch = html.match(/"articleBody"\s*:\s*"([\s\S]*?)(?<!\\)"/);
const text = jldMatch
? jldMatch[1].replace(/\\n/g," ").replace(/\\"/g,'"')
: html.replace(/<[^>]+>/g," ");
// Pattern: "CB: XX bps (YY% probability of rate hike/cut/no change)"
const re = /\b(fed|fomc|ecb|boe|boj|boc|rba|rbnz|snb)\b[\s\S]{0,150}?(\d+)\s*bps?[\s\S]{0,80}?(\d+)%\s*probability\s*of\s*(rate\s*)?(hike|cut|no.?change)/gi;
for (const m of text.matchAll(re)) {
const ccy = IL_CB_MAP[m[1].toLowerCase()];
if (!ccy || results[ccy]) continue;
const bps = parseInt(m[2]);
const probPct = parseInt(m[3]);
const isNoChg = /no.?change/i.test(m[5]);
const isCutDirection = !isNoChg && /cut/i.test(m[5]);
// "XX bps" est une magnitude signée du biais year-end : on ne la force négative
// que si la direction annoncée est explicitement "cut". Pour "no change", le bps
// reste positif par défaut (biais hausse), comme pour "hike" — le bug précédent
// négativait TOUT ce qui n'était pas explicitement "hike", cassant EUR/GBP/JPY/
// CAD/AUD/CHF (tous en "no change" la plupart des semaines).
const bpsYearEnd = isCutDirection && bps > 0 ? -bps : bps;
// "no change" à la prochaine réunion : la probabilité de mouvement est le complément
// de la probabilité de statu quo (ex: "72% no change" → 28% de mouvement), pas 0.
const probMovePct = isNoChg ? 100 - probPct : probPct;
results[ccy] = { bpsYearEnd, probMovePct, isCut: bpsYearEnd < 0 };
}
console.log(`[IL] parsed: ${Object.keys(results).join(", ")}`);
return results;
}
function buildILFallback(ccy, il) {
const nowIso = new Date().toISOString().slice(0,10);
const yearEnd = `${new Date().getFullYear()}-12-31`;
const rate = FALLBACK_RATES[ccy] ?? 0;
return {
today: {
midpoint: rate,
source: "InvestingLive — estimation hebdomadaire analyste (pas de futures/OIS coté public pour cette devise)",
rows: [{
meeting: "Dec",
meeting_iso: yearEnd,
implied_rate_post_meeting: rate + il.bpsYearEnd / 100,
prob_move_pct: il.probMovePct ?? 50,
prob_is_cut: il.isCut,
change_bps: il.bpsYearEnd,
num_moves: Math.abs(il.bpsYearEnd) / 25,
}]
}
};
}
// ── Main ──────────────────────────────────────────────────────────────────────
const CCYS = ["USD","EUR","GBP","JPY","CAD","AUD","NZD","CHF"];
const results = {};
// Charge les données déjà en place AVANT tout fetch — sert de filet de sécurité
// plus bas : investing.com renvoie parfois 403 spécifiquement aux IPs GitHub
// Actions (bloc IP anti-bot, confirmé en direct — pas un problème de parsing),
// et ce blocage est intermittent. Sans ce filet, un seul run bloqué écrasait
// la vraie courbe multi-réunions USD par le fallback InvestingLive (un unique
// point "year-end"), et cette dégradation restait visible jusqu'au prochain
// run réussi. On préfère désormais garder la dernière bonne donnée connue.
let existingData = {};
try {
existingData = JSON.parse(readFileSync("data/rate-probabilities.json", "utf8")).data ?? {};
} catch {}
// 0 — Sources officielles → écrasent les ancres si data/rate_decisions.json a dérivé
console.log("\n=== Fed (official) ===");
const fedRate = await fetchFedRate();
if (fedRate !== null) FALLBACK_RATES.USD = fedRate;
console.log("\n=== SNB (official) ===");
const snbRate = await fetchSnbRate();
if (snbRate !== null) FALLBACK_RATES.CHF = snbRate;
// 1 — Investing.com Fed Rate Monitor → USD uniquement (seule CB avec cet outil ;
// les autres slugs *-rate-monitor n'existent pas sur investing.com → 404 attendus)
// Retry (403/erreur réseau ponctuels) — n'aide pas contre un vrai bloc IP
// persistant sur tout le run, mais couvre les échecs vraiment transitoires.
const IC_SUPPORTED = ["USD"];
console.log("\n=== Investing.com Rate Monitors ===");
for (const ccy of IC_SUPPORTED) {
let data = null;
for (let attempt = 1; attempt <= 3 && !data; attempt++) {
if (attempt > 1) {
console.log(`[IC/${ccy}] retry ${attempt}/3…`);
await new Promise(r => setTimeout(r, 2000 * attempt));
}
data = await fetchInvestingCom(ccy);
}
if (data) {
data.today.source = "Investing.com Fed Rate Monitor (CME 30-Day Fed Funds Futures)";
results[ccy] = data;
}
await new Promise(r => setTimeout(r, 600));
}
// 1bis — Investing.com futures strips (Euribor 3M / SONIA 3M) → EUR/GBP
// (voir doc détaillée plus haut, section "1bis. Investing.com futures strips")
console.log("\n=== Investing.com Futures Strips (EUR/GBP) ===");
for (const ccy of Object.keys(FUTURES_STRIP_CONFIG)) {
let data = null;
for (let attempt = 1; attempt <= 3 && !data; attempt++) {
if (attempt > 1) {
console.log(`[futures/${ccy}] retry ${attempt}/3…`);
await new Promise(r => setTimeout(r, 2000 * attempt));
}
data = await fetchFuturesStrip(ccy);
}
if (data) {
data.today.source = ccy === "EUR"
? "Investing.com — Euribor 3M Futures (Eurex)"
: "Investing.com — Three-Month SONIA Futures (ICE)";
results[ccy] = data;
}
await new Promise(r => setTimeout(r, 600));
}
// 2 — InvestingLive → fallback for any missing CB
const missing = CCYS.filter(c => !results[c]);
if (missing.length) {
console.log(`\n=== InvestingLive fallback (missing: ${missing.join(", ")}) ===`);
const { data: ilData } = await fetchInvestingLive();
for (const ccy of missing) {
if (ilData[ccy]) {
results[ccy] = buildILFallback(ccy, ilData[ccy]);
console.log(`[IL] ${ccy} ✓ (${ilData[ccy].bpsYearEnd}bps year-end)`);
}
}
}
// 3 — Filet de sécurité : si le résultat du jour n'a qu'1 réunion (fallback IL
// dégradé) alors qu'on avait déjà une vraie courbe multi-réunions, on la garde.
for (const ccy of CCYS) {
const newRows = results[ccy]?.today?.rows?.length ?? 0;
const oldRows = existingData[ccy]?.today?.rows?.length ?? 0;
if (newRows <= 1 && oldRows > 1) {
console.log(`[preserve] ${ccy}: nouveau fetch dégradé (${newRows} réunion) → conserve l'ancienne courbe (${oldRows} réunions)`);
results[ccy] = existingData[ccy];
}
}
// ── Multi-week snapshot rotation ──────────────────────────────────────────────
// snapshots = tableau chronologique (plus récent en [0]) de jusqu'à 12 semaines
let snapshots = [];
let previousWeek = null, previousWeekFetchedAt = null; // rétrocompatibilité
try {
const existing = JSON.parse(readFileSync("data/rate-probabilities.json","utf8"));
const ageMs = Date.now() - new Date(existing.fetchedAt).getTime();
const day = 86400000;
// Récupère les snapshots existants et filtre < 12 semaines
const existingSnaps = existing.snapshots ?? [];
const validSnaps = existingSnaps.filter(s => {
const age = Date.now() - new Date(s.fetchedAt).getTime();
return age < 84 * day;
});
// Si les données actuelles ont 5-9 jours → les pousser en snapshot[0]
if (ageMs >= 5*day && ageMs <= 9*day) {
snapshots = [{ data: existing.data, fetchedAt: existing.fetchedAt }, ...validSnaps].slice(0, 12);
console.log(`\nRotated ${(ageMs/day).toFixed(1)}d-old data → snapshots[${snapshots.length}]`);
} else {
snapshots = validSnaps;
}
// Rétrocompatibilité previousWeek
if (snapshots[0]) {
previousWeek = snapshots[0].data;
previousWeekFetchedAt = snapshots[0].fetchedAt;
}
} catch {}
mkdirSync("data", { recursive: true });
writeFileSync("data/rate-probabilities.json", JSON.stringify({
data: results,
fetchedAt: new Date().toISOString(),
snapshots,
...(previousWeek ? { previousWeek, previousWeekFetchedAt } : {}),
}, null, 2));
const saved = CCYS.filter(c => results[c]);
const failed = CCYS.filter(c => !results[c]);
console.log(`\n✓ Saved : ${saved.join(", ")}`);
if (failed.length) console.log(`✗ Failed : ${failed.join(", ")}`);