feat: replace rateprobability.com with CME FedWatch + Investing.com scrapers

- fetch-rate-data.mjs: CME FedWatch (USD), Investing.com rate monitors (all CBs),
  InvestingLive (fallback) — plus aucune dépendance à rateprobability.com
- lib/rateprobability.ts: suppression des appels live rateprobability.com,
  source unique = cache GitHub Actions (data/rate-probabilities.json)
- Logs détaillés pour debugger le premier run GitHub Actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caty21
2026-06-28 13:35:14 +02:00
parent 1e88b02acd
commit 2673686743
2 changed files with 464 additions and 174 deletions
+436 -62
View File
@@ -1,82 +1,456 @@
// Runs in GitHub Actions (not Vercel) — fetches from rateprobability.com
// GitHub Actions IPs are not blocked by the site.
// Sources (in priority order):
// 1. CME FedWatch API — USD seul, JSON officieux, fiable
// 2. Investing.com monitors — toutes CBs, HTML scraped (Cloudflare possible)
// 3. InvestingLive fallback — articles Giuseppe Dellamotta, hebdo, toutes CBs
import { writeFileSync, mkdirSync, readFileSync } from "fs";
const CB_KEYS = [
["USD", "fed"],
["EUR", "ecb"],
["GBP", "boe"],
["JPY", "boj"],
["CAD", "boc"],
["AUD", "rba"],
["NZD", "rbnz"],
["CHF", "snb"], // BNS — essai, null si l'endpoint n'existe pas sur rateprobability.com
];
// ── Helpers ───────────────────────────────────────────────────────────────────
const HEADERS = {
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": "application/json, text/plain, */*",
"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",
"sec-ch-ua": '"Chromium";v="125", "Not.A/Brand";v="24"',
"sec-ch-ua-mobile":"?0",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"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",
};
async function fetchCB(ccy, slug) {
try {
const res = await fetch(`https://rateprobability.com/api/${slug}/latest`, {
headers: { ...HEADERS, "Referer": `https://rateprobability.com/${slug}`, "Origin": "https://rateprobability.com" },
// ── 1. CME FedWatch (USD) ─────────────────────────────────────────────────────
async function fetchCMEFedWatch() {
const year = new Date().getFullYear();
const candidates = [
// API officieuse FedWatch — essai sur SR3 (SOFR) et ZQ (30-day FF)
`https://www.cmegroup.com/CmeWS/mvc/MeetingCalendar/V1/getMeetingCalendarByYear.json?marketCode=SR3&year=${year}`,
`https://www.cmegroup.com/CmeWS/mvc/MeetingCalendar/V1/getMeetingCalendarByYear.json?marketCode=FF&year=${year}`,
`https://www.cmegroup.com/CmeWS/mvc/MeetingCalendar/V1/getMeetingCalendarByYear.json?marketCode=ZQ&year=${year}`,
];
for (const url of candidates) {
try {
const res = await fetch(url, {
headers: {
...CHROME_HEADERS,
"Accept": "application/json, text/plain, */*",
"Referer": "https://www.cmegroup.com/markets/interest-rates/cme-fedwatch-tool.html",
"Origin": "https://www.cmegroup.com",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
}
});
console.log(`[CME] ${url.split('?')[1]}${res.status}`);
if (!res.ok) continue;
const body = await res.json();
console.log(`[CME] keys: ${Object.keys(body).join(", ")}`);
console.log(`[CME] preview: ${JSON.stringify(body).slice(0, 600)}`);
const parsed = parseCMEBody(body);
if (parsed) { console.log(`[CME] ✓ ${parsed.today.rows.length} meetings`); return parsed; }
} catch (e) {
console.error(`[CME] error: ${e.message}`);
}
}
return null;
}
function parseCMEBody(body) {
// Format A : { meetings: [{ date|meetingDate, currentProbs:{minus25,unch,plus25,...}, impliedRate }] }
const arr = body.meetings ?? body.data ?? (Array.isArray(body) ? body : null);
if (!arr?.length) return null;
const nowIso = new Date().toISOString().slice(0,10);
let currentRate = null;
const rows = [];
for (const m of arr) {
const dateIso = normalizeDate(
m.date ?? m.meetingDate ?? m.meetDate ?? m.meeting_date ?? ""
);
if (!dateIso || dateIso < nowIso) continue;
const probs = m.currentProbs ?? m.probs ?? m.probabilities ?? m.probability ?? {};
const probCut = parseFloat(probs.minus25 ?? probs.cut25 ?? probs["-25"] ?? probs.minus50 ?? 0)
+ parseFloat(probs.minus50 ?? probs.cut50 ?? probs["-50"] ?? 0);
const probHike = parseFloat(probs.plus25 ?? probs.hike25 ?? probs["+25"] ?? probs.plus50 ?? 0)
+ parseFloat(probs.plus50 ?? probs.hike50 ?? probs["+50"] ?? 0);
const probHold = parseFloat(probs.unch ?? probs.hold ?? probs.unchanged ?? 0);
const probIsCut = probCut >= probHike;
const probMovePct = probIsCut ? probCut : probHike;
const impliedRate = parseFloat(
m.impliedRate ?? m.implied_rate ?? m.rate ?? m.impliedFedFunds ?? 0
);
if (currentRate === null)
currentRate = parseFloat(m.priorRate ?? m.currentRate ?? m.prior_rate ?? impliedRate ?? 0);
const changeBps = (impliedRate - (currentRate ?? 0)) * 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 (!res.ok) { console.error(`[${ccy}] HTTP ${res.status}`); return null; }
const body = await res.json();
console.log(`[${ccy}] OK — meetings: ${body?.today?.rows?.length ?? "?"}`);
return body;
}
if (!rows.length) return null;
return { today: { midpoint: currentRate ?? 0, rows } };
}
// ── 2. Investing.com Rate Monitors (all CBs) ──────────────────────────────────
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 — fallback si non trouvés dans le HTML
const FALLBACK_RATES = {
USD: 4.33, EUR: 2.40, GBP: 4.25, JPY: 0.50,
CAD: 2.75, AUD: 4.10, NZD: 3.25, CHF: 0.00,
};
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(`[${ccy}] Error: ${e.message}`);
console.error(`[IC/${ccy}] error: ${e.message}`);
return null;
}
}
const results = {};
for (const [ccy, slug] of CB_KEYS) {
const data = await fetchCB(ccy, slug);
if (data) results[ccy] = data;
await new Promise(r => setTimeout(r, 300)); // 300ms entre requêtes
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);
}
// Rotation : si les données existantes datent de 59 jours → les sauvegarder comme previousWeek
let previousWeek = null;
let previousWeekFetchedAt = null;
try {
const existing = JSON.parse(readFileSync("data/rate-probabilities.json", "utf8"));
const ageMs = Date.now() - new Date(existing.fetchedAt).getTime();
const day = 86400000;
if (ageMs >= 5 * day && ageMs <= 9 * day) {
// Les données courantes datent d'une semaine → les promouvoir en previousWeek
previousWeek = existing.data;
previousWeekFetchedAt = existing.fetchedAt;
console.log(`\nRotated existing data (${(ageMs / day).toFixed(1)}d old) to previousWeek`);
} else if (existing.previousWeek && existing.previousWeekFetchedAt) {
// Conserver le previousWeek existant s'il est encore dans la fenêtre utile (< 11 jours)
const prevAge = Date.now() - new Date(existing.previousWeekFetchedAt).getTime();
if (prevAge < 11 * day) {
previousWeek = existing.previousWeek;
previousWeekFetchedAt = existing.previousWeekFetchedAt;
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 } };
}
function parseRateMonitorTable(ccy, html) {
// Find all <table> blocks
const tables = [...html.matchAll(/<table[\s\S]*?<\/table>/gi)];
console.log(`[IC/${ccy}] ${tables.length} tables found`);
for (const [table] of tables) {
// Extract header row to detect probability columns
const headers = [...table.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)]
.map(m => m[1].replace(/<[^>]+>/g,"").trim().toLowerCase());
const hasProbCols = headers.some(h => /cut|hike|unch|hold|basis|bps|prob|\-\d|\+\d/.test(h));
const hasDate = headers.some(h => /meeting|date|month/.test(h));
if (!hasProbCols || !hasDate) continue;
console.log(`[IC/${ccy}] table headers: ${headers.join(" | ")}`);
const dateIdx = headers.findIndex(h => /meeting|date|month/.test(h));
const cutCols = headers.reduce((acc,h,i) => /cut|\-25|\-50|decr/.test(h) ? [...acc,i] : acc, []);
const hikeCols = headers.reduce((acc,h,i) => /hike|\+25|\+50|incr/.test(h) ? [...acc,i] : acc, []);
const holdCols = headers.reduce((acc,h,i) => /unch|hold|no.?change/.test(h) ? [...acc,i] : acc, []);
const rateIdx = headers.findIndex(h => /implied|rate/.test(h));
const rows2 = [];
const nowIso = new Date().toISOString().slice(0,10);
const trs = [...table.matchAll(/<tr[\s\S]*?<\/tr>/gi)].slice(1); // skip header
for (const [tr] of trs) {
const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
.map(m => m[1].replace(/<[^>]+>/g,"").trim());
if (cells.length < 2) continue;
const dateIso = normalizeDate(cells[dateIdx] ?? "");
if (!dateIso || dateIso < nowIso) continue;
const pct = s => parseFloat((s ?? "0").replace(/[^0-9.]/g,"")) || 0;
const probCut = cutCols.reduce((s,i) => s + pct(cells[i]), 0);
const probHike = hikeCols.reduce((s,i) => s + pct(cells[i]), 0);
const probIsCut = probCut >= probHike;
const probMovePct = Math.max(probCut, probHike);
const impliedRate = rateIdx >= 0 ? pct(cells[rateIdx]) : FALLBACK_RATES[ccy] ?? 0;
const changeBps = (impliedRate - (FALLBACK_RATES[ccy] ?? 0)) * 100;
const dateLabel = new Date(dateIso + "T12:00:00Z")
.toLocaleDateString("en-US", { month:"short", day:"numeric" });
rows2.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 (rows2.length) {
console.log(`[IC/${ccy}] ✓ table parsed: ${rows2.length} rows`);
return { today: { midpoint: FALLBACK_RATES[ccy] ?? 0, rows: rows2 } };
}
}
} catch { /* pas de fichier existant */ }
console.warn(`[IC/${ccy}] no parseable table found`);
return null;
}
// ── 3. 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 url = `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`;
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}`);
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 isHike = /hike/i.test(m[5]);
const isNoChg = /no.?change/i.test(m[5]);
results[ccy] = { bpsYearEnd: isHike ? bps : -bps, probMovePct: isNoChg ? 0 : probPct, isCut: !isHike && !isNoChg };
}
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,
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 = {};
// 1 — CME FedWatch → USD
console.log("\n=== CME FedWatch ===");
const cmeData = await fetchCMEFedWatch();
if (cmeData) { results["USD"] = cmeData; console.log("[CME] USD ✓"); }
// 2 — Investing.com → all CBs (USD as backup if CME failed)
console.log("\n=== Investing.com Rate Monitors ===");
for (const ccy of CCYS) {
if (results[ccy]) { console.log(`[IC/${ccy}] skipped (CME)`); continue; }
const data = await fetchInvestingCom(ccy);
if (data) results[ccy] = data;
await new Promise(r => setTimeout(r, 600));
}
// 3 — 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)`);
}
}
}
// ── Previous week rotation ────────────────────────────────────────────────────
let previousWeek = null, previousWeekFetchedAt = null;
try {
const existing = JSON.parse(readFileSync("data/rate-probabilities.json","utf8"));
const ageMs = Date.now() - new Date(existing.fetchedAt).getTime();
const day = 86400000;
if (ageMs >= 5*day && ageMs <= 9*day) {
previousWeek = existing.data; previousWeekFetchedAt = existing.fetchedAt;
console.log(`\nRotated ${(ageMs/day).toFixed(1)}d-old data → previousWeek`);
} else if (existing.previousWeek) {
const prevAge = Date.now() - new Date(existing.previousWeekFetchedAt).getTime();
if (prevAge < 11*day) { previousWeek = existing.previousWeek; previousWeekFetchedAt = existing.previousWeekFetchedAt; }
}
} catch {}
mkdirSync("data", { recursive: true });
writeFileSync(
"data/rate-probabilities.json",
JSON.stringify({
data: results,
fetchedAt: new Date().toISOString(),
...(previousWeek ? { previousWeek, previousWeekFetchedAt } : {}),
}, null, 2)
);
writeFileSync("data/rate-probabilities.json", JSON.stringify({
data: results,
fetchedAt: new Date().toISOString(),
...(previousWeek ? { previousWeek, previousWeekFetchedAt } : {}),
}, null, 2));
console.log(`\nSaved ${Object.keys(results).length} CBs: ${Object.keys(results).join(", ")}`);
if (previousWeek) console.log(`previousWeek preserved (${Object.keys(previousWeek).join(", ")})`);
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(", ")}`);
+28 -112
View File
@@ -1,7 +1,7 @@
// lib/rateprobability.ts
// Données de probabilités de taux depuis rateprobability.com (API publique OIS/futures)
// Endpoint pattern: https://rateprobability.com/api/{cb}/latest
// Fallback: data/rate-probabilities.json mis à jour par GitHub Actions toutes les heures
// Données de probabilités de taux — sources : CME FedWatch + Investing.com
// Collectées par GitHub Actions (toutes les heures) → data/rate-probabilities.json
// InvestingLive (Giuseppe Dellamotta) en enrichissement CHF + deltas hebdo
import { readFileSync } from "fs";
import { join } from "path";
@@ -50,17 +50,9 @@ export interface CBRatePath {
export type RateProbData = Partial<Record<Currency, CBRatePath>>;
// ── Mapping CB → endpoint ──────────────────────────────────────────────────────
// ── Currencies suivies ─────────────────────────────────────────────────────────
const CB_KEYS: [Currency, string][] = [
["USD", "fed"],
["EUR", "ecb"],
["GBP", "boe"],
["JPY", "boj"],
["CAD", "boc"],
["AUD", "rba"],
["NZD", "rbnz"],
];
const CB_KEYS: Currency[] = ["USD","EUR","GBP","JPY","CAD","AUD","NZD"];
// Heures UTC approximatives des annonces
const ANNOUNCE_UTC: Partial<Record<Currency, number>> = {
@@ -99,70 +91,6 @@ function getAsOf(today: Record<string, unknown>): string {
return raw.slice(0, 10);
}
// ── Fetch une CB ───────────────────────────────────────────────────────────────
async function fetchCBPath(ccy: Currency, slug: string): Promise<CBRatePath | null> {
try {
const res = await fetch(`https://rateprobability.com/api/${slug}/latest`, {
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Referer": `https://rateprobability.com/${slug}`,
"Origin": "https://rateprobability.com",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"sec-ch-ua": '"Chromium";v="125", "Not.A/Brand";v="24"',
"sec-ch-ua-mobile":"?0",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
},
cache: "no-store",
});
if (!res.ok) {
console.error(`[rate-prob] ${slug} HTTP ${res.status} ${res.statusText}`);
return null;
}
const body = await res.json() as Record<string, unknown>;
const today = body["today"] as Record<string, unknown> | undefined;
if (!today) return null;
const currentRate = getCurrentRate(ccy, today);
const asOf = getAsOf(today);
const nowIso = new Date().toISOString().slice(0, 10);
const maxIso = new Date(Date.now() + 380 * 86400000).toISOString().slice(0, 10);
const rawRows = (today["rows"] as Array<Record<string, unknown>> | undefined) ?? [];
const meetings: RateProbMeeting[] = rawRows
.filter(r =>
typeof r["meeting_iso"] === "string" &&
(r["meeting_iso"] as string) >= nowIso &&
(r["meeting_iso"] as string) <= maxIso
)
.map(r => ({
label: (r["meeting"] as string).slice(0, 6),
dateIso: r["meeting_iso"] as string,
impliedRate: parseFloat(String(r["implied_rate_post_meeting"] ?? currentRate)),
probMovePct: parseFloat(String(r["prob_move_pct"] ?? 0)),
probIsCut: Boolean(r["prob_is_cut"]),
changeBps: parseFloat(String(r["change_bps"] ?? 0)),
}));
const peakMeeting = meetings.length > 0
? meetings.reduce((best, m) => m.probMovePct > best.probMovePct ? m : best, meetings[0])
: null;
// yearEndImplied = taux implicite à la dernière réunion de l'année en cours (pas mid-2027)
const currentYear = new Date().getFullYear();
const yearEndIso = `${currentYear}-12-31`;
const meetsThisYear = meetings.filter(m => m.dateIso <= yearEndIso);
const yearEndImplied = meetsThisYear.length > 0
? meetsThisYear[meetsThisYear.length - 1].impliedRate
: meetings.length > 0 ? meetings[0].impliedRate : null;
return { currency: ccy, asOf, currentRate, meetings, peakMeeting, yearEndImplied };
} catch { return null; }
}
// ── SNB meeting dates (published par la SNB, trimestrielles) ─────────────────
// Mise à jour annuelle : mars, juin, septembre, décembre
@@ -229,7 +157,7 @@ function buildSNBPath(il: ILExpectationsMap, currentRate: number): CBRatePath |
// ── Fallback : data JSON committé par GitHub Actions ─────────────────────────
function loadCachedRPBody(ccy: string, slug: string): Record<string, unknown> | null {
function loadCachedRPBody(ccy: string, _slug: string): Record<string, unknown> | null {
try {
const filePath = join(process.cwd(), "data", "rate-probabilities.json");
const raw = readFileSync(filePath, "utf8");
@@ -288,57 +216,47 @@ function parseCBBody(ccy: Currency, body: Record<string, unknown>): CBRatePath |
return { currency: ccy, asOf, currentRate, meetings, peakMeeting, yearEndImplied };
}
// ── Fetch toutes les CB en parallèle ──────────────────────────────────────────
// ── Fetch toutes les CB — depuis le cache GitHub Actions + enrichissement IL ───
export async function fetchAllCBPaths(): Promise<RateProbData> {
// rateprobability.com (7 CBs) + InvestingLive (article courant + précédent) en parallèle
const [rpResults, ilHistory] = await Promise.all([
Promise.allSettled(CB_KEYS.map(([ccy, slug]) => fetchCBPath(ccy, slug))),
fetchILExpectationsWithHistory(),
]);
const ilData = ilHistory.current;
const ilPrev = ilHistory.prev;
const prevDate = ilHistory.prevDate;
// GitHub Actions met à jour data/rate-probabilities.json toutes les heures
// (CME FedWatch pour USD, Investing.com pour les autres, InvestingLive en fallback)
const [ilHistory] = await Promise.all([fetchILExpectationsWithHistory()]);
const ilData = ilHistory.current;
const ilPrev = ilHistory.prev;
const prevDate = ilHistory.prevDate;
const data: RateProbData = {};
// Intègre les données rateprobability.com + fallback cache GitHub Actions
for (let i = 0; i < CB_KEYS.length; i++) {
const [ccy, slug] = CB_KEYS[i];
const r = rpResults[i];
if (r.status === "fulfilled" && r.value) {
data[ccy] = r.value;
} else {
// Live fetch failed → try the GitHub Actions hourly cache
const cachedBody = loadCachedRPBody(ccy, slug);
if (cachedBody) {
const parsed = parseCBBody(ccy as Currency, cachedBody);
if (parsed) data[ccy] = parsed;
}
// Charge depuis le cache JSON (GitHub Actions)
for (const ccy of CB_KEYS) {
const cachedBody = loadCachedRPBody(ccy, ccy.toLowerCase());
if (cachedBody) {
const parsed = parseCBBody(ccy, cachedBody);
if (parsed) data[ccy] = parsed;
}
}
// Enrichissement prevMeetings depuis snapshot semaine précédente (GitHub Actions)
for (const [ccy] of CB_KEYS) {
const path = data[ccy as Currency];
// Enrichissement prevMeetings depuis snapshot semaine précédente
for (const ccy of CB_KEYS) {
const path = data[ccy];
if (!path) continue;
const prev = loadPrevWeekCachedBody(ccy);
if (!prev) continue;
const prevPath = parseCBBody(ccy as Currency, prev.body);
const prevPath = parseCBBody(ccy, prev.body);
if (prevPath?.meetings.length) {
data[ccy as Currency] = { ...path, prevMeetings: prevPath.meetings, prevWeekDate: prev.date };
data[ccy] = { ...path, prevMeetings: prevPath.meetings, prevWeekDate: prev.date };
}
}
// CHF/SNB : rateprobability ne couvre pas la SNB → InvestingLive est la seule source
// CHF/SNB : Investing.com rate monitor couvre SNB → déjà dans le JSON si dispo.
// InvestingLive reste en backup si le JSON n'a pas CHF.
if (!data["CHF"] && ilData["CHF"]) {
const snbPath = buildSNBPath(ilData, 0.00); // taux actuel SNB = 0%
const snbPath = buildSNBPath(ilData, 0.00);
if (snbPath) data["CHF"] = snbPath;
}
// IL enrichment : yearEndImplied uniquement pour CHF (RP ne couvre pas la SNB).
// Pour les 7 autres devises, on garde yearEndImplied de rateprobability.com (données OIS live).
// ilDelta (flèches hebdo) reste calculé pour toutes les devises via l'article IL.
// Enrichissement IL : deltas hebdo pour toutes les devises
for (const [ccyStr, ilEntry] of Object.entries(ilData)) {
const ccy = ccyStr as keyof RateProbData;
const path = data[ccy];
@@ -346,12 +264,10 @@ export async function fetchAllCBPaths(): Promise<RateProbData> {
if (typeof ilEntry.bpsYearEnd !== "number") continue;
if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 10) continue;
// CHF only : pas de données RP → IL est la seule source pour yearEndImplied
const yearEndImplied = ccy === "CHF"
? parseFloat((path.currentRate + ilEntry.bpsYearEnd / 100).toFixed(4))
: path.yearEndImplied;
// Delta semaine/semaine depuis l'article précédent de Giuseppe
let ilDelta: ILWeeklyDelta | undefined;
const prevEntry = ilPrev[ccy];
if (prevEntry && prevDate) {