Files
caty21 f5e5d51c72 fix: cache no-store sur routes API, EUR emploi, NZD OIS (RBNZ), ReportTab charts 750px
- app/api/*/route.ts : export const dynamic = force-dynamic + Cache-Control no-store
- lib/tecpi.ts : regex emploi EUR (grew/rose/fell), fallback body HTML, unite %/percent
- lib/rateprobability.ts : RBNZ_MEETINGS 2026-2027, buildRBNZPath (mirrors buildSNBPath)
  CHF/NZD skippes du loop Investing.com (SNB/RBNZ rate monitor = 404)
  Taux RBNZ 2.25% (etait 3.25%)
- components/ReportTab.tsx : hauteur charts 400 -> 750px (SPX, VIX, DXY, Gold)
- lib/tradingeconomics.ts, lib/types.ts, lib/newsfeed.ts : correctifs associes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 12:17:11 +02:00

802 lines
34 KiB
TypeScript
Raw Permalink 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/tecpi.ts
// Scrape deux pages TE country-list en parallèle :
// - core-inflation-rate → cpiCore YoY
// - inflation-rate-mom → cpiMoM
// Une seule requête HTTP par page (cache 6h) — remplace les séries FRED mensuelles
// qui avaient jusqu'à 1.5 an de retard (GBP/JPY/CHF/CAD/AUD/NZD stale FRED).
import type { Currency } from "./types";
const TE_COUNTRY_CPI: Record<Currency, string> = {
USD: "United States",
EUR: "Euro Area",
GBP: "United Kingdom",
JPY: "Japan",
CHF: "Switzerland",
CAD: "Canada",
AUD: "Australia",
NZD: "New Zealand",
};
export interface CoreCPIEntry {
value: number; // YoY %
prev: number; // valeur précédente
refMonth: string; // "2026-04-01"
}
export type CoreCPIMap = Partial<Record<Currency, CoreCPIEntry>>;
export interface MoMCPIEntry {
value: number; // MoM % (ou QoQ pour AUD/NZD)
prev: number | null; // période précédente (null si calculé depuis index)
refMonth: string;
isQoQ?: boolean; // true pour AUD et NZD (données trimestrielles)
}
export type MoMCPIMap = Partial<Record<Currency, MoMCPIEntry>>;
// Convertit "Apr/26" → "2026-04-01"
function parseRefDate(raw: string): string {
const MONTHS: Record<string, string> = {
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
};
const m = raw.match(/([A-Za-z]+)\/(\d{2})/);
if (!m) return "";
const month = MONTHS[m[1]] ?? "01";
const year = `20${m[2]}`;
return `${year}-${month}-01`;
}
export async function fetchTECoreInflation(): Promise<CoreCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/core-inflation-rate?continent=world",
{
next: { revalidate: 21600 }, // 6h — données mensuelles, inutile de re-fetcher trop souvent
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) {
console.warn("[tecpi] HTTP", res.status);
return {};
}
const html = await res.text();
return parseCoreInflationHTML(html);
} catch (err) {
console.error("[tecpi] error:", err);
return {};
}
}
// ── Inflation Rate YoY (headline) ────────────────────────────────────────────
export async function fetchTEInflationYoY(): Promise<CoreCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/inflation-rate?continent=world",
{
next: { revalidate: 21600 },
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) { console.warn("[tecpi-yoy] HTTP", res.status); return {}; }
const html = await res.text();
return parseCoreInflationHTML(html); // même structure de parsing
} catch (err) {
console.error("[tecpi-yoy] error:", err);
return {};
}
}
// ── CPI Index → MoM% ─────────────────────────────────────────────────────────
// Scrape country-list/consumer-price-index-cpi
// Calcule MoM% = (Last - Previous) / Previous × 100
// Retourne aussi les valeurs brutes pour le tooltip
export interface CPIIndexEntry {
momPct: number; // MoM% calculé
rawLast: number; // valeur brute (index points)
rawPrev: number;
refMonth: string;
}
export type CPIIndexMap = Partial<Record<Currency, CPIIndexEntry>>;
export async function fetchTECPIIndex(): Promise<CPIIndexMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/consumer-price-index-cpi?continent=world",
{
next: { revalidate: 21600 },
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) { console.warn("[tecpi-idx] HTTP", res.status); return {}; }
const html = await res.text();
return parseCPIIndexHTML(html, TE_COUNTRY_CPI);
} catch (err) {
console.error("[tecpi-idx] error:", err);
return {};
}
}
function parseCPIIndexHTML(html: string, countryMap: Record<Currency, string>): CPIIndexMap {
const result: CPIIndexMap = {};
const seen = new Set<Currency>();
const rowPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
let m: RegExpExecArray | null;
while ((m = rowPattern.exec(html)) !== null) {
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
for (const [ccy, country] of Object.entries(countryMap) as [Currency, string][]) {
if (seen.has(ccy)) continue;
if (!text.startsWith(country)) continue;
const nums = text.match(/-?\d+(?:\.\d+)?/g);
const dateMatch = text.match(/([A-Za-z]{3}\/\d{2})/);
if (nums && nums.length >= 2) {
const rawLast = parseFloat(nums[0]);
const rawPrev = parseFloat(nums[1]);
if (rawPrev !== 0) {
result[ccy] = {
momPct: parseFloat(((rawLast - rawPrev) / rawPrev * 100).toFixed(3)),
rawLast,
rawPrev,
refMonth: dateMatch ? parseRefDate(dateMatch[1]) : "",
};
seen.add(ccy);
}
}
break;
}
if (seen.size === 8) break;
}
return result;
}
// ── Core Consumer Prices index → MoM% (depuis pages individuelles) ───────────
// La country-list arrondit à l'entier → 0% faux pour CAD/JPY/CHF.
// On scrape la meta description de chaque page individuelle (décimales disponibles).
// Format meta : "...increased to 0.40 percent in April from 0.20 percent..."
// Pour CHF on utilise /switzerland/core-consumer-prices (pas de page -mom)
// Pages existantes avec % direct : USD, EUR, GBP, CAD
// Pages index seulement (calcul delta) : JPY, CHF, AUD (QoQ), NZD (QoQ)
const TE_CORE_MOM_SLUG: Record<Currency, string> = {
USD: "united-states/core-inflation-rate-mom",
EUR: "euro-area/core-inflation-rate-mom",
GBP: "united-kingdom/core-inflation-rate-mom",
JPY: "japan/core-consumer-prices", // pas de page -mom directe
CAD: "canada/core-inflation-rate-mom",
AUD: "australia/core-consumer-prices", // QoQ (données trimestrielles)
NZD: "new-zealand/core-consumer-prices", // QoQ (données trimestrielles)
CHF: "switzerland/core-consumer-prices", // index → delta calculé
};
// Devises publiées en QoQ (trimestriel) plutôt que MoM
export const CORE_CPI_QOQ: Set<Currency> = new Set<Currency>(["AUD", "NZD"]);
async function fetchOneTEMeta(url: string): Promise<string> {
try {
const res = await fetch(url, {
next: { revalidate: 21600 },
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 "";
const html = await res.text();
const m = html.match(/name=["']description["'][^>]*content=["']([^"']+)["']/i)
?? html.match(/content=["']([^"']+)["'][^>]*name=["']description["']/i);
return m?.[1] ?? "";
} catch { return ""; }
}
function parseMetaForMoM(desc: string): { value: number; prev: number; isIndex?: boolean } | null {
// Pattern 1 : "increased/decreased to X percent from Y percent"
const p1 = desc.match(
/(?:increased|decreased|declined|rose|fell|eased|changed)\s+to\s*([\d.]+)\s+percent[^.]*?from\s+([\d.]+)\s+percent/i
);
if (p1) return { value: parseFloat(p1[1]), prev: parseFloat(p1[2]) };
// Pattern 2 : "remained unchanged at X percent" → current = prev = X, delta = 0
const p2 = desc.match(/(?:remained unchanged at|is unchanged at)\s*([\d.]+)\s+percent/i);
if (p2) { const v = parseFloat(p2[1]); return { value: v, prev: v }; }
// Pattern 3 : "increased to X points from Y points" → calcul MoM% = (X-Y)/Y*100
const p3 = desc.match(
/(?:increased|decreased|declined|rose|fell|eased|changed)\s+to\s*([\d.]+)\s+points?[^.]*?from\s+([\d.]+)\s+points?/i
);
if (p3) {
const last = parseFloat(p3[1]);
const prev = parseFloat(p3[2]);
if (prev !== 0) return { value: parseFloat(((last - prev) / prev * 100).toFixed(3)), prev, isIndex: true };
}
// Pattern 4 : "remained unchanged at X points" → delta = 0, on retourne 0
const p4 = desc.match(/(?:remained unchanged at|is unchanged at)\s*([\d.]+)\s+points?/i);
if (p4) return { value: 0, prev: parseFloat(p4[1]), isIndex: true };
return null;
}
export async function fetchTECoreCPIMoM(): Promise<MoMCPIMap> {
const entries = await Promise.all(
(Object.entries(TE_CORE_MOM_SLUG) as [Currency, string][]).map(async ([ccy, slug]) => {
const desc = await fetchOneTEMeta(`https://tradingeconomics.com/${slug}`);
const parsed = parseMetaForMoM(desc);
if (!parsed) return null;
const MONTHS: Record<string, string> = { January:"01",February:"02",March:"03",April:"04",May:"05",June:"06",July:"07",August:"08",September:"09",October:"10",November:"11",December:"12" };
const QUARTERS: Record<string, string> = { first:"01",second:"04",third:"07",fourth:"10" };
const curYear = new Date().getFullYear().toString();
// Mois courant = premier "to/at X [points/percent] in [Month]" dans la description
const dateCurrM = desc.match(/(?:to|at)\s+[\d.]+\s+(?:points?|percent)\s+in\s+([A-Za-z]+)/i);
// Année = "of [Year]" n'importe où
const yearM = desc.match(/\bof\s+(\d{4})\b/);
// Trimestriel NZD
const dateQ = desc.match(/in\s+the\s+(first|second|third|fourth)\s+quarter\s+of\s+(\d{4})/i);
let refMonth = "";
if (dateQ) {
refMonth = `${dateQ[2]}-${QUARTERS[dateQ[1].toLowerCase()]}-01`;
} else if (dateCurrM) {
const mn = dateCurrM[1];
const cap = mn.charAt(0).toUpperCase() + mn.slice(1).toLowerCase();
if (MONTHS[cap]) refMonth = `${yearM?.[1] ?? curYear}-${MONTHS[cap]}-01`;
}
const entry: MoMCPIEntry = {
value: parsed.value,
// Pour les devises index-only (JPY/CHF/AUD/NZD), parsed.prev est la valeur brute
// de l'index, pas le MoM% précédent → on met null
prev: parsed.isIndex ? null : parsed.prev,
refMonth,
isQoQ: CORE_CPI_QOQ.has(ccy) || undefined,
};
return [ccy, entry] as [Currency, MoMCPIEntry];
})
);
const result: MoMCPIMap = {};
for (const entry of entries) {
if (entry) result[entry[0]] = entry[1];
}
return result;
}
// ── Core Consumer Prices index (for tooltip raw values alongside CoreMoM) ────
export async function fetchTECoreConsumerPricesIndex(): Promise<CPIIndexMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/core-consumer-prices?continent=world",
{
next: { revalidate: 21600 },
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 {};
const html = await res.text();
return parseCPIIndexHTML(html, TE_COUNTRY_CPI);
} catch { return {}; }
}
// ── MoM inflation rate ────────────────────────────────────────────────────────
export async function fetchTEMoMInflation(): Promise<MoMCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/inflation-rate-mom?continent=world",
{
next: { revalidate: 21600 },
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) { console.warn("[tecpi-mom] HTTP", res.status); return {}; }
const html = await res.text();
return parseMoMHTML(html);
} catch (err) {
console.error("[tecpi-mom] error:", err);
return {};
}
}
function parseMoMHTML(html: string, countryMap: Record<Currency, string> = TE_COUNTRY_CPI): MoMCPIMap {
const result: MoMCPIMap = {};
const seen = new Set<Currency>();
const rowPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
let m: RegExpExecArray | null;
while ((m = rowPattern.exec(html)) !== null) {
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const textLower = text.toLowerCase();
for (const [ccy, country] of Object.entries(countryMap) as [Currency, string][]) {
if (seen.has(ccy)) continue;
if (!textLower.startsWith(country.toLowerCase())) continue;
const nums = text.match(/-?\d+\.?\d*/g);
const dateMatch = text.match(/([A-Za-z]{3}\/\d{2})/);
if (nums && nums.length >= 2) {
result[ccy] = {
value: parseFloat(nums[0]),
prev: parseFloat(nums[1]),
refMonth: dateMatch ? parseRefDate(dateMatch[1]) : "",
};
seen.add(ccy);
}
break;
}
if (seen.size === 8) break;
}
return result;
}
// ── PPI MoM ──────────────────────────────────────────────────────────────────
export async function fetchTEPPIMoM(): Promise<MoMCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/producer-price-inflation-mom?continent=world",
{
next: { revalidate: 21600 },
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) { console.warn("[tecpi-ppi] HTTP", res.status); return {}; }
const html = await res.text();
return parseMoMHTML(html); // case-insensitive → "Euro area" matche "Euro Area"
} catch (err) {
console.error("[tecpi-ppi] error:", err);
return {};
}
}
// ── Core YoY ──────────────────────────────────────────────────────────────────
function parseCoreInflationHTML(html: string): CoreCPIMap {
const result: CoreCPIMap = {};
const seen = new Set<Currency>();
const rowPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
let m: RegExpExecArray | null;
while ((m = rowPattern.exec(html)) !== null) {
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
for (const [ccy, country] of Object.entries(TE_COUNTRY_CPI) as [Currency, string][]) {
if (seen.has(ccy)) continue;
if (!text.startsWith(country)) continue;
// Format : "Country Last Previous Mon/YY %"
// Last / Previous peuvent être négatifs (ex : Morocco -0.3)
const nums = text.match(/-?\d+\.?\d*/g);
// Chercher la date de référence (format Mon/YY)
const dateMatch = text.match(/([A-Za-z]{3}\/\d{2})/);
if (nums && nums.length >= 2) {
result[ccy] = {
value: parseFloat(nums[0]),
prev: parseFloat(nums[1]),
refMonth: dateMatch ? parseRefDate(dateMatch[1]) : "",
};
seen.add(ccy);
}
break;
}
if (seen.size === 8) break;
}
return result;
}
// ── Core CPI YoY individuel (valeur précise + consensus via TEForecast) ───────
// Utilisé pour EUR (fiabilité) et JPY (consensus).
// Retourne { value, consensus, refMonth } — prev reste fourni par le country-list.
export interface CoreCPIPageEntry {
value: number;
consensus: number | null; // TEForecast[0] si disponible
refMonth: string;
}
const TE_CORE_YOY_SLUG: Partial<Record<Currency, string>> = {
EUR: "euro-area/core-inflation-rate",
JPY: "japan/core-inflation-rate",
};
// Pages individuelles pour Inflation Rate YoY (headline) — plus à jour que le country-list
const TE_INFLATION_YOY_SLUG: Partial<Record<Currency, string>> = {
EUR: "euro-area/inflation-cpi",
GBP: "united-kingdom/inflation-cpi",
JPY: "japan/inflation-cpi",
};
export async function fetchTECoreInflationPages(): Promise<Partial<Record<Currency, CoreCPIPageEntry>>> {
const MONTHS: Record<string, string> = { January:"01",February:"02",March:"03",April:"04",May:"05",June:"06",July:"07",August:"08",September:"09",October:"10",November:"11",December:"12" };
const entries = await Promise.all(
(Object.entries(TE_CORE_YOY_SLUG) as [Currency, string][]).map(async ([ccy, slug]) => {
try {
const res = await fetch(`https://tradingeconomics.com/${slug}`, {
next: { revalidate: 21600 },
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 null;
const html = await res.text();
// Meta description → valeur courante
const metaM = html.match(/name=["']description["'][^>]*content=["']([^"']+)["']/i)
?? html.match(/content=["']([^"']+)["'][^>]*name=["']description["']/i);
const desc = metaM?.[1] ?? "";
const valM = desc.match(/(?:increased|decreased|declined|rose|fell|eased|remained unchanged at)\s+([\d.]+)\s+percent/i);
if (!valM) return null;
const value = parseFloat(valM[1]);
// Date : "in [Month] of [Year]"
const dateM = desc.match(/in\s+([A-Za-z]+)\s+of\s+(\d{4})/i);
let refMonth = "";
if (dateM && MONTHS[dateM[1]]) refMonth = `${dateM[2]}-${MONTHS[dateM[1]]}-01`;
// Consensus = TEForecast[0]
let consensus: number | null = null;
const fcM = html.match(/TEForecast\s*=\s*\[\s*([\d.,\s]+)\]/);
if (fcM) {
const first = fcM[1].split(",")[0].trim();
if (first) consensus = parseFloat(first);
}
return [ccy, { value, consensus, refMonth }] as [Currency, CoreCPIPageEntry];
} catch { return null; }
})
);
const result: Partial<Record<Currency, CoreCPIPageEntry>> = {};
for (const e of entries) if (e) result[e[0]] = e[1];
return result;
}
// ── Inflation Rate YoY individuel (EUR valeur à jour + GBP/JPY consensus) ────
// Meta format : "increased to X.XX percent in [Month] from Y.YY percent in [PrevMonth] of [Year]"
export interface InflationYoYPageEntry {
value: number;
prev: number | null;
consensus: number | null;
refMonth: string;
}
export async function fetchTEInflationYoYPages(): Promise<Partial<Record<Currency, InflationYoYPageEntry>>> {
const MONTHS: Record<string, string> = { January:"01",February:"02",March:"03",April:"04",May:"05",June:"06",July:"07",August:"08",September:"09",October:"10",November:"11",December:"12" };
const entries = await Promise.all(
(Object.entries(TE_INFLATION_YOY_SLUG) as [Currency, string][]).map(async ([ccy, slug]) => {
try {
const res = await fetch(`https://tradingeconomics.com/${slug}`, {
next: { revalidate: 21600 },
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 null;
const html = await res.text();
const metaM = html.match(/name=["']description["'][^>]*content=["']([^"']+)["']/i)
?? html.match(/content=["']([^"']+)["'][^>]*name=["']description["']/i);
const desc = metaM?.[1] ?? "";
// "increased/decreased to X percent in [Month] from Y percent in [PrevMonth] of [Year]"
const p1 = desc.match(
/(?:increased|decreased|declined|rose|fell|eased|changed)\s+to\s+([\d.]+)\s+percent[^.]*?from\s+([\d.]+)\s+percent/i
);
// "remained unchanged at X percent in [Month]"
const p2 = !p1 ? desc.match(/(?:remained unchanged at|is unchanged at)\s*([\d.]+)\s+percent/i) : null;
const value = p1 ? parseFloat(p1[1]) : p2 ? parseFloat(p2[1]) : null;
const prev = p1 ? parseFloat(p1[2]) : p2 ? parseFloat(p2[1]) : null;
if (value === null) return null;
// Date : current month + year
const dateCurrM = desc.match(/(?:to|at)\s+[\d.]+\s+percent\s+in\s+([A-Za-z]+)/i);
const yearM = desc.match(/\bof\s+(\d{4})\b/);
const curYear = new Date().getFullYear().toString();
let refMonth = "";
if (dateCurrM) {
const mn = dateCurrM[1];
const cap = mn.charAt(0).toUpperCase() + mn.slice(1).toLowerCase();
if (MONTHS[cap]) refMonth = `${yearM?.[1] ?? curYear}-${MONTHS[cap]}-01`;
}
// Consensus = TEForecast[0]
let consensus: number | null = null;
const fcM = html.match(/TEForecast\s*=\s*\[\s*([\d.,\s]+)\]/);
if (fcM) {
const first = fcM[1].split(",")[0].trim();
if (first) consensus = parseFloat(first);
}
return [ccy, { value, prev, consensus, refMonth }] as [Currency, InflationYoYPageEntry];
} catch { return null; }
})
);
const result: Partial<Record<Currency, InflationYoYPageEntry>> = {};
for (const e of entries) if (e) result[e[0]] = e[1];
return result;
}
// ── AUD Commodity Prices YoY ─────────────────────────────────────────────────
// Indicateur spécifique AUD : https://tradingeconomics.com/australia/commodity-prices-yoy
// Format meta : "increased to X.XX percent in [Month] from Y.YY percent in [PrevMonth] of [Year]"
export async function fetchTEAUDCommodityYoY(): Promise<InflationYoYPageEntry | null> {
const MONTHS: Record<string, string> = { January:"01",February:"02",March:"03",April:"04",May:"05",June:"06",July:"07",August:"08",September:"09",October:"10",November:"11",December:"12" };
try {
const res = await fetch("https://tradingeconomics.com/australia/commodity-prices-yoy", {
next: { revalidate: 21600 },
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 null;
const html = await res.text();
const metaM = html.match(/name=["']description["'][^>]*content=["']([^"']+)["']/i)
?? html.match(/content=["']([^"']+)["'][^>]*name=["']description["']/i);
const desc = metaM?.[1] ?? "";
const p1 = desc.match(/(?:increased|decreased|declined|rose|fell|eased|changed)\s+to\s+([\d.]+)\s+percent[^.]*?from\s+([\d.]+)\s+percent/i);
const p2 = !p1 ? desc.match(/(?:remained unchanged at|is unchanged at)\s*([\d.]+)\s+percent/i) : null;
const value = p1 ? parseFloat(p1[1]) : p2 ? parseFloat(p2[1]) : null;
const prev = p1 ? parseFloat(p1[2]) : p2 ? parseFloat(p2[1]) : null;
if (value === null) return null;
const dateCurrM = desc.match(/(?:to|at)\s+[\d.]+\s+percent\s+in\s+([A-Za-z]+)/i);
const yearM = desc.match(/\bof\s+(\d{4})\b/);
const curYear = new Date().getFullYear().toString();
let refMonth = "";
if (dateCurrM) {
const mn = dateCurrM[1];
const cap = mn.charAt(0).toUpperCase() + mn.slice(1).toLowerCase();
if (MONTHS[cap]) refMonth = `${yearM?.[1] ?? curYear}-${MONTHS[cap]}-01`;
}
let consensus: number | null = null;
const fcM = html.match(/TEForecast\s*=\s*\[\s*([\d.,\s]+)\]/);
if (fcM) {
const first = fcM[1].split(",")[0].trim();
if (first) consensus = parseFloat(first);
}
return { value, prev, consensus, refMonth };
} catch { return null; }
}
// ── Unemployment Rate ─────────────────────────────────────────────────────────
// Source : https://tradingeconomics.com/country-list/unemployment-rate?continent=world
// Une seule requête HTTP (cache 6h) pour les 8 devises.
// Donne le taux national officiel (ex : SECO ~3% pour CHF) au lieu du taux ILO harmonisé
// FRED qui peut être de 2-3 pp supérieur (ex : LRHUTTTTCHQ156S ~5% pour CHF).
export async function fetchTEUnemploymentRate(): Promise<CoreCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/unemployment-rate?continent=world",
{
next: { revalidate: 21600 },
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) { console.warn("[tecpi-une] HTTP", res.status); return {}; }
const html = await res.text();
return parseCoreInflationHTML(html);
} catch (err) {
console.error("[tecpi-une] error:", err);
return {};
}
}
// ── STIR — Taux interbancaire 3 mois ─────────────────────────────────────────
// Source : https://tradingeconomics.com/country-list/3-month-interbank-rate
// Donne le taux de marché à 3 mois (SOFR 3M, EURIBOR 3M, SONIA 3M, TIBOR 3M…)
// pour les 8 devises en une seule requête.
// Interprétation :
// STIR > taux directeur → marché price des hausses → signal hawkish
// STIR < taux directeur → marché price des baisses → signal dovish
// STIR en hausse → conditions de crédit plus restrictives (prêter = plus risqué/cher)
// STIR en baisse → conditions de crédit plus souples (prêter = plus sûr/moins cher)
export async function fetchTESTIRRate(): Promise<CoreCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/3-month-interbank-rate?continent=world",
{
next: { revalidate: 21600 },
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) { console.warn("[tecpi-stir] HTTP", res.status); return {}; }
const html = await res.text();
return parseCoreInflationHTML(html);
} catch (err) {
console.error("[tecpi-stir] error:", err);
return {};
}
}
// ── GDP Growth Rate QoQ% ──────────────────────────────────────────────────────
// Source : https://tradingeconomics.com/country-list/gdp-growth-rate
// Une seule requête HTTP (cache 6h) couvre les 8 devises.
// Remplace les séries FRED qui retournent souvent un indice niveau à la place
// du QoQ% réel, et qui sont en retard d'1 à 4 trimestres selon la devise.
export async function fetchTEGDPGrowthRate(): Promise<CoreCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/gdp-growth-rate?continent=world",
{
next: { revalidate: 21600 },
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) { console.warn("[tecpi-gdp] HTTP", res.status); return {}; }
const html = await res.text();
return parseCoreInflationHTML(html);
} catch (err) {
console.error("[tecpi-gdp] error:", err);
return {};
}
}
// ── Employment Change — pages individuelles TradingEconomics ─────────────────
// USD : /united-states/non-farm-payrolls → en milliers, MoM
// GBP : /united-kingdom/employment-change → en milliers, MoM (3 mois glissants)
// AUD : /australia/employment-change → en personnes → /1000
// EUR : /euro-area/employment-change → QoQ% converti en milliers via le niveau total
export interface TEEmploymentEntry {
value: number; // milliers de personnes (toutes devises, EUR converti depuis le %)
prev: number | null;
}
const TE_EMP_CONFIG: Partial<Record<Currency, { slug: string; unitKw: string; divisor: number; precision: number }>> = {
USD: { slug: "united-states/non-farm-payrolls", unitKw: "thousand", divisor: 1, precision: 0 },
GBP: { slug: "united-kingdom/employment-change", unitKw: "thousand", divisor: 1, precision: 0 },
AUD: { slug: "australia/employment-change", unitKw: "person", divisor: 1000, precision: 1 },
EUR: { slug: "euro-area/employment-change", unitKw: "percent", divisor: 1, precision: 2 },
};
export async function fetchTEEmploymentChange(): Promise<Partial<Record<Currency, TEEmploymentEntry>>> {
const 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",
};
const entries = await Promise.all(
(Object.entries(TE_EMP_CONFIG) as [Currency, NonNullable<typeof TE_EMP_CONFIG[Currency]>][])
.map(async ([ccy, { slug, unitKw, divisor, precision }]) => {
try {
const res = await fetch(`https://tradingeconomics.com/${slug}`, {
next: { revalidate: 21600 },
headers,
});
if (!res.ok) return null;
const html = await res.text();
// Current value — meta description (préféré) OU body HTML si absent
const metaM = html.match(/name=["']description["'][^>]*content=["']([^"']+)["']/i)
?? html.match(/content=["']([^"']+)["'][^>]*name=["']description["']/i);
// Fallback : chercher le passage clé directement dans le body (supprime les balises)
const bodyText = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
const desc = metaM?.[1] ?? bodyText.slice(0, 3000); // body limité à 3k chars
// Verbes positifs/négatifs larges (grew/rose/fell/dropped/etc.)
const incrM = desc.match(/(?:increased|grew|rose|gained|expanded|climbed)\s+by\s+([\d,]+\.?\d*)/i);
const decrM = desc.match(/(?:decreased|fell|dropped|declined|contracted|shrank|slipped)\s+by\s+([\d,]+\.?\d*)/i);
if (!incrM && !decrM) return null;
const sign = incrM ? 1 : -1;
const absVal = parseFloat((incrM?.[1] ?? decrM![1]).replace(/,/g, ""));
const curRaw = sign * absVal;
// Find previous from table: groups of (value, prev, unit-label)
// Pour EUR : la colonne unité contient "%" pas "percent" → vérifier les deux
const tdVals: string[] = [];
const tdRe = /<td[^>]*>([^<]+)<\/td>/g;
let m: RegExpExecArray | null;
while ((m = tdRe.exec(html)) !== null) tdVals.push(m[1].trim());
let prevRaw: number | null = null;
for (let i = 0; i < tdVals.length - 2; i++) {
const a = parseFloat(tdVals[i].replace(/[,%]/g, ""));
const b = parseFloat(tdVals[i + 1].replace(/[,%]/g, ""));
const c = tdVals[i + 2].toLowerCase();
const unitMatch = unitKw === "percent"
? (c.includes("percent") || c.includes("%"))
: c.includes(unitKw);
if (isNaN(a) || isNaN(b) || !unitMatch) continue;
const tol = Math.max(1, Math.abs(curRaw) * 0.02);
if (Math.abs(a - curRaw) <= tol) { prevRaw = b; break; }
}
// EUR : la page donne un % QoQ → convertir en Δ milliers de personnes.
// Niveau total d'emploi dans le body ("to 176.308 million") → Δk = niveau_k × %/100.
// Fallback hardcodé ≈ 176 000k (zone euro Q1 2026) si le niveau n'est pas parseable.
if (unitKw === "percent") {
const lvlM = desc.match(/to\s+([\d.]+)\s*(million|thousand)/i)
?? bodyText.match(/to\s+([\d.]+)\s*(million|thousand)/i);
let levelK = 176000; // fallback ≈ zone euro
if (lvlM) {
const lvlNum = parseFloat(lvlM[1]);
levelK = /million/i.test(lvlM[2]) ? lvlNum * 1000 : lvlNum;
}
return [ccy, {
value: parseFloat((levelK * curRaw / 100).toFixed(1)),
prev: prevRaw !== null ? parseFloat((levelK * prevRaw / 100).toFixed(1)) : null,
}] as [Currency, TEEmploymentEntry];
}
return [ccy, {
value: parseFloat((curRaw / divisor).toFixed(precision)),
prev: prevRaw !== null ? parseFloat((prevRaw / divisor).toFixed(precision)) : null,
}] as [Currency, TEEmploymentEntry];
} catch { return null; }
})
);
const result: Partial<Record<Currency, TEEmploymentEntry>> = {};
for (const e of entries) if (e) result[e[0]] = e[1];
return result;
}