Files
forex-dashboard/lib/investinglive.ts
T
Capucine Gest eb416be780 feat: calendrier économique TE+Investing, OIS SNB, yields 10Y, taux directeurs corrects
Calendrier économique
- Nouvelles sources : TE HTML scraping (483 events/2 sem.) + Investing.com en co-primaires
- Dédupe déterministe par clé currency+category+date (±1j pour policy_rate)
- Filtre catégorie "other", filtre discours CB (fix doublon TE vs Investing)
- CHF/Switzerland via page séparée TE (hors G20)
- Fenêtre 14 jours avec ?startDate=&endDate= passés à TE
- ForexFactory+FRED en secours uniquement si les deux scrapers tombent à vide

Probabilités OIS
- rateprobability.com (7 CBs : FED/ECB/BOJ/BOE/BOC/RBA/RBNZ)
- investinglive.com (Dellamotta) : scan URL-date 14 jours → couvre SNB/CHF manquant
- SNB : 3% probabilité hausse au 19 juin 2026 (source : IL article 2026-05-29)
- rate_expectations.json MAJ : RBNZ 79% hike (corrigé depuis 70% no-change stale)
- Dédup ±1 jour pour policy_rate (fix doublon SNB TE-18/06 vs IL-19/06)

Taux directeurs
- USD : scrapeTeRate() = 3.75% (fin midpoint FRED 3.625%)
- AUD prev : 4.10% (corrigé depuis 4.60%)
- NZD/CHF/JPY/CAD : cohérents avec TE officiel

Yields 10Y souverains
- Nouvelle lib lib/tebonds.ts : scrape TE bonds, données du jour, cache 1h
- Remplace FRED IRLTLT01XXM156N mensuel (JPY/CHF/AUD/NZD avaient 1 mois de retard)
- 8 devises + spread vs USD calculé automatiquement
- /api/yields et /api/drivers utilisent la même lib

Drivers globaux
- BTC : ticker/price Binance (plus précis que 24hr lastPrice)
- HY Spread cache : 86400s → 3600s (données FRED du jour)
- DriversBar : suppression affichage direct US 10Y, tooltip Crb 2-10 affiche US10Y+US2Y

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:16:18 +02:00

143 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/
// Published after major market events (typically on Fridays or post-CB-decision)
//
// Data format in articleBody JSON-LD:
// "CB: XX bps (YY% probability of rate hike/no change at the next meeting)"
// Covers all 8 CBs including SNB — the only free public source with CHF OIS-equivalent data.
import type { Currency } from "./types";
// ── CB name → Currency ────────────────────────────────────────────────────────
const CB_TO_CCY: Record<string, Currency> = {
"fed": "USD",
"fomc": "USD",
"ecb": "EUR",
"boe": "GBP",
"boj": "JPY",
"boc": "CAD",
"rba": "AUD",
"rbnz": "NZD",
"snb": "CHF",
};
// ── Types ─────────────────────────────────────────────────────────────────────
export interface ILRateExpectation {
currency: Currency;
nextMeetingProbPct: number; // probability of change at next meeting (0100)
nextMeetingIsHike: boolean; // true = hike, false = cut (no-change → isHike stays false)
nextMeetingIsNoChange: boolean;
bpsYearEnd: number; // cumulative bps priced in by year-end
publishedDate: string; // YYYY-MM-DD
}
export type ILExpectationsMap = Partial<Record<Currency, ILRateExpectation>>;
// ── URL discovery ─────────────────────────────────────────────────────────────
// Try the last 14 days to find the most recently published article.
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 */ }
}
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 = {};
// 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)"
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;
while ((m = linePattern.exec(text)) !== null) {
const [, cbRaw, bpsStr, probStr, directionRaw] = m;
const ccy = CB_TO_CCY[cbRaw.toLowerCase()];
if (!ccy) continue;
const bpsYearEnd = parseInt(bpsStr);
const probPct = parseInt(probStr);
const direction = directionRaw.toLowerCase();
const nextMeetingIsNoChange = direction === "no change";
const nextMeetingIsHike = !nextMeetingIsNoChange && direction.includes("hike");
// Probability of change = 100 - probNoChange OR directProb if it's a hike/cut
const nextMeetingProbPct = nextMeetingIsNoChange ? 100 - probPct : probPct;
result[ccy] = {
currency: ccy,
nextMeetingProbPct,
nextMeetingIsHike,
nextMeetingIsNoChange,
bpsYearEnd,
publishedDate,
};
}
return result;
}
// ── Main fetch ────────────────────────────────────────────────────────────────
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);
return {};
}
}