fix: InvestingLive URL (/centralbank/events), procure.ch PMI title, remove signals badge

- investinglive.ts / expectations/route.ts / fetch-rate-data.mjs : try 4 URL variants
  (/centralbank/ or /news/, events or event) to handle site restructure
- calendar/route.ts: displayTitle catches procure.ch prefix to return PMI Manufacturier
- page.tsx: remove divergenceCount badge and unused Zap import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caty21
2026-06-29 10:45:15 +02:00
co-authored by Claude Sonnet 4.6
parent 94602c765f
commit eca5800372
5 changed files with 123 additions and 72 deletions
+46 -19
View File
@@ -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));
+17 -5
View File
@@ -57,7 +57,7 @@ const CURRENCIES = new Set<string>(["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;
+17 -5
View File
@@ -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<string[]> {
}
// ── É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<string[]> {
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<string[]> {
.catch(() => null)
)
);
const seen = new Set<string>();
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() {
+22 -30
View File
@@ -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() {
<Activity size={15} className="text-black" />
</div>
<div>
<span className="text-sm font-bold text-white tracking-tight">
<span className="text-sm font-bold text-white tracking-tight" suppressHydrationWarning>
{new Date().getHours() < 18 ? "Bonjour" : "Bonsoir"} 👋
</span>
</div>
</div>
<div className="flex items-center gap-3">
{divergenceCount > 0 && (
<button
onClick={() => setActiveTab("dashboard")}
title="Devises avec score macro ≥ 2 — cliquer pour voir le dashboard"
className="flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full px-3 py-1.5 hover:bg-amber-500/20 transition-colors"
>
<Zap size={12} className="text-amber-400" />
<span className="text-xs font-medium text-amber-400">
{divergenceCount} divergence{divergenceCount > 1 ? "s" : ""} active{divergenceCount > 1 ? "s" : ""}
</span>
</button>
)}
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
{driversFromCache && driversCacheAge && (
@@ -327,7 +316,7 @@ export default function Dashboard() {
<span>cache {driversCacheAge}</span>
</span>
)}
<span>{lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
<span suppressHydrationWarning>{lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
<button
@@ -373,8 +362,8 @@ export default function Dashboard() {
))}
</div>
{/* Global drivers bar — visible sur les deux onglets */}
{drivers && <DriversBar drivers={drivers} />}
{/* Global drivers bar — uniquement sur le dashboard */}
{activeTab === "dashboard" && drivers && <DriversBar drivers={drivers} />}
{activeTab === "dashboard" && (
<>
@@ -593,6 +582,9 @@ export default function Dashboard() {
onCardTabChange={macroSyncEnabled ? (setGlobalCardTab as (id: "overview"|"mispricing"|"focus") => void) : undefined}
syncSignauxSlide={macroSyncEnabled ? globalSignauxSlide : undefined}
onSignauxSlideChange={macroSyncEnabled ? setGlobalSignauxSlide : undefined}
syncOisChartTab={macroSyncEnabled ? globalOisChartTab : undefined}
onOisChartTabChange={macroSyncEnabled ? setGlobalOisChartTab : undefined}
isLoading={loading}
/>
))}
</div>
@@ -607,10 +599,10 @@ export default function Dashboard() {
<div className="h-px flex-1 bg-sky-500/20" />
</div>
<div className="grid grid-cols-2 gap-4">
<TvAdvancedChart symbol="FOREXCOM:SPXUSD" label="S&P 500 · Daily" interval="D" height={220} />
<TvAdvancedChart symbol="CBOE:VIX" label="VIX · Daily" interval="D" height={220} />
<TvAdvancedChart symbol="CAPITALCOM:DXY" label="DXY Dollar Index · Weekly" interval="W" height={220} />
<TvAdvancedChart symbol="TVC:GOLD" label="Or (XAU/USD) · Weekly" interval="W" height={220} />
<TvAdvancedChart symbol="FOREXCOM:SPXUSD" label="S&P 500" interval="D" height={220} />
<TvAdvancedChart symbol="PEPPERSTONE:VIX" label="VIX" interval="D" height={220} />
<TvAdvancedChart symbol="CAPITALCOM:DXY" label="DXY Dollar Index" interval="W" height={220} />
<TvAdvancedChart symbol="TVC:GOLD" label="Or (XAU/USD)" interval="W" height={220} />
</div>
</div>
)}
+21 -13
View File
@@ -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<ArticleRef | null> {
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 }> {