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
+86 -2
View File
@@ -9,7 +9,7 @@ import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTE
import { fetchTEInflationForecasts } from "@/lib/tradingeconomics";
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
const REVALIDATE = 86400; // cache 24h
const REVALIDATE = 3600; // cache 1h (les données FRED sont disponibles ~1h après publication)
// ── FRED ─────────────────────────────────────────────────────────────────────
@@ -428,6 +428,49 @@ async function scrapeTeGdp(country: string): Promise<{ value: number | null; pre
} catch { return empty; }
}
// ── Trading Economics — Retail Sales MoM (country-list, toutes devises en une requête) ─────
// Parse le tableau https://tradingeconomics.com/country-list/retail-sales-mom
// Valeurs positives : <td data-heatmap-value="N">0.5</td>
// Valeurs négatives : <td data-heatmap-value="N"><span class='te-value-negative'>-0.4</span></td>
let _teRetailMoMCache: { data: Map<string, { value: number; prev: number | null }>; ts: number } | null = null;
async function fetchTeRetailSalesMoM(): Promise<Map<string, { value: number; prev: number | null }>> {
if (_teRetailMoMCache && Date.now() - _teRetailMoMCache.ts < 3_600_000) {
return _teRetailMoMCache.data;
}
const result = new Map<string, { value: number; prev: number | null }>();
const slugToCurrency: Record<string, string> = {
"united-states": "USD", "euro-area": "EUR", "united-kingdom": "GBP",
"japan": "JPY", "switzerland": "CHF", "canada": "CAD",
"australia": "AUD", "new-zealand": "NZD",
};
try {
const res = await fetch("https://tradingeconomics.com/country-list/retail-sales-mom", {
next: { revalidate: 3600 },
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) 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.5",
},
});
if (!res.ok) return result;
const html = await res.text();
// Regex robuste : capture slug + valeur courante + valeur précédente
// Gère les valeurs négatives wrappées dans <span class='te-value-negative'>
const re = /<a href='\/([^']+)\/retail-sales'>\s*[^<]+\s*<\/a><\/td>\s*<td[^>]*>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>\s*<td>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) {
const currency = slugToCurrency[m[1]];
if (currency) {
result.set(currency, { value: parseFloat(m[2]), prev: parseFloat(m[3]) });
}
}
} catch { /* retourner map vide si TE indisponible */ }
_teRetailMoMCache = { data: result, ts: Date.now() };
return result;
}
// ── Trading Economics — Balance commerciale (niveau, normalisé en Milliards devise locale)
// Format meta TE :
// "X recorded a trade deficit/surplus of Y.YY EUR/USD/etc. Billion/Million in Month of Year"
@@ -618,14 +661,39 @@ function parseTeF(s: string | null | undefined): number | null {
const _cache = new Map<string, { data: unknown; ts: number }>();
// TTL adaptatif : 15 min si un event à fort/moyen impact vient d'être publié dans les 2h
// pour cette devise, sinon 1h.
async function getSmartTtl(currency: string): Promise<number> {
const BASE_TTL = 3_600_000; // 1h par défaut
const HOT_TTL = 900_000; // 15min après publication récente
const WINDOW_MS = 7_200_000; // fenêtre de 2h
try {
const events = await fetchFFThisWeek();
const now = Date.now();
const hasRecent = events.some(e => {
const t = new Date(e.date).getTime();
return (
e.country === currency &&
e.actual !== "" && // données publiées (actual rempli)
(e.impact === "High" || e.impact === "Medium") &&
t <= now && // passé (pas futur)
now - t < WINDOW_MS // dans les 2h
);
});
return hasRecent ? HOT_TTL : BASE_TTL;
} catch { return BASE_TTL; }
}
export async function GET(req: NextRequest) {
const currency = (new URL(req.url).searchParams.get("currency") ?? "").toUpperCase() as Currency;
const series = FRED_SERIES[currency];
if (!series) return NextResponse.json({ error: "Unknown currency" }, { status: 400 });
// TTL smart : 15 min si publication récente, sinon 1h
const ttl = await getSmartTtl(currency);
const cached = _cache.get(currency);
const staleCache = cached ?? null;
if (cached && Date.now() - cached.ts < 86_400_000) return NextResponse.json(cached.data);
if (cached && Date.now() - cached.ts < ttl) return NextResponse.json(cached.data);
const key = process.env.FRED_API_KEY;
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
@@ -956,6 +1024,22 @@ export async function GET(req: NextRequest) {
indicators.cpiMoM = toIndicatorPct(_cpiCoreObs);
}
// ── Retail Sales MoM : override depuis Trading Economics (plus à jour que FRED OCDE) ────────
{
const teRS = await fetchTeRetailSalesMoM();
const rs = teRS.get(currency);
if (rs !== undefined) {
const surprise = rs.prev !== null ? parseFloat((rs.value - rs.prev).toFixed(2)) : null;
indicators.retailSales = {
value: rs.value,
prev: rs.prev,
surprise,
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
lastUpdated: today,
};
}
}
// ── PMI (Mfg + Services + Composite) + consensus FF ──────────────────────────
const toDateObj = new Date();
toDateObj.setDate(toDateObj.getDate() + 21);