diff --git a/.github/scripts/fetch-money-supply.mjs b/.github/scripts/fetch-money-supply.mjs
new file mode 100644
index 0000000..91e213a
--- /dev/null
+++ b/.github/scripts/fetch-money-supply.mjs
@@ -0,0 +1,99 @@
+// Scrape Trading Economics pour la masse monétaire M3 (niveau, devise locale)
+// de chaque zone monétaire suivie par le dashboard.
+//
+// Source : meta description de https://tradingeconomics.com/{slug}/money-supply-m3
+// Format observé : "Money Supply M3 in/In the {Country} increased/decreased to
+// {value} {CCY} {Million|Billion} in {Month} from {value} {CCY} {Million|Billion}
+// in {Month} of {Year}."
+//
+// USD : la Fed a arrêté de publier M3 en 2006 → proxy M2
+// (https://tradingeconomics.com/united-states/money-supply-m2, même format de meta desc).
+//
+// Cadence : ces données sont mensuelles avec ~4-6 semaines de retard (le mois le
+// plus récent apparaît des semaines après sa clôture) → un scraping hebdomadaire
+// suffit largement, pas besoin d'une fréquence horaire comme pour les taux/OIS.
+
+import { writeFileSync, readFileSync, mkdirSync } from "fs";
+
+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",
+};
+
+const MONTHS = { January:"01",February:"02",March:"03",April:"04",May:"05",June:"06",
+ July:"07",August:"08",September:"09",October:"10",November:"11",December:"12" };
+
+// slug TE + éventuel slug d'indicateur différent (USD → money-supply-m2, proxy)
+const SERIES = {
+ USD: { slug: "united-states", indicator: "money-supply-m2", isProxy: true, proxyLabel: "M2 (M3 non publié par la Fed depuis 2006)" },
+ EUR: { slug: "euro-area", indicator: "money-supply-m3", isProxy: false },
+ GBP: { slug: "united-kingdom", indicator: "money-supply-m3", isProxy: false },
+ JPY: { slug: "japan", indicator: "money-supply-m3", isProxy: false },
+ CHF: { slug: "switzerland", indicator: "money-supply-m3", isProxy: false },
+ CAD: { slug: "canada", indicator: "money-supply-m3", isProxy: false },
+ AUD: { slug: "australia", indicator: "money-supply-m3", isProxy: false },
+ NZD: { slug: "new-zealand", indicator: "money-supply-m3", isProxy: false },
+};
+
+const DESC_RE = /Money Supply M[23][^.]*?\bto\s+([\d.]+)\s+([A-Z]{3})\s+(Million|Billion)\s+in\s+(\w+)\s+from\s+[\d.]+\s+[A-Z]{3}\s+(?:Million|Billion)\s+in\s+\w+\s+of\s+(\d{4})/i;
+
+async function fetchMoneySupply(ccy, { slug, indicator }) {
+ try {
+ const res = await fetch(`https://tradingeconomics.com/${slug}/${indicator}`, { headers: CHROME_HEADERS });
+ if (!res.ok) return null;
+ const html = await res.text();
+ const meta = html.match(/]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
+ ?? html.match(/]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
+ if (!meta) return null;
+
+ const m = meta[1].match(DESC_RE);
+ if (!m) return null;
+ const [, value, ccyCode, unitWord, month, year] = m;
+ const monthNum = MONTHS[month];
+ if (!monthNum) return null;
+
+ return {
+ value: parseFloat(value),
+ unit: `${ccyCode} ${unitWord === "Billion" ? "Bn" : "Mn"}`,
+ period: `${year}-${monthNum}`,
+ source: `https://tradingeconomics.com/${slug}/${indicator}`,
+ };
+ } catch { return null; }
+}
+
+// ── Fusionne avec le fichier existant : garde l'ancienne valeur si le scrape échoue ──
+
+let existing = {};
+try {
+ existing = JSON.parse(readFileSync("data/money-supply-m3.json", "utf8"))[0]?.series ?? {};
+} catch {}
+
+const series = {};
+for (const [ccy, cfg] of Object.entries(SERIES)) {
+ const fetched = await fetchMoneySupply(ccy, cfg);
+ if (fetched) {
+ series[ccy] = { ...fetched, isProxy: cfg.isProxy, ...(cfg.proxyLabel ? { proxyLabel: cfg.proxyLabel } : {}) };
+ console.log(`[TE] ${ccy} ✓ ${fetched.value} ${fetched.unit} (${fetched.period})`);
+ } else if (existing[ccy]) {
+ series[ccy] = existing[ccy];
+ console.log(`[TE] ${ccy} ✗ scrape échoué → conservé (${existing[ccy].period})`);
+ } else {
+ console.log(`[TE] ${ccy} ✗ scrape échoué, pas de fallback disponible`);
+ }
+ await new Promise(r => setTimeout(r, 800)); // évite le rate-limiting TE
+}
+
+mkdirSync("data", { recursive: true });
+writeFileSync("data/money-supply-m3.json", JSON.stringify([{
+ updated_at: new Date().toISOString().slice(0, 10),
+ note: "Masse monétaire M3 (niveau, devise locale) par zone — scrapé automatiquement depuis tradingeconomics.com (meta description des pages /money-supply-m3). USD : proxy M2 (Fed n'a plus publié M3 depuis 2006). Mis à jour par .github/workflows/fetch-money-supply.yml (hebdomadaire).",
+ series,
+}], null, 2) + "\n");
+
+const ok = Object.keys(SERIES).filter(c => series[c]);
+const failed = Object.keys(SERIES).filter(c => !series[c]);
+console.log(`\n✓ Saved : ${ok.join(", ")}`);
+if (failed.length) console.log(`✗ Failed : ${failed.join(", ")}`);
diff --git a/.github/scripts/fetch-rate-data.mjs b/.github/scripts/fetch-rate-data.mjs
index 5602130..7c38786 100644
--- a/.github/scripts/fetch-rate-data.mjs
+++ b/.github/scripts/fetch-rate-data.mjs
@@ -1,7 +1,10 @@
// 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
+// 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";
@@ -33,97 +36,7 @@ const CHROME_HEADERS = {
"Upgrade-Insecure-Requests": "1",
};
-// ── 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 (!rows.length) return null;
- return { today: { midpoint: currentRate ?? 0, rows } };
-}
-
-// ── 2. Investing.com Rate Monitors (all CBs) ──────────────────────────────────
+// ── 1. Investing.com Rate Monitors (Fed only — voir note en tête de fichier) ──
const IC_SLUGS = {
USD: "fed-rate-monitor",
@@ -136,12 +49,83 @@ const IC_SLUGS = {
CHF: "snb-rate-monitor",
};
-// Taux directeurs actuels — fallback si non trouvés dans le HTML
-// NZD : RBNZ a coupé à 2.25% (juin 2026) ; CHF : SNB à 0.00%
-const FALLBACK_RATES = {
- USD: 4.33, EUR: 2.40, GBP: 3.75, JPY: 0.50,
- CAD: 2.25, AUD: 4.10, NZD: 2.25, CHF: 0.00,
-};
+// 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];
@@ -261,69 +245,65 @@ function parseJsonData(ccy, json) {
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: Jul 29, 2026 02:00PM ET" suivi d'un
+// 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) {
- // Find all
+ {/* Pas de overflow-hidden ici : le menu déroulant "Restaurer" est en position
+ absolute et serait rogné par un ancêtre overflow-hidden (cf. bug signalé). */}
+
- {/* Texte brut (sans tags HTML) */}
+ {/* Texte brut (sans tags HTML) — les tags de bloc sont convertis en
+ retours à la ligne avant extraction, sinon .textContent colle tous
+ les paragraphes/lignes de liste bout à bout sans séparation. */}
{(() => {
- const doc = new DOMParser().parseFromString(a.slot.notes, "text/html");
- const text = doc.body.textContent?.trim() ?? "";
+ const withBreaks = a.slot.notes
+ .replace(/<\/(p|div|li|h[1-6])>/gi, "\n")
+ .replace(/ /gi, "\n");
+ const doc = new DOMParser().parseFromString(withBreaks, "text/html");
+ const text = (doc.body.textContent ?? "").replace(/\n{3,}/g, "\n\n").trim();
const imgs = Array.from(doc.images);
return (
<>
@@ -564,6 +572,14 @@ export default function IdeesTab() {
DEFAULT_SLOT("FX:GBPUSD"),
]);
const [archives, setArchives] = useState([]);
+ // Passe à true si une écriture localStorage échoue (ex: quota dépassé à cause
+ // des images en base64 dans les notes archivées) — sans ça l'échec est silencieux
+ // et une suppression/modification peut sembler ne "pas marcher" après rechargement.
+ const [saveError, setSaveError] = useState(false);
+
+ const persist = useCallback((key: string, val: unknown) => {
+ setSaveError(!saveLS(key, val));
+ }, []);
useEffect(() => {
setSlots(loadLS<[SlotState, SlotState]>(LS_SLOTS, [DEFAULT_SLOT("FX:EURUSD"), DEFAULT_SLOT("FX:GBPUSD")]));
@@ -574,30 +590,30 @@ export default function IdeesTab() {
setSlots(prev => {
const next: [SlotState, SlotState] = [prev[0], prev[1]];
next[idx] = s;
- saveLS(LS_SLOTS, next);
+ persist(LS_SLOTS, next);
return next;
});
- }, []);
+ }, [persist]);
const archiveSlot = useCallback((idx: 0 | 1) => {
const entry: Archive = { id: Date.now().toString(), savedAt: new Date().toISOString(), slot: slots[idx] };
const next = [entry, ...archives];
setArchives(next);
- saveLS(LS_ARCHIVES, next);
+ persist(LS_ARCHIVES, next);
const reset = DEFAULT_SLOT(idx === 0 ? "FX:EURUSD" : "FX:GBPUSD");
setSlots(prev => {
const n: [SlotState, SlotState] = [prev[0], prev[1]];
n[idx] = reset;
- saveLS(LS_SLOTS, n);
+ persist(LS_SLOTS, n);
return n;
});
- }, [slots, archives]);
+ }, [slots, archives, persist]);
const deleteArchive = useCallback((id: string) => {
const next = archives.filter(a => a.id !== id);
setArchives(next);
- saveLS(LS_ARCHIVES, next);
- }, [archives]);
+ persist(LS_ARCHIVES, next);
+ }, [archives, persist]);
const restoreArchive = useCallback((id: string, slotIdx: 0 | 1) => {
const entry = archives.find(a => a.id === id);
@@ -605,10 +621,10 @@ export default function IdeesTab() {
setSlots(prev => {
const next = [...prev] as [SlotState, SlotState];
next[slotIdx] = { ...entry.slot };
- saveLS(LS_SLOTS, next);
+ persist(LS_SLOTS, next);
return next;
});
- }, [archives]);
+ }, [archives, persist]);
return (
@@ -619,6 +635,14 @@ export default function IdeesTab() {
+ {saveError && (
+
+ ⚠ Échec de sauvegarde locale — le quota de stockage du navigateur est probablement dépassé
+ (les archives avec images occupent beaucoup de place). Supprime quelques archives pour libérer
+ de la place, sinon tes changements ne seront pas conservés au rechargement.
+
+ )}
+
updateSlot(0, s)} onArchive={() => archiveSlot(0)} />
updateSlot(1, s)} onArchive={() => archiveSlot(1)} />
diff --git a/data/il-enrichment-cache.json b/data/il-enrichment-cache.json
new file mode 100644
index 0000000..8fe4732
--- /dev/null
+++ b/data/il-enrichment-cache.json
@@ -0,0 +1 @@
+{"ts":1783520638250,"data":{"current":{},"prev":{},"prevDate":null}}
\ No newline at end of file
diff --git a/data/money-supply-m3.json b/data/money-supply-m3.json
new file mode 100644
index 0000000..f4d38c3
--- /dev/null
+++ b/data/money-supply-m3.json
@@ -0,0 +1,65 @@
+[
+ {
+ "updated_at": "2026-07-08",
+ "note": "Masse monétaire M3 (niveau, devise locale) par zone — scrapé automatiquement depuis tradingeconomics.com (meta description des pages /money-supply-m3). USD : proxy M2 (Fed n'a plus publié M3 depuis 2006). Mis à jour par .github/workflows/fetch-money-supply.yml (hebdomadaire).",
+ "series": {
+ "USD": {
+ "value": 22804.5,
+ "unit": "USD Bn",
+ "period": "2026-04",
+ "source": "https://tradingeconomics.com/united-states/money-supply-m2",
+ "isProxy": true,
+ "proxyLabel": "M2 (M3 non publié par la Fed depuis 2006)"
+ },
+ "EUR": {
+ "value": 17552244,
+ "unit": "EUR Mn",
+ "period": "2026-05",
+ "source": "https://tradingeconomics.com/euro-area/money-supply-m3",
+ "isProxy": false
+ },
+ "GBP": {
+ "value": 3811591,
+ "unit": "GBP Mn",
+ "period": "2026-05",
+ "source": "https://tradingeconomics.com/united-kingdom/money-supply-m3",
+ "isProxy": false
+ },
+ "JPY": {
+ "value": 1642404.5,
+ "unit": "JPY Bn",
+ "period": "2026-05",
+ "source": "https://tradingeconomics.com/japan/money-supply-m3",
+ "isProxy": false
+ },
+ "CHF": {
+ "value": 1228273,
+ "unit": "CHF Mn",
+ "period": "2026-05",
+ "source": "https://tradingeconomics.com/switzerland/money-supply-m3",
+ "isProxy": false
+ },
+ "CAD": {
+ "value": 4020356,
+ "unit": "CAD Mn",
+ "period": "2026-04",
+ "source": "https://tradingeconomics.com/canada/money-supply-m3",
+ "isProxy": false
+ },
+ "AUD": {
+ "value": 3447.08,
+ "unit": "AUD Bn",
+ "period": "2026-05",
+ "source": "https://tradingeconomics.com/australia/money-supply-m3",
+ "isProxy": false
+ },
+ "NZD": {
+ "value": 451046,
+ "unit": "NZD Mn",
+ "period": "2026-05",
+ "source": "https://tradingeconomics.com/new-zealand/money-supply-m3",
+ "isProxy": false
+ }
+ }
+ }
+]
diff --git a/data/rate-probabilities.json b/data/rate-probabilities.json
index 970cf7c..1d1e363 100644
--- a/data/rate-probabilities.json
+++ b/data/rate-probabilities.json
@@ -1,5 +1,233 @@
{
- "data": {},
- "fetchedAt": "2026-07-08T12:14:53.611Z",
+ "data": {
+ "USD": {
+ "today": {
+ "midpoint": 3.75,
+ "rows": [
+ {
+ "meeting": "Jul 29",
+ "meeting_iso": "2026-07-29",
+ "implied_rate_post_meeting": 3.7998,
+ "prob_move_pct": 20,
+ "prob_is_cut": false,
+ "change_bps": 4.979999999999984,
+ "num_moves": 0.19919999999999938
+ },
+ {
+ "meeting": "Sep 16",
+ "meeting_iso": "2026-09-16",
+ "implied_rate_post_meeting": 3.908,
+ "prob_move_pct": 55,
+ "prob_is_cut": false,
+ "change_bps": 15.799999999999992,
+ "num_moves": 0.6319999999999997
+ },
+ {
+ "meeting": "Oct 28",
+ "meeting_iso": "2026-10-28",
+ "implied_rate_post_meeting": 3.96,
+ "prob_move_pct": 64,
+ "prob_is_cut": false,
+ "change_bps": 20.999999999999996,
+ "num_moves": 0.8399999999999999
+ },
+ {
+ "meeting": "Dec 9",
+ "meeting_iso": "2026-12-09",
+ "implied_rate_post_meeting": 4.055,
+ "prob_move_pct": 78,
+ "prob_is_cut": false,
+ "change_bps": 30.49999999999997,
+ "num_moves": 1.2199999999999989
+ },
+ {
+ "meeting": "Jan 27",
+ "meeting_iso": "2027-01-27",
+ "implied_rate_post_meeting": 4.0851,
+ "prob_move_pct": 80,
+ "prob_is_cut": false,
+ "change_bps": 33.50999999999998,
+ "num_moves": 1.3403999999999991
+ },
+ {
+ "meeting": "Mar 17",
+ "meeting_iso": "2027-03-17",
+ "implied_rate_post_meeting": 4.1286,
+ "prob_move_pct": 84,
+ "prob_is_cut": false,
+ "change_bps": 37.85999999999996,
+ "num_moves": 1.5143999999999982
+ },
+ {
+ "meeting": "Apr 28",
+ "meeting_iso": "2027-04-28",
+ "implied_rate_post_meeting": 4.1322,
+ "prob_move_pct": 84,
+ "prob_is_cut": false,
+ "change_bps": 38.22000000000001,
+ "num_moves": 1.5288000000000006
+ },
+ {
+ "meeting": "Jun 9",
+ "meeting_iso": "2027-06-09",
+ "implied_rate_post_meeting": 4.1065,
+ "prob_move_pct": 80,
+ "prob_is_cut": false,
+ "change_bps": 35.64999999999996,
+ "num_moves": 1.4259999999999986
+ },
+ {
+ "meeting": "Jul 28",
+ "meeting_iso": "2027-07-28",
+ "implied_rate_post_meeting": 4.0895,
+ "prob_move_pct": 78,
+ "prob_is_cut": false,
+ "change_bps": 33.95000000000002,
+ "num_moves": 1.3580000000000008
+ },
+ {
+ "meeting": "Sep 15",
+ "meeting_iso": "2027-09-15",
+ "implied_rate_post_meeting": 4.0654,
+ "prob_move_pct": 75,
+ "prob_is_cut": false,
+ "change_bps": 31.540000000000035,
+ "num_moves": 1.2616000000000014
+ },
+ {
+ "meeting": "Oct 27",
+ "meeting_iso": "2027-10-27",
+ "implied_rate_post_meeting": 4.0297,
+ "prob_move_pct": 70,
+ "prob_is_cut": false,
+ "change_bps": 27.970000000000006,
+ "num_moves": 1.1188000000000002
+ },
+ {
+ "meeting": "Dec 8",
+ "meeting_iso": "2027-12-08",
+ "implied_rate_post_meeting": 4.0103,
+ "prob_move_pct": 67,
+ "prob_is_cut": false,
+ "change_bps": 26.029999999999998,
+ "num_moves": 1.0412
+ }
+ ]
+ }
+ },
+ "EUR": {
+ "today": {
+ "midpoint": 2.15,
+ "rows": [
+ {
+ "meeting": "Dec",
+ "meeting_iso": "2026-12-31",
+ "implied_rate_post_meeting": 2.3899999999999997,
+ "prob_move_pct": 28,
+ "prob_is_cut": false,
+ "change_bps": 24,
+ "num_moves": 0.96
+ }
+ ]
+ }
+ },
+ "GBP": {
+ "today": {
+ "midpoint": 3.75,
+ "rows": [
+ {
+ "meeting": "Dec",
+ "meeting_iso": "2026-12-31",
+ "implied_rate_post_meeting": 3.93,
+ "prob_move_pct": 7,
+ "prob_is_cut": false,
+ "change_bps": 18,
+ "num_moves": 0.72
+ }
+ ]
+ }
+ },
+ "JPY": {
+ "today": {
+ "midpoint": 0.75,
+ "rows": [
+ {
+ "meeting": "Dec",
+ "meeting_iso": "2026-12-31",
+ "implied_rate_post_meeting": 0.95,
+ "prob_move_pct": 1,
+ "prob_is_cut": false,
+ "change_bps": 20,
+ "num_moves": 0.8
+ }
+ ]
+ }
+ },
+ "CAD": {
+ "today": {
+ "midpoint": 2.25,
+ "rows": [
+ {
+ "meeting": "Dec",
+ "meeting_iso": "2026-12-31",
+ "implied_rate_post_meeting": 2.33,
+ "prob_move_pct": 9,
+ "prob_is_cut": false,
+ "change_bps": 8,
+ "num_moves": 0.32
+ }
+ ]
+ }
+ },
+ "AUD": {
+ "today": {
+ "midpoint": 4.35,
+ "rows": [
+ {
+ "meeting": "Dec",
+ "meeting_iso": "2026-12-31",
+ "implied_rate_post_meeting": 4.449999999999999,
+ "prob_move_pct": 15,
+ "prob_is_cut": false,
+ "change_bps": 10,
+ "num_moves": 0.4
+ }
+ ]
+ }
+ },
+ "NZD": {
+ "today": {
+ "midpoint": 2.25,
+ "rows": [
+ {
+ "meeting": "Dec",
+ "meeting_iso": "2026-12-31",
+ "implied_rate_post_meeting": 2.87,
+ "prob_move_pct": 82,
+ "prob_is_cut": false,
+ "change_bps": 62,
+ "num_moves": 2.48
+ }
+ ]
+ }
+ },
+ "CHF": {
+ "today": {
+ "midpoint": 0,
+ "rows": [
+ {
+ "meeting": "Dec",
+ "meeting_iso": "2026-12-31",
+ "implied_rate_post_meeting": 0.05,
+ "prob_move_pct": 4,
+ "prob_is_cut": false,
+ "change_bps": 5,
+ "num_moves": 0.2
+ }
+ ]
+ }
+ }
+ },
+ "fetchedAt": "2026-07-03T12:51:24.776Z",
"snapshots": []
}
\ No newline at end of file
diff --git a/data/rate_decisions.json b/data/rate_decisions.json
index ba94549..30b4ffa 100644
--- a/data/rate_decisions.json
+++ b/data/rate_decisions.json
@@ -1,47 +1,47 @@
[
{
- "updated_at": "2026-05-30",
- "note": "Taux directeurs officiels CB. current/prev = taux annoncés (pas interbancaires). EUR = MRO (DFR+15bps) pour cohérence avec investing.com/TE. Mettre à jour après chaque décision.",
+ "updated_at": "2026-07-08",
+ "note": "Taux directeurs officiels CB. current/prev = taux annoncés (pas interbancaires). EUR = MRO (DFR+15bps) pour cohérence avec investing.com/TE. prev = taux avant la DERNIÈRE décision (= current si dernière réunion = statu quo). Fichier maintenu automatiquement par .github/workflows/update-rate-decisions.yml (hourly) : quand le taux scrapé sur Trading Economics diffère de 'current', l'ancien 'current' glisse vers 'prev' sans perte de donnée, et 'source' est réécrit pour tracer le changement. Ne plus éditer 'current'/'prev' à la main sauf correction ponctuelle.",
"decisions": {
"USD": {
"current": 3.75,
- "prev": 3.75,
- "source": "Fed funds target upper bound — Trading Economics / TE mai 2026 (fourchette 3.50-3.75%, upper=3.75%)"
+ "prev": 3.75,
+ "source": "Fed funds target upper bound — inchangé 3.50-3.75% (upper=3.75%), 4e réunion consécutive de statu quo, dernière décision 17 juin 2026 (Trading Economics)"
},
"EUR": {
- "current": 2.15,
- "prev": 2.40,
- "source": "ECBDFR (FRED) + 0.15bps → MRO. DFR=2.00, MRO=DFR+15bps (corridor sep 2024)"
+ "current": 2.4,
+ "prev": 2.15,
+ "source": "ECB — hausse de 25bps de tous les taux directeurs (DFR 2.00%→2.25%, MRO 2.15%→2.40%, MLF 2.40%→2.65%) effective 17 juin 2026, en réponse au choc d'inflation lié au conflit Moyen-Orient (communiqué officiel : https://www.ecb.europa.eu/press/pr/date/2026/html/ecb.mp260611~4d41bd5e83.en.html, publié 11 juin 2026). MRO retenu ici pour cohérence avec investing.com/TE."
},
"GBP": {
"current": 3.75,
- "prev": 4.00,
- "source": "BoE Official Bank Rate — Trading Economics mai 2026"
+ "prev": 3.75,
+ "source": "BoE Official Bank Rate — statu quo confirmé 18 juin 2026 (vote MPC 7-2, 2 membres pour +25bps à 4.00%). Trading Economics"
},
"JPY": {
- "current": 0.75,
- "prev": 0.50,
- "source": "BoJ — annonce jan 2026 (hausse 0.50% → 0.75%)"
+ "current": 1,
+ "prev": 0.75,
+ "source": "BoJ — changement détecté automatiquement le 2026-07-08 via Trading Economics (japan/interest-rate) : 0.75% → 1%."
},
"CHF": {
- "current": 0.00,
- "prev": 0.25,
- "source": "SNB — annonce déc 2025 (baisse 0.25% → 0.00%)"
+ "current": 0,
+ "prev": 0,
+ "source": "SNB officiel — inchangé à 0% ; confirmé statu quo le 18 juin 2026. Trading Economics"
},
"CAD": {
"current": 2.25,
- "prev": 2.50,
- "source": "BoC — annonce avr 2026 (baisse 2.50% → 2.25%)"
+ "prev": 2.25,
+ "source": "BoC — statu quo confirmé 10 juin 2026, 5e réunion consécutive à 2.25%. Trading Economics"
},
"AUD": {
"current": 4.35,
- "prev": 4.10,
- "source": "RBA — Trading Economics mai 2026 (Last=4.35, Previous=4.10)"
+ "prev": 4.35,
+ "source": "RBA — statu quo confirmé 16 juin 2026, décision unanime à 4.35%. Trading Economics"
},
"NZD": {
- "current": 2.25,
- "prev": 2.50,
- "source": "RBNZ — annonce avr 2026 (baisse 2.50% → 2.25%)"
+ "current": 2.5,
+ "prev": 2.25,
+ "source": "RBNZ — hausse de 25bps (2.25%→2.50%) le 8 juillet 2026, première hausse en 3 ans après 3 statu quo consécutifs à 2.25%. Trading Economics"
}
}
}
diff --git a/lib/calendar-countries.ts b/lib/calendar-countries.ts
new file mode 100644
index 0000000..06a612f
--- /dev/null
+++ b/lib/calendar-countries.ts
@@ -0,0 +1,50 @@
+// lib/calendar-countries.ts
+// Univers de pays pour le calendrier économique — restreint le 2026-07-08 à la
+// demande explicite de l'utilisateur : les 8 devises majeures tradées par le
+// dashboard + la France en complément (grosse économie de la zone euro, dont
+// les publications (Ifo-like, PMI, CPI...) précèdent souvent l'agrégat EMU).
+//
+// Sert de table de correspondance commune entre :
+// - Trading Economics : slug URL (/xxx/calendar) + nom affiché en data-country
+// - investingLive (widget FXStreet calendar.fxstreet.com) : code pays + data-countryname
+//
+// currency = devise ISO 4217 associée (EMU et FR partagent EUR).
+
+export interface CalendarCountry {
+ code: string; // code FXStreet (aussi utilisé comme identifiant canonique)
+ teSlug: string; // slug Trading Economics : https://tradingeconomics.com/{slug}/calendar
+ teName: string; // valeur data-country de TE (lowercase)
+ fxName: string; // valeur data-countryname du widget FXStreet
+ currency: string; // ISO 4217
+ displayName: string;
+}
+
+export const CALENDAR_COUNTRIES: CalendarCountry[] = [
+ { code: "EMU", teSlug: "euro-area", teName: "euro area", fxName: "Eurozone", currency: "EUR", displayName: "Zone Euro" },
+ { code: "FR", teSlug: "france", teName: "france", fxName: "France", currency: "EUR", displayName: "France" },
+ { code: "US", teSlug: "united-states", teName: "united states", fxName: "United States", currency: "USD", displayName: "États-Unis" },
+ { code: "CA", teSlug: "canada", teName: "canada", fxName: "Canada", currency: "CAD", displayName: "Canada" },
+ { code: "UK", teSlug: "united-kingdom", teName: "united kingdom", fxName: "United Kingdom", currency: "GBP", displayName: "Royaume-Uni" },
+ { code: "CH", teSlug: "switzerland", teName: "switzerland", fxName: "Switzerland", currency: "CHF", displayName: "Suisse" },
+ { code: "JP", teSlug: "japan", teName: "japan", fxName: "Japan", currency: "JPY", displayName: "Japon" },
+ { code: "AU", teSlug: "australia", teName: "australia", fxName: "Australia", currency: "AUD", displayName: "Australie" },
+ { code: "NZ", teSlug: "new-zealand", teName: "new zealand", fxName: "New Zealand", currency: "NZD", displayName: "Nouvelle-Zélande" },
+];
+
+export const TE_NAME_TO_CURRENCY: Record = Object.fromEntries(
+ CALENDAR_COUNTRIES.map(c => [c.teName, c.currency])
+);
+
+export const FX_NAME_TO_CURRENCY: Record = Object.fromEntries(
+ CALENDAR_COUNTRIES.map(c => [c.fxName, c.currency])
+);
+
+export const TE_NAME_TO_CODE: Record = Object.fromEntries(
+ CALENDAR_COUNTRIES.map(c => [c.teName, c.code])
+);
+
+export const FX_NAME_TO_CODE: Record = Object.fromEntries(
+ CALENDAR_COUNTRIES.map(c => [c.fxName, c.code])
+);
+
+export const FXSTREET_COUNTRYCODES = CALENDAR_COUNTRIES.map(c => c.code).join(",");
diff --git a/lib/calendar-taxonomy.ts b/lib/calendar-taxonomy.ts
new file mode 100644
index 0000000..5ef9263
--- /dev/null
+++ b/lib/calendar-taxonomy.ts
@@ -0,0 +1,79 @@
+// lib/calendar-taxonomy.ts
+// Règles de tri du fourre-tout "other" du calendrier économique, décidées avec
+// l'utilisateur devise par devise (session du 2026-07-08) :
+// - Confiance/sentiment, immobilier (prix uniquement), M3 (EUR uniquement),
+// commerce extérieur détaillé, Fed régionale (Philly uniquement), flux de
+// portefeuille étrangers, finances publiques, jours fériés → catégories
+// dédiées (visibles, triables séparément).
+// - Adjudications obligataires, production industrielle, énergie hebdo US,
+// hypothécaires hebdo US, CPI infranational, réunions institutionnelles
+// sans chiffre → exclus du calendrier (bruit, cf. décisions utilisateur).
+// Tout le reste (non couvert par une règle ci-dessous) continue d'apparaître
+// sous "other" / "Autre".
+
+import type { EventCategory } from "@/app/api/calendar/route";
+
+const SENTIMENT_RE = /\bifo\b|\bzew\b|\bgfk\b|consumer confidence|business confidence|business climate|economic sentiment|\bsentix\b|westpac consumer confidence|anz.*consumer confidence|anz business confidence|anz activity outlook|nab business confidence|cbi business optimism|cbi distributive trades|cbi industrial trends|eco watchers survey|reuters tankan/i;
+
+const HOUSING_PRICE_RE = /house price|housing price|nationwide housing|case-shiller|rightmove|rics house price|cotality dwelling|residential property price/i;
+
+const MONEY_SUPPLY_RE = /m3 money supply/i;
+
+const TRADE_DETAIL_RE = /^exports?\b|^imports?\b|export price|import price/i;
+
+const REGIONAL_FED_RE = /philly fed|philadelphia fed/i;
+
+const PORTFOLIO_FLOWS_RE = /tic flows|net capital flows|foreign bond investment|stock investment by foreigners|cftc \w+ nc net positions/i;
+
+const PUBLIC_FINANCE_RE = /budget balance|monthly budget statement|public sector net borrowing/i;
+
+const HOLIDAY_RE = /\bholiday\b|\b(independence|canada|marine|labor|labour|thanksgiving|christmas|boxing|memorial|veterans|presidents?|columbus|mlk|good friday|easter monday)\s+day\b/i;
+
+// ── Exclusions (bruit écarté du calendrier, décision utilisateur) ────────────
+
+const AUCTION_RE = /\bauction\b|\btender\b|conventional gilt|index-linked gilt/i;
+const INDUSTRIAL_PROD_RE = /industrial production|industrial output|manufacturing production|capacity utilization|tertiary industry index/i;
+const US_ENERGY_WEEKLY_RE = /\beia\b|\bapi\b crude|baker hughes|crude oil stock|natural gas stock|gasoline stock|distillate|heating oil stock|refinery crude/i;
+const US_MORTGAGE_WEEKLY_RE = /\bmba\b|mortgage application|mortgage market index|mortgage refinance index|mba purchase index/i;
+const SUBNATIONAL_CPI_RE = /baden wuerttemberg cpi|bavaria cpi|brandenburg cpi|hesse cpi|north rhine westphalia cpi|saxony cpi|tokyo cpi|tokyo core cpi/i;
+const INSTITUTIONAL_MEETING_RE = /ecofin meeting|eurogroup meeting|iea oil market report|wasde report|nopa crush report/i;
+
+export function classifyOtherTitle(title: string): EventCategory {
+ if (SENTIMENT_RE.test(title)) return "sentiment";
+ if (HOUSING_PRICE_RE.test(title)) return "housing";
+ if (MONEY_SUPPLY_RE.test(title)) return "money_supply";
+ if (TRADE_DETAIL_RE.test(title)) return "trade_detail";
+ if (REGIONAL_FED_RE.test(title)) return "regional_fed";
+ if (PORTFOLIO_FLOWS_RE.test(title)) return "portfolio_flows";
+ if (PUBLIC_FINANCE_RE.test(title)) return "public_finance";
+ if (HOLIDAY_RE.test(title)) return "holiday";
+ return "other";
+}
+
+export function isExcludedEventTitle(title: string): boolean {
+ return (
+ AUCTION_RE.test(title) ||
+ INDUSTRIAL_PROD_RE.test(title) ||
+ US_ENERGY_WEEKLY_RE.test(title) ||
+ US_MORTGAGE_WEEKLY_RE.test(title) ||
+ SUBNATIONAL_CPI_RE.test(title) ||
+ INSTITUTIONAL_MEETING_RE.test(title)
+ );
+}
+
+// ── Plancher d'impact (demande explicite : PPI, Trade Balance, Construction PMI
+// ne doivent jamais rester masqués par le filtre "Impact faible" par défaut) ──
+// TE/investingLive taggent souvent ces indicateurs "low" par pays (surtout hors
+// USD), ce qui les cache dans le calendrier tant que la case "Impact faible"
+// n'est pas cochée. On relève le plancher à "medium" sans jamais rétrograder
+// un impact déjà "high" décidé par la source (ex. PPI MoM US, Balance of Trade JP).
+
+const IMPACT_FLOOR_RE = /construction pmi|\bppi\b|producer price|balance of trade|trade balance|goods trade balance|foreign trade balance/i;
+
+export function applyImpactFloor(
+ title: string,
+ impact: "high" | "medium" | "low"
+): "high" | "medium" | "low" {
+ if (impact === "low" && IMPACT_FLOOR_RE.test(title)) return "medium";
+ return impact;
+}
diff --git a/lib/centralBankGovernance.ts b/lib/centralBankGovernance.ts
new file mode 100644
index 0000000..46be11d
--- /dev/null
+++ b/lib/centralBankGovernance.ts
@@ -0,0 +1,865 @@
+// lib/centralBankGovernance.ts
+// Données de gouvernance des banques centrales : vote de la dernière réunion,
+// lien vers le dernier rapport de politique monétaire (PDF), lien vers le site
+// officiel, et pour la Fed le dot plot (Summary of Economic Projections).
+//
+// Sources (HTML public, sans clé API) :
+// Fed : federalreserve.gov — statement (vote) + SEP "accessible version" (dot plot)
+// BoE : bankofengland.co.uk — Monetary Policy Summary and Minutes (vote)
+// Les autres banques (ECB, BoJ, SNB, BoC, RBA, RBNZ) exposent au minimum les
+// liens statiques (site officiel + dernier rapport connu) ; scraping vote/PDF
+// ajouté au fur et à mesure de ce qui est effectivement accessible sans
+// contournement de WAF (RBA/RBNZ bloquent tout fetch() non-navigateur).
+
+import type { Currency } from "./types";
+import rateDecisionsData from "@/data/rate_decisions.json";
+import { fetchTECalendarForCountry } from "./tradingeconomics";
+
+const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
+
+async function fetchText(url: string): Promise {
+ try {
+ const res = await fetch(url, {
+ headers: { "User-Agent": UA, "Accept-Language": "en-US,en;q=0.9" },
+ next: { revalidate: 6 * 3600 },
+ });
+ if (!res.ok) return null;
+ return await res.text();
+ } catch { return null; }
+}
+
+function htmlToText(html: string): string {
+ return html
+ .replace(/