diff --git a/.github/scripts/fetch-rate-data.mjs b/.github/scripts/fetch-rate-data.mjs index 2b04c3d..5602130 100644 --- a/.github/scripts/fetch-rate-data.mjs +++ b/.github/scripts/fetch-rate-data.mjs @@ -137,9 +137,10 @@ const IC_SLUGS = { }; // 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: 4.25, JPY: 0.50, - CAD: 2.75, AUD: 4.10, NZD: 3.25, CHF: 0.00, + USD: 4.33, EUR: 2.40, GBP: 3.75, JPY: 0.50, + CAD: 2.25, AUD: 4.10, NZD: 2.25, CHF: 0.00, }; async function fetchInvestingCom(ccy) { @@ -339,14 +340,21 @@ 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 {} + const candidates = [ + `https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`, + `https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`, + `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`, + `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`, + ]; + for (const url of candidates) { + 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} at ${url}`); + return { data: parseILArticle(html), date: d.toISOString().slice(0,10) }; + } catch {} + } } return { data: {}, date: null }; } @@ -406,9 +414,11 @@ 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) +// 2 — Investing.com → CBs qui ont une page rate-monitor (pas CHF ni NZD → 404) +// CHF (snb-rate-monitor) et NZD (rbnz-rate-monitor) n'existent pas sur Investing.com +const IC_SUPPORTED = CCYS.filter(c => c !== "CHF" && c !== "NZD"); console.log("\n=== Investing.com Rate Monitors ==="); -for (const ccy of CCYS) { +for (const ccy of IC_SUPPORTED) { if (results[ccy]) { console.log(`[IC/${ccy}] skipped (CME)`); continue; } const data = await fetchInvestingCom(ccy); if (data) results[ccy] = data; @@ -428,18 +438,34 @@ if (missing.length) { } } -// ── Previous week rotation ──────────────────────────────────────────────────── -let previousWeek = null, previousWeekFetchedAt = null; +// ── Multi-week snapshot rotation ────────────────────────────────────────────── +// snapshots = tableau chronologique (plus récent en [0]) de jusqu'à 12 semaines +let snapshots = []; +let previousWeek = null, previousWeekFetchedAt = null; // rétrocompatibilité try { const existing = JSON.parse(readFileSync("data/rate-probabilities.json","utf8")); const ageMs = Date.now() - new Date(existing.fetchedAt).getTime(); const day = 86400000; + + // Récupère les snapshots existants et filtre < 12 semaines + const existingSnaps = existing.snapshots ?? []; + const validSnaps = existingSnaps.filter(s => { + const age = Date.now() - new Date(s.fetchedAt).getTime(); + return age < 84 * day; + }); + + // Si les données actuelles ont 5-9 jours → les pousser en snapshot[0] 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; } + snapshots = [{ data: existing.data, fetchedAt: existing.fetchedAt }, ...validSnaps].slice(0, 12); + console.log(`\nRotated ${(ageMs/day).toFixed(1)}d-old data → snapshots[${snapshots.length}]`); + } else { + snapshots = validSnaps; + } + + // Rétrocompatibilité previousWeek + if (snapshots[0]) { + previousWeek = snapshots[0].data; + previousWeekFetchedAt = snapshots[0].fetchedAt; } } catch {} @@ -447,6 +473,7 @@ mkdirSync("data", { recursive: true }); writeFileSync("data/rate-probabilities.json", JSON.stringify({ data: results, fetchedAt: new Date().toISOString(), + snapshots, ...(previousWeek ? { previousWeek, previousWeekFetchedAt } : {}), }, null, 2)); diff --git a/app/api/calendar/route.ts b/app/api/calendar/route.ts index 8c7fb26..59bab85 100644 --- a/app/api/calendar/route.ts +++ b/app/api/calendar/route.ts @@ -57,7 +57,7 @@ const CURRENCIES = new Set(["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "A function detectCategory(title: string): EventCategory { const t = title.toLowerCase(); - if (/nonfarm|non.farm|employment\s+change|jobs\s+added|employment\s+report|claimant|jobless\s+claims|unemployment\s+rate|jobless\s+rate/.test(t)) + if (/nonfarm|non.farm|employment\s+change|jobs\s+added|employment\s+report|claimant|jobless\s+claims|unemployment\s+rate|jobless\s+rate|\badp\b|jolts|job\s+openings|job\s+quits|ism\s+\w+\s+employ/.test(t)) return "employment"; if (/\bpmi\b|purchasing\s+managers/.test(t)) @@ -69,13 +69,13 @@ function detectCategory(title: string): EventCategory { if (/speaks?|press\s+conf|testimony|speech|statement\b|governor|chair\b|president\b/.test(t)) return "cb_speech"; - if (/\bcpi\b|\bhicp\b|core\s+inflation|flash\s+cpi|inflation\s+rate|consumer\s+price/.test(t)) + if (/\bcpi\b|\bhicp\b|core\s+inflation|flash\s+cpi|inflation\s+rate|consumer\s+price|\bppi\b|producer\s+price/.test(t)) return "inflation"; if (/\bgdp\b|gross\s+domestic/.test(t)) return "gdp"; - if (/retail\s+sales|core\s+retail/.test(t)) + if (/retail\s+sales|core\s+retail|household\s+spending/.test(t)) return "retail_sales"; if (/trade\s+balance|current\s+account/.test(t)) @@ -104,16 +104,25 @@ function displayTitle(rawTitle: string, currency: string): string { // USD-specific if (/nonfarm\s+payrolls/i.test(t) && currency === "USD") return "NFP (Non-Farm Payrolls)"; if (/adp\s+non.farm/i.test(t)) return "ADP Employment"; + if (/jobless.*4.?week|4.?week.*jobless|claims.*4.?week/i.test(t)) return "Dem. alloc. (moy. 4 sem.)"; if (/unemployment\s+claims/i.test(t)) return "Demandes d'allocations"; if (/unemployment\s+rate/i.test(t)) return "Taux de chômage"; if (/employment\s+change/i.test(t)) return "Emploi Δ"; + if (/jolts|job\s+openings/i.test(t)) return "JOLTS (offres d'emploi)"; + if (/job\s+quits/i.test(t)) return "JOLTS (démissions)"; + if (/ism\s+\w+\s+employ/i.test(t)) return "ISM Emploi Manufacturier"; + if (/procure\.ch/i.test(t)) return "PMI Manufacturier"; if (/manufacturing\s+pmi|mfg\s+pmi/i.test(t)) return "PMI Manufacturier"; if (/services?\s+pmi/i.test(t)) return "PMI Services"; if (/composite\s+pmi/i.test(t)) return "PMI Composite"; if (/ism\s+non.manufactur/i.test(t)) return "ISM Services PMI"; if (/ism\s+manufactur/i.test(t)) return "ISM Manufacturier"; - if (/flash.*cpi|cpi.*flash/i.test(t)) return "IPC Flash (YoY)"; + if (/flash.*cpi|cpi.*flash/i.test(t)) return "IPC Flash"; + if (/final.*cpi|cpi.*final/i.test(t)) return "IPC Final"; if (/core.*cpi/i.test(t)) return "IPC Core"; + if (/ppi.*m.?m|producer.*price.*m.?m/i.test(t)) return "IPP (MoM)"; + if (/ppi.*y.?y|producer.*price.*y.?y/i.test(t)) return "IPP (YoY)"; + if (/\bppi\b|producer\s+price/i.test(t)) return "IPP"; if (/\bhicp\b/i.test(t)) return "HICP (YoY)"; if (/\bcpi\b.*y.*y/i.test(t)) return "IPC (YoY)"; if (/\bcpi\b.*m.*m/i.test(t)) return "IPC (MoM)"; @@ -122,6 +131,9 @@ function displayTitle(rawTitle: string, currency: string): string { if (/gdp.*m.*m/i.test(t)) return "PIB (MoM)"; if (/\bgdp\b/i.test(t)) return "PIB"; if (/core\s+retail/i.test(t)) return "Ventes détail Core"; + if (/household\s+spending.*m.?m/i.test(t)) return "Dép. ménages (MoM)"; + if (/household\s+spending.*y.?y/i.test(t)) return "Dép. ménages (YoY)"; + if (/household\s+spending/i.test(t)) return "Dép. ménages"; if (/retail\s+sales/i.test(t)) return "Ventes au détail"; if (/trade\s+balance/i.test(t)) return "Balance commerciale"; if (/interest\s+rate|rate\s+decision/i.test(t)) return "Décision de taux"; @@ -145,7 +157,7 @@ interface GroupingResult { const EMPLOYMENT_PARENTS = /nonfarm|non.farm|employment\s+change|claimant|jobless\s+claims/i; const EMPLOYMENT_CHILDREN = /unemployment\s+rate|jobless\s+rate/i; const PMI_PARENTS = /composite\s+pmi|ism\s+(manufactur|non.manufactur)/i; -const PMI_CHILDREN = /manufacturing\s+pmi|services?\s+pmi|mfg\s+pmi/i; +const PMI_CHILDREN = /manufacturing\s+pmi|services?\s+pmi|mfg\s+pmi|procure\.ch/i; const CPI_PARENTS = /\bcpi\b.*y.*y|flash.*cpi|\bhicp\b/i; const CPI_CHILDREN = /core.*cpi|core.*inflation/i; diff --git a/app/api/expectations/route.ts b/app/api/expectations/route.ts index bee95ff..ad9d707 100644 --- a/app/api/expectations/route.ts +++ b/app/api/expectations/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { readFileSync } from "fs"; import { join } from "path"; +export const dynamic = "force-dynamic"; + const AUTHOR_URL = "https://investinglive.com/author/giuseppe-dellamotta/"; const CATEGORY_URL = "https://investinglive.com/CentralBanks"; const RSS_URLS = [ @@ -186,16 +188,22 @@ async function candidatesFromHtml(): Promise { } // ── Étape 0 : scan URL-date direct (le plus fiable, publié chaque semaine) ──── -// Pattern : /news/how-have-interest-rate-expectations-changed-after-this-weeks-event-YYYYMMDD/ +// Pattern : /centralbank/...events-YYYYMMDD/ (+ fallbacks /news/, singulier/pluriel) // On teste les 14 derniers jours (une publication par semaine environ) async function candidatesFromUrlScan(): Promise { const results: string[] = []; const now = Date.now(); + // 4 variantes d'URL par jour (la structure du site a changé mi-2026) const checks = Array.from({ length: 14 }, (_, i) => { const d = new Date(now - i * 86400000); const s = d.toISOString().slice(0, 10).replace(/-/g, ""); - return `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${s}/`; - }); + return [ + `https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-events-${s}/`, + `https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-event-${s}/`, + `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-events-${s}/`, + `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${s}/`, + ]; + }).flat(); // HEAD requests en parallèle (rapide, peu de bande passante) const settled = await Promise.allSettled( checks.map(url => @@ -204,10 +212,14 @@ async function candidatesFromUrlScan(): Promise { .catch(() => null) ) ); + const seen = new Set(); for (const r of settled) { - if (r.status === "fulfilled" && r.value) results.push(r.value); + if (r.status === "fulfilled" && r.value && !seen.has(r.value)) { + seen.add(r.value); + results.push(r.value); + } } - return results; // déjà triés du plus récent au plus ancien + return results; // triés du plus récent au plus ancien (ordre du .flat()) } async function loadRemoteExpectation() { diff --git a/app/page.tsx b/app/page.tsx index ecd500b..1897808 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import { RefreshCw, Zap, Database, Activity, Maximize2, Minimize2, X, BarChart2 } from "lucide-react"; +import { RefreshCw, Database, Activity, Maximize2, Minimize2, X, BarChart2 } from "lucide-react"; import { CURRENCIES, CURRENCY_META } from "@/lib/constants"; import type { Currency, DriverData, SentimentEntry, CotEntry, MacroSection } from "@/lib/types"; import type { RateProbData } from "@/lib/rateprobability"; @@ -50,6 +50,7 @@ export default function Dashboard() { const [globalMacroSlide, setGlobalMacroSlide] = useState<"mon"|"infl"|"cro"|"empl">("mon"); const [globalCardTab, setGlobalCardTab] = useState<"overview"|"mispricing"|"focus">("overview"); const [globalSignauxSlide, setGlobalSignauxSlide] = useState<"ois"|"cot"|"sent">("ois"); + const [globalOisChartTab, setGlobalOisChartTab] = useState<"curve"|"implied"|"scenarios">("curve"); const [macroSyncEnabled, setMacroSyncEnabled] = useState(false); // ── Sentiment multi-paires Myfxbook → {CCY: {longPct, shortPct, pair}} ────── @@ -139,15 +140,16 @@ export default function Dashboard() { const refresh = useCallback(async () => { setLoading(true); try { + const NO_CACHE = { cache: "no-store" } as const; const [driversRes, expectRes, yieldsRes, fxRes, sentimentRes, cotRes, calRes, rateProbRes] = await Promise.allSettled([ - fetch("/api/drivers").then((r) => r.json()), - fetch("/api/expectations").then((r) => r.json()), - fetch("/api/yields").then((r) => r.json()), - fetch("/api/fx").then((r) => r.json()), - fetch("/api/sentiment").then((r) => r.json()), - fetch("/api/cot").then((r) => r.json()), - fetch("/api/calendar").then((r) => r.json()), - fetch("/api/rate-probabilities").then((r) => r.json()), + fetch("/api/drivers", NO_CACHE).then((r) => r.json()), + fetch("/api/expectations", NO_CACHE).then((r) => r.json()), + fetch("/api/yields", NO_CACHE).then((r) => r.json()), + fetch("/api/fx", NO_CACHE).then((r) => r.json()), + fetch("/api/sentiment", NO_CACHE).then((r) => r.json()), + fetch("/api/cot", NO_CACHE).then((r) => r.json()), + fetch("/api/calendar", NO_CACHE).then((r) => r.json()), + fetch("/api/rate-probabilities",NO_CACHE).then((r) => r.json()), ]); // ── Drivers ─────────────────────────────────────────────────────────── @@ -299,26 +301,13 @@ export default function Dashboard() {
- + {new Date().getHours() < 18 ? "Bonjour" : "Bonsoir"} 👋
- {divergenceCount > 0 && ( - - )} -
{driversFromCache && driversCacheAge && ( @@ -327,7 +316,7 @@ export default function Dashboard() { cache {driversCacheAge} )} - {lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} + {lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
@@ -607,10 +599,10 @@ export default function Dashboard() {
- - - - + + + +
)} diff --git a/lib/investinglive.ts b/lib/investinglive.ts index c1162ea..04c3add 100644 --- a/lib/investinglive.ts +++ b/lib/investinglive.ts @@ -1,6 +1,6 @@ // lib/investinglive.ts // Scrapes Giuseppe Dellamotta's recurring rate-expectations article on investinglive.com -// URL pattern: /news/how-have-interest-rate-expectations-changed-after-this-weeks-event-YYYYMMDD/ +// URL pattern: /centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-events-YYYYMMDD/ (+ fallbacks) // Published after major market events (typically on Fridays or post-CB-decision) // // Data format in articleBody JSON-LD: @@ -50,18 +50,26 @@ async function tryUrl(daysAgo: number): Promise { const d = new Date(Date.now() - daysAgo * 86_400_000); const yyyymmdd = d.toISOString().slice(0, 10).replace(/-/g, ""); const dateStr = `${yyyymmdd.slice(0,4)}-${yyyymmdd.slice(4,6)}-${yyyymmdd.slice(6,8)}`; - const url = `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`; - try { - const res = await fetch(url, { - method: "GET", - headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" }, - cache: "no-store", - }); - if (!res.ok) return null; - // Cancel séparé pour ne pas masquer le succès en cas d'erreur de libération - res.body?.cancel().catch(() => {}); - return { url, dateStr, daysAgo }; - } catch { return null; } + // Tester les 4 variantes : /centralbank/ ou /news/, events (pluriel) ou event (singulier) + const candidates = [ + `https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`, + `https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`, + `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`, + `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`, + ]; + for (const url of candidates) { + try { + const res = await fetch(url, { + method: "GET", + headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" }, + cache: "no-store", + }); + if (!res.ok) { res.body?.cancel().catch(() => {}); continue; } + res.body?.cancel().catch(() => {}); + return { url, dateStr, daysAgo }; + } catch { continue; } + } + return null; } async function findArticleRefs(): Promise<{ current: ArticleRef | null; previous: ArticleRef | null }> {