2026-06-01 23:16:18 +02:00
|
|
|
|
// lib/rateprobability.ts
|
2026-06-28 13:35:14 +02:00
|
|
|
|
// 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
|
2026-06-01 23:16:18 +02:00
|
|
|
|
|
2026-06-29 17:43:49 +02:00
|
|
|
|
import { readFileSync, writeFileSync } from "fs";
|
2026-06-27 16:33:38 +02:00
|
|
|
|
import { join } from "path";
|
2026-06-01 23:16:18 +02:00
|
|
|
|
import type { Currency } from "./types";
|
2026-06-08 19:41:50 +02:00
|
|
|
|
import { fetchILExpectationsWithHistory } from "./investinglive";
|
2026-06-29 17:43:49 +02:00
|
|
|
|
import type { ILExpectationsMap, ILExpectationsWithHistory } from "./investinglive";
|
|
|
|
|
|
|
|
|
|
|
|
// ── Cache fichier pour les données InvestingLive (évite 56 HTTP HEAD par appel) ──
|
|
|
|
|
|
|
|
|
|
|
|
const IL_CACHE_FILE = join(process.cwd(), "data", "il-enrichment-cache.json");
|
|
|
|
|
|
const IL_CACHE_TTL = 2 * 60 * 60 * 1000; // 2h
|
|
|
|
|
|
|
|
|
|
|
|
async function getCachedILHistory(): Promise<ILExpectationsWithHistory> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = readFileSync(IL_CACHE_FILE, "utf8");
|
|
|
|
|
|
const cached = JSON.parse(raw) as { ts: number; data: ILExpectationsWithHistory };
|
|
|
|
|
|
if (Date.now() - cached.ts < IL_CACHE_TTL) {
|
|
|
|
|
|
console.log(`[IL-cache] HIT (${Math.round((Date.now() - cached.ts) / 60000)}min old)`);
|
|
|
|
|
|
return cached.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log("[IL-cache] STALE — refetch");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
console.log("[IL-cache] MISS — premier fetch");
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await fetchILExpectationsWithHistory();
|
|
|
|
|
|
try {
|
|
|
|
|
|
writeFileSync(IL_CACHE_FILE, JSON.stringify({ ts: Date.now(), data }));
|
|
|
|
|
|
} catch { /* /tmp read-only en prod Vercel — ignoré */ }
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
2026-06-01 23:16:18 +02:00
|
|
|
|
|
|
|
|
|
|
// ── Types publics ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export interface RateProbMeeting {
|
|
|
|
|
|
label: string; // "Jun 11" — 6 chars max
|
|
|
|
|
|
dateIso: string; // "2026-06-11"
|
|
|
|
|
|
impliedRate: number; // taux implicite post-réunion
|
|
|
|
|
|
probMovePct: number; // 0–100 : probabilité d'un mouvement
|
|
|
|
|
|
probIsCut: boolean; // true = baisse, false = hausse
|
|
|
|
|
|
changeBps: number; // bps attendus à cette réunion (cumulatif)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 19:41:50 +02:00
|
|
|
|
export interface ILWeeklyDelta {
|
|
|
|
|
|
probDelta: number; // Δ nextMeetingProbPct (courant - semaine précédente)
|
|
|
|
|
|
bpsDelta: number; // Δ bpsYearEnd (courant - semaine précédente)
|
|
|
|
|
|
isCut: boolean; // contexte : le pic actuel est un cut
|
|
|
|
|
|
prevDate: string; // date de l'article de référence (semaine précédente)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 20:12:18 +02:00
|
|
|
|
export interface ILCurrent {
|
|
|
|
|
|
bpsYearEnd: number; // bps fin d'an selon l'article IL courant
|
2026-06-29 13:49:52 +02:00
|
|
|
|
probPct: number; // probabilité de move à la prochaine réunion (IL analyste)
|
|
|
|
|
|
stirProbPct?: number; // probabilité originale STIR/IC (avant fusion IL)
|
2026-06-08 20:12:18 +02:00
|
|
|
|
isNoChange: boolean; // l'analyste anticipe un statu quo
|
|
|
|
|
|
isCut: boolean; // l'analyste anticipe une baisse
|
|
|
|
|
|
articleDate: string; // date de publication de l'article (YYYY-MM-DD)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
export interface CBRatePath {
|
|
|
|
|
|
currency: Currency;
|
|
|
|
|
|
asOf: string; // "2026-05-31"
|
|
|
|
|
|
currentRate: number;
|
|
|
|
|
|
meetings: RateProbMeeting[];
|
|
|
|
|
|
peakMeeting: RateProbMeeting | null; // réunion avec proba max de mouvement
|
2026-06-08 20:12:18 +02:00
|
|
|
|
yearEndImplied: number | null; // taux impliqué à la dernière réunion connue (SOFR)
|
|
|
|
|
|
ilCurrent?: ILCurrent; // valeurs absolues de l'article IL courant
|
2026-06-08 19:41:50 +02:00
|
|
|
|
ilDelta?: ILWeeklyDelta; // delta vs article IL semaine précédente
|
2026-06-27 19:40:48 +02:00
|
|
|
|
prevMeetings?: RateProbMeeting[]; // réunions semaine précédente (snapshot RP)
|
|
|
|
|
|
prevWeekDate?: string; // date du snapshot semaine précédente
|
2026-06-29 12:17:11 +02:00
|
|
|
|
history?: Array<{ date: string; meetings: RateProbMeeting[] }>; // snapshots hebdo accumulés
|
2026-06-01 23:16:18 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type RateProbData = Partial<Record<Currency, CBRatePath>>;
|
|
|
|
|
|
|
2026-06-28 13:35:14 +02:00
|
|
|
|
// ── Currencies suivies ─────────────────────────────────────────────────────────
|
2026-06-01 23:16:18 +02:00
|
|
|
|
|
2026-06-28 13:35:14 +02:00
|
|
|
|
const CB_KEYS: Currency[] = ["USD","EUR","GBP","JPY","CAD","AUD","NZD"];
|
2026-06-01 23:16:18 +02:00
|
|
|
|
|
|
|
|
|
|
// Heures UTC approximatives des annonces
|
|
|
|
|
|
const ANNOUNCE_UTC: Partial<Record<Currency, number>> = {
|
|
|
|
|
|
USD: 18, EUR: 12, GBP: 11, JPY: 2, CAD: 14, AUD: 3, NZD: 2, CHF: 8,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Titres pour le calendrier
|
|
|
|
|
|
const MEETING_TITLES: Partial<Record<Currency, string>> = {
|
|
|
|
|
|
USD: "FOMC — Décision taux Fed",
|
|
|
|
|
|
EUR: "BCE — Governing Council",
|
|
|
|
|
|
GBP: "BoE MPC — Décision taux",
|
|
|
|
|
|
JPY: "BoJ — Policy Board",
|
|
|
|
|
|
CAD: "BoC — Décision taux",
|
|
|
|
|
|
AUD: "RBA — Décision taux",
|
|
|
|
|
|
NZD: "RBNZ — Décision taux",
|
|
|
|
|
|
CHF: "SNB — Décision taux",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Extraction des champs (nommage hétérogène selon les CB) ───────────────────
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
// .github/scripts/fetch-rate-data.mjs écrit toujours "midpoint", quelle que soit
|
|
|
|
|
|
// la devise (CME/Investing.com/InvestingLive écrivent tous le même schéma). Les
|
|
|
|
|
|
// noms par devise ci-dessous ("current_target", "cash_rate_target"…) datent de
|
|
|
|
|
|
// l'ancienne source rateprobability.com (remplacée par le commit 2673686) et ne
|
|
|
|
|
|
// sont plus jamais écrits par le pipeline actuel — d'où le "0.00%" affiché pour
|
|
|
|
|
|
// toute devise autre que USD/NZD (les deux seules à retomber sur "midpoint").
|
2026-06-01 23:16:18 +02:00
|
|
|
|
function getCurrentRate(ccy: Currency, today: Record<string, unknown>): number {
|
2026-07-08 16:46:05 +02:00
|
|
|
|
if (typeof today["midpoint"] === "number") return today["midpoint"] as number;
|
2026-06-01 23:16:18 +02:00
|
|
|
|
switch (ccy) {
|
|
|
|
|
|
case "EUR": return (today["ecb_main_refinancing"] as number) ?? (today["ecb_deposit_facility"] as number) ?? 0;
|
|
|
|
|
|
case "GBP": return (today["current_target"] as number) ?? 0;
|
|
|
|
|
|
case "JPY": return (today["current_target"] as number) ?? 0;
|
|
|
|
|
|
case "CAD": return (today["Overnight Rate Target"] as number) ?? 0;
|
|
|
|
|
|
case "AUD": return (today["cash_rate_target"] as number) ?? 0;
|
2026-07-08 16:46:05 +02:00
|
|
|
|
case "NZD": return (today["Official Cash Rate (OCR)"] as number) ?? (today["current_target"] as number) ?? 0;
|
2026-06-01 23:16:18 +02:00
|
|
|
|
default: return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAsOf(today: Record<string, unknown>): string {
|
|
|
|
|
|
const raw = String(today["As of"] ?? today["as_of"] ?? today["run_date"] ?? "");
|
|
|
|
|
|
return raw.slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
// ── Calendriers officiels par banque centrale ─────────────────────────────────
|
|
|
|
|
|
// Utilisés pour les CB sans page Investing.com dédiée (ou dont le fallback IL
|
|
|
|
|
|
// n'a qu'un seul point "year-end") : on construit une vraie courbe multi-réunions
|
|
|
|
|
|
// à partir des dates réelles + de l'estimation InvestingLive (proba/direction).
|
|
|
|
|
|
// Sources officielles, à mettre à jour quand chaque banque publie son calendrier
|
|
|
|
|
|
// suivant (généralement 1x/an) :
|
|
|
|
|
|
// SNB : snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions
|
|
|
|
|
|
// RBNZ : rbnz.govt.nz
|
|
|
|
|
|
// ECB : ecb.europa.eu/press/calendars/mgcgc (jour 2 = décision + conf. presse)
|
|
|
|
|
|
// BoE : bankofengland.co.uk/monetary-policy/upcoming-mpc-dates
|
|
|
|
|
|
// BoJ : boj.or.jp/en/mopo/mpmsche_minu (2027 pas encore publié à l'écriture de ceci)
|
|
|
|
|
|
// BoC : bankofcanada.ca (annonce annuelle du calendrier, pas encore publié pour 2027)
|
|
|
|
|
|
// RBA : rba.gov.au/schedules-events/board-meeting-schedules.html (jour 2 = décision)
|
2026-06-01 23:16:18 +02:00
|
|
|
|
|
|
|
|
|
|
const SNB_MEETINGS: string[] = [
|
2026-07-08 16:46:05 +02:00
|
|
|
|
"2026-06-19", "2026-09-25", "2026-12-11",
|
|
|
|
|
|
"2027-03-18", "2027-06-17", "2027-09-23", "2027-12-09",
|
2026-06-01 23:16:18 +02:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-06-29 12:17:11 +02:00
|
|
|
|
const RBNZ_MEETINGS: string[] = [
|
2026-07-08 16:46:05 +02:00
|
|
|
|
"2026-07-09", "2026-08-19", "2026-10-14", "2026-11-25",
|
|
|
|
|
|
"2027-02-24", "2027-04-09", "2027-05-26", "2027-07-14",
|
|
|
|
|
|
"2027-08-18", "2027-10-13", "2027-11-24",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const ECB_MEETINGS: string[] = [
|
|
|
|
|
|
"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: string[] = [
|
|
|
|
|
|
"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 BOJ_MEETINGS: string[] = [
|
|
|
|
|
|
"2026-07-31", "2026-09-18", "2026-10-30", "2026-12-18",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const BOC_MEETINGS: string[] = [
|
|
|
|
|
|
"2026-07-15", "2026-09-02", "2026-10-28", "2026-12-09",
|
2026-06-29 12:17:11 +02:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const RBA_MEETINGS: string[] = [
|
|
|
|
|
|
"2026-08-11", "2026-09-29", "2026-11-03", "2026-12-08",
|
|
|
|
|
|
"2027-02-09", "2027-03-23", "2027-05-04", "2027-06-22",
|
|
|
|
|
|
"2027-08-10", "2027-09-28", "2027-11-02", "2027-12-14",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// Construit un CBRatePath depuis un calendrier officiel + l'estimation InvestingLive
|
|
|
|
|
|
// (proba/direction de la prochaine réunion, biais year-end pour les suivantes).
|
|
|
|
|
|
function buildOfficialCalendarPath(
|
|
|
|
|
|
currency: Currency,
|
|
|
|
|
|
officialMeetings: string[],
|
|
|
|
|
|
il: ILExpectationsMap,
|
|
|
|
|
|
currentRate: number,
|
|
|
|
|
|
): CBRatePath | null {
|
|
|
|
|
|
const ilData = il[currency];
|
|
|
|
|
|
if (!ilData) return null;
|
2026-06-29 12:17:11 +02:00
|
|
|
|
|
|
|
|
|
|
const nowIso = new Date().toISOString().slice(0, 10);
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const upcomingMeetings = officialMeetings.filter(d => d >= nowIso);
|
2026-06-29 12:17:11 +02:00
|
|
|
|
if (upcomingMeetings.length === 0) return null;
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const yearEndIsCut = ilData.bpsYearEnd < 0;
|
2026-06-29 12:17:11 +02:00
|
|
|
|
|
|
|
|
|
|
const meetings: RateProbMeeting[] = upcomingMeetings.map((dateIso, i) => {
|
|
|
|
|
|
const isNext = i === 0;
|
2026-07-08 16:46:05 +02:00
|
|
|
|
const probMovePct = isNext ? ilData.nextMeetingProbPct : 0;
|
2026-06-29 12:17:11 +02:00
|
|
|
|
const probIsCut = isNext
|
2026-07-08 16:46:05 +02:00
|
|
|
|
? (ilData.nextMeetingIsNoChange ? yearEndIsCut : !ilData.nextMeetingIsHike)
|
2026-06-29 12:17:11 +02:00
|
|
|
|
: yearEndIsCut;
|
|
|
|
|
|
const changeBps = isNext ? (probMovePct > 50 ? (probIsCut ? -25 : 25) : 0) : 0;
|
|
|
|
|
|
const impliedRate = isNext && probMovePct > 50
|
|
|
|
|
|
? parseFloat((currentRate + (probIsCut ? -0.25 : 0.25)).toFixed(4))
|
|
|
|
|
|
: currentRate;
|
|
|
|
|
|
|
|
|
|
|
|
return { label: dateIso.slice(0, 7), dateIso, impliedRate, probMovePct, probIsCut, changeBps };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const peakMeeting = meetings.reduce((best, m) =>
|
|
|
|
|
|
m.probMovePct > best.probMovePct ? m : best, meetings[0]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-07-08 16:46:05 +02:00
|
|
|
|
currency,
|
|
|
|
|
|
asOf: ilData.publishedDate,
|
2026-06-01 23:16:18 +02:00
|
|
|
|
currentRate,
|
|
|
|
|
|
meetings,
|
|
|
|
|
|
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
|
|
|
|
|
|
yearEndImplied: meetings.at(-1)?.impliedRate ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 16:33:38 +02:00
|
|
|
|
// ── Fallback : data JSON committé par GitHub Actions ─────────────────────────
|
|
|
|
|
|
|
2026-06-28 13:35:14 +02:00
|
|
|
|
function loadCachedRPBody(ccy: string, _slug: string): Record<string, unknown> | null {
|
2026-06-27 16:33:38 +02:00
|
|
|
|
try {
|
|
|
|
|
|
const filePath = join(process.cwd(), "data", "rate-probabilities.json");
|
|
|
|
|
|
const raw = readFileSync(filePath, "utf8");
|
|
|
|
|
|
const parsed = JSON.parse(raw) as { data: Record<string, unknown>; fetchedAt: string };
|
|
|
|
|
|
const entry = parsed.data?.[ccy] as Record<string, unknown> | undefined;
|
|
|
|
|
|
if (!entry) return null;
|
|
|
|
|
|
const ageMs = Date.now() - new Date(parsed.fetchedAt).getTime();
|
2026-06-29 17:05:50 +02:00
|
|
|
|
if (ageMs > 168 * 60 * 60 * 1000) { // ignore si > 7 jours
|
2026-06-27 16:33:38 +02:00
|
|
|
|
console.warn(`[rate-prob] cache stale (${Math.round(ageMs / 3600000)}h), skipping`);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[rate-prob] ${ccy} loaded from GitHub Actions cache (${Math.round(ageMs / 60000)}min old)`);
|
|
|
|
|
|
return entry;
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 12:17:11 +02:00
|
|
|
|
function loadHistorySnapshots(): Array<{ date: string; raw: Record<string, unknown> }> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const filePath = join(process.cwd(), "data", "rate-probabilities.json");
|
|
|
|
|
|
const raw = readFileSync(filePath, "utf8");
|
|
|
|
|
|
const parsed = JSON.parse(raw) as { snapshots?: Array<{ data: Record<string, unknown>; fetchedAt: string }> };
|
|
|
|
|
|
if (!parsed.snapshots?.length) return [];
|
|
|
|
|
|
return parsed.snapshots.map(s => ({ date: s.fetchedAt.slice(0, 10), raw: s.data }));
|
|
|
|
|
|
} catch { return []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 19:40:48 +02:00
|
|
|
|
function loadPrevWeekCachedBody(ccy: string): { body: Record<string, unknown>; date: string } | null {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const filePath = join(process.cwd(), "data", "rate-probabilities.json");
|
|
|
|
|
|
const raw = readFileSync(filePath, "utf8");
|
|
|
|
|
|
const parsed = JSON.parse(raw) as { previousWeek?: Record<string, unknown>; previousWeekFetchedAt?: string };
|
|
|
|
|
|
if (!parsed.previousWeek || !parsed.previousWeekFetchedAt) return null;
|
|
|
|
|
|
const entry = parsed.previousWeek[ccy] as Record<string, unknown> | undefined;
|
|
|
|
|
|
if (!entry) return null;
|
|
|
|
|
|
const ageMs = Date.now() - new Date(parsed.previousWeekFetchedAt).getTime();
|
|
|
|
|
|
// Le snapshot semaine précédente doit dater de 4 à 10 jours
|
|
|
|
|
|
if (ageMs < 3 * 86400000 || ageMs > 11 * 86400000) return null;
|
|
|
|
|
|
return { body: entry, date: parsed.previousWeekFetchedAt.slice(0, 10) };
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 16:33:38 +02:00
|
|
|
|
// Reparse un body brut de rateprobability.com (même format que fetchCBPath)
|
|
|
|
|
|
function parseCBBody(ccy: Currency, body: Record<string, unknown>): CBRatePath | null {
|
|
|
|
|
|
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)),
|
|
|
|
|
|
}));
|
|
|
|
|
|
if (!meetings.length) return null;
|
|
|
|
|
|
const peakMeeting = meetings.reduce((best, m) => m.probMovePct > best.probMovePct ? m : best, meetings[0]);
|
|
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
|
|
const meetsThisYear = meetings.filter(m => m.dateIso <= `${currentYear}-12-31`);
|
|
|
|
|
|
const yearEndImplied = meetsThisYear.length > 0 ? meetsThisYear.at(-1)!.impliedRate : meetings[0].impliedRate;
|
|
|
|
|
|
return { currency: ccy, asOf, currentRate, meetings, peakMeeting, yearEndImplied };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 13:35:14 +02:00
|
|
|
|
// ── Fetch toutes les CB — depuis le cache GitHub Actions + enrichissement IL ───
|
2026-06-01 23:16:18 +02:00
|
|
|
|
|
|
|
|
|
|
export async function fetchAllCBPaths(): Promise<RateProbData> {
|
2026-06-28 13:35:14 +02:00
|
|
|
|
// GitHub Actions met à jour data/rate-probabilities.json toutes les heures
|
|
|
|
|
|
// (CME FedWatch pour USD, Investing.com pour les autres, InvestingLive en fallback)
|
2026-06-29 17:43:49 +02:00
|
|
|
|
const [ilHistory] = await Promise.all([getCachedILHistory()]);
|
2026-06-28 13:35:14 +02:00
|
|
|
|
const ilData = ilHistory.current;
|
|
|
|
|
|
const ilPrev = ilHistory.prev;
|
|
|
|
|
|
const prevDate = ilHistory.prevDate;
|
2026-06-01 23:16:18 +02:00
|
|
|
|
|
|
|
|
|
|
const data: RateProbData = {};
|
|
|
|
|
|
|
2026-06-28 13:35:14 +02:00
|
|
|
|
// 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;
|
2026-06-27 16:33:38 +02:00
|
|
|
|
}
|
2026-06-01 23:16:18 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 13:35:14 +02:00
|
|
|
|
// Enrichissement prevMeetings depuis snapshot semaine précédente
|
|
|
|
|
|
for (const ccy of CB_KEYS) {
|
|
|
|
|
|
const path = data[ccy];
|
2026-06-27 19:40:48 +02:00
|
|
|
|
if (!path) continue;
|
|
|
|
|
|
const prev = loadPrevWeekCachedBody(ccy);
|
|
|
|
|
|
if (!prev) continue;
|
2026-06-28 13:35:14 +02:00
|
|
|
|
const prevPath = parseCBBody(ccy, prev.body);
|
2026-06-27 19:40:48 +02:00
|
|
|
|
if (prevPath?.meetings.length) {
|
2026-06-28 13:35:14 +02:00
|
|
|
|
data[ccy] = { ...path, prevMeetings: prevPath.meetings, prevWeekDate: prev.date };
|
2026-06-27 19:40:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 12:17:11 +02:00
|
|
|
|
// Historique multi-semaines (snapshots accumulés par GitHub Actions)
|
|
|
|
|
|
const historySnaps = loadHistorySnapshots();
|
|
|
|
|
|
if (historySnaps.length) {
|
|
|
|
|
|
for (const ccy of CB_KEYS) {
|
|
|
|
|
|
const path = data[ccy];
|
|
|
|
|
|
if (!path) continue;
|
|
|
|
|
|
const history: CBRatePath["history"] = [];
|
|
|
|
|
|
for (const snap of historySnaps) {
|
|
|
|
|
|
const entry = snap.raw[ccy] as Record<string, unknown> | undefined;
|
|
|
|
|
|
if (!entry) continue;
|
|
|
|
|
|
const snapPath = parseCBBody(ccy, entry);
|
|
|
|
|
|
if (snapPath?.meetings.length) {
|
|
|
|
|
|
history.push({ date: snap.date, meetings: snapPath.meetings });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (history.length) data[ccy] = { ...path, history };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 16:46:05 +02:00
|
|
|
|
// Investing.com n'a de Rate Monitor que pour la Fed (USD) — toutes les autres
|
|
|
|
|
|
// devises retombent sur le fallback InvestingLive, qui n'a qu'un seul point
|
|
|
|
|
|
// "year-end" (buildILFallback dans fetch-rate-data.mjs). On reconstruit ici une
|
|
|
|
|
|
// vraie courbe multi-réunions à partir du calendrier officiel de chaque CB +
|
|
|
|
|
|
// de l'estimation InvestingLive (proba/direction), comme déjà fait pour NZD/CHF.
|
|
|
|
|
|
const OFFICIAL_CALENDARS: Partial<Record<Currency, { meetings: string[]; fallbackRate: number }>> = {
|
|
|
|
|
|
CHF: { meetings: SNB_MEETINGS, fallbackRate: 0.00 },
|
|
|
|
|
|
NZD: { meetings: RBNZ_MEETINGS, fallbackRate: 2.25 },
|
|
|
|
|
|
EUR: { meetings: ECB_MEETINGS, fallbackRate: 2.15 },
|
|
|
|
|
|
GBP: { meetings: BOE_MEETINGS, fallbackRate: 3.75 },
|
|
|
|
|
|
JPY: { meetings: BOJ_MEETINGS, fallbackRate: 0.75 },
|
|
|
|
|
|
CAD: { meetings: BOC_MEETINGS, fallbackRate: 2.25 },
|
|
|
|
|
|
AUD: { meetings: RBA_MEETINGS, fallbackRate: 4.35 },
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const [ccyStr, cal] of Object.entries(OFFICIAL_CALENDARS)) {
|
|
|
|
|
|
const ccy = ccyStr as Currency;
|
|
|
|
|
|
if (!cal) continue;
|
|
|
|
|
|
// Reconstruit si aucune donnée, ou si le fallback IL n'a mis qu'un seul point.
|
|
|
|
|
|
if ((data[ccy] && data[ccy]!.meetings.length > 1) || !ilData[ccy]) continue;
|
|
|
|
|
|
const rate = data[ccy]?.currentRate || cal.fallbackRate;
|
|
|
|
|
|
const path = buildOfficialCalendarPath(ccy, cal.meetings, ilData, rate);
|
|
|
|
|
|
if (path) data[ccy] = path;
|
2026-06-29 12:17:11 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 13:49:52 +02:00
|
|
|
|
// Enrichissement IL : fusion proba première réunion + deltas hebdo
|
2026-06-03 00:02:09 +02:00
|
|
|
|
for (const [ccyStr, ilEntry] of Object.entries(ilData)) {
|
|
|
|
|
|
const ccy = ccyStr as keyof RateProbData;
|
|
|
|
|
|
const path = data[ccy];
|
|
|
|
|
|
if (!path) continue;
|
|
|
|
|
|
if (typeof ilEntry.bpsYearEnd !== "number") continue;
|
2026-06-29 17:05:50 +02:00
|
|
|
|
if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 5) continue;
|
2026-06-03 00:02:09 +02:00
|
|
|
|
|
2026-06-29 17:05:50 +02:00
|
|
|
|
// Giuseppe lit le STIR complet (toutes les réunions jusqu'en déc) et publie le cumul bps year-end.
|
|
|
|
|
|
// Le Rate Monitor Investing.com ne capture parfois que 2-3 réunions → yearEndImplied partiel.
|
|
|
|
|
|
// → On utilise toujours Giuseppe comme source authoritative pour le cumul fin d'année.
|
|
|
|
|
|
const yearEndImplied = parseFloat((path.currentRate + ilEntry.bpsYearEnd / 100).toFixed(4));
|
2026-06-08 19:41:50 +02:00
|
|
|
|
|
2026-06-08 19:51:57 +02:00
|
|
|
|
let ilDelta: ILWeeklyDelta | undefined;
|
2026-06-08 19:41:50 +02:00
|
|
|
|
const prevEntry = ilPrev[ccy];
|
|
|
|
|
|
if (prevEntry && prevDate) {
|
|
|
|
|
|
ilDelta = {
|
|
|
|
|
|
probDelta: parseFloat((ilEntry.nextMeetingProbPct - prevEntry.nextMeetingProbPct).toFixed(1)),
|
|
|
|
|
|
bpsDelta: ilEntry.bpsYearEnd - prevEntry.bpsYearEnd,
|
2026-06-29 17:05:50 +02:00
|
|
|
|
isCut: ilEntry.bpsYearEnd < 0,
|
2026-06-08 19:41:50 +02:00
|
|
|
|
prevDate,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 13:49:52 +02:00
|
|
|
|
const ilProb = ilEntry.nextMeetingProbPct;
|
2026-06-29 17:05:50 +02:00
|
|
|
|
// Direction basée sur le signe de bpsYearEnd (plus fiable que nextMeetingIsHike
|
|
|
|
|
|
// qui est faux quand Giuseppe dit "no change" à la prochaine réunion mais hausse year-end)
|
|
|
|
|
|
const ilIsCut = ilEntry.bpsYearEnd < 0;
|
2026-06-29 13:49:52 +02:00
|
|
|
|
const m0 = path.meetings[0];
|
|
|
|
|
|
const stirProb = m0?.probMovePct ?? 0;
|
|
|
|
|
|
|
2026-06-08 20:12:18 +02:00
|
|
|
|
const ilCurrent: ILCurrent = {
|
2026-06-29 13:49:52 +02:00
|
|
|
|
bpsYearEnd: ilEntry.bpsYearEnd,
|
|
|
|
|
|
probPct: ilProb,
|
|
|
|
|
|
stirProbPct: stirProb || undefined,
|
|
|
|
|
|
isNoChange: ilEntry.nextMeetingIsNoChange,
|
|
|
|
|
|
isCut: ilIsCut,
|
|
|
|
|
|
articleDate: ilEntry.publishedDate,
|
2026-06-08 20:12:18 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-29 13:49:52 +02:00
|
|
|
|
// Fusion STIR + IL pour la première réunion :
|
|
|
|
|
|
// Si l'IL a une proba valide ET qu'elle diffère du STIR de plus de 8pp → on fusionne
|
|
|
|
|
|
// (le STIR IC peut avoir des artefacts de parsing ; l'analyste IL lit la même donnée proprement)
|
|
|
|
|
|
let updatedMeetings = path.meetings;
|
|
|
|
|
|
if (m0 && ilProb > 0 && !ilEntry.nextMeetingIsNoChange && Math.abs(ilProb - stirProb) > 8) {
|
|
|
|
|
|
const updatedM0: RateProbMeeting = {
|
|
|
|
|
|
...m0,
|
|
|
|
|
|
probMovePct: ilProb,
|
|
|
|
|
|
probIsCut: ilIsCut,
|
|
|
|
|
|
changeBps: ilProb > 50 ? (ilIsCut ? -25 : 25) : 0,
|
|
|
|
|
|
impliedRate: ilProb > 50
|
|
|
|
|
|
? parseFloat((path.currentRate + (ilIsCut ? -0.25 : 0.25)).toFixed(4))
|
|
|
|
|
|
: path.currentRate,
|
|
|
|
|
|
};
|
|
|
|
|
|
updatedMeetings = [updatedM0, ...path.meetings.slice(1)];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Recalcule peakMeeting après fusion
|
|
|
|
|
|
const peakMeeting = updatedMeetings.length
|
|
|
|
|
|
? updatedMeetings.reduce((best, m) => m.probMovePct > best.probMovePct ? m : best, updatedMeetings[0])
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
data[ccy] = {
|
|
|
|
|
|
...path,
|
|
|
|
|
|
meetings: updatedMeetings,
|
|
|
|
|
|
peakMeeting: peakMeeting && peakMeeting.probMovePct > 0 ? peakMeeting : path.peakMeeting,
|
|
|
|
|
|
yearEndImplied,
|
|
|
|
|
|
ilCurrent,
|
|
|
|
|
|
...(ilDelta ? { ilDelta } : {}),
|
|
|
|
|
|
};
|
2026-06-03 00:02:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 23:16:18 +02:00
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Helper calendrier : dates de réunions extraites des paths ─────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export interface CBMeetingEvent {
|
|
|
|
|
|
currency: Currency;
|
|
|
|
|
|
dateIso: string;
|
|
|
|
|
|
utcHour: number;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
probMovePct: number;
|
|
|
|
|
|
probIsCut: boolean;
|
|
|
|
|
|
changeBps: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function extractMeetingEvents(data: RateProbData, fromDate: string): CBMeetingEvent[] {
|
|
|
|
|
|
const events: CBMeetingEvent[] = [];
|
|
|
|
|
|
for (const entry of Object.entries(data) as [Currency, CBRatePath][]) {
|
|
|
|
|
|
const [ccy, path] = entry;
|
|
|
|
|
|
const utcHour = ANNOUNCE_UTC[ccy] ?? 12;
|
|
|
|
|
|
const title = MEETING_TITLES[ccy] ?? `Décision taux ${ccy}`;
|
|
|
|
|
|
for (const m of path.meetings) {
|
|
|
|
|
|
if (m.dateIso < fromDate) continue;
|
|
|
|
|
|
events.push({ currency: ccy, dateIso: m.dateIso, utcHour, title, probMovePct: m.probMovePct, probIsCut: m.probIsCut, changeBps: m.changeBps });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return events;
|
|
|
|
|
|
}
|