mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-17 14:38:05 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
1a2ea02b22
commit
f82afe9d5b
+102
-66
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user