diff --git a/.github/scripts/fetch-rate-data.mjs b/.github/scripts/fetch-rate-data.mjs index 99b0998..595fa00 100644 --- a/.github/scripts/fetch-rate-data.mjs +++ b/.github/scripts/fetch-rate-data.mjs @@ -516,6 +516,7 @@ function buildILFallback(ccy, il) { return { today: { midpoint: rate, + source: "InvestingLive — estimation hebdomadaire analyste (pas de futures/OIS coté public pour cette devise)", rows: [{ meeting: "Dec", meeting_iso: yearEnd, @@ -570,7 +571,10 @@ for (const ccy of IC_SUPPORTED) { } data = await fetchInvestingCom(ccy); } - if (data) results[ccy] = data; + if (data) { + data.today.source = "Investing.com Fed Rate Monitor (CME 30-Day Fed Funds Futures)"; + results[ccy] = data; + } await new Promise(r => setTimeout(r, 600)); } @@ -586,7 +590,12 @@ for (const ccy of Object.keys(FUTURES_STRIP_CONFIG)) { } data = await fetchFuturesStrip(ccy); } - if (data) results[ccy] = data; + if (data) { + data.today.source = ccy === "EUR" + ? "Investing.com — Euribor 3M Futures (Eurex)" + : "Investing.com — Three-Month SONIA Futures (ICE)"; + results[ccy] = data; + } await new Promise(r => setTimeout(r, 600)); } diff --git a/app/api/rate-probabilities/route.ts b/app/api/rate-probabilities/route.ts index a965f36..20ca0e8 100644 --- a/app/api/rate-probabilities/route.ts +++ b/app/api/rate-probabilities/route.ts @@ -13,11 +13,23 @@ export interface RateProbabilitiesResponse { } export async function GET() { - const data = await fetchAllCBPaths(); - const currencies = Object.keys(data); - console.log(`[rate-prob] fetched ${currencies.length} CBs: ${currencies.join(", ")}`); - return NextResponse.json( - { data, fetchedAt: new Date().toISOString() } satisfies RateProbabilitiesResponse, - { headers: { "Cache-Control": "no-store" } } - ); + try { + const data = await fetchAllCBPaths(); + const currencies = Object.keys(data); + console.log(`[rate-prob] fetched ${currencies.length} CBs: ${currencies.join(", ")}`); + return NextResponse.json( + { data, fetchedAt: new Date().toISOString() } satisfies RateProbabilitiesResponse, + { headers: { "Cache-Control": "no-store" } } + ); + } catch (e) { + // Ne jamais laisser une exception (ex: timeout InvestingLive) faire planter la route + // entière — ça faisait échouer le fetch() client pour les 8 devises d'un coup au lieu + // de dégrader currency par currency. On renvoie un JSON valide (vide) : le client bascule + // sur son cache localStorage plutôt que de rester bloqué sur "Données OIS indisponibles". + console.error(`[rate-prob] fatal error: ${e instanceof Error ? e.message : e}`); + return NextResponse.json( + { data: {}, fetchedAt: new Date().toISOString() } satisfies RateProbabilitiesResponse, + { headers: { "Cache-Control": "no-store" } } + ); + } } diff --git a/app/page.tsx b/app/page.tsx index 5ab68fe..46afa69 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -222,8 +222,12 @@ export default function Dashboard() { } // ── Probabilités de taux (rateprobability.com OIS) ─────────────────── - if (rateProbRes.status === "fulfilled" && rateProbRes.value?.data) { + if (rateProbRes.status === "fulfilled" && rateProbRes.value?.data && Object.keys(rateProbRes.value.data).length > 0) { setRateProbabilities(rateProbRes.value.data as RateProbData); + saveCache("rateProbabilities", rateProbRes.value.data); + } else { + const cached = loadCache("rateProbabilities"); + if (cached) setRateProbabilities(cached.data); } setLastRefresh(new Date()); diff --git a/components/CurrencyCard.tsx b/components/CurrencyCard.tsx index ce04a83..4a4a0a3 100644 --- a/components/CurrencyCard.tsx +++ b/components/CurrencyCard.tsx @@ -358,29 +358,41 @@ const TE_COUNTRY: Record = { USD:"united-states", EUR:"euro-area", GBP:"united-kingdom", JPY:"japan", CHF:"switzerland", CAD:"canada", AUD:"australia", NZD:"new-zealand", }; -const IC_SLUG: Record = { - USD:"fed-rate-monitor", EUR:"ecb-rate-monitor", GBP:"boe-rate-monitor", - JPY:"boj-rate-monitor", CAD:"boc-rate-monitor", AUD:"rba-rate-monitor", - NZD:"rbnz-rate-monitor", CHF:"snb-rate-monitor", -}; const CB_NAME: Record = { USD:"Fed (FOMC)", EUR:"BCE", GBP:"BoE MPC", JPY:"BoJ", CHF:"SNB", CAD:"BoC", AUD:"RBA", NZD:"RBNZ", }; +// Instrument réellement interrogé par le pipeline de fetch pour chaque devise +// (voir .github/scripts/fetch-rate-data.mjs) — à garder synchronisé avec les +// labels "source" écrits là-bas. USD seul a un Rate Monitor Investing.com ; +// EUR/GBP utilisent des futures 3M en proxy ; les 5 autres devises n'ont aucune +// source de marché publique gratuite et retombent sur l'estimation hebdomadaire +// InvestingLive (pas un vrai OIS/futures coté). +const OIS_SOURCE_INFO: Record = { + USD: { label: "Investing.com — Fed Rate Monitor", url: "https://www.investing.com/central-banks/fed-rate-monitor", note: "Dérivé des CME 30-Day Fed Funds Futures · probabilités par réunion" }, + EUR: { label: "Investing.com — Euribor 3M Futures (Eurex)", url: "https://www.investing.com/rates-bonds/euribor-futures", note: "Pas de Rate Monitor BCE dédié · résolution mensuelle" }, + GBP: { label: "Investing.com — Three-Month SONIA Futures (ICE)", url: "https://www.investing.com/rates-bonds/three-month-sonia-futures", note: "Pas de Rate Monitor BoE dédié · résolution trimestrielle" }, +}; + function SourcesPopup({ currency, onClose }: { currency: string; onClose: () => void }) { const country = TE_COUNTRY[currency] ?? currency.toLowerCase(); - const icSlug = IC_SLUG[currency]; const cbName = CB_NAME[currency] ?? currency; + const oisSource = OIS_SOURCE_INFO[currency]; const sections: Array<{ title: string; icon: string; sources: Array<{ label: string; url: string; note?: string }> }> = [ { title: "OIS · Futures (onglet Signaux)", icon: "📡", sources: [ - ...(currency === "USD" ? [{ label: "CME FedWatch — contrats SOFR", url: "https://www.cmegroup.com/markets/interest-rates/cme-fedwatch-tool.html", note: "Probabilités Fed par réunion · source primaire USD" }] : []), - ...(icSlug ? [{ label: `Investing.com — ${cbName} Rate Monitor`, url: `https://www.investing.com/central-banks/${icSlug}`, note: "OIS implicites par réunion" }] : []), - { label: "InvestingLive — Giuseppe Dellamotta", url: "https://investinglive.com", note: "Articles hebdo : variation bps fin d'an (fallback)" }, + ...(oisSource ? [oisSource] : []), + { + label: "InvestingLive — Giuseppe Dellamotta", + url: "https://investinglive.com", + note: oisSource + ? "Articles hebdo : variation bps fin d'an (fallback si Investing.com échoue)" + : "Seule source publique gratuite pour cette devise · pas de futures/OIS coté · estimation hebdomadaire", + }, ], }, { @@ -852,6 +864,17 @@ function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: { )} {(() => { + // instrumentSource = ce que le pipeline a réellement interrogé pour produire CES + // données (ground truth, écrit par .github/scripts/fetch-rate-data.mjs). Fallback + // sur la table statique STIR_INSTRUMENT uniquement pour du cache ancien qui n'a pas + // encore ce champ (avant migration). + if (ratePath.instrumentSource) { + return ( +
+ Instrument utilisé : {ratePath.instrumentSource} +
+ ); + } const stir = STIR_INSTRUMENT[ratePath.currency]; if (!stir) return null; return ( diff --git a/lib/investinglive.ts b/lib/investinglive.ts index 2beda23..8c0ce6c 100644 --- a/lib/investinglive.ts +++ b/lib/investinglive.ts @@ -59,15 +59,19 @@ async function tryUrl(daysAgo: number): Promise { `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`, ]; const hits = await Promise.all(candidates.map(async (url) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); 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", + signal: controller.signal, }); res.body?.cancel().catch(() => {}); return res.ok ? url : null; } catch { return null; } + finally { clearTimeout(timeout); } })); const found = hits.find((u): u is string => u !== null); return found ? { url: found, dateStr, daysAgo } : null; @@ -106,6 +110,8 @@ async function findArticleRefs(): Promise<{ current: ArticleRef | null; previous // ── Article fetch + parse ───────────────────────────────────────────────────── async function fetchAndParse(ref: ArticleRef): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); try { const res = await fetch(ref.url, { headers: { @@ -113,7 +119,8 @@ async function fetchAndParse(ref: ArticleRef): Promise { "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.9", }, - next: { revalidate: 21600 }, + next: { revalidate: 21600 }, + signal: controller.signal, }); if (!res.ok) return {}; @@ -136,6 +143,8 @@ async function fetchAndParse(ref: ArticleRef): Promise { } catch (err) { console.error("[IL] fetch error:", err); return {}; + } finally { + clearTimeout(timeout); } } diff --git a/lib/rateprobability.ts b/lib/rateprobability.ts index 3e43607..68eb160 100644 --- a/lib/rateprobability.ts +++ b/lib/rateprobability.ts @@ -72,6 +72,7 @@ export interface CBRatePath { prevMeetings?: RateProbMeeting[]; // réunions semaine précédente (snapshot RP) prevWeekDate?: string; // date du snapshot semaine précédente history?: Array<{ date: string; meetings: RateProbMeeting[] }>; // snapshots hebdo accumulés + instrumentSource?: string; // instrument réellement utilisé pour produire ces données (ground truth, écrit par le pipeline de fetch) } export type RateProbData = Partial>; @@ -217,6 +218,7 @@ function buildOfficialCalendarPath( meetings, peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null, yearEndImplied: meetings.at(-1)?.impliedRate ?? null, + instrumentSource: "InvestingLive — estimation hebdomadaire analyste (pas de futures/OIS coté public pour cette devise) + calendrier officiel de réunions", }; } @@ -288,7 +290,8 @@ function parseCBBody(ccy: Currency, body: Record): CBRatePath | 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 }; + const instrumentSource = typeof today["source"] === "string" ? today["source"] as string : undefined; + return { currency: ccy, asOf, currentRate, meetings, peakMeeting, yearEndImplied, instrumentSource }; } // ── Fetch toutes les CB — depuis le cache GitHub Actions + enrichissement IL ───