mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
eb416be780
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>
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
// lib/tradingeconomics.ts
|
|
// Trading Economics Economic Calendar
|
|
//
|
|
// Strategy:
|
|
// 1. Primary : TE paid API (TRADING_ECONOMICS_API_KEY in .env.local)
|
|
// 2. Fallback : HTML scraping of https://tradingeconomics.com/calendar
|
|
// → The HTML includes previous/consensus/forecast in static DOM.
|
|
// Actual values are pushed via Socket.IO when released (real-time only),
|
|
// so upcoming events get full forecast data; recently published events
|
|
// may show null actual (supplemented by ForexFactory/FRED for key releases).
|
|
//
|
|
// Socket.IO details (for reference):
|
|
// URL : https://live.tradingeconomics.com?key=sun
|
|
// Auth : JWT token embedded in page (epoch+IP bound, refreshes each fetch)
|
|
// Crypto : NaCl secretbox (XSalsa20-Poly1305) + pako inflate
|
|
// → Not used here: Socket.IO is for real-time actuals, not the snapshot we need.
|
|
|
|
import type { Currency } from "./types";
|
|
import type { EventCategory } from "@/app/api/calendar/route";
|
|
|
|
// ── Country → Currency ────────────────────────────────────────────────────────
|
|
|
|
const TE_COUNTRY_TO_CCY: Record<string, Currency> = {
|
|
"united states": "USD",
|
|
"euro area": "EUR",
|
|
"united kingdom": "GBP",
|
|
"japan": "JPY",
|
|
"switzerland": "CHF",
|
|
"canada": "CAD",
|
|
"australia": "AUD",
|
|
"new zealand": "NZD",
|
|
};
|
|
|
|
// ── Category ──────────────────────────────────────────────────────────────────
|
|
|
|
// eventName = data-event attribute (titre brut TE), utilisé pour détecter les discours
|
|
// indépendamment du data-category (qui vaut souvent "interest rate" pour les speeches CB)
|
|
function teCategory(cat: string, eventName?: string): EventCategory {
|
|
const e = (eventName ?? "").toLowerCase();
|
|
if (/\bspeech\b|speaks?\b|testimony|\bpress\s+conf/.test(e)) return "cb_speech";
|
|
const c = cat.toLowerCase();
|
|
if (/\bpmi\b|purchasing\s+managers/.test(c)) return "pmi";
|
|
if (/interest\s+rate|monetary\s+policy|rate\s+decision|bank\s+rate/.test(c)) return "policy_rate";
|
|
if (/speech|speaks?|testimony|press\s+conf|central\s+bank/.test(c)) return "cb_speech";
|
|
if (/inflation|cpi|hicp|consumer\s+price|ppi/.test(c)) return "inflation";
|
|
if (/\bgdp\b|gross\s+domestic|growth\s+rate/.test(c)) return "gdp";
|
|
if (/retail\s+sales|core\s+retail/.test(c)) return "retail_sales";
|
|
if (/trade\s+balance|current\s+account|balance\s+of\s+trade/.test(c)) return "trade_balance";
|
|
if (/employment|payrolls|nonfarm|jobless|unemployment|job\s+creation/.test(c)) return "employment";
|
|
return "other";
|
|
}
|
|
|
|
// ── Importance (1/2/3) → impact ───────────────────────────────────────────────
|
|
|
|
function teImpact(n: number): "high" | "medium" | "low" {
|
|
if (n >= 3) return "high";
|
|
if (n >= 2) return "medium";
|
|
return "low";
|
|
}
|
|
|
|
// ── 12h → ISO time ────────────────────────────────────────────────────────────
|
|
// TE times are in UTC (default when no timezone cookie is set on the server)
|
|
|
|
function to24h(t: string): string {
|
|
const m = t.trim().match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
|
if (!m) return "12:00";
|
|
let h = parseInt(m[1]);
|
|
const pm = m[3].toUpperCase() === "PM";
|
|
if (pm && h !== 12) h += 12;
|
|
if (!pm && h === 12) h = 0;
|
|
return `${String(h).padStart(2, "0")}:${m[2]}`;
|
|
}
|
|
|
|
// ── Decode HTML entities ───────────────────────────────────────────────────────
|
|
|
|
function decodeHTMLEntities(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/ /g, " ")
|
|
.trim();
|
|
}
|
|
|
|
// ── Text from HTML element ─────────────────────────────────────────────────────
|
|
|
|
function extractText(html: string, id: string): string | null {
|
|
// Matches <span id='X'>VALUE</span> or <a id='X' ...>VALUE</a>
|
|
const m = html.match(new RegExp(`<(?:span|a)[^>]+id=['"]${id}['"][^>]*>([^<]*)<`, "i"));
|
|
const val = m?.[1]?.trim();
|
|
return val && val.length > 0 ? decodeHTMLEntities(val) : null;
|
|
}
|
|
|
|
// ── Public type ───────────────────────────────────────────────────────────────
|
|
|
|
export interface TECalendarEvent {
|
|
id: string;
|
|
date: string; // ISO with UTC time
|
|
currency: Currency;
|
|
category: EventCategory;
|
|
title: string;
|
|
impact: "high" | "medium" | "low";
|
|
actual: string | null;
|
|
forecast: string | null; // consensus preferred over TE forecast
|
|
previous: string | null;
|
|
isPublished: boolean;
|
|
teId: string;
|
|
}
|
|
|
|
// ── HTML scraper ──────────────────────────────────────────────────────────────
|
|
// La page /calendar couvre le G20 (USD/EUR/GBP/JPY/CAD/AUD/NZD) mais PAS la Suisse (CHF).
|
|
// On fetche aussi /switzerland/calendar pour avoir les events CHF.
|
|
|
|
async function fetchOneTEPage(url: string): Promise<string> {
|
|
try {
|
|
const res = await fetch(url, {
|
|
next: { revalidate: 1800 },
|
|
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,application/xml;q=0.9,*/*;q=0.8",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
},
|
|
});
|
|
if (!res.ok) return "";
|
|
return res.text();
|
|
} catch { return ""; }
|
|
}
|
|
|
|
// fromDate / toDate au format YYYY-MM-DD
|
|
export async function fetchTECalendarHTML(fromDate?: string, toDate?: string): Promise<TECalendarEvent[]> {
|
|
const qs = fromDate && toDate ? `?startDate=${fromDate}&endDate=${toDate}` : "";
|
|
const pages = await Promise.all([
|
|
fetchOneTEPage(`https://tradingeconomics.com/calendar${qs}`),
|
|
fetchOneTEPage(`https://tradingeconomics.com/switzerland/calendar${qs}`), // CHF — hors G20
|
|
]);
|
|
|
|
const allEvents: TECalendarEvent[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const html of pages) {
|
|
if (!html) continue;
|
|
for (const ev of parseCalendarHTML(html)) {
|
|
if (seen.has(ev.id)) continue;
|
|
seen.add(ev.id);
|
|
allEvents.push(ev);
|
|
}
|
|
}
|
|
|
|
allEvents.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
return allEvents;
|
|
}
|
|
|
|
function parseCalendarHTML(html: string): TECalendarEvent[] {
|
|
const events: TECalendarEvent[] = [];
|
|
const now = new Date();
|
|
|
|
// Split on each event row — each <tr data-url= ... data-id= ... data-country= ...>
|
|
const rowPattern = /<tr\s+data-url="[^"]*"\s+data-id="(\d+)"\s+data-country="([^"]+)"\s+data-category="([^"]+)"\s+data-event="([^"]+)"[^>]*>([\s\S]*?)(?=<tr\s+data-url=|<\/tbody>)/g;
|
|
|
|
let match: RegExpExecArray | null;
|
|
while ((match = rowPattern.exec(html)) !== null) {
|
|
const [, id, country, category, eventAttr, body] = match;
|
|
|
|
const ccy = TE_COUNTRY_TO_CCY[country.toLowerCase()];
|
|
if (!ccy) continue; // only our 8 currencies
|
|
|
|
// Date: <td ... class=' 2026-06-01'>
|
|
const dateMatch = body.match(/class=' (\d{4}-\d{2}-\d{2})'/);
|
|
if (!dateMatch) continue;
|
|
const dateStr = dateMatch[1];
|
|
|
|
// Time + importance: <span class="event-52 calendar-date-3"> 02:00 PM </span>
|
|
const timeMatch = body.match(/calendar-date-(\d)[^"]*"[^>]*>\s*([\d:]+\s*[AP]M)\s*/i);
|
|
const importance = timeMatch ? parseInt(timeMatch[1]) : 1;
|
|
const timeStr = timeMatch ? timeMatch[2].trim() : "00:00 AM";
|
|
const time24 = to24h(timeStr);
|
|
const isoDate = `${dateStr}T${time24}:00Z`;
|
|
const evDate = new Date(isoDate);
|
|
|
|
// Display title: <a class='calendar-event' ...>ISM Manufacturing PMI</a>
|
|
const titleMatch = body.match(/<a\s+class='calendar-event'[^>]*>([^<]+)<\/a>/);
|
|
const title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : decodeHTMLEntities(eventAttr);
|
|
|
|
// Reference period: <span class="calendar-reference">MAY</span>
|
|
const refMatch = body.match(/class="calendar-reference"\s*>([^<]*)</);
|
|
const ref = refMatch ? refMatch[1].trim() : "";
|
|
|
|
// Values — all live in id='' span/anchor elements
|
|
const actual = extractText(body, "actual");
|
|
const previous = extractText(body, "previous");
|
|
const consensus = extractText(body, "consensus");
|
|
const forecast = extractText(body, "forecast");
|
|
|
|
const displayTitle = ref ? `${title} ${ref}` : title;
|
|
|
|
events.push({
|
|
id: `te_${id}`,
|
|
date: isoDate,
|
|
currency: ccy,
|
|
category: teCategory(category, eventAttr),
|
|
title: displayTitle,
|
|
impact: teImpact(importance),
|
|
actual,
|
|
forecast: consensus ?? forecast, // TE consensus > TE proprietary forecast
|
|
previous,
|
|
isPublished: actual !== null || evDate < now,
|
|
teId: id,
|
|
});
|
|
}
|
|
|
|
// Sort by date ascending
|
|
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
return events;
|
|
}
|
|
|
|
// ── Paid API (when TRADING_ECONOMICS_API_KEY is set) ─────────────────────────
|
|
|
|
export async function fetchTECalendar(
|
|
apiKey: string,
|
|
fromDate: string,
|
|
toDate: string,
|
|
): Promise<TECalendarEvent[]> {
|
|
const countries = [
|
|
"united states", "euro area", "united kingdom", "japan",
|
|
"switzerland", "canada", "australia", "new zealand",
|
|
].join(",");
|
|
|
|
const url = [
|
|
`https://api.tradingeconomics.com/calendar/country/${encodeURIComponent(countries)}`,
|
|
`/${fromDate}/${toDate}`,
|
|
`?c=${encodeURIComponent(apiKey)}&f=json`,
|
|
].join("");
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
next: { revalidate: 1800 },
|
|
headers: { "Accept": "application/json" },
|
|
});
|
|
if (!res.ok) return [];
|
|
const raw = await res.json() as Array<Record<string, unknown>>;
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
const now = new Date();
|
|
return raw
|
|
.map(ev => {
|
|
const ccy = TE_COUNTRY_TO_CCY[(ev["Country"] as string)?.toLowerCase()];
|
|
if (!ccy) return null;
|
|
const dateRaw = ev["Date"] as string ?? "";
|
|
const isoDate = dateRaw.endsWith("Z") ? dateRaw : dateRaw + "Z";
|
|
const evDate = new Date(isoDate);
|
|
const actual = (ev["Actual"] as string | null) || null;
|
|
const forecast = (ev["Forecast"] as string | null) || (ev["TEForecast"] as string | null) || null;
|
|
const previous = (ev["Previous"] as string | null) || null;
|
|
return {
|
|
id: `te_${ev["CalendarId"]}`,
|
|
date: isoDate,
|
|
currency: ccy,
|
|
category: teCategory(ev["Category"] as string ?? ""),
|
|
title: (ev["Event"] as string ?? ""),
|
|
impact: teImpact(ev["Importance"] as number ?? 1),
|
|
actual: actual !== "" ? actual : null,
|
|
forecast: forecast !== "" ? forecast : null,
|
|
previous: previous !== "" ? previous : null,
|
|
isPublished: actual !== null || evDate < now,
|
|
teId: String(ev["CalendarId"] ?? ""),
|
|
} satisfies TECalendarEvent;
|
|
})
|
|
.filter((e): e is TECalendarEvent => e !== null);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|