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
co-authored by Claude Sonnet 4.6
parent 1a2ea02b22
commit f82afe9d5b
5 changed files with 351 additions and 118 deletions
+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;