mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
3a384eb820
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>
669 lines
30 KiB
JavaScript
669 lines
30 KiB
JavaScript
// 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(", ")}`);
|