feat: retail sales MoM (TE scraping), smart cache, IL weekly delta arrows, OIS phase labels

- Retail Sales: switch from FRED OECD series (4-6w lag) to Trading Economics real-time scraping
  for all 8 currencies; label changed to "Retail Sales MoM"
- Cache TTL: 24h → 1h base + 15min hot when ForexFactory detects recent high-impact event
- Rate probability trend arrows: server-side IL weekly delta (current vs previous Dellamotta article)
  with per-metric thresholds (prob ≥3%/10%, bps ≥10/25) and colour coding (sky=dovish, amber=hawkish)
- Cycle phase: replaced FRED trend heuristic with OIS-based logic using peakMeeting.probMovePct,
  probIsCut, and yearEndBps; dynamic descriptions include real market numbers
- investinglive.ts: find both current + previous article to compute week-over-week delta server-side

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Capucine Gest
2026-06-08 19:41:50 +02:00
parent 1a2ea02b22
commit f82afe9d5b
5 changed files with 351 additions and 118 deletions
+86 -2
View File
@@ -9,7 +9,7 @@ import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTE
import { fetchTEInflationForecasts } from "@/lib/tradingeconomics";
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
const REVALIDATE = 86400; // cache 24h
const REVALIDATE = 3600; // cache 1h (les données FRED sont disponibles ~1h après publication)
// ── FRED ─────────────────────────────────────────────────────────────────────
@@ -428,6 +428,49 @@ async function scrapeTeGdp(country: string): Promise<{ value: number | null; pre
} catch { return empty; }
}
// ── Trading Economics — Retail Sales MoM (country-list, toutes devises en une requête) ─────
// Parse le tableau https://tradingeconomics.com/country-list/retail-sales-mom
// Valeurs positives : <td data-heatmap-value="N">0.5</td>
// Valeurs négatives : <td data-heatmap-value="N"><span class='te-value-negative'>-0.4</span></td>
let _teRetailMoMCache: { data: Map<string, { value: number; prev: number | null }>; ts: number } | null = null;
async function fetchTeRetailSalesMoM(): Promise<Map<string, { value: number; prev: number | null }>> {
if (_teRetailMoMCache && Date.now() - _teRetailMoMCache.ts < 3_600_000) {
return _teRetailMoMCache.data;
}
const result = new Map<string, { value: number; prev: number | null }>();
const slugToCurrency: Record<string, string> = {
"united-states": "USD", "euro-area": "EUR", "united-kingdom": "GBP",
"japan": "JPY", "switzerland": "CHF", "canada": "CAD",
"australia": "AUD", "new-zealand": "NZD",
};
try {
const res = await fetch("https://tradingeconomics.com/country-list/retail-sales-mom", {
next: { revalidate: 3600 },
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
},
});
if (!res.ok) return result;
const html = await res.text();
// Regex robuste : capture slug + valeur courante + valeur précédente
// Gère les valeurs négatives wrappées dans <span class='te-value-negative'>
const re = /<a href='\/([^']+)\/retail-sales'>\s*[^<]+\s*<\/a><\/td>\s*<td[^>]*>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>\s*<td>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) {
const currency = slugToCurrency[m[1]];
if (currency) {
result.set(currency, { value: parseFloat(m[2]), prev: parseFloat(m[3]) });
}
}
} catch { /* retourner map vide si TE indisponible */ }
_teRetailMoMCache = { data: result, ts: Date.now() };
return result;
}
// ── Trading Economics — Balance commerciale (niveau, normalisé en Milliards devise locale)
// Format meta TE :
// "X recorded a trade deficit/surplus of Y.YY EUR/USD/etc. Billion/Million in Month of Year"
@@ -618,14 +661,39 @@ function parseTeF(s: string | null | undefined): number | null {
const _cache = new Map<string, { data: unknown; ts: number }>();
// TTL adaptatif : 15 min si un event à fort/moyen impact vient d'être publié dans les 2h
// pour cette devise, sinon 1h.
async function getSmartTtl(currency: string): Promise<number> {
const BASE_TTL = 3_600_000; // 1h par défaut
const HOT_TTL = 900_000; // 15min après publication récente
const WINDOW_MS = 7_200_000; // fenêtre de 2h
try {
const events = await fetchFFThisWeek();
const now = Date.now();
const hasRecent = events.some(e => {
const t = new Date(e.date).getTime();
return (
e.country === currency &&
e.actual !== "" && // données publiées (actual rempli)
(e.impact === "High" || e.impact === "Medium") &&
t <= now && // passé (pas futur)
now - t < WINDOW_MS // dans les 2h
);
});
return hasRecent ? HOT_TTL : BASE_TTL;
} catch { return BASE_TTL; }
}
export async function GET(req: NextRequest) {
const currency = (new URL(req.url).searchParams.get("currency") ?? "").toUpperCase() as Currency;
const series = FRED_SERIES[currency];
if (!series) return NextResponse.json({ error: "Unknown currency" }, { status: 400 });
// TTL smart : 15 min si publication récente, sinon 1h
const ttl = await getSmartTtl(currency);
const cached = _cache.get(currency);
const staleCache = cached ?? null;
if (cached && Date.now() - cached.ts < 86_400_000) return NextResponse.json(cached.data);
if (cached && Date.now() - cached.ts < ttl) return NextResponse.json(cached.data);
const key = process.env.FRED_API_KEY;
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
@@ -956,6 +1024,22 @@ export async function GET(req: NextRequest) {
indicators.cpiMoM = toIndicatorPct(_cpiCoreObs);
}
// ── Retail Sales MoM : override depuis Trading Economics (plus à jour que FRED OCDE) ────────
{
const teRS = await fetchTeRetailSalesMoM();
const rs = teRS.get(currency);
if (rs !== undefined) {
const surprise = rs.prev !== null ? parseFloat((rs.value - rs.prev).toFixed(2)) : null;
indicators.retailSales = {
value: rs.value,
prev: rs.prev,
surprise,
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
lastUpdated: today,
};
}
}
// ── PMI (Mfg + Services + Composite) + consensus FF ──────────────────────────
const toDateObj = new Date();
toDateObj.setDate(toDateObj.getDate() + 21);
+131 -40
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
TrendingUp, TrendingDown, Minus, Loader2, Database,
@@ -15,7 +15,7 @@ import { CURRENCY_META, COUNTRY_PROFILES } from "@/lib/constants";
import { biasLabel, calcMacroScore } from "@/lib/scoring";
import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
import type { Currency, BiasPhase, RateExpectation } from "@/lib/types";
import type { CBRatePath } from "@/lib/rateprobability";
import type { CBRatePath, ILWeeklyDelta } from "@/lib/rateprobability";
import type { SentimentEntry, CotEntry } from "@/lib/types";
import NarrativeButton from "./NarrativeButton";
@@ -76,6 +76,48 @@ function phaseLabel(p: BiasPhase) {
return "Transition";
}
// ─── Phase depuis OIS (source primaire) ──────────────────────────────────────
// Ordre de priorité :
// 1. Resserrement actif : proba de hausse >60% ET bps annuels >+15
// 2. Assouplissement actif: proba de coupe >60% OU bps annuels <40
// 3. Pause dovish : bps annuels ≤ 15 (coupes attendues, pas imminentes)
// 4. Pause hawkish : bps annuels ≥ +20 OU biais sans coupe modérément pricé
// 5. Transition : anticipations neutres (fin de cycle, CB en attente)
function computePhaseFromOIS(ratePath: CBRatePath | null): BiasPhase | null {
if (!ratePath?.peakMeeting) return null;
const yearEndBps = ratePath.yearEndImplied !== null
? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100)
: 0;
const { probMovePct, probIsCut } = ratePath.peakMeeting;
if (!probIsCut && probMovePct > 60 && yearEndBps >= 15) return "tightening";
if (probIsCut && (probMovePct > 60 || yearEndBps <= -40)) return "easing";
if (yearEndBps <= -15) return "dovish_pause";
if (yearEndBps >= 20 || (!probIsCut && probMovePct > 40 && yearEndBps > 5)) return "hawkish_pause";
return "transition";
}
// ─── Description contextuelle de la phase (avec vrais chiffres OIS) ──────────
function phaseDescription(phase: BiasPhase, ratePath: CBRatePath | null): string {
const yearEndBps = (ratePath?.yearEndImplied != null && ratePath?.currentRate != null)
? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100)
: null;
const peak = ratePath?.peakMeeting;
const bpsStr = yearEndBps !== null ? `${yearEndBps > 0 ? "+" : ""}${yearEndBps}bps fin d'an` : "";
const probStr = peak ? `${peak.probMovePct.toFixed(0)}% de ${peak.probIsCut ? "coupe" : "hausse"} (${peak.label})` : "";
switch (phase) {
case "tightening":
return `Cycle de resserrement actif — ${probStr}${bpsStr ? ` · ${bpsStr}` : ""}. Surveiller : inflation, emploi solide, PMI > 50.`;
case "easing":
return `Cycle d'assouplissement — ${probStr}${bpsStr ? ` · ${bpsStr}` : ""}. Surveiller : désinflation, ralentissement emploi, PMI < 50.`;
case "dovish_pause":
return `Pause dovish${bpsStr ? `${bpsStr} anticipés` : ""}. Prochaine réunion stable mais coupes attendues plus tard. Surveiller : timing désinflation.`;
case "hawkish_pause":
return `Pause hawkish${bpsStr ? `${bpsStr} anticipés` : ""}. CB en attente. Surveiller : regain d'inflation ou fort ralentissement.`;
case "transition":
return `Anticipations neutres${bpsStr ? ` (${bpsStr})` : ""}. CB données-dépendantes — tous les indicateurs comptent.`;
}
}
function scoreDir(score: number): SignalDir {
if (score >= 3) return "bullish";
if (score <= -3) return "bearish";
@@ -165,6 +207,29 @@ function trendDir(t: "up"|"down"|"flat"|null): SignalDir {
return "neutral";
}
// ─── RpArrow : flèche de tendance pour probabilités de taux (delta IL hebdo) ──
// delta = valeur_actuelle - valeur_précédent_article
// isBearishIfUp = true si une hausse du delta est baissière pour la devise
function RpArrow({
delta, isBearishIfPositive, suffix, strongT, modT,
}: {
delta: number; isBearishIfPositive: boolean; suffix: string; strongT: number; modT: number;
}) {
const abs = Math.abs(delta);
if (abs < modT) return null;
const strong = abs >= strongT;
const up = delta > 0;
const bearish = isBearishIfPositive ? up : !up;
const color = bearish ? "text-sky-400" : "text-amber-400";
const arrow = strong ? (up ? "↑↑" : "↓↓") : (up ? "↑" : "↓");
return (
<span className={`font-bold ${color}`}>
{arrow}{up ? "+" : ""}{Math.abs(Math.round(abs * 10) / 10)}{suffix}
</span>
);
}
// ─── Sous-composants ─────────────────────────────────────────────────────────
function MacroBlock({ title, children }: { title: string; children: React.ReactNode }) {
@@ -260,7 +325,15 @@ export default function CurrencyCard({
// ── State ────────────────────────────────────────────────────────────────────
const [data, setData] = useState<MacroData | null>(null);
const [phase, setPhase] = useState<BiasPhase>("hawkish_pause");
// Phase dérivée des probabilités OIS (source primaire) ou du trend FRED (fallback)
const phase = useMemo<BiasPhase>(() => {
const oisPhase = computePhaseFromOIS(ratePath);
if (oisPhase) return oisPhase;
const rateInd = data?.indicators?.policyRate;
if (rateInd?.trend === "up") return "tightening";
if (rateInd?.trend === "down") return "easing";
return "hawkish_pause";
}, [ratePath, data]);
const [loading, setLoading] = useState(true);
const [rateExp, setRateExp] = useState<RateExpectation | null>(null);
const [fromCache, setFromCache] = useState(false);
@@ -293,21 +366,12 @@ export default function CurrencyCard({
setFromCache(false);
setCacheAge(null);
saveCache(cacheKey, merged);
const rateInd = merged.indicators.policyRate;
if (rateInd?.trend === "up") setPhase("tightening");
else if (rateInd?.trend === "down") setPhase("easing");
else setPhase("hawkish_pause");
} catch {
const cached = loadCache<MacroData>(cacheKey);
if (cached) {
setData(cached.data);
setFromCache(true);
setCacheAge(formatCacheDate(cached.savedAt));
const rateInd = cached.data.indicators.policyRate;
if (rateInd?.trend === "up") setPhase("tightening");
else if (rateInd?.trend === "down") setPhase("easing");
else setPhase("hawkish_pause");
}
} finally {
setLoading(false);
@@ -505,7 +569,7 @@ export default function CurrencyCard({
{ label: "PMI Mfg", value: inds?.pmiMfg?.value != null ? `${inds.pmiMfg.value.toFixed(1)}` : "", sig: inds?.pmiMfg?.value != null ? (inds.pmiMfg.value > 50 ? 1 : -1) : 0 },
{ label: "PMI Services", value: inds?.pmiServices?.value != null ? `${inds.pmiServices.value.toFixed(1)}` : "", sig: inds?.pmiServices?.value != null ? (inds.pmiServices.value > 50 ? 1 : -1) : 0 },
{ label: "PIB QoQ", value: inds?.gdp?.value != null ? `${inds.gdp.value > 0 ? "+" : ""}${inds.gdp.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.gdp?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() },
{ label: "Retail Sales", value: inds?.retailSales?.value != null ? `${inds.retailSales.value > 0 ? "+" : ""}${inds.retailSales.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.retailSales?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() },
{ label: "Retail Sales MoM", value: inds?.retailSales?.value != null ? `${inds.retailSales.value > 0 ? "+" : ""}${inds.retailSales.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.retailSales?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() },
{ label: "Chômage", value: inds?.unemployment?.value != null ? `${inds.unemployment.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.unemployment?.surprise; return s == null ? 0 : s < -0.3 ? 1 : s > 0.3 ? -1 : 0; })() },
{ label: "Emploi", value: inds?.employment?.value != null ? `${inds.employment.value > 0 ? "+" : ""}${inds.employment.value.toFixed(1)}k` : "", sig: (() => { const s = inds?.employment?.surprise; return s == null ? 0 : s > 10 ? 1 : s < -10 ? -1 : 0; })() },
].filter(c => c.value !== "");
@@ -641,23 +705,49 @@ export default function CurrencyCard({
</LineChart>
</ResponsiveContainer>
{ratePath.peakMeeting && (
<div className="flex items-center justify-between mt-1 text-[10px]">
<span className="text-slate-600">
Pic : <span className="text-slate-400">{ratePath.peakMeeting.label}</span>
</span>
<span className={ratePath.peakMeeting.probIsCut ? "text-sky-400 font-bold" : "text-red-400 font-bold"}>
{ratePath.peakMeeting.probMovePct.toFixed(0)}% {ratePath.peakMeeting.probIsCut ? "Cut" : "Hike"}
</span>
{ratePath.yearEndImplied !== null && (() => {
const bps = Math.round((ratePath.yearEndImplied! - ratePath.currentRate) * 100);
const cls = bps < 0 ? "text-sky-400 font-bold" : bps > 0 ? "text-red-400 font-bold" : "text-slate-500";
return (
<span className={cls} title={`Taux actuel: ${ratePath.currentRate.toFixed(2)}% → fin an: ${ratePath.yearEndImplied!.toFixed(2)}%`}>
{bps > 0 ? "+" : ""}{bps}bps fin an
<>
<div className="flex items-center justify-between mt-1 text-[10px]">
<span className="text-slate-600">
Pic : <span className="text-slate-400">{ratePath.peakMeeting.label}</span>
</span>
<span className={ratePath.peakMeeting.probIsCut ? "text-sky-400 font-bold" : "text-red-400 font-bold"}>
{ratePath.peakMeeting.probMovePct.toFixed(0)}% {ratePath.peakMeeting.probIsCut ? "Cut" : "Hike"}
</span>
{ratePath.yearEndImplied !== null && (() => {
const bps = Math.round((ratePath.yearEndImplied! - ratePath.currentRate) * 100);
const cls = bps < 0 ? "text-sky-400 font-bold" : bps > 0 ? "text-red-400 font-bold" : "text-slate-500";
return (
<span className={cls} title={`Taux actuel: ${ratePath.currentRate.toFixed(2)}% → fin an: ${ratePath.yearEndImplied!.toFixed(2)}%`}>
{bps > 0 ? "+" : ""}{bps}bps fin an
</span>
);
})()}
</div>
{/* ── Flèches de tendance vs article IL semaine précédente ── */}
{ratePath.ilDelta && (Math.abs(ratePath.ilDelta.probDelta) >= 3 || Math.abs(ratePath.ilDelta.bpsDelta) >= 10) && (
<div className="flex items-center gap-2 mt-0.5 text-[9px] text-slate-600">
<span title={`vs article du ${ratePath.ilDelta.prevDate}`}>
vs sem. préc. :
</span>
);
})()}
</div>
<RpArrow
delta={ratePath.ilDelta.probDelta}
isBearishIfPositive={ratePath.ilDelta.isCut}
suffix="%"
strongT={10}
modT={3}
/>
{ratePath.ilDelta.bpsDelta !== 0 && (
<RpArrow
delta={ratePath.ilDelta.bpsDelta}
isBearishIfPositive={true}
suffix="bps"
strongT={25}
modT={10}
/>
)}
</div>
)}
</>
)}
</div>
)}
@@ -933,7 +1023,7 @@ export default function CurrencyCard({
<MacroBlock title="Croissance">
<IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} surpriseVsCons={fc?.gdpSurprise ?? null} />
<IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} surpriseVsCons={fc?.pmiCompositeSurprise ?? null} />
<IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} surpriseVsCons={fc?.retailSalesSurprise ?? null} />
<IRow label="Retail Sales MoM" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} surpriseVsCons={fc?.retailSalesSurprise ?? null} />
</MacroBlock>
{/* Emploi */}
@@ -1025,17 +1115,18 @@ export default function CurrencyCard({
{/* ════ FOCUS DONNÉES ══════════════════════════════════════════════ */}
{activeTab === "focus" && (
<>
{/* Context phase */}
{/* Context phase — source : OIS / probabilités de taux (fallback : trend FRED) */}
<div className={`rounded-xl border p-3 text-[11px] ${sigBg(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
<div className={`font-semibold mb-1 ${sigColor(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
Phase {phaseLabel(phase)}
<div className="flex items-center justify-between mb-1">
<span className={`font-semibold ${sigColor(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
Phase {phaseLabel(phase)}
</span>
<span className="text-[9px] text-slate-600 italic">
{ratePath ? "Source : OIS / probabilités" : "Source : trend taux FRED"}
</span>
</div>
<p className="text-slate-400 leading-relaxed">
{phase === "tightening" && "Surveiller les données soutenant une poursuite du resserrement : inflation, emploi solide, PMI expansionniste."}
{phase === "easing" && "Surveiller les données justifiant des baisses : désinflation, ralentissement du marché de l'emploi, PMI en contraction."}
{phase === "hawkish_pause" && "Surveiller les données qui pourraient forcer la main : regain d'inflation ou au contraire fort ralentissement."}
{phase === "dovish_pause" && "Surveiller les signes de reprise permettant une normalisation de la politique monétaire."}
{phase === "transition" && "Phase de transition — tous les indicateurs sont importants pour déterminer la direction future."}
{phaseDescription(phase, ratePath)}
</p>
</div>
@@ -1047,7 +1138,7 @@ export default function CurrencyCard({
<FocusRow importance="critical"><IRow label="Variation emploi" ind={inds?.employment ?? null} unit="k" consensus={fc?.employment ?? null} /></FocusRow>
<FocusRow importance="high"><IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} /></FocusRow>
<FocusRow importance="high"><IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} /></FocusRow>
<FocusRow importance="medium"><IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} /></FocusRow>
<FocusRow importance="medium"><IRow label="Retail Sales MoM" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} /></FocusRow>
</>
: <>
<FocusRow importance="critical"><IRow label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" consensus={fc?.cpiMoM ?? null} /></FocusRow>
@@ -1070,7 +1161,7 @@ export default function CurrencyCard({
: <>
<FocusRow importance="medium"><IRow label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise /></FocusRow>
<FocusRow importance="medium"><IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" /></FocusRow>
<FocusRow importance="medium"><IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" /></FocusRow>
<FocusRow importance="medium"><IRow label="Retail Sales MoM" ind={inds?.retailSales ?? null} unit="%" /></FocusRow>
</>
}
</MacroBlock>
+102 -66
View File
@@ -36,28 +36,89 @@ export interface ILRateExpectation {
export type ILExpectationsMap = Partial<Record<Currency, ILRateExpectation>>;
// ── URL discovery ─────────────────────────────────────────────────────────────
// Try the last 14 days to find the most recently published article.
export interface ILExpectationsWithHistory {
current: ILExpectationsMap;
prev: ILExpectationsMap;
prevDate: string | null;
}
async function findLatestArticleUrl(): Promise<{ url: string; dateStr: string } | null> {
for (let daysAgo = 0; daysAgo <= 14; daysAgo++) {
const d = new Date(Date.now() - daysAgo * 86400000);
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, {
method: "HEAD",
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
next: { revalidate: 21600 }, // re-check every 6h
});
if (res.ok) return { url, dateStr: `${yyyymmdd.slice(0,4)}-${yyyymmdd.slice(4,6)}-${yyyymmdd.slice(6,8)}` };
} catch { /* try next day */ }
// ── URL discovery ─────────────────────────────────────────────────────────────
interface ArticleRef { url: string; dateStr: string; daysAgo: number; }
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: "HEAD",
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
next: { revalidate: 21600 },
});
return res.ok ? { url, dateStr, daysAgo } : null;
} catch { return null; }
}
// Find current article (last 14 days) + previous article (14 days before current, up to 21 days)
async function findArticleRefs(): Promise<{ current: ArticleRef | null; previous: ArticleRef | null }> {
let current: ArticleRef | null = null;
for (let d = 0; d <= 14; d++) {
const found = await tryUrl(d);
if (found) { current = found; break; }
}
if (!current) return { current: null, previous: null };
let previous: ArticleRef | null = null;
// Start the day after the current article and look up to 21 days further back
for (let d = current.daysAgo + 1; d <= current.daysAgo + 21; d++) {
const found = await tryUrl(d);
if (found) { previous = found; break; }
}
return { current, previous };
}
// ── Article fetch + parse ─────────────────────────────────────────────────────
async function fetchAndParse(ref: ArticleRef): Promise<ILExpectationsMap> {
try {
const res = await fetch(ref.url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
},
next: { revalidate: 21600 },
});
if (!res.ok) return {};
const html = await res.text();
const jsonLdMatch = html.match(/"articleBody"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (!jsonLdMatch) {
console.warn("[IL] articleBody not found:", ref.url);
return {};
}
const body = jsonLdMatch[1]
.replace(/\\n/g, "\n")
.replace(/\\"/g, '"')
.replace(/\\u003c/g, "<")
.replace(/\\u003e/g, ">");
const result = parseArticleBody(body, ref.dateStr);
console.log(`[IL] Parsed ${Object.keys(result).length} CBs from article dated ${ref.dateStr}`);
return result;
} catch (err) {
console.error("[IL] fetch error:", err);
return {};
}
return null;
}
// ── Article parser ─────────────────────────────────────────────────────────────
// The structured data JSON-LD "articleBody" contains the raw text we need.
function parseArticleBody(text: string, publishedDate: string): ILExpectationsMap {
const result: ILExpectationsMap = {};
@@ -65,10 +126,6 @@ function parseArticleBody(text: string, publishedDate: string): ILExpectationsMa
// Matches: "RBNZ: 75 bps (79% probability of rate hike at the next meeting)"
// "Fed: 13 bps (99% probability of no change at the next meeting)"
// "ECB: 53 bps (99% probability of rate cut at the next meeting)"
// Note: bps value in the article is always unsigned — sign is inferred from direction.
// rate hike → positive bps (rate going up)
// rate cut → negative bps (rate going down)
// no change → keep unsigned (residual expectation for later meetings)
const linePattern = /([A-Za-z]+)\s*:\s*(-?\d+)\s*bps\s*\(\s*(\d+)%\s*probability\s+of\s+(rate\s+(?:hike|cut)|no\s+change)\s+at\s+the\s+next\s+meeting\)/gi;
let m: RegExpExecArray | null;
@@ -83,15 +140,13 @@ function parseArticleBody(text: string, publishedDate: string): ILExpectationsMa
const nextMeetingIsHike = !nextMeetingIsNoChange && direction.includes("hike");
const nextMeetingIsCut = !nextMeetingIsNoChange && direction.includes("cut");
// Apply sign: if direction is cut and value is positive, negate it
let bpsYearEnd = parseInt(bpsStr);
if (nextMeetingIsCut && bpsYearEnd > 0) bpsYearEnd = -bpsYearEnd;
// Probability of change = 100 - probNoChange OR directProb if it's a hike/cut
const nextMeetingProbPct = nextMeetingIsNoChange ? 100 - probPct : probPct;
result[ccy] = {
currency: ccy,
currency: ccy,
nextMeetingProbPct,
nextMeetingIsHike,
nextMeetingIsNoChange,
@@ -103,50 +158,31 @@ function parseArticleBody(text: string, publishedDate: string): ILExpectationsMa
return result;
}
// ── Main fetch ────────────────────────────────────────────────────────────────
// ── Exports ───────────────────────────────────────────────────────────────────
/** Retourne uniquement l'article le plus récent (compatibilité descendante). */
export async function fetchILExpectations(): Promise<ILExpectationsMap> {
try {
const found = await findLatestArticleUrl();
if (!found) {
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
return {};
}
const res = await fetch(found.url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
},
next: { revalidate: 21600 }, // cache 6h — new article only published after major events
});
if (!res.ok) {
console.warn("[IL] Fetch failed:", res.status);
return {};
}
const html = await res.text();
// Extract articleBody from JSON-LD structured data
const jsonLdMatch = html.match(/"articleBody"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (!jsonLdMatch) {
console.warn("[IL] articleBody not found in JSON-LD");
return {};
}
const articleBody = jsonLdMatch[1]
.replace(/\\n/g, "\n")
.replace(/\\"/g, '"')
.replace(/\\u003c/g, "<")
.replace(/\\u003e/g, ">");
const parsed = parseArticleBody(articleBody, found.dateStr);
const count = Object.keys(parsed).length;
console.log(`[IL] Parsed ${count} CBs from article dated ${found.dateStr}`);
return parsed;
} catch (err) {
console.error("[IL] error:", err);
const { current } = await findArticleRefs();
if (!current) {
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
return {};
}
return fetchAndParse(current);
}
/** Retourne l'article courant ET l'article précédent pour calcul de delta semaine/semaine. */
export async function fetchILExpectationsWithHistory(): Promise<ILExpectationsWithHistory> {
const { current, previous } = await findArticleRefs();
if (!current) {
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
return { current: {}, prev: {}, prevDate: null };
}
const [currentData, prevData] = await Promise.all([
fetchAndParse(current),
previous ? fetchAndParse(previous) : Promise.resolve({} as ILExpectationsMap),
]);
return { current: currentData, prev: prevData, prevDate: previous?.dateStr ?? null };
}
+31 -9
View File
@@ -3,7 +3,7 @@
// Endpoint pattern: https://rateprobability.com/api/{cb}/latest
import type { Currency } from "./types";
import { fetchILExpectations } from "./investinglive";
import { fetchILExpectationsWithHistory } from "./investinglive";
import type { ILExpectationsMap } from "./investinglive";
// ── Types publics ──────────────────────────────────────────────────────────────
@@ -17,6 +17,13 @@ export interface RateProbMeeting {
changeBps: number; // bps attendus à cette réunion (cumulatif)
}
export interface ILWeeklyDelta {
probDelta: number; // Δ nextMeetingProbPct (courant - semaine précédente)
bpsDelta: number; // Δ bpsYearEnd (courant - semaine précédente)
isCut: boolean; // contexte : le pic actuel est un cut
prevDate: string; // date de l'article de référence (semaine précédente)
}
export interface CBRatePath {
currency: Currency;
asOf: string; // "2026-05-31"
@@ -24,6 +31,7 @@ export interface CBRatePath {
meetings: RateProbMeeting[];
peakMeeting: RateProbMeeting | null; // réunion avec proba max de mouvement
yearEndImplied: number | null; // taux impliqué à la dernière réunion connue
ilDelta?: ILWeeklyDelta; // delta vs article IL semaine précédente
}
export type RateProbData = Partial<Record<Currency, CBRatePath>>;
@@ -193,11 +201,14 @@ function buildSNBPath(il: ILExpectationsMap, currentRate: number): CBRatePath |
// ── Fetch toutes les CB en parallèle ──────────────────────────────────────────
export async function fetchAllCBPaths(): Promise<RateProbData> {
// rateprobability.com (7 CBs) + InvestingLive (tous CBs + CHF) en parallèle
const [rpResults, ilData] = await Promise.all([
// rateprobability.com (7 CBs) + InvestingLive (article courant + précédent) en parallèle
const [rpResults, ilHistory] = await Promise.all([
Promise.allSettled(CB_KEYS.map(([ccy, slug]) => fetchCBPath(ccy, slug))),
fetchILExpectations(),
fetchILExpectationsWithHistory(),
]);
const ilData = ilHistory.current;
const ilPrev = ilHistory.prev;
const prevDate = ilHistory.prevDate;
const data: RateProbData = {};
@@ -215,18 +226,29 @@ export async function fetchAllCBPaths(): Promise<RateProbData> {
}
// Enrichir yearEndImplied avec bpsYearEnd de IL (Giuseppe Dellamotta — source humaine)
// pour toutes les devises où IL a une donnée ET rateprobability.com a réussi.
// Règle : pour les hikes (bpsYearEnd > 0) et cuts (bpsYearEnd < 0), mettre à jour.
// Pour "no change" (bpsYearEnd proche de 0), garder la valeur rateprobability.com.
// + calculer ilDelta (Δ vs article semaine précédente) pour les flèches de tendance.
for (const [ccyStr, ilEntry] of Object.entries(ilData)) {
const ccy = ccyStr as keyof RateProbData;
const path = data[ccy];
if (!path) continue;
if (typeof ilEntry.bpsYearEnd !== "number") continue;
if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 10) continue; // garder RP si "no change" + bps résiduel faible
if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 10) continue;
const ilYearEnd = parseFloat((path.currentRate + ilEntry.bpsYearEnd / 100).toFixed(4));
data[ccy] = { ...path, yearEndImplied: ilYearEnd };
// Delta semaine/semaine depuis l'article précédent de Giuseppe
let ilDelta: import("./rateprobability").ILWeeklyDelta | undefined;
const prevEntry = ilPrev[ccy];
if (prevEntry && prevDate) {
ilDelta = {
probDelta: parseFloat((ilEntry.nextMeetingProbPct - prevEntry.nextMeetingProbPct).toFixed(1)),
bpsDelta: ilEntry.bpsYearEnd - prevEntry.bpsYearEnd,
isCut: !ilEntry.nextMeetingIsHike && !ilEntry.nextMeetingIsNoChange,
prevDate,
};
}
data[ccy] = { ...path, yearEndImplied: ilYearEnd, ...(ilDelta ? { ilDelta } : {}) };
}
return data;
+1 -1
View File
File diff suppressed because one or more lines are too long