mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-07 17:57:44 +00:00
feat: remplace Atlanta Fed par des futures Euribor/SONIA réels pour EUR/GBP
- Retire l'onglet Atlanta Fed MPT (USD) : jugé sans valeur ajoutée réelle. - EUR/GBP n'avaient qu'un unique point synthétique (agrégat year-end InvestingLive) gonflé en fausses réunions plates à 0% sur Courbe/ Probabilités/Réunions — d'où l'aspect cassé. Investing.com n'a de Rate Monitor pré-calculé (comme pour la Fed) ni pour la BCE ni pour la BoE (404 confirmé), donc pas d'équivalent direct au pipeline USD. - Remplace ce point unique par un vrai scrape des futures Euribor 3M (Eurex) et Three-Month SONIA (ICE), cotés sur investing.com (delayed, gratuit, pas de login) : conversion prix→taux (100−prix), mapping sur le calendrier réel BCE/BoE (échéance la plus proche ≥ date de réunion, fenêtre de grâce de 60j au-delà du dernier contrat coté, sinon on s'arrête plutôt que d'inventer), probabilité approximée avec la convention bps/25 déjà utilisée pour le fallback InvestingLive. - Résolution plus grossière que l'USD (mensuelle EUR, trimestrielle GBP) et clairement étiquetée comme telle dans un footnote dédié. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,166 +0,0 @@
|
||||
// Atlanta Fed Market Probability Tracker (MPT) — méthodologie alternative à
|
||||
// Investing.com Fed Rate Monitor : au lieu des 30-day Fed Fund Futures, la
|
||||
// Fed d'Atlanta déduit une distribution de probabilité du niveau du taux Fed
|
||||
// à partir des OPTIONS sur futures SOFR 3 mois cotées au CME (le rationnel :
|
||||
// la Fed conduit sa politique via des opérations repo, donc la distribution
|
||||
// implicite du SOFR composé sur la fenêtre de référence du contrat permet de
|
||||
// déduire les anticipations sur la fourchette cible du FOMC).
|
||||
// Doc + téléchargements : https://www.atlantafed.org/cenfis/market-probability-tracker
|
||||
//
|
||||
// Format du fichier xl/worksheets/sheet3.xml (vérifié 2026-07-08) — format
|
||||
// long, une ligne par (date, reference_start, target_range, field) :
|
||||
// date : date d'observation (YYYY-MM-DD), publiée quotidiennement
|
||||
// reference_start : date de début de la fenêtre de 3 mois référencée par le
|
||||
// contrat SOFR (trimestrielle, style IMM)
|
||||
// target_range : pour les champs agrégés (Rate:*, Prob: cut, Prob: hike)
|
||||
// = fourchette cible ACTUELLE (contexte) ; pour les champs
|
||||
// "Prob: XXXbps - YYYbps" = LA fourchette évaluée
|
||||
// field / value : nom du champ + valeur (en bps, ex. "373.91" = 3.7391%)
|
||||
//
|
||||
// Pas de dépendance xlsx npm (le paquet du registre public a des CVE critiques
|
||||
// non patchées depuis que SheetJS a arrêté d'y publier) — on lit le zip et le
|
||||
// XML OOXML directement avec les modules Node natifs (zlib + regex).
|
||||
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { inflateRawSync } from "zlib";
|
||||
|
||||
const XLSX_URL = "https://www.atlantafed.org/-/media/Project/Atlanta/FRBA/Documents/cenfis/market-probability-tracker/mpt_histdata.xlsx";
|
||||
|
||||
// ── Lecteur ZIP minimal (central directory + inflate raw deflate) ────────────
|
||||
|
||||
function readZip(buf) {
|
||||
let eocdOff = -1;
|
||||
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65536); i--) {
|
||||
if (buf.readUInt32LE(i) === 0x06054b50) { eocdOff = i; break; }
|
||||
}
|
||||
if (eocdOff === -1) throw new Error("ZIP: End Of Central Directory introuvable");
|
||||
const cdEntries = buf.readUInt16LE(eocdOff + 10);
|
||||
const cdOffset = buf.readUInt32LE(eocdOff + 16);
|
||||
|
||||
const entries = {};
|
||||
let p = cdOffset;
|
||||
for (let i = 0; i < cdEntries; i++) {
|
||||
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`ZIP: central directory corrompue à l'offset ${p}`);
|
||||
const compMethod = buf.readUInt16LE(p + 10);
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const lfhOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.toString("utf8", p + 46, p + 46 + nameLen);
|
||||
entries[name] = { compMethod, compSize, lfhOffset };
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return {
|
||||
read(name) {
|
||||
const e = entries[name];
|
||||
if (!e) return null;
|
||||
const nameLen = buf.readUInt16LE(e.lfhOffset + 26);
|
||||
const extraLen = buf.readUInt16LE(e.lfhOffset + 28);
|
||||
const dataStart = e.lfhOffset + 30 + nameLen + extraLen;
|
||||
const raw = buf.subarray(dataStart, dataStart + e.compSize);
|
||||
return e.compMethod === 0 ? raw : inflateRawSync(raw);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseSharedStrings(xml) {
|
||||
return Array.from(xml.matchAll(/<si>(?:<t[^>]*>([^<]*)<\/t>|<r>[\s\S]*?<\/r>)*<\/si>/g))
|
||||
.map(m => m[0].match(/<t[^>]*>([^<]*)<\/t>/g)?.map(t => t.replace(/<[^>]+>/g, "")).join("") ?? "");
|
||||
}
|
||||
|
||||
function parseSheetRows(xml, strings) {
|
||||
const rowRe = /<row r="\d+"[^>]*>([\s\S]*?)<\/row>/g;
|
||||
const cellRe = /<c r="([A-Z]+)\d+"(?:\s+t="([a-z]+)")?[^>]*>(?:<v>([^<]*)<\/v>)?<\/c>/g;
|
||||
const rows = [];
|
||||
let rm, first = true;
|
||||
while ((rm = rowRe.exec(xml)) !== null) {
|
||||
if (first) { first = false; continue; } // ligne d'en-tête
|
||||
const cells = {};
|
||||
let cm; cellRe.lastIndex = 0;
|
||||
while ((cm = cellRe.exec(rm[1])) !== null) {
|
||||
const [, ref, type, val] = cm;
|
||||
cells[ref] = val === undefined ? null : (type === "s" ? strings[+val] : val);
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function excelSerialToIso(serial) {
|
||||
const epoch = Date.UTC(1899, 11, 30);
|
||||
return new Date(epoch + Number(serial) * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Fetch + parse ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAtlantaMpt() {
|
||||
const res = await fetch(XLSX_URL, {
|
||||
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" },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
const zip = readZip(buf);
|
||||
const strings = parseSharedStrings(zip.read("xl/sharedStrings.xml").toString("utf8"));
|
||||
const rows = parseSheetRows(zip.read("xl/worksheets/sheet3.xml").toString("utf8"), strings);
|
||||
|
||||
// { A: date, B: reference_start (serial), C: target_range, D: field, E: value }
|
||||
const records = rows
|
||||
.filter(r => r.A && r.B && r.D && r.E !== null)
|
||||
.map(r => ({ date: r.A, ref: Number(r.B), range: r.C, field: r.D, value: parseFloat(String(r.E).trim()) }));
|
||||
|
||||
if (!records.length) throw new Error("aucune ligne exploitable trouvée dans sheet3");
|
||||
|
||||
const maxDate = records.reduce((m, r) => (r.date > m ? r.date : m), "");
|
||||
const latest = records.filter(r => r.date === maxDate);
|
||||
const refStarts = [...new Set(latest.map(r => r.ref))].sort((a, b) => a - b);
|
||||
|
||||
const windows = refStarts.map(ref => {
|
||||
const windowRows = latest.filter(r => r.ref === ref);
|
||||
const get = (field) => windowRows.find(r => r.field === field)?.value ?? null;
|
||||
const anchorRange = windowRows.find(r => r.field === "Prob: hike")?.range ?? null;
|
||||
// La colonne "range" (C) vaut toujours la fourchette ACTUELLE/ancre pour
|
||||
// toutes les lignes de la fenêtre (y compris les lignes de distribution) —
|
||||
// la fourchette évaluée par chaque bucket est encodée dans le nom du champ
|
||||
// lui-même ("Prob: 375bps - 400bps"), pas dans la colonne range.
|
||||
const distribution = windowRows
|
||||
.filter(r => r.field.startsWith("Prob: ") && r.field !== "Prob: cut" && r.field !== "Prob: hike")
|
||||
.map(r => ({ rangeLabel: r.field.replace(/^Prob:\s*/, ""), probPct: r.value }))
|
||||
.sort((a, b) => parseInt(a.rangeLabel) - parseInt(b.rangeLabel));
|
||||
return {
|
||||
windowStartIso: excelSerialToIso(ref),
|
||||
anchorRange,
|
||||
probCutPct: get("Prob: cut"),
|
||||
probHikePct: get("Prob: hike"),
|
||||
rate25: get("Rate: 25th percentile"),
|
||||
rateMean: get("Rate: mean"),
|
||||
rateMode: get("Rate: mode"),
|
||||
rate75: get("Rate: 75th percentile"),
|
||||
distribution,
|
||||
};
|
||||
});
|
||||
|
||||
return { asOf: maxDate, windows };
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
console.log("Fetching Atlanta Fed MPT historical data…");
|
||||
const data = await fetchAtlantaMpt();
|
||||
console.log(`✓ asOf=${data.asOf}, ${data.windows.length} fenêtres trimestrielles`);
|
||||
console.log(` front window: ${data.windows[0].windowStartIso} — hike=${data.windows[0].probHikePct}% cut=${data.windows[0].probCutPct}%`);
|
||||
|
||||
mkdirSync("data", { recursive: true });
|
||||
writeFileSync("data/atlanta-fed-mpt.json", JSON.stringify({
|
||||
updated_at: new Date().toISOString().slice(0, 10),
|
||||
source: "https://www.atlantafed.org/cenfis/market-probability-tracker",
|
||||
note: "Probabilités dérivées des options sur futures SOFR 3 mois (CME) — méthodologie Atlanta Fed, mise à jour quotidienne. Fourchettes exprimées en bps (350bps-375bps = 3.50%-3.75%). Alternative aux 30-day Fed Fund Futures (Investing.com Fed Rate Monitor).",
|
||||
...data,
|
||||
}, null, 2) + "\n");
|
||||
console.log("✓ Saved data/atlanta-fed-mpt.json");
|
||||
} catch (e) {
|
||||
console.error(`✗ Atlanta Fed MPT fetch failed: ${e.message}`);
|
||||
process.exit(0); // échec silencieux — ne bloque pas le workflow, données précédentes conservées
|
||||
}
|
||||
@@ -303,6 +303,140 @@ function parseRateMonitorTable(ccy, html) {
|
||||
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 = {
|
||||
@@ -440,6 +574,22 @@ for (const ccy of IC_SUPPORTED) {
|
||||
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) 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) {
|
||||
|
||||
Reference in New Issue
Block a user