diff --git a/.github/scripts/fetch-rate-data.mjs b/.github/scripts/fetch-rate-data.mjs index b75c04f..2b04c3d 100644 --- a/.github/scripts/fetch-rate-data.mjs +++ b/.github/scripts/fetch-rate-data.mjs @@ -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(/