mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-25 18:38:07 +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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user