fix: OIS indisponible sur toutes les cards + transparence sur l'instrument utilisé

Le fetch /api/rate-probabilities n'avait aucun fallback cache côté client
(contrairement aux autres widgets) et pouvait planter en bloc si InvestingLive
timeoutait (fetch sans AbortController) - une seule panne coupait les 8 devises
d'un coup au lieu de dégrader devise par devise.

- route.ts : try/catch pour toujours renvoyer un JSON valide
- page.tsx : fallback localStorage si le fetch échoue
- investinglive.ts : timeout 8s sur les fetches vers investinglive.com
- fetch-rate-data.mjs / rateprobability.ts : le pipeline écrit maintenant la
  source réelle utilisée par devise (Rate Monitor USD, futures Euribor/SONIA
  pour EUR/GBP, InvestingLive pour les 5 autres) et la carte l'affiche
- CurrencyCard.tsx : SourcesPopup corrigé (CME FedWatch retiré, plus de fausse
  mention "Rate Monitor" pour les devises qui n'en ont pas)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
caty21
2026-07-14 23:05:37 +02:00
parent 55eca6e2f0
commit 3a384eb820
6 changed files with 81 additions and 21 deletions
+11 -2
View File
@@ -516,6 +516,7 @@ function buildILFallback(ccy, il) {
return { return {
today: { today: {
midpoint: rate, midpoint: rate,
source: "InvestingLive — estimation hebdomadaire analyste (pas de futures/OIS coté public pour cette devise)",
rows: [{ rows: [{
meeting: "Dec", meeting: "Dec",
meeting_iso: yearEnd, meeting_iso: yearEnd,
@@ -570,7 +571,10 @@ for (const ccy of IC_SUPPORTED) {
} }
data = await fetchInvestingCom(ccy); 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)); await new Promise(r => setTimeout(r, 600));
} }
@@ -586,7 +590,12 @@ for (const ccy of Object.keys(FUTURES_STRIP_CONFIG)) {
} }
data = await fetchFuturesStrip(ccy); 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)); await new Promise(r => setTimeout(r, 600));
} }
+19 -7
View File
@@ -13,11 +13,23 @@ export interface RateProbabilitiesResponse {
} }
export async function GET() { export async function GET() {
const data = await fetchAllCBPaths(); try {
const currencies = Object.keys(data); const data = await fetchAllCBPaths();
console.log(`[rate-prob] fetched ${currencies.length} CBs: ${currencies.join(", ")}`); const currencies = Object.keys(data);
return NextResponse.json( console.log(`[rate-prob] fetched ${currencies.length} CBs: ${currencies.join(", ")}`);
{ data, fetchedAt: new Date().toISOString() } satisfies RateProbabilitiesResponse, return NextResponse.json(
{ headers: { "Cache-Control": "no-store" } } { 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" } }
);
}
} }
+5 -1
View File
@@ -222,8 +222,12 @@ export default function Dashboard() {
} }
// ── Probabilités de taux (rateprobability.com OIS) ─────────────────── // ── 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); setRateProbabilities(rateProbRes.value.data as RateProbData);
saveCache("rateProbabilities", rateProbRes.value.data);
} else {
const cached = loadCache<RateProbData>("rateProbabilities");
if (cached) setRateProbabilities(cached.data);
} }
setLastRefresh(new Date()); setLastRefresh(new Date());
+32 -9
View File
@@ -358,29 +358,41 @@ const TE_COUNTRY: Record<string, string> = {
USD:"united-states", EUR:"euro-area", GBP:"united-kingdom", JPY:"japan", USD:"united-states", EUR:"euro-area", GBP:"united-kingdom", JPY:"japan",
CHF:"switzerland", CAD:"canada", AUD:"australia", NZD:"new-zealand", CHF:"switzerland", CAD:"canada", AUD:"australia", NZD:"new-zealand",
}; };
const IC_SLUG: Record<string, string> = {
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<string, string> = { const CB_NAME: Record<string, string> = {
USD:"Fed (FOMC)", EUR:"BCE", GBP:"BoE MPC", JPY:"BoJ", USD:"Fed (FOMC)", EUR:"BCE", GBP:"BoE MPC", JPY:"BoJ",
CHF:"SNB", CAD:"BoC", AUD:"RBA", NZD:"RBNZ", 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<string, { label: string; url: string; note: string }> = {
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 }) { function SourcesPopup({ currency, onClose }: { currency: string; onClose: () => void }) {
const country = TE_COUNTRY[currency] ?? currency.toLowerCase(); const country = TE_COUNTRY[currency] ?? currency.toLowerCase();
const icSlug = IC_SLUG[currency];
const cbName = CB_NAME[currency] ?? 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 }> }> = [ const sections: Array<{ title: string; icon: string; sources: Array<{ label: string; url: string; note?: string }> }> = [
{ {
title: "OIS · Futures (onglet Signaux)", title: "OIS · Futures (onglet Signaux)",
icon: "📡", icon: "📡",
sources: [ 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" }] : []), ...(oisSource ? [oisSource] : []),
...(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)" }, 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 }: {
</div> </div>
)} )}
{(() => { {(() => {
// 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 (
<div className="text-[7px] text-slate-700">
Instrument utilisé : <span className="text-slate-600">{ratePath.instrumentSource}</span>
</div>
);
}
const stir = STIR_INSTRUMENT[ratePath.currency]; const stir = STIR_INSTRUMENT[ratePath.currency];
if (!stir) return null; if (!stir) return null;
return ( return (
+10 -1
View File
@@ -59,15 +59,19 @@ async function tryUrl(daysAgo: number): Promise<ArticleRef | null> {
`https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`, `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 hits = await Promise.all(candidates.map(async (url) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: "GET", 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" }, 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", cache: "no-store",
signal: controller.signal,
}); });
res.body?.cancel().catch(() => {}); res.body?.cancel().catch(() => {});
return res.ok ? url : null; return res.ok ? url : null;
} catch { return null; } } catch { return null; }
finally { clearTimeout(timeout); }
})); }));
const found = hits.find((u): u is string => u !== null); const found = hits.find((u): u is string => u !== null);
return found ? { url: found, dateStr, daysAgo } : null; return found ? { url: found, dateStr, daysAgo } : null;
@@ -106,6 +110,8 @@ async function findArticleRefs(): Promise<{ current: ArticleRef | null; previous
// ── Article fetch + parse ───────────────────────────────────────────────────── // ── Article fetch + parse ─────────────────────────────────────────────────────
async function fetchAndParse(ref: ArticleRef): Promise<ILExpectationsMap> { async function fetchAndParse(ref: ArticleRef): Promise<ILExpectationsMap> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try { try {
const res = await fetch(ref.url, { const res = await fetch(ref.url, {
headers: { headers: {
@@ -113,7 +119,8 @@ async function fetchAndParse(ref: ArticleRef): Promise<ILExpectationsMap> {
"Accept": "text/html,application/xhtml+xml", "Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
}, },
next: { revalidate: 21600 }, next: { revalidate: 21600 },
signal: controller.signal,
}); });
if (!res.ok) return {}; if (!res.ok) return {};
@@ -136,6 +143,8 @@ async function fetchAndParse(ref: ArticleRef): Promise<ILExpectationsMap> {
} catch (err) { } catch (err) {
console.error("[IL] fetch error:", err); console.error("[IL] fetch error:", err);
return {}; return {};
} finally {
clearTimeout(timeout);
} }
} }
+4 -1
View File
@@ -72,6 +72,7 @@ export interface CBRatePath {
prevMeetings?: RateProbMeeting[]; // réunions semaine précédente (snapshot RP) prevMeetings?: RateProbMeeting[]; // réunions semaine précédente (snapshot RP)
prevWeekDate?: string; // date du snapshot semaine précédente prevWeekDate?: string; // date du snapshot semaine précédente
history?: Array<{ date: string; meetings: RateProbMeeting[] }>; // snapshots hebdo accumulés 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<Record<Currency, CBRatePath>>; export type RateProbData = Partial<Record<Currency, CBRatePath>>;
@@ -217,6 +218,7 @@ function buildOfficialCalendarPath(
meetings, meetings,
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null, peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
yearEndImplied: meetings.at(-1)?.impliedRate ?? 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<string, unknown>): CBRatePath |
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const meetsThisYear = meetings.filter(m => m.dateIso <= `${currentYear}-12-31`); const meetsThisYear = meetings.filter(m => m.dateIso <= `${currentYear}-12-31`);
const yearEndImplied = meetsThisYear.length > 0 ? meetsThisYear.at(-1)!.impliedRate : meetings[0].impliedRate; 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 ─── // ── Fetch toutes les CB — depuis le cache GitHub Actions + enrichissement IL ───