mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
feat: banques centrales (vote+dot plot+previsions), calendrier elargi, fix InvestingLive, M3 auto
Central bank governance (nouvel onglet Banques centrales) : - lib/centralBankGovernance.ts + app/api/central-bank-sources : scraping live du vote de la derniere reunion, dot plot Fed (SEP), et desormais les previsions macro (PIB + inflation) publiees par chaque BC elle-meme (Fed SEP, Eurosystem staff projections, BoJ Outlook Report PDF, SNB conditional forecast, BoC MPR, RBA SMP). GBP/NZD laisses honnetement vides quand aucune source chiffree fiable n'est accessible (RBNZ bloque par Cloudflare). - components/CentralBankSourcesTab.tsx : nouvel onglet avec cards par banque. Taux directeurs + Money Supply M3 : - data/rate_decisions.json corrige (JPY, EUR, NZD etc. etaient perimes d'1-2 decisions) et desormais auto-maintenu : .github/workflows/update-rate-decisions.yml (horaire) detecte les changements de taux via Trading Economics et fait glisser current -> prev sans perte de donnee. - data/money-supply-m3.json (nouveau) + .github/workflows/fetch-money-supply.yml (hebdo) : M3 par devise (proxy M2 pour l'USD, la Fed ne publiant plus M3 depuis 2006), affiche dans CurrencyCard. Calendrier economique elargi : - lib/calendar-countries.ts, lib/calendar-taxonomy.ts, lib/fxstreetCalendar.ts : couverture pays elargie + classification des evenements + source FXStreet. Fix InvestingLive : - lib/investinglive.ts : l'ancienne API WordPress (wp-json) renvoyait 404 depuis leur migration Nuxt.js -> reecrit vers api.investinglive.com/api/homepage/articles, + fix crash silencieux (Tldr pas toujours un tableau). Divers : - .vercel/ ajoute au .gitignore (ne doit jamais etre commite, cf. son propre README). - scripts/ (lancement PWA, push env Vercel), captures d'ecran, cache InvestingLive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// Scrape Trading Economics pour la masse monétaire M3 (niveau, devise locale)
|
||||
// de chaque zone monétaire suivie par le dashboard.
|
||||
//
|
||||
// Source : meta description de https://tradingeconomics.com/{slug}/money-supply-m3
|
||||
// Format observé : "Money Supply M3 in/In the {Country} increased/decreased to
|
||||
// {value} {CCY} {Million|Billion} in {Month} from {value} {CCY} {Million|Billion}
|
||||
// in {Month} of {Year}."
|
||||
//
|
||||
// USD : la Fed a arrêté de publier M3 en 2006 → proxy M2
|
||||
// (https://tradingeconomics.com/united-states/money-supply-m2, même format de meta desc).
|
||||
//
|
||||
// Cadence : ces données sont mensuelles avec ~4-6 semaines de retard (le mois le
|
||||
// plus récent apparaît des semaines après sa clôture) → un scraping hebdomadaire
|
||||
// suffit largement, pas besoin d'une fréquence horaire comme pour les taux/OIS.
|
||||
|
||||
import { writeFileSync, readFileSync, mkdirSync } from "fs";
|
||||
|
||||
const CHROME_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
};
|
||||
|
||||
const MONTHS = { January:"01",February:"02",March:"03",April:"04",May:"05",June:"06",
|
||||
July:"07",August:"08",September:"09",October:"10",November:"11",December:"12" };
|
||||
|
||||
// slug TE + éventuel slug d'indicateur différent (USD → money-supply-m2, proxy)
|
||||
const SERIES = {
|
||||
USD: { slug: "united-states", indicator: "money-supply-m2", isProxy: true, proxyLabel: "M2 (M3 non publié par la Fed depuis 2006)" },
|
||||
EUR: { slug: "euro-area", indicator: "money-supply-m3", isProxy: false },
|
||||
GBP: { slug: "united-kingdom", indicator: "money-supply-m3", isProxy: false },
|
||||
JPY: { slug: "japan", indicator: "money-supply-m3", isProxy: false },
|
||||
CHF: { slug: "switzerland", indicator: "money-supply-m3", isProxy: false },
|
||||
CAD: { slug: "canada", indicator: "money-supply-m3", isProxy: false },
|
||||
AUD: { slug: "australia", indicator: "money-supply-m3", isProxy: false },
|
||||
NZD: { slug: "new-zealand", indicator: "money-supply-m3", isProxy: false },
|
||||
};
|
||||
|
||||
const DESC_RE = /Money Supply M[23][^.]*?\bto\s+([\d.]+)\s+([A-Z]{3})\s+(Million|Billion)\s+in\s+(\w+)\s+from\s+[\d.]+\s+[A-Z]{3}\s+(?:Million|Billion)\s+in\s+\w+\s+of\s+(\d{4})/i;
|
||||
|
||||
async function fetchMoneySupply(ccy, { slug, indicator }) {
|
||||
try {
|
||||
const res = await fetch(`https://tradingeconomics.com/${slug}/${indicator}`, { headers: CHROME_HEADERS });
|
||||
if (!res.ok) return null;
|
||||
const html = await res.text();
|
||||
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
||||
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
if (!meta) return null;
|
||||
|
||||
const m = meta[1].match(DESC_RE);
|
||||
if (!m) return null;
|
||||
const [, value, ccyCode, unitWord, month, year] = m;
|
||||
const monthNum = MONTHS[month];
|
||||
if (!monthNum) return null;
|
||||
|
||||
return {
|
||||
value: parseFloat(value),
|
||||
unit: `${ccyCode} ${unitWord === "Billion" ? "Bn" : "Mn"}`,
|
||||
period: `${year}-${monthNum}`,
|
||||
source: `https://tradingeconomics.com/${slug}/${indicator}`,
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Fusionne avec le fichier existant : garde l'ancienne valeur si le scrape échoue ──
|
||||
|
||||
let existing = {};
|
||||
try {
|
||||
existing = JSON.parse(readFileSync("data/money-supply-m3.json", "utf8"))[0]?.series ?? {};
|
||||
} catch {}
|
||||
|
||||
const series = {};
|
||||
for (const [ccy, cfg] of Object.entries(SERIES)) {
|
||||
const fetched = await fetchMoneySupply(ccy, cfg);
|
||||
if (fetched) {
|
||||
series[ccy] = { ...fetched, isProxy: cfg.isProxy, ...(cfg.proxyLabel ? { proxyLabel: cfg.proxyLabel } : {}) };
|
||||
console.log(`[TE] ${ccy} ✓ ${fetched.value} ${fetched.unit} (${fetched.period})`);
|
||||
} else if (existing[ccy]) {
|
||||
series[ccy] = existing[ccy];
|
||||
console.log(`[TE] ${ccy} ✗ scrape échoué → conservé (${existing[ccy].period})`);
|
||||
} else {
|
||||
console.log(`[TE] ${ccy} ✗ scrape échoué, pas de fallback disponible`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 800)); // évite le rate-limiting TE
|
||||
}
|
||||
|
||||
mkdirSync("data", { recursive: true });
|
||||
writeFileSync("data/money-supply-m3.json", JSON.stringify([{
|
||||
updated_at: new Date().toISOString().slice(0, 10),
|
||||
note: "Masse monétaire M3 (niveau, devise locale) par zone — scrapé automatiquement depuis tradingeconomics.com (meta description des pages /money-supply-m3). USD : proxy M2 (Fed n'a plus publié M3 depuis 2006). Mis à jour par .github/workflows/fetch-money-supply.yml (hebdomadaire).",
|
||||
series,
|
||||
}], null, 2) + "\n");
|
||||
|
||||
const ok = Object.keys(SERIES).filter(c => series[c]);
|
||||
const failed = Object.keys(SERIES).filter(c => !series[c]);
|
||||
console.log(`\n✓ Saved : ${ok.join(", ")}`);
|
||||
if (failed.length) console.log(`✗ Failed : ${failed.join(", ")}`);
|
||||
+151
-157
@@ -1,7 +1,10 @@
|
||||
// Sources (in priority order):
|
||||
// 1. CME FedWatch API — USD seul, JSON officieux, fiable
|
||||
// 2. Investing.com monitors — toutes CBs, HTML scraped (Cloudflare possible)
|
||||
// 3. InvestingLive fallback — articles Giuseppe Dellamotta, hebdo, toutes CBs
|
||||
// 1. Investing.com Fed Rate Monitor — USD seul (seule CB pour laquelle Investing.com
|
||||
// publie cet outil ; les autres slugs *-rate-monitor n'existent pas → 404 attendus)
|
||||
// 2. InvestingLive fallback — articles Giuseppe Dellamotta, hebdo, toutes CBs
|
||||
//
|
||||
// (CME FedWatch retiré : endpoint derrière le WAF Akamai de cmegroup.com, 403/404
|
||||
// systématique, et de toute façon redondant avec la source 1 pour l'USD.)
|
||||
|
||||
import { writeFileSync, mkdirSync, readFileSync } from "fs";
|
||||
|
||||
@@ -33,97 +36,7 @@ const CHROME_HEADERS = {
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
};
|
||||
|
||||
// ── 1. CME FedWatch (USD) ─────────────────────────────────────────────────────
|
||||
|
||||
async function fetchCMEFedWatch() {
|
||||
const year = new Date().getFullYear();
|
||||
const candidates = [
|
||||
// API officieuse FedWatch — essai sur SR3 (SOFR) et ZQ (30-day FF)
|
||||
`https://www.cmegroup.com/CmeWS/mvc/MeetingCalendar/V1/getMeetingCalendarByYear.json?marketCode=SR3&year=${year}`,
|
||||
`https://www.cmegroup.com/CmeWS/mvc/MeetingCalendar/V1/getMeetingCalendarByYear.json?marketCode=FF&year=${year}`,
|
||||
`https://www.cmegroup.com/CmeWS/mvc/MeetingCalendar/V1/getMeetingCalendarByYear.json?marketCode=ZQ&year=${year}`,
|
||||
];
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
...CHROME_HEADERS,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Referer": "https://www.cmegroup.com/markets/interest-rates/cme-fedwatch-tool.html",
|
||||
"Origin": "https://www.cmegroup.com",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
}
|
||||
});
|
||||
console.log(`[CME] ${url.split('?')[1]} → ${res.status}`);
|
||||
if (!res.ok) continue;
|
||||
|
||||
const body = await res.json();
|
||||
console.log(`[CME] keys: ${Object.keys(body).join(", ")}`);
|
||||
console.log(`[CME] preview: ${JSON.stringify(body).slice(0, 600)}`);
|
||||
|
||||
const parsed = parseCMEBody(body);
|
||||
if (parsed) { console.log(`[CME] ✓ ${parsed.today.rows.length} meetings`); return parsed; }
|
||||
} catch (e) {
|
||||
console.error(`[CME] error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCMEBody(body) {
|
||||
// Format A : { meetings: [{ date|meetingDate, currentProbs:{minus25,unch,plus25,...}, impliedRate }] }
|
||||
const arr = body.meetings ?? body.data ?? (Array.isArray(body) ? body : null);
|
||||
if (!arr?.length) return null;
|
||||
|
||||
const nowIso = new Date().toISOString().slice(0,10);
|
||||
let currentRate = null;
|
||||
const rows = [];
|
||||
|
||||
for (const m of arr) {
|
||||
const dateIso = normalizeDate(
|
||||
m.date ?? m.meetingDate ?? m.meetDate ?? m.meeting_date ?? ""
|
||||
);
|
||||
if (!dateIso || dateIso < nowIso) continue;
|
||||
|
||||
const probs = m.currentProbs ?? m.probs ?? m.probabilities ?? m.probability ?? {};
|
||||
const probCut = parseFloat(probs.minus25 ?? probs.cut25 ?? probs["-25"] ?? probs.minus50 ?? 0)
|
||||
+ parseFloat(probs.minus50 ?? probs.cut50 ?? probs["-50"] ?? 0);
|
||||
const probHike = parseFloat(probs.plus25 ?? probs.hike25 ?? probs["+25"] ?? probs.plus50 ?? 0)
|
||||
+ parseFloat(probs.plus50 ?? probs.hike50 ?? probs["+50"] ?? 0);
|
||||
const probHold = parseFloat(probs.unch ?? probs.hold ?? probs.unchanged ?? 0);
|
||||
|
||||
const probIsCut = probCut >= probHike;
|
||||
const probMovePct = probIsCut ? probCut : probHike;
|
||||
|
||||
const impliedRate = parseFloat(
|
||||
m.impliedRate ?? m.implied_rate ?? m.rate ?? m.impliedFedFunds ?? 0
|
||||
);
|
||||
if (currentRate === null)
|
||||
currentRate = parseFloat(m.priorRate ?? m.currentRate ?? m.prior_rate ?? impliedRate ?? 0);
|
||||
|
||||
const changeBps = (impliedRate - (currentRate ?? 0)) * 100;
|
||||
const dateLabel = new Date(dateIso + "T12:00:00Z")
|
||||
.toLocaleDateString("en-US", { month:"short", day:"numeric" });
|
||||
|
||||
rows.push({
|
||||
meeting: dateLabel,
|
||||
meeting_iso: dateIso,
|
||||
implied_rate_post_meeting: impliedRate,
|
||||
prob_move_pct: probMovePct,
|
||||
prob_is_cut: probIsCut,
|
||||
change_bps: changeBps,
|
||||
num_moves: changeBps / 25,
|
||||
});
|
||||
}
|
||||
|
||||
if (!rows.length) return null;
|
||||
return { today: { midpoint: currentRate ?? 0, rows } };
|
||||
}
|
||||
|
||||
// ── 2. Investing.com Rate Monitors (all CBs) ──────────────────────────────────
|
||||
// ── 1. Investing.com Rate Monitors (Fed only — voir note en tête de fichier) ──
|
||||
|
||||
const IC_SLUGS = {
|
||||
USD: "fed-rate-monitor",
|
||||
@@ -136,12 +49,83 @@ const IC_SLUGS = {
|
||||
CHF: "snb-rate-monitor",
|
||||
};
|
||||
|
||||
// Taux directeurs actuels — fallback si non trouvés dans le HTML
|
||||
// NZD : RBNZ a coupé à 2.25% (juin 2026) ; CHF : SNB à 0.00%
|
||||
const FALLBACK_RATES = {
|
||||
USD: 4.33, EUR: 2.40, GBP: 3.75, JPY: 0.50,
|
||||
CAD: 2.25, AUD: 4.10, NZD: 2.25, CHF: 0.00,
|
||||
};
|
||||
// Taux directeurs actuels — lus depuis data/rate_decisions.json (source unique,
|
||||
// maintenue manuellement "après chaque décision") pour éviter qu'une copie
|
||||
// codée en dur ici ne dérive silencieusement de la réalité au fil des mois.
|
||||
const rateDecisions = JSON.parse(readFileSync("data/rate_decisions.json", "utf8"))[0]?.decisions ?? {};
|
||||
const FALLBACK_RATES = Object.fromEntries(
|
||||
Object.entries(rateDecisions).map(([ccy, d]) => [ccy, d.current])
|
||||
);
|
||||
|
||||
// ── SNB officiel (CHF) ─────────────────────────────────────────────────────────
|
||||
// snb.ch publie ses décisions sous /press-releases-restricted/pre_YYYYMMDD.
|
||||
// On découvre la plus récente depuis la page listing (1er lien du pattern),
|
||||
// puis on lit le taux dans le titre : "leaves ... unchanged at X%" / "lowers/raises ... to X%".
|
||||
async function fetchSnbRate() {
|
||||
try {
|
||||
const listRes = await fetch("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions", { headers: CHROME_HEADERS });
|
||||
if (!listRes.ok) return null;
|
||||
const listHtml = await listRes.text();
|
||||
const linkMatch = listHtml.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_(\d{8})[^"]*)"/);
|
||||
if (!linkMatch) return null;
|
||||
const dateIso = `${linkMatch[2].slice(0,4)}-${linkMatch[2].slice(4,6)}-${linkMatch[2].slice(6,8)}`;
|
||||
|
||||
const prRes = await fetch(`https://www.snb.ch${linkMatch[1]}`, { headers: CHROME_HEADERS });
|
||||
if (!prRes.ok) return null;
|
||||
const prHtml = await prRes.text();
|
||||
const rateMatch = prHtml.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i);
|
||||
if (!rateMatch) return null;
|
||||
|
||||
console.log(`[SNB] official: ${rateMatch[1]}% (decision ${dateIso})`);
|
||||
return parseFloat(rateMatch[1]);
|
||||
} catch (e) {
|
||||
console.error(`[SNB] error: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fed officiel (USD) ──────────────────────────────────────────────────────────
|
||||
// federalreserve.gov/monetarypolicy/fomccalendars.htm liste, pour chaque réunion,
|
||||
// un lien de statement /newsevents/pressreleases/monetary(YYYYMMDD)a.htm. On prend
|
||||
// la réunion passée la plus récente, puis on lit la fourchette officielle dans le
|
||||
// texte : "target range for the federal funds rate at/to X to Y percent" (X/Y en
|
||||
// fractions type "3-1/2"). On retourne le haut de fourchette (convention "upper
|
||||
// bound" déjà utilisée ailleurs dans ce repo pour l'USD).
|
||||
function parseFedFraction(s) {
|
||||
const m = s.match(/^(\d+)(?:-(\d+)\/(\d+))?$/);
|
||||
if (!m) return NaN;
|
||||
const whole = parseFloat(m[1]);
|
||||
return m[2] ? whole + parseFloat(m[2]) / parseFloat(m[3]) : whole;
|
||||
}
|
||||
|
||||
async function fetchFedRate() {
|
||||
try {
|
||||
const calRes = await fetch("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", { headers: CHROME_HEADERS });
|
||||
if (!calRes.ok) return null;
|
||||
const calHtml = await calRes.text();
|
||||
|
||||
const todayCompact = new Date().toISOString().slice(0,10).replace(/-/g,"");
|
||||
const dates = new Set();
|
||||
for (const m of calHtml.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g)) dates.add(m[1]);
|
||||
const latestPast = [...dates].filter(d => d <= todayCompact).sort().reverse()[0];
|
||||
if (!latestPast) return null;
|
||||
const dateIso = `${latestPast.slice(0,4)}-${latestPast.slice(4,6)}-${latestPast.slice(6,8)}`;
|
||||
|
||||
const stRes = await fetch(`https://www.federalreserve.gov/newsevents/pressreleases/monetary${latestPast}a.htm`, { headers: CHROME_HEADERS });
|
||||
if (!stRes.ok) return null;
|
||||
const stHtml = await stRes.text();
|
||||
const rateMatch = stHtml.match(/target range for the federal funds rate[\s\S]{0,60}?(\d+(?:-\d\/\d)?)\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i);
|
||||
if (!rateMatch) return null;
|
||||
|
||||
const upper = parseFedFraction(rateMatch[2]);
|
||||
if (isNaN(upper)) return null;
|
||||
console.log(`[FED] official: ${rateMatch[1]}-${rateMatch[2]}% → upper=${upper}% (decision ${dateIso})`);
|
||||
return upper;
|
||||
} catch (e) {
|
||||
console.error(`[FED] error: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInvestingCom(ccy) {
|
||||
const slug = IC_SLUGS[ccy];
|
||||
@@ -261,51 +245,52 @@ function parseJsonData(ccy, json) {
|
||||
return { today: { midpoint: currentRate, rows } };
|
||||
}
|
||||
|
||||
// Structure réelle de la page (vérifiée juillet 2026) : chaque réunion FOMC est un
|
||||
// bloc "Meeting Time: <i>Jul 29, 2026 02:00PM ET</i>" suivi d'un <table class="fedRateTbl">
|
||||
// dont les lignes sont des fourchettes de taux ("3.50 - 3.75") + probabilité courante.
|
||||
// Il n'y a AUCUNE colonne date dans la table elle-même (elle vit dans le bloc parent) —
|
||||
// c'est pourquoi l'ancienne heuristique par en-têtes de colonnes ne trouvait jamais rien.
|
||||
function parseRateMonitorTable(ccy, html) {
|
||||
// Find all <table> blocks
|
||||
const tables = [...html.matchAll(/<table[\s\S]*?<\/table>/gi)];
|
||||
console.log(`[IC/${ccy}] ${tables.length} tables found`);
|
||||
|
||||
for (const [table] of tables) {
|
||||
// Extract header row to detect probability columns
|
||||
const headers = [...table.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)]
|
||||
.map(m => m[1].replace(/<[^>]+>/g,"").trim().toLowerCase());
|
||||
|
||||
const hasProbCols = headers.some(h => /cut|hike|unch|hold|basis|bps|prob|\-\d|\+\d/.test(h));
|
||||
const hasDate = headers.some(h => /meeting|date|month/.test(h));
|
||||
if (!hasProbCols || !hasDate) continue;
|
||||
|
||||
console.log(`[IC/${ccy}] table headers: ${headers.join(" | ")}`);
|
||||
|
||||
const dateIdx = headers.findIndex(h => /meeting|date|month/.test(h));
|
||||
const cutCols = headers.reduce((acc,h,i) => /cut|\-25|\-50|decr/.test(h) ? [...acc,i] : acc, []);
|
||||
const hikeCols = headers.reduce((acc,h,i) => /hike|\+25|\+50|incr/.test(h) ? [...acc,i] : acc, []);
|
||||
const holdCols = headers.reduce((acc,h,i) => /unch|hold|no.?change/.test(h) ? [...acc,i] : acc, []);
|
||||
const rateIdx = headers.findIndex(h => /implied|rate/.test(h));
|
||||
|
||||
const rows2 = [];
|
||||
const currentRate = FALLBACK_RATES[ccy] ?? 0;
|
||||
const nowIso = new Date().toISOString().slice(0,10);
|
||||
const trs = [...table.matchAll(/<tr[\s\S]*?<\/tr>/gi)].slice(1); // skip header
|
||||
const blockRe = /Meeting Time:<\/span>\s*<i>([^<]+)<\/i>[\s\S]*?<table class="genTbl openTbl fedRateTbl">([\s\S]*?)<\/table>/g;
|
||||
const rowRe = /<td class="left">\s*([\d.]+\s*-\s*[\d.]+)[\s\S]*?<\/td>\s*<td>\s*([\d.]+)%<\/td>/g;
|
||||
|
||||
for (const [tr] of trs) {
|
||||
const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
|
||||
.map(m => m[1].replace(/<[^>]+>/g,"").trim());
|
||||
if (cells.length < 2) continue;
|
||||
|
||||
const dateIso = normalizeDate(cells[dateIdx] ?? "");
|
||||
const rows = [];
|
||||
let bm;
|
||||
while ((bm = blockRe.exec(html)) !== null) {
|
||||
const dateIso = normalizeDate(bm[1].trim());
|
||||
if (!dateIso || dateIso < nowIso) continue;
|
||||
|
||||
const pct = s => parseFloat((s ?? "0").replace(/[^0-9.]/g,"")) || 0;
|
||||
const probCut = cutCols.reduce((s,i) => s + pct(cells[i]), 0);
|
||||
const probHike = hikeCols.reduce((s,i) => s + pct(cells[i]), 0);
|
||||
const probIsCut = probCut >= probHike;
|
||||
const probMovePct = Math.max(probCut, probHike);
|
||||
const impliedRate = rateIdx >= 0 ? pct(cells[rateIdx]) : FALLBACK_RATES[ccy] ?? 0;
|
||||
const changeBps = (impliedRate - (FALLBACK_RATES[ccy] ?? 0)) * 100;
|
||||
let rm, totalProb = 0, weightedRate = 0, probBelow = 0, probAbove = 0;
|
||||
rowRe.lastIndex = 0;
|
||||
while ((rm = rowRe.exec(bm[2])) !== null) {
|
||||
const parts = rm[1].match(/([\d.]+)\s*-\s*([\d.]+)/);
|
||||
if (!parts) continue;
|
||||
const hi = parseFloat(parts[2]);
|
||||
const prob = parseFloat(rm[2]);
|
||||
totalProb += prob;
|
||||
// currentRate suit la convention "upper bound" (ex: 3.75 pour la fourchette
|
||||
// 3.50-3.75, cf. data/rate_decisions.json / app/api/macro) : chaque fourchette
|
||||
// doit donc être représentée par SON haut (hi), pas son milieu — sinon la
|
||||
// fourchette actuelle (mid=3.625) ne matche jamais currentRate (3.75), ce qui
|
||||
// introduit un biais fantôme de ~-12.5bps même à 100% de probabilité de statu quo.
|
||||
weightedRate += hi * prob;
|
||||
// La fourchette dont le haut == currentRate EST la fourchette actuelle → ni cut ni hike.
|
||||
if (Math.abs(hi - currentRate) < 0.01) continue;
|
||||
if (hi < currentRate) probBelow += prob;
|
||||
else probAbove += prob;
|
||||
}
|
||||
if (!totalProb) continue;
|
||||
|
||||
const impliedRate = parseFloat((weightedRate / totalProb).toFixed(4));
|
||||
const probIsCut = probBelow > probAbove; // strict : à 0/0 (aucun biais), ne pas défaulter sur "cut"
|
||||
const probMovePct = Math.round(Math.max(probBelow, probAbove));
|
||||
const changeBps = (impliedRate - currentRate) * 100;
|
||||
const dateLabel = new Date(dateIso + "T12:00:00Z")
|
||||
.toLocaleDateString("en-US", { month:"short", day:"numeric" });
|
||||
|
||||
rows2.push({
|
||||
rows.push({
|
||||
meeting: dateLabel, meeting_iso: dateIso,
|
||||
implied_rate_post_meeting: impliedRate,
|
||||
prob_move_pct: probMovePct, prob_is_cut: probIsCut,
|
||||
@@ -313,17 +298,12 @@ function parseRateMonitorTable(ccy, html) {
|
||||
});
|
||||
}
|
||||
|
||||
if (rows2.length) {
|
||||
console.log(`[IC/${ccy}] ✓ table parsed: ${rows2.length} rows`);
|
||||
return { today: { midpoint: FALLBACK_RATES[ccy] ?? 0, rows: rows2 } };
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`[IC/${ccy}] no parseable table found`);
|
||||
return null;
|
||||
if (!rows.length) { console.warn(`[IC/${ccy}] no parseable meeting block found`); return null; }
|
||||
console.log(`[IC/${ccy}] ✓ table parsed: ${rows.length} meetings`);
|
||||
return { today: { midpoint: currentRate, rows } };
|
||||
}
|
||||
|
||||
// ── 3. InvestingLive fallback (Giuseppe Dellamotta, hebdomadaire) ─────────────
|
||||
// ── 2. InvestingLive fallback (Giuseppe Dellamotta, hebdomadaire) ─────────────
|
||||
|
||||
const IL_CB_MAP = {
|
||||
fed: "USD", fomc: "USD",
|
||||
@@ -375,9 +355,20 @@ function parseILArticle(html) {
|
||||
if (!ccy || results[ccy]) continue;
|
||||
const bps = parseInt(m[2]);
|
||||
const probPct = parseInt(m[3]);
|
||||
const isHike = /hike/i.test(m[5]);
|
||||
const isNoChg = /no.?change/i.test(m[5]);
|
||||
results[ccy] = { bpsYearEnd: isHike ? bps : -bps, probMovePct: isNoChg ? 0 : probPct, isCut: !isHike && !isNoChg };
|
||||
const isCutDirection = !isNoChg && /cut/i.test(m[5]);
|
||||
|
||||
// "XX bps" est une magnitude signée du biais year-end : on ne la force négative
|
||||
// que si la direction annoncée est explicitement "cut". Pour "no change", le bps
|
||||
// reste positif par défaut (biais hausse), comme pour "hike" — le bug précédent
|
||||
// négativait TOUT ce qui n'était pas explicitement "hike", cassant EUR/GBP/JPY/
|
||||
// CAD/AUD/CHF (tous en "no change" la plupart des semaines).
|
||||
const bpsYearEnd = isCutDirection && bps > 0 ? -bps : bps;
|
||||
// "no change" à la prochaine réunion : la probabilité de mouvement est le complément
|
||||
// de la probabilité de statu quo (ex: "72% no change" → 28% de mouvement), pas 0.
|
||||
const probMovePct = isNoChg ? 100 - probPct : probPct;
|
||||
|
||||
results[ccy] = { bpsYearEnd, probMovePct, isCut: bpsYearEnd < 0 };
|
||||
}
|
||||
|
||||
console.log(`[IL] parsed: ${Object.keys(results).join(", ")}`);
|
||||
@@ -409,23 +400,26 @@ function buildILFallback(ccy, il) {
|
||||
const CCYS = ["USD","EUR","GBP","JPY","CAD","AUD","NZD","CHF"];
|
||||
const results = {};
|
||||
|
||||
// 1 — CME FedWatch → USD
|
||||
console.log("\n=== CME FedWatch ===");
|
||||
const cmeData = await fetchCMEFedWatch();
|
||||
if (cmeData) { results["USD"] = cmeData; console.log("[CME] USD ✓"); }
|
||||
// 0 — Sources officielles → écrasent les ancres si data/rate_decisions.json a dérivé
|
||||
console.log("\n=== Fed (official) ===");
|
||||
const fedRate = await fetchFedRate();
|
||||
if (fedRate !== null) FALLBACK_RATES.USD = fedRate;
|
||||
|
||||
// 2 — Investing.com → CBs qui ont une page rate-monitor (pas CHF ni NZD → 404)
|
||||
// CHF (snb-rate-monitor) et NZD (rbnz-rate-monitor) n'existent pas sur Investing.com
|
||||
const IC_SUPPORTED = CCYS.filter(c => c !== "CHF" && c !== "NZD");
|
||||
console.log("\n=== SNB (official) ===");
|
||||
const snbRate = await fetchSnbRate();
|
||||
if (snbRate !== null) FALLBACK_RATES.CHF = snbRate;
|
||||
|
||||
// 1 — Investing.com Fed Rate Monitor → USD uniquement (seule CB avec cet outil ;
|
||||
// les autres slugs *-rate-monitor n'existent pas sur investing.com → 404 attendus)
|
||||
const IC_SUPPORTED = ["USD"];
|
||||
console.log("\n=== Investing.com Rate Monitors ===");
|
||||
for (const ccy of IC_SUPPORTED) {
|
||||
if (results[ccy]) { console.log(`[IC/${ccy}] skipped (CME)`); continue; }
|
||||
const data = await fetchInvestingCom(ccy);
|
||||
if (data) results[ccy] = data;
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
}
|
||||
|
||||
// 3 — InvestingLive → fallback for any missing CB
|
||||
// 2 — InvestingLive → fallback for any missing CB
|
||||
const missing = CCYS.filter(c => !results[c]);
|
||||
if (missing.length) {
|
||||
console.log(`\n=== InvestingLive fallback (missing: ${missing.join(", ")}) ===`);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Détecte automatiquement les changements de taux directeur (Trading Economics)
|
||||
// et fait glisser l'ancien "current" vers "prev" — sans perte de donnée, sans
|
||||
// intervention manuelle.
|
||||
//
|
||||
// Principe (demandé explicitement) :
|
||||
// - Si le taux scrapé == data/rate_decisions.json[ccy].current → rien ne bouge
|
||||
// (le "prev" existant reste tel quel, ce n'est pas une nouvelle décision).
|
||||
// - Si le taux scrapé != .current → c'est une nouvelle décision de la banque
|
||||
// centrale : l'ancien "current" glisse vers "prev", le nouveau taux devient
|
||||
// "current". Le "source" de cette devise est réécrit pour tracer le switch.
|
||||
// - Si le scrape échoue (page indisponible / format changé) → on garde les
|
||||
// valeurs existantes telles quelles, aucune perte de données.
|
||||
//
|
||||
// Source : meta description de https://tradingeconomics.com/{slug}/interest-rate
|
||||
// Format observé : "The benchmark interest rate in/In {Country} was last
|
||||
// recorded at {value} percent."
|
||||
// Note EUR : TE reporte directement le taux MRO sur cette page (2.40% le
|
||||
// 2026-07-08), cohérent avec la convention MRO déjà utilisée dans ce fichier.
|
||||
|
||||
import { writeFileSync, readFileSync, mkdirSync } from "fs";
|
||||
|
||||
const CHROME_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
};
|
||||
|
||||
const TE_SLUGS = {
|
||||
USD: "united-states",
|
||||
EUR: "euro-area",
|
||||
GBP: "united-kingdom",
|
||||
JPY: "japan",
|
||||
CHF: "switzerland",
|
||||
CAD: "canada",
|
||||
AUD: "australia",
|
||||
NZD: "new-zealand",
|
||||
};
|
||||
|
||||
const CB_NAME = {
|
||||
USD: "Fed (FOMC)", EUR: "BCE", GBP: "BoE MPC", JPY: "BoJ",
|
||||
CHF: "SNB", CAD: "BoC", AUD: "RBA", NZD: "RBNZ",
|
||||
};
|
||||
|
||||
async function fetchTERate(slug) {
|
||||
try {
|
||||
const res = await fetch(`https://tradingeconomics.com/${slug}/interest-rate`, { headers: CHROME_HEADERS });
|
||||
if (!res.ok) return null;
|
||||
const html = await res.text();
|
||||
const meta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
||||
?? html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
if (!meta) return null;
|
||||
const m = meta[1].match(/at\s+([\d.]+)\s*percent/i);
|
||||
return m ? parseFloat(m[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let existing;
|
||||
try {
|
||||
existing = JSON.parse(readFileSync("data/rate_decisions.json", "utf8"))[0];
|
||||
} catch {
|
||||
console.error("✗ Impossible de lire data/rate_decisions.json existant, abandon.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const decisions = { ...existing.decisions };
|
||||
let changed = false;
|
||||
const changeLog = [];
|
||||
|
||||
for (const [ccy, slug] of Object.entries(TE_SLUGS)) {
|
||||
const scraped = await fetchTERate(slug);
|
||||
const prevEntry = decisions[ccy];
|
||||
|
||||
if (scraped === null) {
|
||||
console.log(`[TE] ${ccy} ✗ scrape échoué → conservé (${prevEntry?.current ?? "?"}%)`);
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!prevEntry) {
|
||||
// Devise jamais vue — initialisation sans historique de "prev"
|
||||
decisions[ccy] = {
|
||||
current: scraped,
|
||||
prev: scraped,
|
||||
source: `${CB_NAME[ccy] ?? ccy} — initialisation automatique via Trading Economics (${slug}/interest-rate), pas d'historique disponible pour "prev".`,
|
||||
};
|
||||
changed = true;
|
||||
changeLog.push(`${ccy}: init @ ${scraped}%`);
|
||||
console.log(`[TE] ${ccy} ⊕ init ${scraped}%`);
|
||||
} else if (Math.abs(scraped - prevEntry.current) > 0.001) {
|
||||
// Changement détecté : l'ancien "current" glisse vers "prev"
|
||||
decisions[ccy] = {
|
||||
current: scraped,
|
||||
prev: prevEntry.current,
|
||||
source: `${CB_NAME[ccy] ?? ccy} — changement détecté automatiquement le ${today} via Trading Economics (${slug}/interest-rate) : ${prevEntry.current}% → ${scraped}%.`,
|
||||
};
|
||||
changed = true;
|
||||
changeLog.push(`${ccy}: ${prevEntry.current}% → ${scraped}% (prev glisse)`);
|
||||
console.log(`[TE] ${ccy} ⚡ CHANGEMENT ${prevEntry.current}% → ${scraped}%`);
|
||||
} else {
|
||||
console.log(`[TE] ${ccy} = ${scraped}% (inchangé)`);
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 800)); // évite le rate-limiting TE
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
console.log("\nAucun changement de taux directeur détecté — fichier inchangé.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
mkdirSync("data", { recursive: true });
|
||||
writeFileSync("data/rate_decisions.json", JSON.stringify([{
|
||||
updated_at: today,
|
||||
note: existing.note,
|
||||
decisions,
|
||||
}], null, 2) + "\n");
|
||||
|
||||
console.log(`\n✓ Mis à jour : ${changeLog.join(" | ")}`);
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Fetch Money Supply M3
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # tous les lundis 6h UTC (données mensuelles, pas besoin de plus fréquent)
|
||||
workflow_dispatch: # déclenchement manuel
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fetch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Fetch Money Supply M3 (Trading Economics)
|
||||
run: node .github/scripts/fetch-money-supply.mjs
|
||||
|
||||
- name: Commit updated data (if changed)
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add data/money-supply-m3.json
|
||||
git diff --staged --quiet || git commit -m "chore: update money supply M3 data [skip ci]"
|
||||
git push
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Update Rate Decisions (CB current/prev)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '20 * * * *' # toutes les heures (décalé de 20min vs fetch-rate-data.yml)
|
||||
workflow_dispatch: # déclenchement manuel
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Detect CB rate changes (Trading Economics) — shift current → prev
|
||||
run: node .github/scripts/update-rate-decisions.mjs
|
||||
|
||||
- name: Commit updated data (if changed)
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add data/rate_decisions.json
|
||||
git diff --staged --quiet || git commit -m "chore: update CB rate decision (current/prev) [skip ci]"
|
||||
git push
|
||||
@@ -2,4 +2,5 @@
|
||||
.next/
|
||||
node_modules/
|
||||
*.log
|
||||
.vercel
|
||||
|
||||
|
||||
+64
-40
@@ -6,8 +6,9 @@ export const dynamic = "force-dynamic";
|
||||
import type { FFEvent } from "@/lib/forexfactory";
|
||||
import type { Currency } from "@/lib/types";
|
||||
import { fetchAllCBPaths, extractMeetingEvents } from "@/lib/rateprobability";
|
||||
import { fetchTECalendarHTML } from "@/lib/tradingeconomics";
|
||||
import { fetchInvestingCalendar } from "@/lib/investing";
|
||||
import { fetchTECalendarWide } from "@/lib/tradingeconomics";
|
||||
import { fetchFXStreetCalendar } from "@/lib/fxstreetCalendar";
|
||||
import { isExcludedEventTitle, applyImpactFloor } from "@/lib/calendar-taxonomy";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,12 +21,21 @@ export type EventCategory =
|
||||
| "gdp"
|
||||
| "retail_sales"
|
||||
| "trade_balance"
|
||||
| "sentiment"
|
||||
| "housing"
|
||||
| "money_supply"
|
||||
| "trade_detail"
|
||||
| "regional_fed"
|
||||
| "portfolio_flows"
|
||||
| "public_finance"
|
||||
| "holiday"
|
||||
| "other";
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
date: string; // ISO string from FF
|
||||
currency: Currency;
|
||||
currency: string; // ISO 4217 — univers élargi (45 pays), pas seulement les 8 majeures
|
||||
countryCode: string; // code pays (plusieurs pays peuvent partager une devise, ex. EUR)
|
||||
category: EventCategory;
|
||||
title: string; // display-friendly
|
||||
rawTitle: string; // original FF title
|
||||
@@ -52,6 +62,12 @@ export interface CalendarResponse {
|
||||
|
||||
const CURRENCIES = new Set<string>(["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]);
|
||||
|
||||
// Devise majeure → code pays CALENDAR_COUNTRIES (pour aligner les réunions BC,
|
||||
// qui sont par nature au niveau devise, avec le dédupe par pays de TE/investingLive).
|
||||
const MAJOR_CCY_TO_COUNTRY: Record<string, string> = {
|
||||
USD: "US", EUR: "EMU", GBP: "UK", JPY: "JP", CHF: "CH", CAD: "CA", AUD: "AU", NZD: "NZ",
|
||||
};
|
||||
|
||||
// ── Category detection ─────────────────────────────────────────────────────────
|
||||
|
||||
function detectCategory(title: string): EventCategory {
|
||||
@@ -199,6 +215,7 @@ function mapEvent(ff: FFEvent): CalendarEvent | null {
|
||||
id: `${ff.country}_${ff.title}_${ff.date}`.replace(/\s+/g, "_"),
|
||||
date: ff.date,
|
||||
currency: ff.country as Currency,
|
||||
countryCode: ff.country,
|
||||
category,
|
||||
title: displayTitle(ff.title, ff.country),
|
||||
rawTitle: ff.title,
|
||||
@@ -285,6 +302,7 @@ async function fetchFREDCalendar(
|
||||
id: `fred_${rd.release_id}_${rd.date}`,
|
||||
date: isoDate,
|
||||
currency: def.currency,
|
||||
countryCode: def.currency,
|
||||
category: def.category,
|
||||
title: def.title,
|
||||
rawTitle: def.title,
|
||||
@@ -305,16 +323,18 @@ async function fetchFREDCalendar(
|
||||
}
|
||||
|
||||
// ── Dedup key ─────────────────────────────────────────────────────────────────
|
||||
// Clé déterministe pour identifier un doublon entre TE et Investing.
|
||||
// Clé déterministe pour identifier un doublon entre TE et investingLive.
|
||||
// Clé par PAYS (pas devise) : plusieurs pays partagent l'EUR (France, Allemagne,
|
||||
// Italie...) et publient chacun leurs propres indicateurs le même jour — dédupliquer
|
||||
// par devise fusionnerait à tort des events distincts (ex. CPI FR ≠ CPI DE).
|
||||
// PMI et discours BC peuvent avoir plusieurs events dans la même journée →
|
||||
// on affine à l'heure UTC pour les distinguer.
|
||||
// Toutes les autres catégories sont uniques par (devise, catégorie, jour).
|
||||
|
||||
function dedupeKey(currency: string, category: EventCategory, isoDate: string): string {
|
||||
function dedupeKey(countryCode: string, category: EventCategory, isoDate: string): string {
|
||||
if (category === "pmi" || category === "cb_speech") {
|
||||
return `${currency}_${category}_${isoDate.slice(0, 13)}`; // YYYY-MM-DDTHH
|
||||
return `${countryCode}_${category}_${isoDate.slice(0, 13)}`; // YYYY-MM-DDTHH
|
||||
}
|
||||
return `${currency}_${category}_${isoDate.slice(0, 10)}`; // YYYY-MM-DD
|
||||
return `${countryCode}_${category}_${isoDate.slice(0, 10)}`; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
// ── GET ────────────────────────────────────────────────────────────────────────
|
||||
@@ -351,15 +371,15 @@ export async function GET() {
|
||||
toDateObj.setDate(toDateObj.getDate() + 14);
|
||||
const toDate = toDateObj.toISOString().slice(0, 10);
|
||||
|
||||
// Fetch TE HTML + Investing + CB paths toujours en parallèle
|
||||
// Fetch TE (45 pays) + investingLive (widget FXStreet, 45 pays) + CB paths en parallèle
|
||||
// FF+FRED uniquement si les deux scraping tombent à vide
|
||||
const [teEvents, invEvents, cbPaths] = await Promise.all([
|
||||
fetchTECalendarHTML(fromDate, toDate),
|
||||
fetchInvestingCalendar(fromDate, toDate),
|
||||
const [teEvents, ilEvents, cbPaths] = await Promise.all([
|
||||
fetchTECalendarWide(fromDate, toDate),
|
||||
fetchFXStreetCalendar(fromDate, toDate),
|
||||
fetchAllCBPaths(),
|
||||
]);
|
||||
|
||||
const useScraping = teEvents.length > 0 || invEvents.length > 0;
|
||||
const useScraping = teEvents.length > 0 || ilEvents.length > 0;
|
||||
|
||||
// Fetch FF+FRED en secours seulement si les deux scrapers ont échoué
|
||||
const [ffEvents, fredEvents] = useScraping
|
||||
@@ -377,23 +397,24 @@ export async function GET() {
|
||||
date >= thisMonday ? "current" : "prev";
|
||||
|
||||
if (useScraping) {
|
||||
// ── BASE : TE HTML ────────────────────────────────────────────────────────
|
||||
// ── BASE : Trading Economics (45 pays) ─────────────────────────────────────
|
||||
// Index de dédupe : clé → index dans events[]
|
||||
const dedupeIndex = new Map<string, number>();
|
||||
|
||||
for (const te of teEvents) {
|
||||
if (te.category === "other") continue; // filtre les events sans catégorie pertinente
|
||||
if (isExcludedEventTitle(te.title)) continue; // adjudications, prod. industrielle, énergie/hypothécaire hebdo US, CPI infranational, réunions institutionnelles
|
||||
const evDate = new Date(te.date);
|
||||
const key = dedupeKey(te.currency, te.category, te.date);
|
||||
const key = dedupeKey(te.countryCode, te.category, te.date);
|
||||
dedupeIndex.set(key, events.length);
|
||||
events.push({
|
||||
id: te.id,
|
||||
date: te.date,
|
||||
currency: te.currency,
|
||||
countryCode: te.countryCode,
|
||||
category: te.category,
|
||||
title: te.title,
|
||||
rawTitle: te.title,
|
||||
impact: te.impact,
|
||||
impact: applyImpactFloor(te.title, te.impact),
|
||||
actual: te.actual,
|
||||
forecast: te.forecast,
|
||||
previous: te.previous,
|
||||
@@ -406,35 +427,37 @@ export async function GET() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── COMPLÉMENT : Investing.com ────────────────────────────────────────────
|
||||
// ── COMPLÉMENT : investingLive (widget FXStreet) ───────────────────────────
|
||||
// Dédupe immédiat via dedupeIndex : même clé = doublon, on enrichit seulement.
|
||||
// Absent de TE = on ajoute l'event Investing directement.
|
||||
for (const inv of invEvents) {
|
||||
if (inv.category === "other") continue;
|
||||
const key = dedupeKey(inv.currency, inv.category, inv.date);
|
||||
// Absent de TE = on ajoute l'event investingLive directement — c'est ce qui
|
||||
// permet aux deux sources de se compléter l'une l'autre.
|
||||
for (const il of ilEvents) {
|
||||
if (isExcludedEventTitle(il.title)) continue;
|
||||
const key = dedupeKey(il.countryCode, il.category, il.date);
|
||||
const existingIdx = dedupeIndex.get(key);
|
||||
if (existingIdx !== undefined) {
|
||||
// Doublon — enrichir avec les valeurs manquantes d'Investing
|
||||
// Doublon — enrichir avec les valeurs manquantes d'investingLive
|
||||
const ev = events[existingIdx];
|
||||
if (!ev.actual && inv.actual) ev.actual = inv.actual;
|
||||
if (!ev.forecast && inv.forecast) ev.forecast = inv.forecast;
|
||||
if (!ev.previous && inv.previous) ev.previous = inv.previous;
|
||||
if (!ev.actual && il.actual) ev.actual = il.actual;
|
||||
if (!ev.forecast && il.forecast) ev.forecast = il.forecast;
|
||||
if (!ev.previous && il.previous) ev.previous = il.previous;
|
||||
} else {
|
||||
// Présent sur Investing mais absent de TE — on l'ajoute
|
||||
const evDate = new Date(inv.date);
|
||||
// Présent sur investingLive mais absent de TE — on l'ajoute
|
||||
const evDate = new Date(il.date);
|
||||
dedupeIndex.set(key, events.length);
|
||||
events.push({
|
||||
id: inv.id,
|
||||
date: inv.date,
|
||||
currency: inv.currency,
|
||||
category: inv.category,
|
||||
title: inv.title,
|
||||
rawTitle: inv.title,
|
||||
impact: inv.impact,
|
||||
actual: inv.actual,
|
||||
forecast: inv.forecast,
|
||||
previous: inv.previous,
|
||||
isPublished: inv.isPublished,
|
||||
id: il.id,
|
||||
date: il.date,
|
||||
currency: il.currency,
|
||||
countryCode: il.countryCode,
|
||||
category: il.category,
|
||||
title: il.title,
|
||||
rawTitle: il.title,
|
||||
impact: applyImpactFloor(il.title, il.impact),
|
||||
actual: il.actual,
|
||||
forecast: il.forecast,
|
||||
previous: il.previous,
|
||||
isPublished: il.isPublished,
|
||||
week: weekOf(evDate),
|
||||
source: "fred",
|
||||
groupKey: null,
|
||||
@@ -508,6 +531,7 @@ export async function GET() {
|
||||
id: `cb_${meeting.currency}_${meeting.dateIso}`,
|
||||
date: isoDate,
|
||||
currency: meeting.currency,
|
||||
countryCode: MAJOR_CCY_TO_COUNTRY[meeting.currency] ?? meeting.currency,
|
||||
category: "policy_rate",
|
||||
title: meeting.title + probLabel,
|
||||
rawTitle: meeting.title,
|
||||
@@ -545,7 +569,7 @@ export async function GET() {
|
||||
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const sourceLabel = useScraping
|
||||
? `tradingeconomics-html(${teEvents.length})+investing(${invEvents.length})+rateprobability`
|
||||
? `tradingeconomics-html(${teEvents.length})+investinglive(${ilEvents.length})+rateprobability`
|
||||
: (fredKey ? "forexfactory+fred+rateprobability" : "forexfactory+rateprobability");
|
||||
|
||||
const result: CalendarResponse = {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchAllCBGovernance } from "@/lib/centralBankGovernance";
|
||||
import type { CBGovernance } from "@/lib/centralBankGovernance";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export type { CBGovernance, FedDotPlot, FedDot, FedSepHistoryPoint } from "@/lib/centralBankGovernance";
|
||||
|
||||
export interface CentralBankSourcesResponse {
|
||||
data: Record<string, CBGovernance>;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const data = await fetchAllCBGovernance();
|
||||
return NextResponse.json(
|
||||
{ data, fetchedAt: new Date().toISOString() } satisfies CentralBankSourcesResponse,
|
||||
{ headers: { "Cache-Control": "s-maxage=3600, stale-while-revalidate=21600" } }
|
||||
);
|
||||
}
|
||||
+105
-17
@@ -5,6 +5,7 @@ import type { Currency } from "@/lib/types";
|
||||
export const dynamic = "force-dynamic";
|
||||
import cpiOverridesRaw from "@/data/cpi_overrides.json";
|
||||
import rateDecisionsRaw from "@/data/rate_decisions.json";
|
||||
import moneySupplyM3Raw from "@/data/money-supply-m3.json";
|
||||
import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
|
||||
import type { FFEvent } from "@/lib/forexfactory";
|
||||
import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate, fetchTESTIRRate, fetchTEEmploymentChange } from "@/lib/tecpi";
|
||||
@@ -129,6 +130,78 @@ async function boeRate(): Promise<Obs[]> {
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── SNB officiel (CHF policy rate) ─────────────────────────────────────────────
|
||||
// snb.ch publie ses décisions sous /press-releases-restricted/pre_YYYYMMDD.
|
||||
// On découvre la plus récente depuis la page listing (1er lien du pattern),
|
||||
// puis on lit le taux dans le titre : "leaves ... unchanged at X%" / "lowers/raises ... to X%".
|
||||
async function scrapeSnbRate(): Promise<number | null> {
|
||||
const 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,*/*;q=0.8",
|
||||
};
|
||||
try {
|
||||
const listRes = await fetch("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions", {
|
||||
next: { revalidate: REVALIDATE }, headers,
|
||||
});
|
||||
if (!listRes.ok) return null;
|
||||
const listHtml = await listRes.text();
|
||||
const linkMatch = listHtml.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_\d{8}[^"]*)"/);
|
||||
if (!linkMatch) return null;
|
||||
|
||||
const prRes = await fetch(`https://www.snb.ch${linkMatch[1]}`, {
|
||||
next: { revalidate: REVALIDATE }, headers,
|
||||
});
|
||||
if (!prRes.ok) return null;
|
||||
const prHtml = await prRes.text();
|
||||
const rateMatch = prHtml.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i);
|
||||
return rateMatch ? parseFloat(rateMatch[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Fed officiel (USD policy rate) ──────────────────────────────────────────────
|
||||
// fomccalendars.htm liste, pour chaque réunion, un lien de statement
|
||||
// /newsevents/pressreleases/monetary(YYYYMMDD)a.htm. On prend la réunion passée
|
||||
// la plus récente puis on lit la fourchette officielle : "target range for the
|
||||
// federal funds rate at/to X to Y percent" (X/Y en fractions type "3-1/2").
|
||||
// On retourne le haut de fourchette (convention "upper bound" déjà utilisée ici).
|
||||
function parseFedFraction(s: string): number {
|
||||
const m = s.match(/^(\d+)(?:-(\d+)\/(\d+))?$/);
|
||||
if (!m) return NaN;
|
||||
const whole = parseFloat(m[1]);
|
||||
return m[2] ? whole + parseFloat(m[2]) / parseFloat(m[3]) : whole;
|
||||
}
|
||||
|
||||
async function scrapeFedRate(): Promise<number | null> {
|
||||
const 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,*/*;q=0.8",
|
||||
};
|
||||
try {
|
||||
const calRes = await fetch("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", {
|
||||
next: { revalidate: REVALIDATE }, headers,
|
||||
});
|
||||
if (!calRes.ok) return null;
|
||||
const calHtml = await calRes.text();
|
||||
|
||||
const todayCompact = new Date().toISOString().slice(0,10).replace(/-/g,"");
|
||||
const dates = new Set<string>();
|
||||
for (const m of Array.from(calHtml.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g))) dates.add(m[1]);
|
||||
const latestPast = Array.from(dates).filter(d => d <= todayCompact).sort().reverse()[0];
|
||||
if (!latestPast) return null;
|
||||
|
||||
const stRes = await fetch(`https://www.federalreserve.gov/newsevents/pressreleases/monetary${latestPast}a.htm`, {
|
||||
next: { revalidate: REVALIDATE }, headers,
|
||||
});
|
||||
if (!stRes.ok) return null;
|
||||
const stHtml = await stRes.text();
|
||||
const rateMatch = stHtml.match(/target range for the federal funds rate[\s\S]{0,60}?\d+(?:-\d\/\d)?\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i);
|
||||
if (!rateMatch) return null;
|
||||
|
||||
const upper = parseFedFraction(rateMatch[1]);
|
||||
return isNaN(upper) ? null : upper;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── DBnomics API (agrégateur IMF/IFS, BIS, OECD…) ────────────────────────────
|
||||
// Format : https://api.db.nomics.world/v22/series/{provider}/{dataset}/{code}?observations=1
|
||||
// Utilisé pour les séries absentes de FRED : JPY CPI, AUD/NZD CPI fallback
|
||||
@@ -530,6 +603,19 @@ function getRateDecision(ccy: string): RateDecision | null {
|
||||
return entry?.decisions?.[ccy] ?? null;
|
||||
}
|
||||
|
||||
// ── money-supply-m3.json — masse monétaire M3 (niveau, statique) ─────────────
|
||||
|
||||
type MoneySupplyM3 = {
|
||||
value: number; unit: string; period: string; isProxy: boolean;
|
||||
proxyLabel?: string; source: string;
|
||||
};
|
||||
|
||||
function getMoneySupplyM3(ccy: string): MoneySupplyM3 | null {
|
||||
type MsRaw = [{ series: Record<string, MoneySupplyM3> }];
|
||||
const entry = (moneySupplyM3Raw as unknown as MsRaw)[0];
|
||||
return entry?.series?.[ccy] ?? null;
|
||||
}
|
||||
|
||||
/** Construit un IndicatorResult à partir de la valeur TE + prev de l'override */
|
||||
function buildRateIndicator(current: number, prev: number, today: string): IndicatorResult {
|
||||
const surprise = parseFloat((current - prev).toFixed(4));
|
||||
@@ -846,11 +932,13 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// USD — midpoint de la fourchette cible (DFEDTARU + DFEDTARL) / 2
|
||||
// Rateprobability.com / marchés quotent le midpoint (3.625%) pas l'upper bound (3.75%)
|
||||
// USD — TE scraping (taux Fed upper bound officiel = 3.75%)
|
||||
// USD — Fed officiel en primaire (source directe federalreserve.gov, cf. scrapeFedRate)
|
||||
// Repli TE scraping puis FRED upper bound si federalreserve.gov indisponible.
|
||||
// Ancien calcul midpoint FRED (DFEDTARU+DFEDTARL)/2 = 3.625% → pas le taux annoncé
|
||||
if (currency === "USD") {
|
||||
const te = await scrapeTeRate(TE_COUNTRY.USD);
|
||||
const fed = await scrapeFedRate();
|
||||
const ovr = getRateDecision("USD");
|
||||
const te = fed ?? await scrapeTeRate(TE_COUNTRY.USD);
|
||||
if (te !== null && ovr) {
|
||||
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
||||
} else if (te !== null) {
|
||||
@@ -930,15 +1018,17 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// CHF — TE scraping (taux SNB officiel exact = 0.00%)
|
||||
// CHF — SNB officiel en primaire (source directe snb.ch, cf. scrapeSnbRate)
|
||||
// Repli TE scraping si snb.ch indisponible.
|
||||
// IR3TIB01CHM156N = SARON 3M (~-0.04%) ≠ taux SNB officiel
|
||||
if (currency === "CHF") {
|
||||
const te = await scrapeTeRate(TE_COUNTRY.CHF);
|
||||
const snb = await scrapeSnbRate();
|
||||
const ovr = getRateDecision("CHF");
|
||||
if (te !== null && ovr) {
|
||||
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
|
||||
} else if (te !== null) {
|
||||
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
|
||||
const rate = snb ?? await scrapeTeRate(TE_COUNTRY.CHF);
|
||||
if (rate !== null && ovr) {
|
||||
indicators.policyRate = buildRateIndicator(rate, ovr.prev, today);
|
||||
} else if (rate !== null) {
|
||||
indicators.policyRate = { value: rate, prev: null, surprise: null, trend: null, lastUpdated: today };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,18 +1137,16 @@ export async function GET(req: NextRequest) {
|
||||
toDateObj.setDate(toDateObj.getDate() + 21);
|
||||
const toDate = toDateObj.toISOString().slice(0, 10);
|
||||
|
||||
const [ffPMI, pmiMfgRaw, pmiSvcRaw, pmiCompositeRaw, ffForecasts, teForecastMap, invForecastMap] = await Promise.all([
|
||||
const [ffPMI, pmiMfgRaw, pmiSvcRaw, pmiCompositeRaw, ffForecasts, teForecastMap] = await Promise.all([
|
||||
fetchFFPMI(currency),
|
||||
scrapePMI(currency, "manufacturing-pmi"),
|
||||
scrapePMI(currency, "services-pmi"),
|
||||
scrapePMI(currency, "composite-pmi"),
|
||||
fetchFFForecasts(currency),
|
||||
fetchTEInflationForecasts(today, toDate),
|
||||
(await import("@/lib/investing")).fetchInvestingInflationForecasts(today, toDate),
|
||||
]);
|
||||
|
||||
const teCpiForecast = teForecastMap[currency];
|
||||
const invForecast = invForecastMap[currency];
|
||||
// FF en priorité (forecast + actual) ; TE en fallback
|
||||
indicators.pmiMfg = ffPMI.mfg ? toPmiIndicator(ffPMI.mfg) : toPmiIndicator(pmiMfgRaw);
|
||||
indicators.pmiServices = ffPMI.svc ? toPmiIndicator(ffPMI.svc) : toPmiIndicator(pmiSvcRaw);
|
||||
@@ -1337,14 +1425,14 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const data = {
|
||||
currency, indicators,
|
||||
moneySupplyM3: getMoneySupplyM3(currency),
|
||||
forecasts: {
|
||||
// CPI — TE calendar forecast (priorité) puis ForexFactory
|
||||
// Les forecasts TE sont des strings "2.8%" → parseFloat les convertit en number
|
||||
cpi: parseTeF(teCpiForecast?.cpiYoY) ?? parseTeF(invForecast?.cpiYoY) ?? ffForecasts.cpi ?? null,
|
||||
cpiCore: parseTeF(teCpiForecast?.cpiCore) ?? parseTeF(invForecast?.cpiCore) ?? null,
|
||||
cpiMoM: parseTeF(teCpiForecast?.cpiMoM) ?? parseTeF(invForecast?.cpiMoM) ?? null,
|
||||
cpiCoreMoM: parseTeF(teCpiForecast?.cpiCoreMoM) ?? parseTeF(invForecast?.cpiCoreMoM) ?? null,
|
||||
ppiMoM: parseTeF(teCpiForecast?.ppiMoM) ?? parseTeF(invForecast?.ppiMoM) ?? null,
|
||||
cpi: parseTeF(teCpiForecast?.cpiYoY) ?? ffForecasts.cpi ?? null,
|
||||
cpiCore: parseTeF(teCpiForecast?.cpiCore) ?? null,
|
||||
cpiMoM: parseTeF(teCpiForecast?.cpiMoM) ?? null,
|
||||
cpiCoreMoM: parseTeF(teCpiForecast?.cpiCoreMoM) ?? null,
|
||||
ppiMoM: parseTeF(teCpiForecast?.ppiMoM) ?? null,
|
||||
cpiSurprise: ffForecasts.cpiSurprise,
|
||||
unemployment: ffForecasts.unemployment,
|
||||
unemploymentSurprise: ffForecasts.unemploymentSurprise,
|
||||
|
||||
+6
-22
@@ -9,12 +9,10 @@ import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
|
||||
import CurrencyCard from "@/components/CurrencyCard";
|
||||
import DriversBar from "@/components/DriversBar";
|
||||
import CalendarTab from "@/components/CalendarTab";
|
||||
import SentimentPairsTab from "@/components/SentimentPairsTab";
|
||||
import YieldsTab from "@/components/YieldsTab";
|
||||
import NewsTab from "@/components/NewsTab";
|
||||
import CotTab from "@/components/CotTab";
|
||||
import ReportTab from "@/components/ReportTab";
|
||||
import IdeesTab from "@/components/IdeesTab";
|
||||
import CentralBankSourcesTab from "@/components/CentralBankSourcesTab";
|
||||
import { TvAdvancedChart } from "@/components/TvChart";
|
||||
import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||
import type { NewsItem } from "@/app/api/news/route";
|
||||
@@ -30,12 +28,11 @@ export default function Dashboard() {
|
||||
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
|
||||
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
|
||||
const [nextWeekAvail, setNextWeekAvail] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields" | "news" | "cot" | "report" | "markets" | "idees">("dashboard");
|
||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "news" | "cot" | "markets" | "idees" | "cbsources">("dashboard");
|
||||
const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
|
||||
const [newsLoading, setNewsLoading] = useState(false);
|
||||
const [cotHistory, setCotHistory] = useState<CotHistory | null>(null);
|
||||
const [cotLoading, setCotLoading] = useState(false);
|
||||
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; longVolume: number; shortVolume: number; longPositions: number; shortPositions: number; totalPositions: number; avgLongPrice?: number; avgShortPrice?: number }> | null>(null);
|
||||
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -200,7 +197,6 @@ export default function Dashboard() {
|
||||
// ── Sentiment Myfxbook ────────────────────────────────────────────────
|
||||
if (sentimentRes.status === "fulfilled" && !sentimentRes.value?.error && sentimentRes.value?.symbols) {
|
||||
const syms = sentimentRes.value.symbols as Array<{ name: string; longPercentage: number; shortPercentage: number; longVolume: number; shortVolume: number; longPositions: number; shortPositions: number; totalPositions: number; avgLongPrice?: number; avgShortPrice?: number }>;
|
||||
setRawSymbols(syms);
|
||||
const mapped = parseSentimentSymbols(syms);
|
||||
setSentiment(mapped);
|
||||
saveCache("sentiment", mapped);
|
||||
@@ -347,7 +343,7 @@ export default function Dashboard() {
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-0 border-b border-slate-800 mb-4">
|
||||
{(["dashboard", "markets", "idees", "calendar", "pairs", "yields", "news", "cot", "report"] as const).map((tab) => (
|
||||
{(["dashboard", "markets", "idees", "calendar", "cbsources", "news", "cot"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -361,11 +357,9 @@ export default function Dashboard() {
|
||||
: tab === "markets" ? "🌍 Marchés"
|
||||
: tab === "idees" ? "💡 Idées"
|
||||
: tab === "calendar" ? "📅 Calendrier"
|
||||
: tab === "pairs" ? "↕ Paires"
|
||||
: tab === "yields" ? "📈 Yields 10Y"
|
||||
: tab === "cbsources" ? "🏛️ Banques centrales"
|
||||
: tab === "news" ? "📰 Actualités"
|
||||
: tab === "cot" ? "📊 COT"
|
||||
: "📋 Rapport"}
|
||||
: "📊 COT"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -619,13 +613,7 @@ export default function Dashboard() {
|
||||
<CalendarTab events={calEvents} loading={loading} nextWeekAvail={nextWeekAvail} />
|
||||
)}
|
||||
|
||||
{activeTab === "pairs" && (
|
||||
<SentimentPairsTab symbols={rawSymbols} />
|
||||
)}
|
||||
|
||||
{activeTab === "yields" && (
|
||||
<YieldsTab yieldsData={yields} fxDayPct={yields?.fxDayPct ?? null} />
|
||||
)}
|
||||
{activeTab === "cbsources" && <CentralBankSourcesTab />}
|
||||
|
||||
{activeTab === "news" && (
|
||||
<NewsTab items={newsItems} loading={newsLoading} onRefresh={refreshNews} />
|
||||
@@ -637,10 +625,6 @@ export default function Dashboard() {
|
||||
|
||||
{activeTab === "idees" && <IdeesTab />}
|
||||
|
||||
{activeTab === "report" && (
|
||||
<ReportTab calEvents={calEvents} drivers={drivers} cotHistory={cotHistory} />
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center gap-5 flex-wrap text-[10px] text-slate-600">
|
||||
<div className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" /> Haussier / Sous-évalué</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const b = await chromium.launch();
|
||||
const p = await b.newPage();
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
p.on('console', msg => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
if (msg.type() === 'warning') warnings.push(msg.text());
|
||||
});
|
||||
p.on('pageerror', err => errors.push('PAGE ERROR: ' + err.message));
|
||||
await p.setViewportSize({width: 589, height: 900});
|
||||
await p.goto('http://localhost:3099/');
|
||||
await p.waitForTimeout(10000);
|
||||
console.log('ERRORS:', JSON.stringify(errors, null, 2));
|
||||
console.log('WARNINGS:', JSON.stringify(warnings.slice(0, 5), null, 2));
|
||||
|
||||
// Also check page height
|
||||
const height = await p.evaluate(() => document.body.scrollHeight);
|
||||
console.log('Page height:', height);
|
||||
|
||||
// Check if any element has overflow issues
|
||||
const overflows = await p.evaluate(() => {
|
||||
const issues = [];
|
||||
document.querySelectorAll('*').forEach(el => {
|
||||
if (el.scrollWidth > el.clientWidth + 5) {
|
||||
issues.push(el.className.substring(0, 80));
|
||||
}
|
||||
});
|
||||
return issues.slice(0, 10);
|
||||
});
|
||||
console.log('Overflow elements:', overflows);
|
||||
|
||||
await b.close();
|
||||
})();
|
||||
+61
-12
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useMemo, useRef } from "react";
|
||||
import { ChevronDown, ChevronRight, Loader2, Calendar } from "lucide-react";
|
||||
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
|
||||
import type { Currency } from "@/lib/types";
|
||||
import { CURRENCY_META } from "@/lib/constants";
|
||||
import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||
|
||||
interface Props {
|
||||
@@ -21,9 +20,19 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
gdp: "PIB",
|
||||
retail_sales: "Ventes détail",
|
||||
trade_balance: "Balance comm.",
|
||||
sentiment: "Confiance",
|
||||
housing: "Immobilier",
|
||||
money_supply: "Masse monétaire",
|
||||
trade_detail: "Commerce (détail)",
|
||||
regional_fed: "Fed régionale",
|
||||
portfolio_flows:"Flux portefeuille",
|
||||
public_finance: "Finances publiques",
|
||||
holiday: "Jour férié",
|
||||
other: "Autre",
|
||||
};
|
||||
|
||||
// ── Currency → ISO alpha-2 (pour flagcdn.com) ─────────────────────────────────
|
||||
// Univers élargi (45 pays côté calendrier, au-delà des 8 devises majeures tradées).
|
||||
|
||||
const CCY_ISO: Record<string, string> = {
|
||||
USD: "us", EUR: "eu", GBP: "gb", JPY: "jp",
|
||||
@@ -31,8 +40,15 @@ const CCY_ISO: Record<string, string> = {
|
||||
CNY: "cn", SEK: "se", NOK: "no", DKK: "dk",
|
||||
SGD: "sg", HKD: "hk", MXN: "mx", BRL: "br",
|
||||
ZAR: "za", INR: "in", KRW: "kr", TRY: "tr",
|
||||
ARS: "ar", CLP: "cl", COP: "co", CZK: "cz",
|
||||
HUF: "hu", ISK: "is", IDR: "id", ILS: "il",
|
||||
KWD: "kw", PLN: "pl", RON: "ro", RUB: "ru", VND: "vn",
|
||||
};
|
||||
|
||||
// Ordre d'affichage préféré des chips devise : majeures d'abord, puis le reste
|
||||
// trié alphabétiquement (calculé dynamiquement depuis les events reçus).
|
||||
const MAJOR_ORDER = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function isoToLocalDate(iso: string): string {
|
||||
@@ -100,7 +116,6 @@ function EventRow({ ev, isChild, expanded, onToggle }: {
|
||||
ev: CalendarEvent; isChild: boolean; expanded: boolean; onToggle: () => void;
|
||||
}) {
|
||||
const { day, time } = fmtDate(ev.date);
|
||||
const meta = CURRENCY_META[ev.currency];
|
||||
|
||||
const borderCls = !ev.isPublished && ev.impact === "high" ? "border-l-2 border-l-red-500"
|
||||
: !ev.isPublished && ev.impact === "medium" ? "border-l-2 border-l-amber-400"
|
||||
@@ -177,11 +192,12 @@ function EventRow({ ev, isChild, expanded, onToggle }: {
|
||||
type WeekTab = "prev" | "current" | "next" | "next2" | "all";
|
||||
|
||||
export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
|
||||
const [filterCcy, setFilterCcy] = useState<string>("ALL");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [showLow, setShowLow] = useState(false);
|
||||
const [weekTab, setWeekTab] = useState<WeekTab>("all");
|
||||
const [fromDate, setFromDate] = useState<string>(todayIso());
|
||||
const fromDateRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { prevWeekLabel, currentWeekLabel, nextWeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
|
||||
void nextMondayIso;
|
||||
@@ -206,6 +222,19 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
return true;
|
||||
}), [events, filterCcy, showLow, expanded, weekTab, fromDate]);
|
||||
|
||||
// Chips devise : toutes les devises présentes dans les events reçus,
|
||||
// majeures d'abord (ordre trading), puis le reste par ordre alphabétique.
|
||||
const availableCurrencies = useMemo(() => {
|
||||
const set = new Set(events.map(ev => ev.currency));
|
||||
return Array.from(set).sort((a, b) => {
|
||||
const ia = MAJOR_ORDER.indexOf(a), ib = MAJOR_ORDER.indexOf(b);
|
||||
if (ia !== -1 && ib !== -1) return ia - ib;
|
||||
if (ia !== -1) return -1;
|
||||
if (ib !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
const days: string[] = [];
|
||||
const dayMap: Record<string, CalendarEvent[]> = {};
|
||||
for (const ev of filtered) {
|
||||
@@ -228,7 +257,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-200">Calendrier économique</h2>
|
||||
<p className="text-[10px] text-slate-600 mt-0.5">Sources : ForexFactory · FRED · Banques centrales</p>
|
||||
<p className="text-[10px] text-slate-600 mt-0.5">Sources : Trading Economics · investingLive · Banques centrales</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer">
|
||||
<input
|
||||
@@ -290,14 +319,27 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Calendar size={11} className="text-slate-600" />
|
||||
<span className="text-[10px] text-slate-500">Depuis</span>
|
||||
{/* defaultValue (pas value) : un input date="" contrôlé re-render à chaque
|
||||
frappe et casse la saisie clavier native (les segments jour/mois/année
|
||||
se mélangent, y compris si le re-render est déclenché indirectement via
|
||||
une key). Non-contrôlé après le mount ; le bouton "Aujourd'hui" resynchronise
|
||||
l'affichage à la main via la ref, sans jamais re-render l'input lui-même. */}
|
||||
<input
|
||||
ref={fromDateRef}
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={e => setFromDate(e.target.value)}
|
||||
defaultValue={fromDate}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) setFromDate(v);
|
||||
}}
|
||||
className="text-[10px] bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-slate-300 focus:outline-none focus:border-amber-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFromDate(todayIso())}
|
||||
onClick={() => {
|
||||
const today = todayIso();
|
||||
setFromDate(today);
|
||||
if (fromDateRef.current) fromDateRef.current.value = today;
|
||||
}}
|
||||
className="text-[9px] text-amber-500 hover:text-amber-400 underline"
|
||||
>
|
||||
Aujourd'hui
|
||||
@@ -317,7 +359,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
>
|
||||
Tout
|
||||
</button>
|
||||
{CURRENCIES.map(ccy => (
|
||||
{availableCurrencies.map(ccy => (
|
||||
<button
|
||||
key={ccy}
|
||||
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
|
||||
@@ -327,7 +369,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{CURRENCY_META[ccy].flag} {ccy}
|
||||
{CURRENCY_META[ccy as keyof typeof CURRENCY_META]?.flag ?? ""} {ccy}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -348,7 +390,14 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
</p>
|
||||
)}
|
||||
{fromDate > todayIso() && (
|
||||
<button onClick={() => setFromDate(todayIso())} className="mt-2 text-[10px] text-amber-500 underline">
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = todayIso();
|
||||
setFromDate(today);
|
||||
if (fromDateRef.current) fromDateRef.current.value = today;
|
||||
}}
|
||||
className="mt-2 text-[10px] text-amber-500 underline"
|
||||
>
|
||||
Revenir à aujourd'hui
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Loader2, ExternalLink, FileText, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
ScatterChart, Scatter, ZAxis, LineChart, Line,
|
||||
ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid,
|
||||
} from "recharts";
|
||||
import type { CBGovernance } from "@/app/api/central-bank-sources/route";
|
||||
|
||||
const CCY_ISO: Record<string, string> = {
|
||||
USD: "us", EUR: "eu", GBP: "gb", JPY: "jp",
|
||||
CHF: "ch", CAD: "ca", AUD: "au", NZD: "nz",
|
||||
};
|
||||
|
||||
const ORDER = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
|
||||
|
||||
function Flag({ ccy }: { ccy: string }) {
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full overflow-hidden shrink-0 bg-slate-700">
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${CCY_ISO[ccy] ?? ccy.slice(0, 2).toLowerCase()}.png`}
|
||||
width={24} height={24} alt={ccy} className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dot plot Fed : scatter (année × taux, taille = nb de participants) ────────
|
||||
|
||||
function FedDotPlotChart({ dotPlot }: { dotPlot: NonNullable<CBGovernance["dotPlot"]> }) {
|
||||
const yearIndex = new Map(dotPlot.years.map((y, i) => [y, i]));
|
||||
const scatterData = dotPlot.dots.map(d => ({ x: yearIndex.get(d.year) ?? 0, y: d.rate, z: d.count, year: d.year }));
|
||||
const medianData = dotPlot.years.map((y, i) => ({ x: i, y: dotPlot.medianByYear[y] }));
|
||||
|
||||
const allRates = dotPlot.dots.map(d => d.rate);
|
||||
const minY = Math.min(...allRates, ...Object.values(dotPlot.medianByYear)) - 0.2;
|
||||
const maxY = Math.max(...allRates, ...Object.values(dotPlot.medianByYear)) + 0.2;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<ScatterChart margin={{ top: 6, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="#1e293b" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number" dataKey="x" domain={[-0.5, dotPlot.years.length - 0.5]}
|
||||
ticks={dotPlot.years.map((_, i) => i)}
|
||||
tickFormatter={(i: number) => dotPlot.years[i] ?? ""}
|
||||
tick={{ fontSize: 8, fill: "#64748b" }} axisLine={false} tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="number" dataKey="y" domain={[minY, maxY]}
|
||||
tickFormatter={(v: number) => `${v.toFixed(1)}%`}
|
||||
tick={{ fontSize: 8, fill: "#64748b" }} axisLine={false} tickLine={false} width={34}
|
||||
/>
|
||||
<ZAxis type="number" dataKey="z" range={[20, 260]} />
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: "3 3" }}
|
||||
content={({ payload }) => {
|
||||
const p = payload?.[0]?.payload as { year: string; y: number; z: number } | undefined;
|
||||
if (!p) return null;
|
||||
return (
|
||||
<div style={{ background: "rgba(8,14,28,0.97)", border: "1px solid #1e293b", borderRadius: 8, padding: "6px 10px" }}>
|
||||
<p style={{ color: "#94a3b8", fontSize: 9, margin: 0 }}>{p.year} · {p.y.toFixed(3)}%</p>
|
||||
<p style={{ color: "#f59e0b", fontSize: 9, margin: 0, fontWeight: 700 }}>{p.z} participant{p.z > 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={scatterData} fill="#f59e0b" fillOpacity={0.65} />
|
||||
<Scatter data={medianData} fill="#38bdf8" shape="diamond" legendType="none" />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Évolution : médiane "année courante" + "long terme" au fil des SEP ────────
|
||||
|
||||
function FedEvolutionChart({ dotPlot }: { dotPlot: NonNullable<CBGovernance["dotPlot"]> }) {
|
||||
const data = dotPlot.history.map(h => {
|
||||
const entries = Object.entries(h.medianByYear);
|
||||
const nearTerm = entries.find(([y]) => y !== "Longer run")?.[1] ?? null;
|
||||
const longRun = h.medianByYear["Longer run"] ?? null;
|
||||
return { date: h.date.slice(2, 7), nearTerm, longRun };
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={110}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 7, fill: "#475569" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 7, fill: "#475569" }} axisLine={false} tickLine={false} width={30} tickFormatter={(v: number) => `${v.toFixed(1)}%`} />
|
||||
<Tooltip
|
||||
content={({ label, payload }) => {
|
||||
if (!payload?.length) return null;
|
||||
const items = payload as { dataKey: string; value: number }[];
|
||||
return (
|
||||
<div style={{ background: "rgba(8,14,28,0.97)", border: "1px solid #1e293b", borderRadius: 8, padding: "6px 10px" }}>
|
||||
<p style={{ color: "#475569", fontSize: 8, margin: "0 0 4px" }}>{label}</p>
|
||||
{items.map(it => (
|
||||
<p key={it.dataKey} style={{ color: it.dataKey === "longRun" ? "#38bdf8" : "#f59e0b", fontSize: 9, margin: 0, fontWeight: 700 }}>
|
||||
{it.dataKey === "longRun" ? "Long terme" : "Année courante"} : {it.value.toFixed(2)}%
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line type="monotone" dataKey="nearTerm" stroke="#f59e0b" strokeWidth={1.5} dot={{ r: 2.5, fill: "#1e293b", stroke: "#f59e0b" }} />
|
||||
<Line type="monotone" dataKey="longRun" stroke="#38bdf8" strokeWidth={1.5} dot={{ r: 2.5, fill: "#1e293b", stroke: "#38bdf8" }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Prévisions macro (comment la BC voit sa propre économie) ──────────────────
|
||||
// PIB + inflation par échéance, publiées par la BC elle-même (SEP Fed,
|
||||
// Eurosystem staff projections, BoJ Outlook Report, MPR BoC, SMP RBA…).
|
||||
|
||||
function fmtForecastYear(y: string): string {
|
||||
const m = y.match(/^(\d{4})-(\d{2})$/);
|
||||
if (!m) return y;
|
||||
const MONTH: Record<string, string> = { "06": "Jun", "12": "Dec" };
|
||||
return `${MONTH[m[2]] ?? m[2]}’${m[1].slice(2)}`;
|
||||
}
|
||||
|
||||
function ForecastBlock({ forecast }: { forecast: NonNullable<CBGovernance["forecast"]> }) {
|
||||
const years = forecast.years;
|
||||
const hasGdp = Object.values(forecast.gdp).some(v => v != null);
|
||||
const hasInfl = Object.values(forecast.inflation).some(v => v != null);
|
||||
if (!years.length || (!hasGdp && !hasInfl)) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Prévisions — comment la BC voit son économie</p>
|
||||
<div className="bg-slate-900/60 rounded-lg px-3 py-2.5 overflow-x-auto">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-slate-600 font-normal pb-1"> </th>
|
||||
{years.map(y => (
|
||||
<th key={y} className="text-right text-slate-500 font-medium pb-1 pl-2.5 tabular-nums whitespace-nowrap">{fmtForecastYear(y)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hasGdp && (
|
||||
<tr>
|
||||
<td className="text-slate-400 pr-2 py-0.5 whitespace-nowrap">PIB</td>
|
||||
{years.map(y => {
|
||||
const v = forecast.gdp[y];
|
||||
return (
|
||||
<td key={y} className="text-right text-slate-200 font-semibold tabular-nums pl-2.5 whitespace-nowrap">
|
||||
{v != null ? `${v > 0 ? "+" : ""}${v}%` : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
{hasInfl && (
|
||||
<tr>
|
||||
<td className="text-slate-400 pr-2 py-0.5 whitespace-nowrap">Inflation</td>
|
||||
{years.map(y => {
|
||||
const v = forecast.inflation[y];
|
||||
return (
|
||||
<td key={y} className="text-right text-amber-400 font-semibold tabular-nums pl-2.5 whitespace-nowrap">
|
||||
{v != null ? `${v > 0 ? "+" : ""}${v}%` : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[8px] text-slate-700 leading-snug">
|
||||
{forecast.label} · publié {forecast.asOf}
|
||||
{forecast.isProxy && forecast.proxyLabel ? ` · ${forecast.proxyLabel}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Carte banque centrale ──────────────────────────────────────────────────────
|
||||
|
||||
function CBCard({ g }: { g: CBGovernance }) {
|
||||
const hasVote = g.voteSummary !== null;
|
||||
return (
|
||||
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-800 flex items-center gap-2">
|
||||
<Flag ccy={g.currency} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-slate-200 truncate">{g.bankName}</h3>
|
||||
<p className="text-[10px] text-slate-600">{g.countryLabel} · {g.currency}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{hasVote ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Dernière réunion</p>
|
||||
<p className="text-xs text-slate-300 font-medium">{g.meetingDate ?? "—"}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Taux</p>
|
||||
<p className="text-sm text-amber-400 font-bold tabular-nums">{g.rateLevel ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/60 rounded-lg px-3 py-2.5">
|
||||
<p className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Vote</p>
|
||||
<p className="text-lg font-black text-slate-100 tabular-nums">{g.voteSummary}</p>
|
||||
{g.voteDetail && (
|
||||
<p className="text-[10px] text-slate-500 mt-1 leading-snug">{g.voteDetail}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{g.dotPlot && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Dot plot — projections des membres</p>
|
||||
<FedDotPlotChart dotPlot={g.dotPlot} />
|
||||
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Évolution (dernières publications SEP)</p>
|
||||
<FedEvolutionChart dotPlot={g.dotPlot} />
|
||||
<div className="flex items-center gap-3 text-[8px] text-slate-600">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-500 inline-block" /> Année courante</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-sky-400 inline-block" /> Long terme</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{g.forecast && <ForecastBlock forecast={g.forecast} />}
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-slate-900/60 rounded-lg px-3 py-3 text-center">
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{g.fetchError === "Scraping non encore implémenté pour cette banque"
|
||||
? "Vote / rapport non encore automatisés pour cette banque"
|
||||
: `Données indisponibles (${g.fetchError ?? "erreur"})`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
{g.reportPdfUrl && (
|
||||
<a
|
||||
href={g.reportPdfUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] font-medium bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-md px-2 py-1.5 transition-colors"
|
||||
>
|
||||
<FileText size={11} /> Rapport PDF
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={g.policyPageUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] font-medium bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-md px-2 py-1.5 transition-colors"
|
||||
>
|
||||
<ExternalLink size={11} /> Site officiel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Onglet principal ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CentralBankSourcesTab() {
|
||||
const [data, setData] = useState<Record<string, CBGovernance> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/central-bank-sources", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
setData(json.data);
|
||||
setFetchedAt(json.fetchedAt);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-200">Sources banques centrales</h2>
|
||||
<p className="text-[10px] text-slate-600 mt-0.5">
|
||||
Vote de la dernière réunion · dot plot (Fed) · rapport de politique monétaire · site officiel
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{fetchedAt && <span className="text-[9px] text-slate-600">MAJ {new Date(fetchedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>}
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-slate-200 border border-slate-800 hover:border-slate-600 rounded-md px-2 py-1 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={11} className={loading ? "animate-spin" : ""} /> Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={20} className="animate-spin text-slate-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 p-4">
|
||||
{ORDER.map(ccy => {
|
||||
const g = data?.[ccy];
|
||||
return g ? <CBCard key={ccy} g={g} /> : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,10 +39,15 @@ interface MacroForecasts {
|
||||
gdp: number | null; gdpSurprise: number | null;
|
||||
employment: number | null; employmentSurprise: number | null;
|
||||
}
|
||||
interface MoneySupplyM3 {
|
||||
value: number; unit: string; period: string; isProxy: boolean;
|
||||
proxyLabel?: string; source: string;
|
||||
}
|
||||
interface MacroData {
|
||||
currency: string;
|
||||
indicators: Record<string, Ind | null>;
|
||||
forecasts?: MacroForecasts | null;
|
||||
moneySupplyM3?: MoneySupplyM3 | null;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
@@ -216,6 +221,16 @@ const ENERGY_PROFILE: Record<string, {
|
||||
NZD: { type: "import", desc: "Import pétrole. Renouvelables ~85% élec (hydro). Indépendant localement.", products: ["Lait / Produits laitiers", "Viande bovine", "Bois", "Laine"] },
|
||||
};
|
||||
|
||||
// ─── Money Supply M3 — formatage compact (niveau, devise locale) ─────────────
|
||||
|
||||
function formatM3(m3: MoneySupplyM3): string {
|
||||
const mult = m3.unit.includes("Bn") ? 1e9 : m3.unit.includes("Mn") ? 1e6 : 1;
|
||||
const raw = m3.value * mult;
|
||||
const ccy = m3.unit.split(" ")[0];
|
||||
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format(raw);
|
||||
return `${compact} ${ccy}`;
|
||||
}
|
||||
|
||||
function trendDir(t: "up"|"down"|"flat"|null): SignalDir {
|
||||
if (t === "up") return "bullish";
|
||||
if (t === "down") return "bearish";
|
||||
@@ -387,6 +402,7 @@ function SourcesPopup({ currency, onClose }: { currency: string; onClose: () =>
|
||||
icon: "🏦",
|
||||
sources: [
|
||||
{ label: `Trading Economics — ${country} interest rate`, url: `https://tradingeconomics.com/${country}/interest-rate`, note: `Taux directeur ${cbName} actuel` },
|
||||
{ label: `Trading Economics — ${country} money supply M3`, url: `https://tradingeconomics.com/${country}/money-supply-m3`, note: currency === "USD" ? "M2 utilisé en proxy (M3 non publié par la Fed depuis 2006)" : "Masse monétaire M3" },
|
||||
{ label: "FRED — Federal Reserve Economic Data", url: "https://fred.stlouisfed.org", note: "Spreads crédit HY/IG (BAMLH0A0HYM2, BAMLC0A0CM)" },
|
||||
],
|
||||
},
|
||||
@@ -523,6 +539,11 @@ function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: {
|
||||
const altRate = mlIsMove ? currentRate : m0.impliedRate;
|
||||
const altProb = mlIsMove ? 100 - m0.probMovePct : m0.probMovePct;
|
||||
const altIsMove = !mlIsMove;
|
||||
// moveLabel vaut toujours "Hold" quand le scénario principal est un statu quo (isMoveExpected
|
||||
// false) — le réutiliser pour l'alternative affichait donc "X% Hold" au lieu de "X% Hike/Cut"
|
||||
// quand l'alternative EST le mouvement. La direction de l'alternative-mouvement est toujours
|
||||
// celle de m0.probIsCut, indépendamment du scénario principal.
|
||||
const altLabel = altIsMove ? (m0.probIsCut ? "Cut" : "Hike") : "Hold";
|
||||
|
||||
// Chart data (limit to 10 meetings)
|
||||
const chartMeetings = meetings.slice(0, 10);
|
||||
@@ -588,7 +609,7 @@ function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: {
|
||||
{altProb >= 15 && altIsMove !== mlIsMove && (
|
||||
<>
|
||||
<span className="text-[8px] text-slate-700 shrink-0">·</span>
|
||||
<span className="text-[9px] text-slate-500 shrink-0">{Math.round(altProb)}% {altIsMove ? moveLabel : "Hold"}</span>
|
||||
<span className="text-[9px] text-slate-500 shrink-0">{Math.round(altProb)}% {altLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-[7px] text-slate-700 shrink-0">au {ratePath.asOf}</span>
|
||||
@@ -939,6 +960,7 @@ export default function CurrencyCard({
|
||||
// ── Computed values ──────────────────────────────────────────────────────────
|
||||
const inds = data?.indicators;
|
||||
const fc = data?.forecasts ?? null;
|
||||
const m3 = data?.moneySupplyM3 ?? null;
|
||||
|
||||
const forScoring = {
|
||||
policyRate: { value: inds?.policyRate?.value ?? null, prev: inds?.policyRate?.prev ?? null, consensus: null, surprise: inds?.policyRate?.surprise ?? null, trend: inds?.policyRate?.trend ?? null, lastUpdated: "" },
|
||||
@@ -1638,8 +1660,24 @@ export default function CurrencyCard({
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
>
|
||||
|
||||
{/* Skeleton chargement — affiché quand pas encore de données */}
|
||||
{loading && !data && (
|
||||
<div className="bg-slate-800/40 rounded-xl border border-slate-700/30 p-3 space-y-2.5 animate-pulse h-full">
|
||||
<div className="h-2 bg-slate-700/60 rounded w-28 mb-3" />
|
||||
{[68, 82, 55, 76, 60].map((w, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="h-2.5 bg-slate-700/50 rounded" style={{ width: `${w}%` }} />
|
||||
<div className="h-2.5 bg-slate-700/40 rounded w-10" />
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-700/25 rounded w-1/3 ml-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MON. — Politique Monétaire */}
|
||||
{macroSlide === "mon" && (
|
||||
{!loading && macroSlide === "mon" && (
|
||||
<MacroBlock title="Politique Monétaire">
|
||||
<IRow label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" consensus={rateConsensus} isNew={indIsNew("policyRate")} />
|
||||
{(() => {
|
||||
@@ -1666,11 +1704,28 @@ export default function CurrencyCard({
|
||||
<span className="font-semibold text-slate-200 tabular-nums">{yield10Y.toFixed(2)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{m3 && (
|
||||
<div className="flex items-center justify-between text-[12px]">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-slate-600 shrink-0">→</span>
|
||||
<span className="text-slate-400 truncate">Money Supply M3{m3.isProxy ? " (M2)" : ""}</span>
|
||||
{m3.isProxy && (
|
||||
<span className="relative group/info inline-flex shrink-0 cursor-help">
|
||||
<span className="inline-flex items-center justify-center w-3 h-3 rounded-full border border-slate-700 text-slate-500 text-[7px] font-bold leading-none">i</span>
|
||||
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 w-52 px-2 py-1.5 rounded-md bg-slate-950 text-slate-300 text-[10px] leading-snug opacity-0 group-hover/info:opacity-100 transition-opacity duration-150 z-50 shadow-lg whitespace-normal border border-slate-700">
|
||||
{m3.proxyLabel}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-semibold text-slate-200 tabular-nums shrink-0" title={`${m3.period} · ${m3.source}`}>{formatM3(m3)}</span>
|
||||
</div>
|
||||
)}
|
||||
</MacroBlock>
|
||||
)}
|
||||
|
||||
{/* INFL. — Inflation */}
|
||||
{macroSlide === "infl" && (
|
||||
{!loading && macroSlide === "infl" && (
|
||||
<MacroBlock title="Inflation">
|
||||
<IRow label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" consensus={fc?.cpiMoM ?? null} isNew={indIsNew("cpiMoM")} />
|
||||
<IRow label="PPI MoM" ind={inds?.ppiMoM ?? null} unit="%" consensus={fc?.ppiMoM ?? null} isNew={indIsNew("ppiMoM")} />
|
||||
@@ -1680,7 +1735,7 @@ export default function CurrencyCard({
|
||||
)}
|
||||
|
||||
{/* CRO. — Croissance */}
|
||||
{macroSlide === "cro" && (
|
||||
{!loading && macroSlide === "cro" && (
|
||||
<MacroBlock title="Croissance">
|
||||
<IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} surpriseVsCons={fc?.gdpSurprise ?? null} isNew={indIsNew("gdp")} />
|
||||
<IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} surpriseVsCons={fc?.pmiCompositeSurprise ?? null} isNew={indIsNew("pmiComposite")} />
|
||||
@@ -1691,7 +1746,7 @@ export default function CurrencyCard({
|
||||
)}
|
||||
|
||||
{/* EMPL. — Emploi */}
|
||||
{macroSlide === "empl" && (
|
||||
{!loading && macroSlide === "empl" && (
|
||||
<MacroBlock title="Emploi">
|
||||
<IRow label="Variation emploi" ind={inds?.employment ?? null} unit="k" consensus={fc?.employment ?? null} surpriseVsCons={fc?.employmentSurprise ?? null} isNew={indIsNew("employment")} />
|
||||
<IRow label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise consensus={fc?.unemployment ?? null} surpriseVsCons={fc?.unemploymentSurprise ?? null} isNew={indIsNew("unemployment")} />
|
||||
|
||||
+42
-18
@@ -52,8 +52,8 @@ function loadLS<T>(key: string, fb: T): T {
|
||||
if (typeof window === "undefined") return fb;
|
||||
try { const r = localStorage.getItem(key); return r ? JSON.parse(r) as T : fb; } catch { return fb; }
|
||||
}
|
||||
function saveLS(key: string, val: unknown) {
|
||||
try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
|
||||
function saveLS(key: string, val: unknown): boolean {
|
||||
try { localStorage.setItem(key, JSON.stringify(val)); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
const DEFAULT_SLOT = (symbol = "FX:EURUSD"): SlotState => ({
|
||||
@@ -444,7 +444,9 @@ function ArchiveCard({ a, onDelete, onRestore }: {
|
||||
)}
|
||||
|
||||
{/* Carte compacte */}
|
||||
<div className="border border-slate-700/30 rounded-xl overflow-hidden bg-slate-900/30 hover:bg-slate-800/30 transition-colors">
|
||||
{/* Pas de overflow-hidden ici : le menu déroulant "Restaurer" est en position
|
||||
absolute et serait rogné par un ancêtre overflow-hidden (cf. bug signalé). */}
|
||||
<div className="border border-slate-700/30 rounded-xl bg-slate-900/30 hover:bg-slate-800/30 transition-colors">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-700/20">
|
||||
<span className="text-[10px] font-bold text-sky-400/80 font-mono">{a.slot.symbol}</span>
|
||||
@@ -483,18 +485,24 @@ function ArchiveCard({ a, onDelete, onRestore }: {
|
||||
className="text-[8px] text-slate-600 hover:text-sky-400 transition-colors ml-1 shrink-0"
|
||||
>↗ Voir</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-[8px] text-red-500/30 hover:text-red-400 transition-colors ml-1 shrink-0"
|
||||
>✕</button>
|
||||
onClick={() => { if (window.confirm("Supprimer définitivement cette archive ?")) onDelete(); }}
|
||||
title="Supprimer définitivement"
|
||||
className="text-[8px] text-red-400/80 hover:text-red-400 transition-colors ml-1 shrink-0"
|
||||
>✕ Supprimer</button>
|
||||
</div>
|
||||
|
||||
{/* Corps : texte + images */}
|
||||
{a.slot.notes && (
|
||||
<div className="px-4 py-3">
|
||||
{/* Texte brut (sans tags HTML) */}
|
||||
{/* Texte brut (sans tags HTML) — les tags de bloc sont convertis en
|
||||
retours à la ligne avant extraction, sinon .textContent colle tous
|
||||
les paragraphes/lignes de liste bout à bout sans séparation. */}
|
||||
{(() => {
|
||||
const doc = new DOMParser().parseFromString(a.slot.notes, "text/html");
|
||||
const text = doc.body.textContent?.trim() ?? "";
|
||||
const withBreaks = a.slot.notes
|
||||
.replace(/<\/(p|div|li|h[1-6])>/gi, "\n")
|
||||
.replace(/<br\s*\/?>/gi, "\n");
|
||||
const doc = new DOMParser().parseFromString(withBreaks, "text/html");
|
||||
const text = (doc.body.textContent ?? "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
const imgs = Array.from(doc.images);
|
||||
return (
|
||||
<>
|
||||
@@ -564,6 +572,14 @@ export default function IdeesTab() {
|
||||
DEFAULT_SLOT("FX:GBPUSD"),
|
||||
]);
|
||||
const [archives, setArchives] = useState<Archive[]>([]);
|
||||
// Passe à true si une écriture localStorage échoue (ex: quota dépassé à cause
|
||||
// des images en base64 dans les notes archivées) — sans ça l'échec est silencieux
|
||||
// et une suppression/modification peut sembler ne "pas marcher" après rechargement.
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const persist = useCallback((key: string, val: unknown) => {
|
||||
setSaveError(!saveLS(key, val));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSlots(loadLS<[SlotState, SlotState]>(LS_SLOTS, [DEFAULT_SLOT("FX:EURUSD"), DEFAULT_SLOT("FX:GBPUSD")]));
|
||||
@@ -574,30 +590,30 @@ export default function IdeesTab() {
|
||||
setSlots(prev => {
|
||||
const next: [SlotState, SlotState] = [prev[0], prev[1]];
|
||||
next[idx] = s;
|
||||
saveLS(LS_SLOTS, next);
|
||||
persist(LS_SLOTS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [persist]);
|
||||
|
||||
const archiveSlot = useCallback((idx: 0 | 1) => {
|
||||
const entry: Archive = { id: Date.now().toString(), savedAt: new Date().toISOString(), slot: slots[idx] };
|
||||
const next = [entry, ...archives];
|
||||
setArchives(next);
|
||||
saveLS(LS_ARCHIVES, next);
|
||||
persist(LS_ARCHIVES, next);
|
||||
const reset = DEFAULT_SLOT(idx === 0 ? "FX:EURUSD" : "FX:GBPUSD");
|
||||
setSlots(prev => {
|
||||
const n: [SlotState, SlotState] = [prev[0], prev[1]];
|
||||
n[idx] = reset;
|
||||
saveLS(LS_SLOTS, n);
|
||||
persist(LS_SLOTS, n);
|
||||
return n;
|
||||
});
|
||||
}, [slots, archives]);
|
||||
}, [slots, archives, persist]);
|
||||
|
||||
const deleteArchive = useCallback((id: string) => {
|
||||
const next = archives.filter(a => a.id !== id);
|
||||
setArchives(next);
|
||||
saveLS(LS_ARCHIVES, next);
|
||||
}, [archives]);
|
||||
persist(LS_ARCHIVES, next);
|
||||
}, [archives, persist]);
|
||||
|
||||
const restoreArchive = useCallback((id: string, slotIdx: 0 | 1) => {
|
||||
const entry = archives.find(a => a.id === id);
|
||||
@@ -605,10 +621,10 @@ export default function IdeesTab() {
|
||||
setSlots(prev => {
|
||||
const next = [...prev] as [SlotState, SlotState];
|
||||
next[slotIdx] = { ...entry.slot };
|
||||
saveLS(LS_SLOTS, next);
|
||||
persist(LS_SLOTS, next);
|
||||
return next;
|
||||
});
|
||||
}, [archives]);
|
||||
}, [archives, persist]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -619,6 +635,14 @@ export default function IdeesTab() {
|
||||
<div className="h-px flex-1 bg-purple-500/20" />
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="text-[10px] text-red-300 bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2">
|
||||
⚠ Échec de sauvegarde locale — le quota de stockage du navigateur est probablement dépassé
|
||||
(les archives avec images occupent beaucoup de place). Supprime quelques archives pour libérer
|
||||
de la place, sinon tes changements ne seront pas conservés au rechargement.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResearchSlot slot={slots[0]} label="Recherche A" onChange={s => updateSlot(0, s)} onArchive={() => archiveSlot(0)} />
|
||||
<ResearchSlot slot={slots[1]} label="Recherche B" onChange={s => updateSlot(1, s)} onArchive={() => archiveSlot(1)} />
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"ts":1783520638250,"data":{"current":{},"prev":{},"prevDate":null}}
|
||||
@@ -0,0 +1,65 @@
|
||||
[
|
||||
{
|
||||
"updated_at": "2026-07-08",
|
||||
"note": "Masse monétaire M3 (niveau, devise locale) par zone — scrapé automatiquement depuis tradingeconomics.com (meta description des pages /money-supply-m3). USD : proxy M2 (Fed n'a plus publié M3 depuis 2006). Mis à jour par .github/workflows/fetch-money-supply.yml (hebdomadaire).",
|
||||
"series": {
|
||||
"USD": {
|
||||
"value": 22804.5,
|
||||
"unit": "USD Bn",
|
||||
"period": "2026-04",
|
||||
"source": "https://tradingeconomics.com/united-states/money-supply-m2",
|
||||
"isProxy": true,
|
||||
"proxyLabel": "M2 (M3 non publié par la Fed depuis 2006)"
|
||||
},
|
||||
"EUR": {
|
||||
"value": 17552244,
|
||||
"unit": "EUR Mn",
|
||||
"period": "2026-05",
|
||||
"source": "https://tradingeconomics.com/euro-area/money-supply-m3",
|
||||
"isProxy": false
|
||||
},
|
||||
"GBP": {
|
||||
"value": 3811591,
|
||||
"unit": "GBP Mn",
|
||||
"period": "2026-05",
|
||||
"source": "https://tradingeconomics.com/united-kingdom/money-supply-m3",
|
||||
"isProxy": false
|
||||
},
|
||||
"JPY": {
|
||||
"value": 1642404.5,
|
||||
"unit": "JPY Bn",
|
||||
"period": "2026-05",
|
||||
"source": "https://tradingeconomics.com/japan/money-supply-m3",
|
||||
"isProxy": false
|
||||
},
|
||||
"CHF": {
|
||||
"value": 1228273,
|
||||
"unit": "CHF Mn",
|
||||
"period": "2026-05",
|
||||
"source": "https://tradingeconomics.com/switzerland/money-supply-m3",
|
||||
"isProxy": false
|
||||
},
|
||||
"CAD": {
|
||||
"value": 4020356,
|
||||
"unit": "CAD Mn",
|
||||
"period": "2026-04",
|
||||
"source": "https://tradingeconomics.com/canada/money-supply-m3",
|
||||
"isProxy": false
|
||||
},
|
||||
"AUD": {
|
||||
"value": 3447.08,
|
||||
"unit": "AUD Bn",
|
||||
"period": "2026-05",
|
||||
"source": "https://tradingeconomics.com/australia/money-supply-m3",
|
||||
"isProxy": false
|
||||
},
|
||||
"NZD": {
|
||||
"value": 451046,
|
||||
"unit": "NZD Mn",
|
||||
"period": "2026-05",
|
||||
"source": "https://tradingeconomics.com/new-zealand/money-supply-m3",
|
||||
"isProxy": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,5 +1,233 @@
|
||||
{
|
||||
"data": {},
|
||||
"fetchedAt": "2026-07-08T12:14:53.611Z",
|
||||
"data": {
|
||||
"USD": {
|
||||
"today": {
|
||||
"midpoint": 3.75,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Jul 29",
|
||||
"meeting_iso": "2026-07-29",
|
||||
"implied_rate_post_meeting": 3.7998,
|
||||
"prob_move_pct": 20,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 4.979999999999984,
|
||||
"num_moves": 0.19919999999999938
|
||||
},
|
||||
{
|
||||
"meeting": "Sep 16",
|
||||
"meeting_iso": "2026-09-16",
|
||||
"implied_rate_post_meeting": 3.908,
|
||||
"prob_move_pct": 55,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 15.799999999999992,
|
||||
"num_moves": 0.6319999999999997
|
||||
},
|
||||
{
|
||||
"meeting": "Oct 28",
|
||||
"meeting_iso": "2026-10-28",
|
||||
"implied_rate_post_meeting": 3.96,
|
||||
"prob_move_pct": 64,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 20.999999999999996,
|
||||
"num_moves": 0.8399999999999999
|
||||
},
|
||||
{
|
||||
"meeting": "Dec 9",
|
||||
"meeting_iso": "2026-12-09",
|
||||
"implied_rate_post_meeting": 4.055,
|
||||
"prob_move_pct": 78,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 30.49999999999997,
|
||||
"num_moves": 1.2199999999999989
|
||||
},
|
||||
{
|
||||
"meeting": "Jan 27",
|
||||
"meeting_iso": "2027-01-27",
|
||||
"implied_rate_post_meeting": 4.0851,
|
||||
"prob_move_pct": 80,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 33.50999999999998,
|
||||
"num_moves": 1.3403999999999991
|
||||
},
|
||||
{
|
||||
"meeting": "Mar 17",
|
||||
"meeting_iso": "2027-03-17",
|
||||
"implied_rate_post_meeting": 4.1286,
|
||||
"prob_move_pct": 84,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 37.85999999999996,
|
||||
"num_moves": 1.5143999999999982
|
||||
},
|
||||
{
|
||||
"meeting": "Apr 28",
|
||||
"meeting_iso": "2027-04-28",
|
||||
"implied_rate_post_meeting": 4.1322,
|
||||
"prob_move_pct": 84,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 38.22000000000001,
|
||||
"num_moves": 1.5288000000000006
|
||||
},
|
||||
{
|
||||
"meeting": "Jun 9",
|
||||
"meeting_iso": "2027-06-09",
|
||||
"implied_rate_post_meeting": 4.1065,
|
||||
"prob_move_pct": 80,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 35.64999999999996,
|
||||
"num_moves": 1.4259999999999986
|
||||
},
|
||||
{
|
||||
"meeting": "Jul 28",
|
||||
"meeting_iso": "2027-07-28",
|
||||
"implied_rate_post_meeting": 4.0895,
|
||||
"prob_move_pct": 78,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 33.95000000000002,
|
||||
"num_moves": 1.3580000000000008
|
||||
},
|
||||
{
|
||||
"meeting": "Sep 15",
|
||||
"meeting_iso": "2027-09-15",
|
||||
"implied_rate_post_meeting": 4.0654,
|
||||
"prob_move_pct": 75,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 31.540000000000035,
|
||||
"num_moves": 1.2616000000000014
|
||||
},
|
||||
{
|
||||
"meeting": "Oct 27",
|
||||
"meeting_iso": "2027-10-27",
|
||||
"implied_rate_post_meeting": 4.0297,
|
||||
"prob_move_pct": 70,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 27.970000000000006,
|
||||
"num_moves": 1.1188000000000002
|
||||
},
|
||||
{
|
||||
"meeting": "Dec 8",
|
||||
"meeting_iso": "2027-12-08",
|
||||
"implied_rate_post_meeting": 4.0103,
|
||||
"prob_move_pct": 67,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 26.029999999999998,
|
||||
"num_moves": 1.0412
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"EUR": {
|
||||
"today": {
|
||||
"midpoint": 2.15,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Dec",
|
||||
"meeting_iso": "2026-12-31",
|
||||
"implied_rate_post_meeting": 2.3899999999999997,
|
||||
"prob_move_pct": 28,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 24,
|
||||
"num_moves": 0.96
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"GBP": {
|
||||
"today": {
|
||||
"midpoint": 3.75,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Dec",
|
||||
"meeting_iso": "2026-12-31",
|
||||
"implied_rate_post_meeting": 3.93,
|
||||
"prob_move_pct": 7,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 18,
|
||||
"num_moves": 0.72
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"JPY": {
|
||||
"today": {
|
||||
"midpoint": 0.75,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Dec",
|
||||
"meeting_iso": "2026-12-31",
|
||||
"implied_rate_post_meeting": 0.95,
|
||||
"prob_move_pct": 1,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 20,
|
||||
"num_moves": 0.8
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"CAD": {
|
||||
"today": {
|
||||
"midpoint": 2.25,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Dec",
|
||||
"meeting_iso": "2026-12-31",
|
||||
"implied_rate_post_meeting": 2.33,
|
||||
"prob_move_pct": 9,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 8,
|
||||
"num_moves": 0.32
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"AUD": {
|
||||
"today": {
|
||||
"midpoint": 4.35,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Dec",
|
||||
"meeting_iso": "2026-12-31",
|
||||
"implied_rate_post_meeting": 4.449999999999999,
|
||||
"prob_move_pct": 15,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 10,
|
||||
"num_moves": 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NZD": {
|
||||
"today": {
|
||||
"midpoint": 2.25,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Dec",
|
||||
"meeting_iso": "2026-12-31",
|
||||
"implied_rate_post_meeting": 2.87,
|
||||
"prob_move_pct": 82,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 62,
|
||||
"num_moves": 2.48
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"CHF": {
|
||||
"today": {
|
||||
"midpoint": 0,
|
||||
"rows": [
|
||||
{
|
||||
"meeting": "Dec",
|
||||
"meeting_iso": "2026-12-31",
|
||||
"implied_rate_post_meeting": 0.05,
|
||||
"prob_move_pct": 4,
|
||||
"prob_is_cut": false,
|
||||
"change_bps": 5,
|
||||
"num_moves": 0.2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"fetchedAt": "2026-07-03T12:51:24.776Z",
|
||||
"snapshots": []
|
||||
}
|
||||
+21
-21
@@ -1,47 +1,47 @@
|
||||
[
|
||||
{
|
||||
"updated_at": "2026-05-30",
|
||||
"note": "Taux directeurs officiels CB. current/prev = taux annoncés (pas interbancaires). EUR = MRO (DFR+15bps) pour cohérence avec investing.com/TE. Mettre à jour après chaque décision.",
|
||||
"updated_at": "2026-07-08",
|
||||
"note": "Taux directeurs officiels CB. current/prev = taux annoncés (pas interbancaires). EUR = MRO (DFR+15bps) pour cohérence avec investing.com/TE. prev = taux avant la DERNIÈRE décision (= current si dernière réunion = statu quo). Fichier maintenu automatiquement par .github/workflows/update-rate-decisions.yml (hourly) : quand le taux scrapé sur Trading Economics diffère de 'current', l'ancien 'current' glisse vers 'prev' sans perte de donnée, et 'source' est réécrit pour tracer le changement. Ne plus éditer 'current'/'prev' à la main sauf correction ponctuelle.",
|
||||
"decisions": {
|
||||
"USD": {
|
||||
"current": 3.75,
|
||||
"prev": 3.75,
|
||||
"source": "Fed funds target upper bound — Trading Economics / TE mai 2026 (fourchette 3.50-3.75%, upper=3.75%)"
|
||||
"source": "Fed funds target upper bound — inchangé 3.50-3.75% (upper=3.75%), 4e réunion consécutive de statu quo, dernière décision 17 juin 2026 (Trading Economics)"
|
||||
},
|
||||
"EUR": {
|
||||
"current": 2.15,
|
||||
"prev": 2.40,
|
||||
"source": "ECBDFR (FRED) + 0.15bps → MRO. DFR=2.00, MRO=DFR+15bps (corridor sep 2024)"
|
||||
"current": 2.4,
|
||||
"prev": 2.15,
|
||||
"source": "ECB — hausse de 25bps de tous les taux directeurs (DFR 2.00%→2.25%, MRO 2.15%→2.40%, MLF 2.40%→2.65%) effective 17 juin 2026, en réponse au choc d'inflation lié au conflit Moyen-Orient (communiqué officiel : https://www.ecb.europa.eu/press/pr/date/2026/html/ecb.mp260611~4d41bd5e83.en.html, publié 11 juin 2026). MRO retenu ici pour cohérence avec investing.com/TE."
|
||||
},
|
||||
"GBP": {
|
||||
"current": 3.75,
|
||||
"prev": 4.00,
|
||||
"source": "BoE Official Bank Rate — Trading Economics mai 2026"
|
||||
"prev": 3.75,
|
||||
"source": "BoE Official Bank Rate — statu quo confirmé 18 juin 2026 (vote MPC 7-2, 2 membres pour +25bps à 4.00%). Trading Economics"
|
||||
},
|
||||
"JPY": {
|
||||
"current": 0.75,
|
||||
"prev": 0.50,
|
||||
"source": "BoJ — annonce jan 2026 (hausse 0.50% → 0.75%)"
|
||||
"current": 1,
|
||||
"prev": 0.75,
|
||||
"source": "BoJ — changement détecté automatiquement le 2026-07-08 via Trading Economics (japan/interest-rate) : 0.75% → 1%."
|
||||
},
|
||||
"CHF": {
|
||||
"current": 0.00,
|
||||
"prev": 0.25,
|
||||
"source": "SNB — annonce déc 2025 (baisse 0.25% → 0.00%)"
|
||||
"current": 0,
|
||||
"prev": 0,
|
||||
"source": "SNB officiel — inchangé à 0% ; confirmé statu quo le 18 juin 2026. Trading Economics"
|
||||
},
|
||||
"CAD": {
|
||||
"current": 2.25,
|
||||
"prev": 2.50,
|
||||
"source": "BoC — annonce avr 2026 (baisse 2.50% → 2.25%)"
|
||||
"prev": 2.25,
|
||||
"source": "BoC — statu quo confirmé 10 juin 2026, 5e réunion consécutive à 2.25%. Trading Economics"
|
||||
},
|
||||
"AUD": {
|
||||
"current": 4.35,
|
||||
"prev": 4.10,
|
||||
"source": "RBA — Trading Economics mai 2026 (Last=4.35, Previous=4.10)"
|
||||
"prev": 4.35,
|
||||
"source": "RBA — statu quo confirmé 16 juin 2026, décision unanime à 4.35%. Trading Economics"
|
||||
},
|
||||
"NZD": {
|
||||
"current": 2.25,
|
||||
"prev": 2.50,
|
||||
"source": "RBNZ — annonce avr 2026 (baisse 2.50% → 2.25%)"
|
||||
"current": 2.5,
|
||||
"prev": 2.25,
|
||||
"source": "RBNZ — hausse de 25bps (2.25%→2.50%) le 8 juillet 2026, première hausse en 3 ans après 3 statu quo consécutifs à 2.25%. Trading Economics"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// lib/calendar-countries.ts
|
||||
// Univers de pays pour le calendrier économique — restreint le 2026-07-08 à la
|
||||
// demande explicite de l'utilisateur : les 8 devises majeures tradées par le
|
||||
// dashboard + la France en complément (grosse économie de la zone euro, dont
|
||||
// les publications (Ifo-like, PMI, CPI...) précèdent souvent l'agrégat EMU).
|
||||
//
|
||||
// Sert de table de correspondance commune entre :
|
||||
// - Trading Economics : slug URL (/xxx/calendar) + nom affiché en data-country
|
||||
// - investingLive (widget FXStreet calendar.fxstreet.com) : code pays + data-countryname
|
||||
//
|
||||
// currency = devise ISO 4217 associée (EMU et FR partagent EUR).
|
||||
|
||||
export interface CalendarCountry {
|
||||
code: string; // code FXStreet (aussi utilisé comme identifiant canonique)
|
||||
teSlug: string; // slug Trading Economics : https://tradingeconomics.com/{slug}/calendar
|
||||
teName: string; // valeur data-country de TE (lowercase)
|
||||
fxName: string; // valeur data-countryname du widget FXStreet
|
||||
currency: string; // ISO 4217
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export const CALENDAR_COUNTRIES: CalendarCountry[] = [
|
||||
{ code: "EMU", teSlug: "euro-area", teName: "euro area", fxName: "Eurozone", currency: "EUR", displayName: "Zone Euro" },
|
||||
{ code: "FR", teSlug: "france", teName: "france", fxName: "France", currency: "EUR", displayName: "France" },
|
||||
{ code: "US", teSlug: "united-states", teName: "united states", fxName: "United States", currency: "USD", displayName: "États-Unis" },
|
||||
{ code: "CA", teSlug: "canada", teName: "canada", fxName: "Canada", currency: "CAD", displayName: "Canada" },
|
||||
{ code: "UK", teSlug: "united-kingdom", teName: "united kingdom", fxName: "United Kingdom", currency: "GBP", displayName: "Royaume-Uni" },
|
||||
{ code: "CH", teSlug: "switzerland", teName: "switzerland", fxName: "Switzerland", currency: "CHF", displayName: "Suisse" },
|
||||
{ code: "JP", teSlug: "japan", teName: "japan", fxName: "Japan", currency: "JPY", displayName: "Japon" },
|
||||
{ code: "AU", teSlug: "australia", teName: "australia", fxName: "Australia", currency: "AUD", displayName: "Australie" },
|
||||
{ code: "NZ", teSlug: "new-zealand", teName: "new zealand", fxName: "New Zealand", currency: "NZD", displayName: "Nouvelle-Zélande" },
|
||||
];
|
||||
|
||||
export const TE_NAME_TO_CURRENCY: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.teName, c.currency])
|
||||
);
|
||||
|
||||
export const FX_NAME_TO_CURRENCY: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.fxName, c.currency])
|
||||
);
|
||||
|
||||
export const TE_NAME_TO_CODE: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.teName, c.code])
|
||||
);
|
||||
|
||||
export const FX_NAME_TO_CODE: Record<string, string> = Object.fromEntries(
|
||||
CALENDAR_COUNTRIES.map(c => [c.fxName, c.code])
|
||||
);
|
||||
|
||||
export const FXSTREET_COUNTRYCODES = CALENDAR_COUNTRIES.map(c => c.code).join(",");
|
||||
@@ -0,0 +1,79 @@
|
||||
// lib/calendar-taxonomy.ts
|
||||
// Règles de tri du fourre-tout "other" du calendrier économique, décidées avec
|
||||
// l'utilisateur devise par devise (session du 2026-07-08) :
|
||||
// - Confiance/sentiment, immobilier (prix uniquement), M3 (EUR uniquement),
|
||||
// commerce extérieur détaillé, Fed régionale (Philly uniquement), flux de
|
||||
// portefeuille étrangers, finances publiques, jours fériés → catégories
|
||||
// dédiées (visibles, triables séparément).
|
||||
// - Adjudications obligataires, production industrielle, énergie hebdo US,
|
||||
// hypothécaires hebdo US, CPI infranational, réunions institutionnelles
|
||||
// sans chiffre → exclus du calendrier (bruit, cf. décisions utilisateur).
|
||||
// Tout le reste (non couvert par une règle ci-dessous) continue d'apparaître
|
||||
// sous "other" / "Autre".
|
||||
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
|
||||
const SENTIMENT_RE = /\bifo\b|\bzew\b|\bgfk\b|consumer confidence|business confidence|business climate|economic sentiment|\bsentix\b|westpac consumer confidence|anz.*consumer confidence|anz business confidence|anz activity outlook|nab business confidence|cbi business optimism|cbi distributive trades|cbi industrial trends|eco watchers survey|reuters tankan/i;
|
||||
|
||||
const HOUSING_PRICE_RE = /house price|housing price|nationwide housing|case-shiller|rightmove|rics house price|cotality dwelling|residential property price/i;
|
||||
|
||||
const MONEY_SUPPLY_RE = /m3 money supply/i;
|
||||
|
||||
const TRADE_DETAIL_RE = /^exports?\b|^imports?\b|export price|import price/i;
|
||||
|
||||
const REGIONAL_FED_RE = /philly fed|philadelphia fed/i;
|
||||
|
||||
const PORTFOLIO_FLOWS_RE = /tic flows|net capital flows|foreign bond investment|stock investment by foreigners|cftc \w+ nc net positions/i;
|
||||
|
||||
const PUBLIC_FINANCE_RE = /budget balance|monthly budget statement|public sector net borrowing/i;
|
||||
|
||||
const HOLIDAY_RE = /\bholiday\b|\b(independence|canada|marine|labor|labour|thanksgiving|christmas|boxing|memorial|veterans|presidents?|columbus|mlk|good friday|easter monday)\s+day\b/i;
|
||||
|
||||
// ── Exclusions (bruit écarté du calendrier, décision utilisateur) ────────────
|
||||
|
||||
const AUCTION_RE = /\bauction\b|\btender\b|conventional gilt|index-linked gilt/i;
|
||||
const INDUSTRIAL_PROD_RE = /industrial production|industrial output|manufacturing production|capacity utilization|tertiary industry index/i;
|
||||
const US_ENERGY_WEEKLY_RE = /\beia\b|\bapi\b crude|baker hughes|crude oil stock|natural gas stock|gasoline stock|distillate|heating oil stock|refinery crude/i;
|
||||
const US_MORTGAGE_WEEKLY_RE = /\bmba\b|mortgage application|mortgage market index|mortgage refinance index|mba purchase index/i;
|
||||
const SUBNATIONAL_CPI_RE = /baden wuerttemberg cpi|bavaria cpi|brandenburg cpi|hesse cpi|north rhine westphalia cpi|saxony cpi|tokyo cpi|tokyo core cpi/i;
|
||||
const INSTITUTIONAL_MEETING_RE = /ecofin meeting|eurogroup meeting|iea oil market report|wasde report|nopa crush report/i;
|
||||
|
||||
export function classifyOtherTitle(title: string): EventCategory {
|
||||
if (SENTIMENT_RE.test(title)) return "sentiment";
|
||||
if (HOUSING_PRICE_RE.test(title)) return "housing";
|
||||
if (MONEY_SUPPLY_RE.test(title)) return "money_supply";
|
||||
if (TRADE_DETAIL_RE.test(title)) return "trade_detail";
|
||||
if (REGIONAL_FED_RE.test(title)) return "regional_fed";
|
||||
if (PORTFOLIO_FLOWS_RE.test(title)) return "portfolio_flows";
|
||||
if (PUBLIC_FINANCE_RE.test(title)) return "public_finance";
|
||||
if (HOLIDAY_RE.test(title)) return "holiday";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function isExcludedEventTitle(title: string): boolean {
|
||||
return (
|
||||
AUCTION_RE.test(title) ||
|
||||
INDUSTRIAL_PROD_RE.test(title) ||
|
||||
US_ENERGY_WEEKLY_RE.test(title) ||
|
||||
US_MORTGAGE_WEEKLY_RE.test(title) ||
|
||||
SUBNATIONAL_CPI_RE.test(title) ||
|
||||
INSTITUTIONAL_MEETING_RE.test(title)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plancher d'impact (demande explicite : PPI, Trade Balance, Construction PMI
|
||||
// ne doivent jamais rester masqués par le filtre "Impact faible" par défaut) ──
|
||||
// TE/investingLive taggent souvent ces indicateurs "low" par pays (surtout hors
|
||||
// USD), ce qui les cache dans le calendrier tant que la case "Impact faible"
|
||||
// n'est pas cochée. On relève le plancher à "medium" sans jamais rétrograder
|
||||
// un impact déjà "high" décidé par la source (ex. PPI MoM US, Balance of Trade JP).
|
||||
|
||||
const IMPACT_FLOOR_RE = /construction pmi|\bppi\b|producer price|balance of trade|trade balance|goods trade balance|foreign trade balance/i;
|
||||
|
||||
export function applyImpactFloor(
|
||||
title: string,
|
||||
impact: "high" | "medium" | "low"
|
||||
): "high" | "medium" | "low" {
|
||||
if (impact === "low" && IMPACT_FLOOR_RE.test(title)) return "medium";
|
||||
return impact;
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
// lib/centralBankGovernance.ts
|
||||
// Données de gouvernance des banques centrales : vote de la dernière réunion,
|
||||
// lien vers le dernier rapport de politique monétaire (PDF), lien vers le site
|
||||
// officiel, et pour la Fed le dot plot (Summary of Economic Projections).
|
||||
//
|
||||
// Sources (HTML public, sans clé API) :
|
||||
// Fed : federalreserve.gov — statement (vote) + SEP "accessible version" (dot plot)
|
||||
// BoE : bankofengland.co.uk — Monetary Policy Summary and Minutes (vote)
|
||||
// Les autres banques (ECB, BoJ, SNB, BoC, RBA, RBNZ) exposent au minimum les
|
||||
// liens statiques (site officiel + dernier rapport connu) ; scraping vote/PDF
|
||||
// ajouté au fur et à mesure de ce qui est effectivement accessible sans
|
||||
// contournement de WAF (RBA/RBNZ bloquent tout fetch() non-navigateur).
|
||||
|
||||
import type { Currency } from "./types";
|
||||
import rateDecisionsData from "@/data/rate_decisions.json";
|
||||
import { fetchTECalendarForCountry } from "./tradingeconomics";
|
||||
|
||||
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
||||
|
||||
async function fetchText(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": UA, "Accept-Language": "en-US,en;q=0.9" },
|
||||
next: { revalidate: 6 * 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function htmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/g, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/g, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/–|–/g, "–")
|
||||
.replace(/−|−/g, "-")
|
||||
.replace(/‑/g, "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ── Types publics ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FedDotYear {
|
||||
year: string; // "2026" | "2027" | "2028" | "Longer run"
|
||||
median: number | null;
|
||||
}
|
||||
|
||||
export interface FedDot {
|
||||
year: string;
|
||||
rate: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FedSepHistoryPoint {
|
||||
date: string; // ISO de la réunion SEP
|
||||
medianByYear: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface FedDotPlot {
|
||||
asOfDate: string;
|
||||
years: string[];
|
||||
medianByYear: Record<string, number>;
|
||||
prevMedianByYear: Record<string, number> | null;
|
||||
prevLabel: string | null; // ex. "March projection"
|
||||
dots: FedDot[];
|
||||
history: FedSepHistoryPoint[]; // dernières réunions SEP, la plus récente en dernier
|
||||
gdpMedian: Record<string, number> | null;
|
||||
pceMedian: Record<string, number> | null;
|
||||
sepHtmlUrl: string;
|
||||
sepPdfUrl: string;
|
||||
}
|
||||
|
||||
// Prévisions macro publiées par la BC elle-même (comment elle perçoit sa propre
|
||||
// économie) : croissance PIB + inflation, par année. gdp/inflation sont indexés
|
||||
// par année ("2026", "2027"…) → valeur en %, null si non disponible pour cette
|
||||
// année précise. isProxy = valeur de substitution (ex. consensus Trading
|
||||
// Economics) quand la BC elle-même n'est pas accessible en scraping (RBNZ).
|
||||
export interface CBForecast {
|
||||
asOf: string; // date de publication de la prévision
|
||||
years: string[]; // ordre d'affichage
|
||||
gdp: Record<string, number | null>;
|
||||
inflation: Record<string, number | null>;
|
||||
label: string; // ex. "Eurosystem staff projections — juin 2026"
|
||||
sourceUrl: string | null;
|
||||
isProxy?: boolean;
|
||||
proxyLabel?: string;
|
||||
}
|
||||
|
||||
export interface CBGovernance {
|
||||
currency: Currency;
|
||||
bankName: string;
|
||||
countryLabel: string;
|
||||
officialSiteUrl: string;
|
||||
policyPageUrl: string;
|
||||
meetingDate: string | null;
|
||||
rateLevel: string | null;
|
||||
voteSummary: string | null; // "12 – 0", "7 – 2", ou "Consensus (pas de vote publié)"
|
||||
voteDetail: string | null;
|
||||
statementUrl: string | null;
|
||||
reportPdfUrl: string | null;
|
||||
reportLabel: string | null;
|
||||
dotPlot?: FedDotPlot;
|
||||
forecast?: CBForecast | null;
|
||||
fetchError?: string;
|
||||
}
|
||||
|
||||
// ── Métadonnées statiques (toujours disponibles, même si le scraping échoue) ──
|
||||
|
||||
export const CB_STATIC_INFO: Record<Currency, { bankName: string; countryLabel: string; officialSiteUrl: string; policyPageUrl: string }> = {
|
||||
USD: { bankName: "Federal Reserve (Fed)", countryLabel: "États-Unis", officialSiteUrl: "https://www.federalreserve.gov", policyPageUrl: "https://www.federalreserve.gov/monetarypolicy.htm" },
|
||||
EUR: { bankName: "Banque Centrale Européenne", countryLabel: "Zone Euro", officialSiteUrl: "https://www.ecb.europa.eu", policyPageUrl: "https://www.ecb.europa.eu/press/govcdec/mopo/html/index.en.html" },
|
||||
GBP: { bankName: "Bank of England (BoE)", countryLabel: "Royaume-Uni", officialSiteUrl: "https://www.bankofengland.co.uk", policyPageUrl: "https://www.bankofengland.co.uk/monetary-policy" },
|
||||
JPY: { bankName: "Bank of Japan (BoJ)", countryLabel: "Japon", officialSiteUrl: "https://www.boj.or.jp/en", policyPageUrl: "https://www.boj.or.jp/en/mopo/index.htm" },
|
||||
CHF: { bankName: "Swiss National Bank (SNB)", countryLabel: "Suisse", officialSiteUrl: "https://www.snb.ch", policyPageUrl: "https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy" },
|
||||
CAD: { bankName: "Bank of Canada (BoC)", countryLabel: "Canada", officialSiteUrl: "https://www.bankofcanada.ca", policyPageUrl: "https://www.bankofcanada.ca/core-functions/monetary-policy/" },
|
||||
AUD: { bankName: "Reserve Bank of Australia (RBA)", countryLabel: "Australie", officialSiteUrl: "https://www.rba.gov.au", policyPageUrl: "https://www.rba.gov.au/monetary-policy/" },
|
||||
NZD: { bankName: "Reserve Bank of New Zealand (RBNZ)", countryLabel: "Nouvelle-Zélande", officialSiteUrl: "https://www.rbnz.govt.nz", policyPageUrl: "https://www.rbnz.govt.nz/monetary-policy" },
|
||||
};
|
||||
|
||||
function staticFallback(ccy: Currency, error: string): CBGovernance {
|
||||
const info = CB_STATIC_INFO[ccy];
|
||||
return {
|
||||
currency: ccy, ...info,
|
||||
meetingDate: null, rateLevel: null, voteSummary: null, voteDetail: null,
|
||||
statementUrl: null, reportPdfUrl: null, reportLabel: null,
|
||||
fetchError: error,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fed (USD) ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFedFraction(s: string): number {
|
||||
const m = s.match(/^(\d+)(?:-(\d+)\/(\d+))?$/);
|
||||
if (!m) return NaN;
|
||||
const whole = parseFloat(m[1]);
|
||||
return m[2] ? whole + parseFloat(m[2]) / parseFloat(m[3]) : whole;
|
||||
}
|
||||
|
||||
async function findLatestFedDates(): Promise<{ statementDates: string[]; sepDates: string[] } | null> {
|
||||
const html = await fetchText("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm");
|
||||
if (!html) return null;
|
||||
const todayCompact = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const statementDates = Array.from(new Set(Array.from(html.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g), m => m[1])))
|
||||
.filter(d => d <= todayCompact).sort();
|
||||
const sepDates = Array.from(new Set(Array.from(html.matchAll(/\/monetarypolicy\/fomcprojtabl[e]?(\d{8})\.htm/g), m => m[1])))
|
||||
.filter(d => d <= todayCompact).sort();
|
||||
return { statementDates, sepDates };
|
||||
}
|
||||
|
||||
async function fetchFedStatement(dateCompact: string): Promise<{ rateLevel: string; voteSummary: string; voteDetail: string | null; statementUrl: string } | null> {
|
||||
const url = `https://www.federalreserve.gov/newsevents/pressreleases/monetary${dateCompact}a.htm`;
|
||||
const html = await fetchText(url);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const rateMatch = text.match(/target range for the federal funds rate[\s\S]{0,60}?(\d+(?:-\d\/\d)?)\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i);
|
||||
const lower = rateMatch ? parseFedFraction(rateMatch[1]) : NaN;
|
||||
const upper = rateMatch ? parseFedFraction(rateMatch[2]) : NaN;
|
||||
const rateLevel = !isNaN(lower) && !isNaN(upper) ? `${lower}–${upper}%` : null;
|
||||
|
||||
const voteMatch = text.match(/by a\s+(\d+)\s*[–—-]\s*(\d+)\s+vote/i);
|
||||
const voteSummary = voteMatch ? `${voteMatch[1]} – ${voteMatch[2]}` : null;
|
||||
|
||||
const dissentMatch = text.match(/Voting against[^:]*:\s*([^.]+)\./i);
|
||||
const voteDetail = dissentMatch ? dissentMatch[1].trim() : null;
|
||||
|
||||
if (!rateLevel || !voteSummary) return null;
|
||||
return { rateLevel, voteSummary, voteDetail, statementUrl: url };
|
||||
}
|
||||
|
||||
// Table 1 : médiane Fed funds rate (trimestre courant + trimestre précédent, si présent)
|
||||
// + médianes GDP/PCE inflation (même table, mêmes 4 colonnes années) pour le bloc
|
||||
// "prévisions" générique (comment le FOMC voit sa propre économie).
|
||||
function parseSepTable1(text: string): {
|
||||
years: string[]; median: Record<string, number>; prevMedian: Record<string, number> | null; prevLabel: string | null;
|
||||
gdpMedian: Record<string, number> | null; pceMedian: Record<string, number> | null;
|
||||
} | null {
|
||||
// Les 3 années couvertes glissent d'une publication SEP à l'autre (ex. déc. 2025
|
||||
// couvre 2025-2028, juin 2026 couvre 2026-2028+LR) — on les lit depuis l'en-tête
|
||||
// de Table 1 plutôt que de les figer, sinon l'historique se retrouve mal étiqueté.
|
||||
const headerM = text.match(/(\d{4})\s+(\d{4})\s+(\d{4})\s+Longer run/);
|
||||
if (!headerM) return null;
|
||||
const years = [headerM[1], headerM[2], headerM[3], "Longer run"];
|
||||
|
||||
const start = text.indexOf("Federal funds rate");
|
||||
if (start === -1) return null;
|
||||
const noteIdx = text.indexOf("Note:", start);
|
||||
const block = noteIdx === -1 ? text.slice(start) : text.slice(start, noteIdx);
|
||||
|
||||
const medM = block.match(/^Federal funds rate\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
if (!medM) return null;
|
||||
|
||||
const prevM = block.match(/(January|March|April|June|July|September|December)\s+projection\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i);
|
||||
|
||||
// "Change in real GDP X X X X ..." / "PCE inflation X X X X ..." — même format
|
||||
// de ligne que "Federal funds rate", ailleurs dans Table 1 (pas dans `block`,
|
||||
// qui commence après ces lignes, donc on cherche dans `text` en entier).
|
||||
const toYearRecord = (vals: [string, string, string, string] | null) =>
|
||||
vals ? { [years[0]]: parseFloat(vals[0]), [years[1]]: parseFloat(vals[1]), [years[2]]: parseFloat(vals[2]), [years[3]]: parseFloat(vals[3]) } : null;
|
||||
const gdpM = text.match(/Change in real GDP\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
const pceM = text.match(/(?<!Core )PCE inflation\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
|
||||
return {
|
||||
years,
|
||||
median: { [years[0]]: parseFloat(medM[1]), [years[1]]: parseFloat(medM[2]), [years[2]]: parseFloat(medM[3]), [years[3]]: parseFloat(medM[4]) },
|
||||
prevMedian: prevM ? { [years[0]]: parseFloat(prevM[2]), [years[1]]: parseFloat(prevM[3]), [years[2]]: parseFloat(prevM[4]), [years[3]]: parseFloat(prevM[5]) } : null,
|
||||
prevLabel: prevM ? `${prevM[1]} projection` : null,
|
||||
gdpMedian: gdpM ? toYearRecord([gdpM[1], gdpM[2], gdpM[3], gdpM[4]]) : null,
|
||||
pceMedian: pceM ? toYearRecord([pceM[1], pceM[2], pceM[3], pceM[4]]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Figure 2 : dots individuels — table HTML sémantique (th.stub = niveau de taux,
|
||||
// td.data|td.emptydata = nb de participants par colonne année), bien plus fiable
|
||||
// qu'un parsing du texte aplati (cellules vides ambiguës une fois le texte collapsé).
|
||||
function parseSepFigure2(html: string): FedDot[] {
|
||||
const dots: FedDot[] = [];
|
||||
const figIdx = html.indexOf("Figure 2");
|
||||
if (figIdx === -1) return dots;
|
||||
const tableStart = html.indexOf("<table", figIdx);
|
||||
const tableEnd = html.indexOf("</table>", tableStart);
|
||||
if (tableStart === -1 || tableEnd === -1) return dots;
|
||||
const tableHtml = html.slice(tableStart, tableEnd);
|
||||
|
||||
const headerM = tableHtml.match(/<thead>([\s\S]*?)<\/thead>/);
|
||||
const years = headerM
|
||||
? Array.from(headerM[1].matchAll(/<th[^>]*>([^<]+)<\/th>/g), m => m[1].trim()).filter(y => !/midpoint/i.test(y))
|
||||
: ["2026", "2027", "2028", "Longer run"];
|
||||
|
||||
const rowRe = /<tr>\s*<th class="stub"[^>]*>([\d.]+)<\/th>([\s\S]*?)<\/tr>/g;
|
||||
let rm: RegExpExecArray | null;
|
||||
while ((rm = rowRe.exec(tableHtml)) !== null) {
|
||||
const rate = parseFloat(rm[1]);
|
||||
const cells = Array.from(rm[2].matchAll(/<td class="(data|emptydata)"[^>]*>([^<]*)<\/td>/g));
|
||||
cells.forEach((c, i) => {
|
||||
if (c[1] === "data") {
|
||||
const count = parseInt(c[2].trim(), 10);
|
||||
if (!isNaN(count) && count > 0 && years[i]) dots.push({ year: years[i], rate, count });
|
||||
}
|
||||
});
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
async function fetchFedDotPlot(sepDates: string[]): Promise<FedDotPlot | null> {
|
||||
if (!sepDates.length) return null;
|
||||
const latest = sepDates.at(-1)!;
|
||||
const htmlUrl = `https://www.federalreserve.gov/monetarypolicy/fomcprojtabl${latest}.htm`;
|
||||
const html = await fetchText(htmlUrl);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const table1 = parseSepTable1(text);
|
||||
if (!table1) return null;
|
||||
const dots = parseSepFigure2(html);
|
||||
|
||||
// Historique : jusqu'à 6 dernières publications SEP (trimestrielles) pour montrer l'évolution
|
||||
const histDates = sepDates.slice(-6);
|
||||
const history: FedSepHistoryPoint[] = [];
|
||||
const histResults = await Promise.all(histDates.map(async d => {
|
||||
if (d === latest) return { date: d, medianByYear: table1.median };
|
||||
const h = await fetchText(`https://www.federalreserve.gov/monetarypolicy/fomcprojtabl${d}.htm`);
|
||||
if (!h) return null;
|
||||
const t1 = parseSepTable1(htmlToText(h));
|
||||
return t1 ? { date: d, medianByYear: t1.median } : null;
|
||||
}));
|
||||
for (const h of histResults) if (h) history.push({ date: `${h.date.slice(0,4)}-${h.date.slice(4,6)}-${h.date.slice(6,8)}`, medianByYear: h.medianByYear });
|
||||
|
||||
return {
|
||||
asOfDate: `${latest.slice(0,4)}-${latest.slice(4,6)}-${latest.slice(6,8)}`,
|
||||
years: table1.years,
|
||||
medianByYear: table1.median,
|
||||
prevMedianByYear: table1.prevMedian,
|
||||
prevLabel: table1.prevLabel,
|
||||
dots,
|
||||
history,
|
||||
gdpMedian: table1.gdpMedian,
|
||||
pceMedian: table1.pceMedian,
|
||||
sepHtmlUrl: htmlUrl,
|
||||
sepPdfUrl: `https://www.federalreserve.gov/monetarypolicy/files/fomcprojtabl${latest}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchFedGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.USD;
|
||||
const dates = await findLatestFedDates();
|
||||
if (!dates || !dates.statementDates.length) return staticFallback("USD", "Impossible de lire le calendrier FOMC");
|
||||
|
||||
const latestStatementDate = dates.statementDates.at(-1)!;
|
||||
const [statement, dotPlot] = await Promise.all([
|
||||
fetchFedStatement(latestStatementDate),
|
||||
fetchFedDotPlot(dates.sepDates),
|
||||
]);
|
||||
|
||||
if (!statement) return staticFallback("USD", "Communiqué FOMC illisible");
|
||||
|
||||
const forecast: CBForecast | null = dotPlot && (dotPlot.gdpMedian || dotPlot.pceMedian)
|
||||
? {
|
||||
asOf: dotPlot.asOfDate,
|
||||
years: dotPlot.years.filter(y => y !== "Longer run"),
|
||||
gdp: dotPlot.gdpMedian ?? {},
|
||||
inflation: dotPlot.pceMedian ?? {},
|
||||
label: `Fed — Summary of Economic Projections (médianes) — ${dotPlot.asOfDate}`,
|
||||
sourceUrl: dotPlot.sepHtmlUrl,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
currency: "USD", ...info,
|
||||
meetingDate: `${latestStatementDate.slice(0,4)}-${latestStatementDate.slice(4,6)}-${latestStatementDate.slice(6,8)}`,
|
||||
rateLevel: statement.rateLevel,
|
||||
voteSummary: statement.voteSummary,
|
||||
voteDetail: statement.voteDetail,
|
||||
statementUrl: statement.statementUrl,
|
||||
reportPdfUrl: dotPlot?.sepPdfUrl ?? null,
|
||||
reportLabel: dotPlot ? `Summary of Economic Projections — ${dotPlot.asOfDate}` : null,
|
||||
...(dotPlot ? { dotPlot } : {}),
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// ── BoE (GBP) ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOE_MONTHS = ["january","february","march","april","may","june","july","august","september","october","november","december"];
|
||||
|
||||
interface BoeParsed { rateLevel: string; voteSummary: string; meetingDate: string | null; voteDetail: string | null; }
|
||||
|
||||
// "voted by a majority of X–Y to maintain/raise/lower Bank Rate at Z%" ou, si
|
||||
// unanime, "voted unanimously to maintain/raise/lower Bank Rate at Z%".
|
||||
function parseBoeVote(text: string): BoeParsed | null {
|
||||
const majM = text.match(/voted by a majority of\s+(\d+)\s*[–—-]\s*(\d+)\s+to\s+(?:maintain|reduce|increase|raise|cut)[^.]*?Bank Rate at\s+([\d.]+)%/i);
|
||||
const unanM = !majM && text.match(/voted unanimously to\s+(?:maintain|reduce|increase|raise|cut)[^.]*?Bank Rate at\s+([\d.]+)%/i);
|
||||
if (!majM && !unanM) return null;
|
||||
|
||||
const dateM = text.match(/At its meeting ending on\s+([^,]+),/i);
|
||||
// Périodes tolérées à l'intérieur (ex. "0.25 percentage points") : on ne coupe
|
||||
// que sur un point réellement suivi d'un espace (fin de phrase), pas un point décimal.
|
||||
const dissentM = text.match(/(\w+\s+members?\s+voted to(?:(?!\.\s)[\s\S])+)\.\s/i);
|
||||
|
||||
return majM
|
||||
? { rateLevel: `${majM[3]}%`, voteSummary: `${majM[1]} – ${majM[2]}`, meetingDate: dateM?.[1]?.trim() ?? null, voteDetail: dissentM?.[1]?.trim() ?? null }
|
||||
: { rateLevel: `${(unanM as RegExpMatchArray)[1]}%`, voteSummary: "Unanime", meetingDate: dateM?.[1]?.trim() ?? null, voteDetail: null };
|
||||
}
|
||||
|
||||
export async function fetchBoeGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.GBP;
|
||||
const now = new Date();
|
||||
|
||||
// Certaines pages du mois courant existent déjà en placeholder avant la réunion
|
||||
// ("to be published at Xpm") — on ne valide donc pas sur la simple présence de
|
||||
// la page, mais sur un vote réellement trouvé, et on recule au besoin.
|
||||
for (let back = 0; back < 5; back++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - back, 1);
|
||||
const monthName = BOE_MONTHS[d.getMonth()];
|
||||
const url = `https://www.bankofengland.co.uk/monetary-policy-summary-and-minutes/${d.getFullYear()}/${monthName}-${d.getFullYear()}`;
|
||||
const html = await fetchText(url);
|
||||
if (!html) continue;
|
||||
const parsed = parseBoeVote(htmlToText(html));
|
||||
if (!parsed) continue;
|
||||
|
||||
return {
|
||||
currency: "GBP", ...info,
|
||||
meetingDate: parsed.meetingDate,
|
||||
rateLevel: parsed.rateLevel,
|
||||
voteSummary: parsed.voteSummary,
|
||||
voteDetail: parsed.voteDetail,
|
||||
statementUrl: url,
|
||||
reportPdfUrl: `${url}.pdf`,
|
||||
reportLabel: "Monetary Policy Summary and Minutes",
|
||||
};
|
||||
}
|
||||
return staticFallback("GBP", "Vote MPC introuvable sur les 5 derniers mois");
|
||||
}
|
||||
|
||||
// ── RBA (AUD) ─────────────────────────────────────────────────────────────────
|
||||
// IMPORTANT : rba.gov.au renvoie 403 (Akamai) dès qu'un User-Agent de navigateur
|
||||
// est envoyé — la demande "brute" (aucun header custom) passe en revanche très
|
||||
// bien. Ne PAS utiliser fetchText() ici (il pose un User-Agent Chrome).
|
||||
|
||||
async function fetchPlain(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, { next: { revalidate: 6 * 3600 } });
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Prévisions semestrielles (Table 3.2 "Detailed Baseline Forecast Table") de la
|
||||
// dernière Statement on Monetary Policy — colonnes Dec/Jun (pas des années
|
||||
// calendaires pleines, la RBA prévoit par semestre).
|
||||
async function fetchRbaForecast(): Promise<CBForecast | null> {
|
||||
const indexHtml = await fetchPlain("https://www.rba.gov.au/publications/smp/");
|
||||
if (!indexHtml) return null;
|
||||
const linkM = indexHtml.match(/href="(\/publications\/smp\/\d{4}\/[a-z]+\/)"/);
|
||||
if (!linkM) return null;
|
||||
const pageUrl = `https://www.rba.gov.au${linkM[1]}outlook.html`;
|
||||
|
||||
const html = await fetchPlain(pageUrl);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const tblIdx = text.indexOf("Table 3.2");
|
||||
if (tblIdx === -1) return null;
|
||||
const endIdx = text.indexOf("Forecasts finalised", tblIdx);
|
||||
const win = text.slice(tblIdx, endIdx === -1 ? tblIdx + 6000 : endIdx);
|
||||
|
||||
const headerM = win.match(/(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})/);
|
||||
if (!headerM) return null;
|
||||
const years = [0, 2, 4, 6, 8, 10].map(i => `${headerM[i + 2]}-${headerM[i + 1] === "Dec" ? "12" : "06"}`);
|
||||
|
||||
const numRe = "(-?[\\d.]+)";
|
||||
const seriesRe = `${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}`;
|
||||
const gdpM = win.match(new RegExp(`Gross domestic product\\s+${seriesRe}`));
|
||||
const cpiM = win.match(new RegExp(`Consumer Price Index\\s+${seriesRe}`));
|
||||
if (!gdpM && !cpiM) return null;
|
||||
|
||||
const toRecord = (m: RegExpMatchArray | null) =>
|
||||
m ? Object.fromEntries(years.map((y, i) => [y, parseFloat(m[i + 1])])) : {};
|
||||
|
||||
return {
|
||||
asOf: new Date().toISOString().slice(0, 10),
|
||||
years,
|
||||
gdp: toRecord(gdpM),
|
||||
inflation: toRecord(cpiM),
|
||||
label: "RBA — Statement on Monetary Policy, prévisions semestrielles",
|
||||
sourceUrl: pageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRbaGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.AUD;
|
||||
const [indexHtml, forecast] = await Promise.all([
|
||||
fetchPlain("https://www.rba.gov.au/monetary-policy/int-rate-decisions/"),
|
||||
fetchRbaForecast().catch(() => null),
|
||||
]);
|
||||
if (!indexHtml) return staticFallback("AUD", "Index des décisions introuvable");
|
||||
|
||||
const links = Array.from(new Set(Array.from(indexHtml.matchAll(/\/media-releases\/(\d{4})\/mr-(\d{2})-(\d{2})\.html/g), m => m[0])));
|
||||
if (!links.length) return staticFallback("AUD", "Aucune décision trouvée sur l'index");
|
||||
// Le numéro de séquence (mr-YY-NN) croît avec le temps mais n'est pas daté — on
|
||||
// trie par (année, numéro) pour prendre la plus récente.
|
||||
links.sort((a, b) => {
|
||||
const pa = a.match(/mr-(\d{2})-(\d{2})/)!, pb = b.match(/mr-(\d{2})-(\d{2})/)!;
|
||||
return pa[1] !== pb[1] ? +pa[1] - +pb[1] : +pa[2] - +pb[2];
|
||||
});
|
||||
const url = `https://www.rba.gov.au${links.at(-1)}`;
|
||||
|
||||
const html = await fetchPlain(url);
|
||||
if (!html) return staticFallback("AUD", "Communiqué RBA illisible");
|
||||
const text = htmlToText(html);
|
||||
|
||||
const dateM = text.match(/\bDate\s+(\d{1,2}\s+\w+\s+\d{4})/);
|
||||
const majM = text.match(/made by majority:\s*([a-z]+)\s+members? voted to\s+(increase|decrease|lower|leave)[^;]*?(?:to\s+([\d.]+)\s+per cent)?;\s*([a-z]+)\s+members? voted to\s+(increase|decrease|lower|leave)[^.]*?(?:at\s+([\d.]+)\s+per cent)?/i);
|
||||
const unanRateM = text.match(/Board decided to\s+(?:leave the cash rate target unchanged at|increase the cash rate target(?:\s+by[^t]*)?\s*to|lower the cash rate target(?:\s+by[^t]*)?\s*to)\s+([\d.]+)\s+per cent/i);
|
||||
|
||||
const WORDNUM: Record<string, number> = { one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9 };
|
||||
|
||||
let voteSummary: string, rateLevel: string | null, voteDetail: string | null;
|
||||
if (majM) {
|
||||
const n1 = WORDNUM[majM[1].toLowerCase()] ?? NaN;
|
||||
const n2 = WORDNUM[majM[4].toLowerCase()] ?? NaN;
|
||||
voteSummary = !isNaN(n1) && !isNaN(n2) ? `${Math.max(n1,n2)} – ${Math.min(n1,n2)}` : "Majorité (détail ci-dessous)";
|
||||
rateLevel = (majM[3] ?? majM[6]) ? `${majM[3] ?? majM[6]}%` : null;
|
||||
voteDetail = majM[0];
|
||||
} else {
|
||||
voteSummary = "Unanime";
|
||||
rateLevel = unanRateM ? `${unanRateM[1]}%` : null;
|
||||
voteDetail = null;
|
||||
}
|
||||
|
||||
return {
|
||||
currency: "AUD", ...info,
|
||||
meetingDate: dateM ? dateM[1] : null,
|
||||
rateLevel,
|
||||
voteSummary,
|
||||
voteDetail,
|
||||
statementUrl: url,
|
||||
reportPdfUrl: null, // Statement on Monetary Policy = trimestriel, pas à chaque réunion
|
||||
reportLabel: "Statement by the Monetary Policy Board",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// ── SNB (CHF) ─────────────────────────────────────────────────────────────────
|
||||
// Décision collégiale (Direction générale à 3 membres) — aucun vote publié.
|
||||
|
||||
export async function fetchSnbGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.CHF;
|
||||
const listHtml = await fetchText("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions");
|
||||
const linkM = listHtml?.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_(\d{8})[^"]*)"/);
|
||||
if (!linkM) return staticFallback("CHF", "Décision SNB introuvable");
|
||||
const dateCompact = linkM[2];
|
||||
const dateIso = `${dateCompact.slice(0,4)}-${dateCompact.slice(4,6)}-${dateCompact.slice(6,8)}`;
|
||||
|
||||
const prHtml = await fetchText(`https://www.snb.ch${linkM[1]}`);
|
||||
if (!prHtml) return staticFallback("CHF", "Communiqué SNB illisible");
|
||||
const prText = htmlToText(prHtml);
|
||||
const rateM = prText.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i);
|
||||
|
||||
// "Conditional inflation forecast" — pas de prévision de croissance chiffrée
|
||||
// publiée par la SNB dans ce même communiqué, seulement l'inflation.
|
||||
const inflM = prText.match(/average annual inflation at\s+(-?[\d.]+)%\s+for\s+(\d{4}),\s+(-?[\d.]+)%\s+for\s+(\d{4})\s+and\s+(-?[\d.]+)%\s+for\s+(\d{4})/i);
|
||||
const forecast: CBForecast | null = inflM ? {
|
||||
asOf: dateIso,
|
||||
years: [inflM[2], inflM[4], inflM[6]],
|
||||
gdp: {},
|
||||
inflation: { [inflM[2]]: parseFloat(inflM[1]), [inflM[4]]: parseFloat(inflM[3]), [inflM[6]]: parseFloat(inflM[5]) },
|
||||
label: "SNB — prévision conditionnelle d'inflation",
|
||||
sourceUrl: `https://www.snb.ch${linkM[1]}`,
|
||||
} : null;
|
||||
|
||||
return {
|
||||
currency: "CHF", ...info,
|
||||
meetingDate: dateIso,
|
||||
rateLevel: rateM ? `${rateM[1]}%` : null,
|
||||
voteSummary: "Décision collégiale (3 membres)",
|
||||
voteDetail: null,
|
||||
statementUrl: `https://www.snb.ch${linkM[1]}`,
|
||||
reportPdfUrl: `https://www.snb.ch/public/asset/en/www-snb-ch/publications/communication/press-releases-restricted/pre_${dateCompact}/publications0_en/pre_${dateCompact}.en.pdf`,
|
||||
reportLabel: "Monetary Policy Assessment",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// Projections du dernier Monetary Policy Report — Table 2 ("Contributions to
|
||||
// average annual real GDP growth") donne les lignes annuelles GDP et CPI
|
||||
// inflation ; Table 3 (juste après) redonne un CPI trimestriel qu'on veut
|
||||
// éviter de capter par erreur, d'où la fenêtre de recherche bornée à
|
||||
// [Table 2: … Table 3:].
|
||||
async function fetchBocForecast(): Promise<CBForecast | null> {
|
||||
const indexHtml = await fetchText("https://www.bankofcanada.ca/publications/mpr/");
|
||||
if (!indexHtml) return null;
|
||||
const mprM = indexHtml.match(/href="(https:\/\/www\.bankofcanada\.ca\/publications\/mpr\/mpr-\d{4}-\d{2}-\d{2}\/)"/);
|
||||
if (!mprM) return null;
|
||||
const projUrl = `${mprM[1]}projections/`;
|
||||
|
||||
const html = await fetchText(projUrl);
|
||||
if (!html) return null;
|
||||
const text = htmlToText(html);
|
||||
|
||||
const t2Start = text.indexOf("Table 2:");
|
||||
const t3Start = text.indexOf("Table 3:", t2Start);
|
||||
if (t2Start === -1 || t3Start === -1) return null;
|
||||
const window = text.slice(t2Start, t3Start);
|
||||
|
||||
const yearsM = window.match(/(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})/);
|
||||
const numRe = "(-?[\\d.]+)(?:\\s*\\([^)]*\\))?";
|
||||
const gdpM = window.match(new RegExp(`\\bGDP\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}`));
|
||||
const cpiM = window.match(new RegExp(`\\bCPI inflation\\s+${numRe}\\s+${numRe}\\s+${numRe}\\s+${numRe}`));
|
||||
if (!yearsM || (!gdpM && !cpiM)) return null;
|
||||
|
||||
const years = [yearsM[1], yearsM[2], yearsM[3], yearsM[4]];
|
||||
const toRecord = (m: RegExpMatchArray | null) =>
|
||||
m ? { [years[0]]: parseFloat(m[1]), [years[1]]: parseFloat(m[2]), [years[2]]: parseFloat(m[3]), [years[3]]: parseFloat(m[4]) } : {};
|
||||
|
||||
return {
|
||||
asOf: new Date().toISOString().slice(0, 10),
|
||||
years,
|
||||
gdp: toRecord(gdpM),
|
||||
inflation: toRecord(cpiM),
|
||||
label: "BoC — Monetary Policy Report, projections",
|
||||
sourceUrl: projUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ── BoC (CAD) ─────────────────────────────────────────────────────────────────
|
||||
// Décision par consensus — aucun vote publié.
|
||||
|
||||
export async function fetchBocGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.CAD;
|
||||
const [html, forecast] = await Promise.all([
|
||||
fetchText("https://www.bankofcanada.ca/core-functions/monetary-policy/key-interest-rate/"),
|
||||
fetchBocForecast().catch(() => null),
|
||||
]);
|
||||
if (!html) return staticFallback("CAD", "Page taux directeur introuvable");
|
||||
const text = htmlToText(html);
|
||||
|
||||
// Tableau "Date* Target (%) Change (%) <date la plus récente> <taux> ..." —
|
||||
// la première ligne du tableau est toujours la décision la plus récente.
|
||||
const rowM = text.match(/Target \(%\)\s+Change \(%\)\s+([A-Z][a-z]+ \d{1,2},\s*\d{4})\s+([\d.]+)/);
|
||||
|
||||
return {
|
||||
currency: "CAD", ...info,
|
||||
meetingDate: rowM ? rowM[1] : null,
|
||||
rateLevel: rowM ? `${rowM[2]}%` : null,
|
||||
voteSummary: "Décision par consensus",
|
||||
voteDetail: null,
|
||||
statementUrl: "https://www.bankofcanada.ca/core-functions/monetary-policy/key-interest-rate/",
|
||||
reportPdfUrl: null,
|
||||
reportLabel: "Monetary Policy Report",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
// Extrait les projections macroéconomiques (staff projections) du même
|
||||
// communiqué de décision déjà fetché — phrasé quasi-identique à chaque
|
||||
// publication trimestrielle : "headline inflation is expected to average X%
|
||||
// in Y1, Y% in Y2 and Z% in Y3" / "economic growth at an average of X% in Y1…".
|
||||
function parseEcbForecast(text: string, sourceUrl: string): CBForecast | null {
|
||||
const inflM = text.match(/(?:headline )?inflation is expected to average\s+([\d.]+)%\s+in\s+(\d{4}),\s+([\d.]+)%\s+in\s+(\d{4})\s+and\s+([\d.]+)%\s+in\s+(\d{4})/i);
|
||||
const gdpM = text.match(/(?:economic growth|GDP growth) at an average of\s+([\d.]+)%\s+in\s+(\d{4}),\s+([\d.]+)%\s+in\s+(\d{4})\s+and\s+([\d.]+)%\s+in\s+(\d{4})/i);
|
||||
if (!inflM && !gdpM) return null;
|
||||
|
||||
const years = Array.from(new Set([inflM?.[2], inflM?.[4], inflM?.[6], gdpM?.[2], gdpM?.[4], gdpM?.[6]].filter((y): y is string => !!y))).sort();
|
||||
|
||||
return {
|
||||
asOf: new Date().toISOString().slice(0, 10),
|
||||
years,
|
||||
gdp: gdpM ? { [gdpM[2]]: parseFloat(gdpM[1]), [gdpM[4]]: parseFloat(gdpM[3]), [gdpM[6]]: parseFloat(gdpM[5]) } : {},
|
||||
inflation: inflM ? { [inflM[2]]: parseFloat(inflM[1]), [inflM[4]]: parseFloat(inflM[3]), [inflM[6]]: parseFloat(inflM[5]) } : {},
|
||||
label: "Eurosystem staff macroeconomic projections",
|
||||
sourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ECB (EUR) ─────────────────────────────────────────────────────────────────
|
||||
// Décision par consensus — aucun vote publié. Découverte du dernier communiqué
|
||||
// via le flux RSS (les URLs de communiqués contiennent un hash non prévisible).
|
||||
|
||||
export async function fetchEcbGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.EUR;
|
||||
// Le site ECB charge sa liste de décisions côté client (pas de lien statique
|
||||
// fiable vers le dernier communiqué — hash d'URL non prévisible depuis la date).
|
||||
// Le flux RSS général capte la décision si elle est encore dans sa fenêtre
|
||||
// glissante ; sinon on retombe sur data/rate_decisions.json (déjà tenu à jour
|
||||
// manuellement ailleurs dans l'app) pour au moins afficher le taux courant.
|
||||
const rss = await fetchText("https://www.ecb.europa.eu/rss/press.html");
|
||||
const itemM = rss ? Array.from(rss.matchAll(/<item>([\s\S]*?)<\/item>/g)).map(m => m[1]).find(item => /ecb\.mp\d{6}~/.test(item)) : undefined;
|
||||
|
||||
if (itemM) {
|
||||
const urlM = itemM.match(/<link>([^<]+)<\/link>/);
|
||||
const dateM = itemM.match(/<pubDate>([^<]+)<\/pubDate>/);
|
||||
if (urlM) {
|
||||
const html = await fetchText(urlM[1]);
|
||||
const text = html ? htmlToText(html) : "";
|
||||
const rateM = text.match(/deposit facility rate[^.]*?to\s+([\d.]+)%/i);
|
||||
if (rateM || text) {
|
||||
return {
|
||||
currency: "EUR", ...info,
|
||||
meetingDate: dateM ? new Date(dateM[1]).toISOString().slice(0, 10) : null,
|
||||
rateLevel: rateM?.[1] ? `${rateM[1]}%` : null,
|
||||
voteSummary: "Décision par consensus",
|
||||
voteDetail: null,
|
||||
statementUrl: urlM[1],
|
||||
reportPdfUrl: null,
|
||||
reportLabel: "Monetary Policy Decision",
|
||||
forecast: parseEcbForecast(text, urlM[1]),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Repli : taux courant connu (data/rate_decisions.json), pas de date/URL de communiqué.
|
||||
const rateDecEntry = (rateDecisionsData as Array<{ decisions: Record<string, { current: number; source?: string }> }>)[0];
|
||||
const eurDecision = rateDecEntry?.decisions?.EUR;
|
||||
if (eurDecision === undefined) return staticFallback("EUR", "Communiqué introuvable et rate_decisions.json indisponible");
|
||||
|
||||
// Le champ "source" de rate_decisions.json contient parfois l'URL du
|
||||
// communiqué (écrite manuellement lors de la dernière correction) — on la
|
||||
// réutilise pour tenter d'en tirer les projections, à défaut de RSS.
|
||||
const fallbackUrlM = eurDecision.source?.match(/https:\/\/www\.ecb\.europa\.eu\/press\/pr\/\S+?\.html/);
|
||||
const fallbackHtml = fallbackUrlM ? await fetchText(fallbackUrlM[0]) : null;
|
||||
const fallbackForecast = fallbackHtml ? parseEcbForecast(htmlToText(fallbackHtml), fallbackUrlM![0]) : null;
|
||||
|
||||
return {
|
||||
currency: "EUR", ...info,
|
||||
meetingDate: null,
|
||||
rateLevel: `${eurDecision.current}%`,
|
||||
voteSummary: "Décision par consensus",
|
||||
voteDetail: null,
|
||||
statementUrl: fallbackUrlM?.[0] ?? null,
|
||||
reportPdfUrl: null,
|
||||
reportLabel: null,
|
||||
forecast: fallbackForecast,
|
||||
fetchError: "Dernier communiqué hors de la fenêtre RSS — taux affiché depuis rate_decisions.json",
|
||||
};
|
||||
}
|
||||
|
||||
// ── BoJ (JPY) ─────────────────────────────────────────────────────────────────
|
||||
// Vote numérique publié (ex. "7-1 majority vote"). Statement PDF-only en 2026 ;
|
||||
// date de réunion découverte par recherche en arrière (par lots concurrents,
|
||||
// même technique que lib/investinglive.ts) faute de calendrier discret exploitable.
|
||||
|
||||
async function tryBojDate(dateCompact: string): Promise<{ text: string; url: string } | null> {
|
||||
const url = `https://www.boj.or.jp/en/mopo/mpmdeci/mpr_2026/k${dateCompact}a.pdf`;
|
||||
try {
|
||||
// Pas de next:{revalidate} ici : le cache fetch de Next.js sur une réponse
|
||||
// binaire (PDF) interfère avec la lecture arrayBuffer() qui suit (échec
|
||||
// silencieux observé en pratique — un fetch "nu" comme pour RBA fonctionne).
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const buf = await res.arrayBuffer();
|
||||
const { PDFParse } = await import("pdf-parse");
|
||||
const parser = new PDFParse({ data: Buffer.from(buf) });
|
||||
const result = await parser.getText();
|
||||
await parser.destroy();
|
||||
return { text: result.text, url };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Outlook for Economic Activity and Prices ("Outlook Report") — publié 4x/an
|
||||
// (env. jan/avr/jul/oct), pas à chaque réunion. Table "Forecasts of the
|
||||
// Majority of the Policy Board Members" : pour chaque exercice fiscal, la
|
||||
// médiane est entre crochets ; on ignore les lignes "Forecasts made in [mois]"
|
||||
// qui sont l'ancienne prévision de comparaison, pas la prévision actuelle.
|
||||
async function fetchBojOutlookPdfText(year: number, month: number): Promise<string | null> {
|
||||
const yy = String(year % 100).padStart(2, "0");
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const url = `https://www.boj.or.jp/en/mopo/outlook/gor${yy}${mm}a.pdf`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const buf = await res.arrayBuffer();
|
||||
const { PDFParse } = await import("pdf-parse");
|
||||
const parser = new PDFParse({ data: Buffer.from(buf) });
|
||||
const result = await parser.getText();
|
||||
await parser.destroy();
|
||||
return result.text;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function fetchBojForecast(): Promise<CBForecast | null> {
|
||||
let y = new Date().getFullYear();
|
||||
let m = new Date().getMonth() + 1;
|
||||
const quarterMonths = [1, 4, 7, 10];
|
||||
|
||||
for (let tries = 0; tries < 8; tries++) {
|
||||
if (quarterMonths.includes(m)) {
|
||||
const text = await fetchBojOutlookPdfText(y, m);
|
||||
if (text) {
|
||||
const flat = text.replace(/\s+/g, " ");
|
||||
const rows = Array.from(flat.matchAll(
|
||||
/Fiscal (20\d{2})\s+[+-][\d.]+\s+to\s+[+-][\d.]+\s+\[([+-][\d.]+)\]\s+([+-][\d.]+)(?:\s+to\s+[+-][\d.]+\s+\[([+-][\d.]+)\])?/g
|
||||
));
|
||||
if (rows.length) {
|
||||
const years: string[] = [];
|
||||
const gdp: Record<string, number> = {};
|
||||
const inflation: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
const [, fy, gdpMedian, cpiValue, cpiMedian] = r;
|
||||
years.push(fy);
|
||||
gdp[fy] = parseFloat(gdpMedian);
|
||||
inflation[fy] = parseFloat(cpiMedian ?? cpiValue);
|
||||
}
|
||||
return {
|
||||
asOf: `${y}-${String(m).padStart(2, "0")}`,
|
||||
years,
|
||||
gdp,
|
||||
inflation,
|
||||
label: "BoJ — Outlook for Economic Activity and Prices (médianes)",
|
||||
sourceUrl: `https://www.boj.or.jp/en/mopo/outlook/gor${String(y % 100).padStart(2, "0")}${String(m).padStart(2, "0")}a.pdf`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
m--; if (m === 0) { m = 12; y--; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchBojGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.JPY;
|
||||
const now = new Date();
|
||||
const forecast = await fetchBojForecast().catch(() => null);
|
||||
|
||||
for (let batchStart = 0; batchStart <= 56; batchStart += 7) {
|
||||
const days = Array.from({ length: 7 }, (_, i) => batchStart + i);
|
||||
const results = await Promise.all(days.map(async d => {
|
||||
const dt = new Date(now); dt.setDate(now.getDate() - d);
|
||||
const compact = dt.toISOString().slice(0, 10).replace(/-/g, "").slice(2); // YYMMDD
|
||||
const r = await tryBojDate(compact);
|
||||
return r ? { ...r, daysAgo: d, dateIso: dt.toISOString().slice(0, 10) } : null;
|
||||
}));
|
||||
const found = results.filter((r): r is NonNullable<typeof r> => r !== null).sort((a, b) => a.daysAgo - b.daysAgo)[0];
|
||||
if (found) {
|
||||
const voteM = found.text.match(/by an?\s+(\d+)-(\d+)\s+majority vote/i);
|
||||
// Ex. "encourage the uncollateralized overnight call rate to remain at around 1.0 percent"
|
||||
const rateM = found.text.match(/(?:overnight call rate|policy rate)[^.]*?around\s+(-?[\d.]+)\s*percent/i);
|
||||
const namesM = found.text.match(/Voting against[^:]*:\s*([^.]+)\./i);
|
||||
return {
|
||||
currency: "JPY", ...info,
|
||||
meetingDate: found.dateIso,
|
||||
rateLevel: rateM ? `${rateM[1]}%` : null,
|
||||
voteSummary: voteM ? `${voteM[1]} – ${voteM[2]}` : "Unanime",
|
||||
voteDetail: namesM ? namesM[1].trim() : null,
|
||||
statementUrl: found.url,
|
||||
reportPdfUrl: found.url,
|
||||
reportLabel: "Statement on Monetary Policy",
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
}
|
||||
return staticFallback("JPY", "Communiqué BoJ introuvable sur les 8 dernières semaines");
|
||||
}
|
||||
|
||||
// ── RBNZ (NZD) ────────────────────────────────────────────────────────────────
|
||||
// rbnz.govt.nz bloque toute requête automatisée derrière un challenge Cloudflare
|
||||
// interactif ("Verify you are human") — vérifié avec un vrai navigateur headless,
|
||||
// pas seulement fetch(). Ce n'est pas contournable proprement (et on ne cherche
|
||||
// pas à résoudre un captcha anti-bot / évader une protection délibérée).
|
||||
//
|
||||
// Repli légitime (aucune requête vers rbnz.govt.nz) :
|
||||
// - Taux OCR courant : data/rate_decisions.json (auto-maintenu par
|
||||
// update-rate-decisions.yml).
|
||||
// - Date de la dernière réunion : calendrier économique Trading Economics
|
||||
// (lib/tradingeconomics.ts, déjà utilisé ailleurs dans l'app pour le
|
||||
// calendrier), qui couvre la Nouvelle-Zélande — pas de scraping du site
|
||||
// RBNZ lui-même.
|
||||
// - Vote : la RBNZ ne publie de toute façon pas de décompte individuel des
|
||||
// votes (confirmé via TE/interest.co.nz — décision par consensus du
|
||||
// comité, contrairement à la Fed/BoE), donc "non publié" est correct sur
|
||||
// le fond, pas seulement une conséquence du blocage.
|
||||
|
||||
export async function fetchRbnzGovernance(): Promise<CBGovernance> {
|
||||
const info = CB_STATIC_INFO.NZD;
|
||||
const rateDecEntry = (rateDecisionsData as Array<{ decisions: Record<string, { current: number }> }>)[0];
|
||||
const nzdRate = rateDecEntry?.decisions?.NZD?.current;
|
||||
if (nzdRate === undefined) return staticFallback("NZD", "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) et rate_decisions.json indisponible");
|
||||
|
||||
let meetingDate: string | null = null;
|
||||
try {
|
||||
const to = new Date();
|
||||
const from = new Date(to); from.setDate(from.getDate() - 70);
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
// "new-zealand" n'est pas dans le G20 → absent de fetchTECalendarHTML (page
|
||||
// /calendar générique), d'où l'usage du fetcher mono-pays dédié.
|
||||
const events = await fetchTECalendarForCountry("new-zealand", iso(from), iso(to));
|
||||
const decisions = events
|
||||
.filter(e => e.category === "policy_rate" && e.isPublished)
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
if (decisions[0]) meetingDate = decisions[0].date.slice(0, 10);
|
||||
} catch { /* meetingDate reste null, pas bloquant */ }
|
||||
|
||||
return {
|
||||
currency: "NZD", ...info,
|
||||
meetingDate,
|
||||
rateLevel: `${nzdRate}%`,
|
||||
voteSummary: "Non publié",
|
||||
voteDetail: "La RBNZ ne publie pas de décompte de vote (décision par consensus du comité) ; communiqué détaillé indisponible — rbnz.govt.nz bloque le scraping automatisé (challenge Cloudflare).",
|
||||
statementUrl: null,
|
||||
reportPdfUrl: null,
|
||||
reportLabel: null,
|
||||
fetchError: "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) — taux (rate_decisions.json) et date de réunion (Trading Economics) affichés en repli.",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Agrégateur ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAllCBGovernance(): Promise<Record<Currency, CBGovernance>> {
|
||||
const [usd, gbp, eur, jpy, chf, cad, aud, nzd] = await Promise.all([
|
||||
fetchFedGovernance().catch(() => staticFallback("USD", "Erreur de scraping")),
|
||||
fetchBoeGovernance().catch(() => staticFallback("GBP", "Erreur de scraping")),
|
||||
fetchEcbGovernance().catch(() => staticFallback("EUR", "Erreur de scraping")),
|
||||
fetchBojGovernance().catch(() => staticFallback("JPY", "Erreur de scraping")),
|
||||
fetchSnbGovernance().catch(() => staticFallback("CHF", "Erreur de scraping")),
|
||||
fetchBocGovernance().catch(() => staticFallback("CAD", "Erreur de scraping")),
|
||||
fetchRbaGovernance().catch(() => staticFallback("AUD", "Erreur de scraping")),
|
||||
fetchRbnzGovernance().catch(() => staticFallback("NZD", "Erreur de scraping")),
|
||||
]);
|
||||
return { USD: usd, GBP: gbp, EUR: eur, JPY: jpy, CHF: chf, CAD: cad, AUD: aud, NZD: nzd };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// lib/fxstreetCalendar.ts
|
||||
// Calendrier économique investingLive (investinglive.com/EconomicCalendar).
|
||||
//
|
||||
// investingLive n'a pas d'API JSON publique : la page embarque le widget
|
||||
// FXStreet (calendar.fxstreet.com), chargé via /fxstreet-calendar.js sur
|
||||
// investinglive.com. On appelle directement l'endpoint HTML de ce widget —
|
||||
// c'est le même appel que fait le navigateur quand on visite
|
||||
// investinglive.com/EconomicCalendar (Referer investingLive requis).
|
||||
//
|
||||
// Endpoint : GET https://calendar.fxstreet.com/EventDateWidget/GetMain
|
||||
// ?culture=en-US&timezone=UTC&start=YYYYMMDD&end=YYYYMMDD&view=range
|
||||
// &rows=5000&countrycode=US,UK,EMU,JP,...
|
||||
// → retourne un fragment HTML (table <tr class="fxst-dateRow"> + <tr ...fxit-eventrow>)
|
||||
// couvrant les 45 pays de CALENDAR_COUNTRIES (bien au-delà des 8 devises majeures).
|
||||
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
import { FXSTREET_COUNTRYCODES, FX_NAME_TO_CURRENCY, FX_NAME_TO_CODE } from "./calendar-countries";
|
||||
import { classifyOtherTitle } from "./calendar-taxonomy";
|
||||
|
||||
export interface WideCalendarEvent {
|
||||
id: string;
|
||||
date: string; // ISO UTC
|
||||
currency: string; // ISO 4217 (univers élargi, pas seulement les 8 majeures)
|
||||
countryCode: string; // code pays (ex. "US", "EMU", "CN")
|
||||
category: EventCategory;
|
||||
title: string;
|
||||
impact: "high" | "medium" | "low";
|
||||
actual: string | null;
|
||||
forecast: string | null;
|
||||
previous: string | null;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
// ── Catégorie (mêmes heuristiques que teCategory/invCategory) ────────────────
|
||||
|
||||
function fxCategory(title: string): EventCategory {
|
||||
const t = title.toLowerCase();
|
||||
if (/\bspeech\b|speaks?\b|testimony|\bpress\s+conf/.test(t)) return "cb_speech";
|
||||
if (/\bpmi\b|purchasing\s+managers/.test(t)) return "pmi";
|
||||
if (/interest\s+rate|monetary\s+policy|rate\s+decision|bank\s+rate/.test(t)) return "policy_rate";
|
||||
if (/inflation|\bcpi\b|\bhicp\b|consumer\s+price|\bppi\b|producer\s+price/.test(t)) return "inflation";
|
||||
if (/\bgdp\b|gross\s+domestic|growth\s+rate/.test(t)) return "gdp";
|
||||
if (/retail\s+sales|core\s+retail|household\s+spending/.test(t)) return "retail_sales";
|
||||
if (/trade\s+balance|current\s+account|balance\s+of\s+trade/.test(t)) return "trade_balance";
|
||||
if (/employment|payrolls|nonfarm|jobless|unemployment|job\s+creation|\badp\b|jolts|job\s+openings|job\s+quits|ism\s+\w+\s+employ/.test(t)) return "employment";
|
||||
return classifyOtherTitle(title);
|
||||
}
|
||||
|
||||
function impactFromVolatility(n: number): "high" | "medium" | "low" {
|
||||
if (n >= 3) return "high";
|
||||
if (n >= 2) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function decodeHTMLEntities(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripTags(s: string): string {
|
||||
return decodeHTMLEntities(s.replace(/<[^>]*>/g, " ").replace(/\s+/g, " "));
|
||||
}
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
|
||||
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
||||
};
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFXStreetHTML(html: string, fromDate: string): WideCalendarEvent[] {
|
||||
const events: WideCalendarEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
let currentYear = parseInt(fromDate.slice(0, 4), 10);
|
||||
let lastMonth = -1;
|
||||
let currentDateStr: string | null = null; // YYYY-MM-DD
|
||||
|
||||
const rowRe =
|
||||
/<tr class="fxst-dateRow">\s*<td colspan="9">([^<]+)<\/td>\s*<\/tr>|<tr class="fxst-tr-event[^"]*"\s+data-eventdateid="([^"]*)"\s+data-actual="([^"]*)"\s+data-unit="([^"]*)"\s+data-precision="([^"]*)"\s+data-pot="([^"]*)"\s+data-isbetter="([^"]*)"\s+data-countryname="([^"]+)">([\s\S]*?)<\/tr>/g;
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rowRe.exec(html)) !== null) {
|
||||
if (m[1] !== undefined) {
|
||||
// ── Ligne séparateur de date : "Wednesday, Jul 01" ──────────────────
|
||||
const dm = m[1].match(/([A-Za-z]+)\s+(\d{1,2})/);
|
||||
if (!dm) continue;
|
||||
const monIdx = MONTHS[dm[1].slice(0, 3).toLowerCase()];
|
||||
if (monIdx === undefined) continue;
|
||||
const day = parseInt(dm[2], 10);
|
||||
if (lastMonth !== -1 && monIdx < lastMonth) currentYear += 1; // rollover déc → jan
|
||||
lastMonth = monIdx;
|
||||
currentDateStr = `${currentYear}-${String(monIdx + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Ligne événement ────────────────────────────────────────────────────
|
||||
const [, , eventDateId, , , , , , countryName, body] = m;
|
||||
if (!currentDateStr) continue;
|
||||
|
||||
const ccy = FX_NAME_TO_CURRENCY[countryName];
|
||||
const code = FX_NAME_TO_CODE[countryName];
|
||||
if (!ccy || !code) continue;
|
||||
|
||||
const cells = Array.from(body.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g), c => c[1]);
|
||||
if (cells.length < 7) continue;
|
||||
|
||||
const timeM = cells[0].match(/(\d{1,2}):(\d{2})/);
|
||||
const time24 = timeM ? `${timeM[1].padStart(2, "0")}:${timeM[2]}` : "00:00";
|
||||
const isoDate = `${currentDateStr}T${time24}:00Z`;
|
||||
const evDate = new Date(isoDate);
|
||||
|
||||
const title = stripTags(cells[2]);
|
||||
if (!title) continue;
|
||||
|
||||
const volM = stripTags(cells[3]).match(/\d+/);
|
||||
const impact = impactFromVolatility(volM ? parseInt(volM[0], 10) : 0);
|
||||
|
||||
const actual = stripTags(cells[4]) || null;
|
||||
const forecast = stripTags(cells[5]) || null;
|
||||
const previous = stripTags(cells[6]) || null;
|
||||
|
||||
events.push({
|
||||
id: `il_${eventDateId}`,
|
||||
date: isoDate,
|
||||
currency: ccy,
|
||||
countryCode: code,
|
||||
category: fxCategory(title),
|
||||
title,
|
||||
impact,
|
||||
actual,
|
||||
forecast,
|
||||
previous,
|
||||
isPublished: actual !== null || evDate < now,
|
||||
});
|
||||
}
|
||||
|
||||
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
||||
// fromDate / toDate au format YYYY-MM-DD
|
||||
|
||||
export async function fetchFXStreetCalendar(fromDate: string, toDate: string): Promise<WideCalendarEvent[]> {
|
||||
const start = fromDate.replace(/-/g, "");
|
||||
const end = toDate.replace(/-/g, "");
|
||||
|
||||
const url =
|
||||
`https://calendar.fxstreet.com/EventDateWidget/GetMain` +
|
||||
`?culture=en-US&timezone=UTC&start=${start}&end=${end}&view=range&rows=5000` +
|
||||
`&countrycode=${FXSTREET_COUNTRYCODES}`;
|
||||
|
||||
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-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://investinglive.com/EconomicCalendar",
|
||||
},
|
||||
});
|
||||
if (!res.ok) { console.warn("[fxstreet] HTTP", res.status); return []; }
|
||||
const html = await res.text();
|
||||
return parseFXStreetHTML(html, fromDate);
|
||||
} catch (err) {
|
||||
console.error("[fxstreet] error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+24
-16
@@ -46,50 +46,58 @@ export interface ILExpectationsWithHistory {
|
||||
|
||||
interface ArticleRef { url: string; dateStr: string; daysAgo: number; }
|
||||
|
||||
// Teste les 4 variantes d'URL (/centralbank/ ou /news/, events pluriel ou singulier)
|
||||
// en parallèle pour un jour donné — un seul aller-retour réseau au lieu de 4 séquentiels.
|
||||
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)}`;
|
||||
// Tester les 4 variantes : /centralbank/ ou /news/, events (pluriel) ou event (singulier)
|
||||
const candidates = [
|
||||
`https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`,
|
||||
`https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`,
|
||||
`https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-events-${yyyymmdd}/`,
|
||||
`https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`,
|
||||
];
|
||||
for (const url of candidates) {
|
||||
const hits = await Promise.all(candidates.map(async (url) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) { res.body?.cancel().catch(() => {}); continue; }
|
||||
res.body?.cancel().catch(() => {});
|
||||
return { url, dateStr, daysAgo };
|
||||
} catch { continue; }
|
||||
return res.ok ? url : null;
|
||||
} catch { return null; }
|
||||
}));
|
||||
const found = hits.find((u): u is string => u !== null);
|
||||
return found ? { url: found, dateStr, daysAgo } : null;
|
||||
}
|
||||
|
||||
// Cherche le jour le plus récent (daysAgo le plus petit) avec un article dans [start, end],
|
||||
// par lots concurrents plutôt que séquentiellement — évite jusqu'à ~150 aller-retours
|
||||
// réseau en série (risque de timeout sur les fonctions serverless Vercel).
|
||||
async function findDayInRange(start: number, end: number, batchSize = 6): Promise<ArticleRef | null> {
|
||||
for (let batchStart = start; batchStart <= end; batchStart += batchSize) {
|
||||
const batchEnd = Math.min(batchStart + batchSize - 1, end);
|
||||
const days = Array.from({ length: batchEnd - batchStart + 1 }, (_, i) => batchStart + i);
|
||||
const results = await Promise.all(days.map(tryUrl));
|
||||
const found = results
|
||||
.filter((r): r is ArticleRef => r !== null)
|
||||
.sort((a, b) => a.daysAgo - b.daysAgo)[0];
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
const current = await findDayInRange(0, 14);
|
||||
|
||||
console.log(`[IL] article courant : ${current ? `day=${current.daysAgo} url=${current.url}` : "introuvable (0–14 jours)"}`);
|
||||
if (!current) return { current: null, previous: null };
|
||||
|
||||
let previous: ArticleRef | null = null;
|
||||
const searchFrom = current.daysAgo + 1;
|
||||
const searchTo = current.daysAgo + 28;
|
||||
for (let d = searchFrom; d <= searchTo; d++) {
|
||||
const found = await tryUrl(d);
|
||||
if (found) { previous = found; break; }
|
||||
}
|
||||
const previous = await findDayInRange(searchFrom, searchTo);
|
||||
|
||||
console.log(`[IL] article précédent : ${previous ? `day=${previous.daysAgo} url=${previous.url}` : `introuvable (day ${searchFrom}–${searchTo})`}`);
|
||||
return { current, previous };
|
||||
|
||||
+23
-17
@@ -620,42 +620,48 @@ async function fetchRssFeed(url: string, source: string): Promise<NewsItem[]> {
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── Source 1 : InvestingLive via WordPress REST API ───────────────────────────
|
||||
// Beaucoup plus fiable que le scraping HTML — retourne du JSON structuré
|
||||
// ── Source 1 : InvestingLive via leur API interne (Nuxt/api.investinglive.com) ─
|
||||
// L'ancien endpoint WordPress (wp-json) renvoie 404 depuis la migration du site
|
||||
// vers Nuxt.js (rebrand ForexLive → investingLive) — remplacé par l'API JSON
|
||||
// qui alimente leur propre page d'accueil (aucune clé requise, publique).
|
||||
|
||||
async function fetchInvestingLiveNews(): Promise<NewsItem[]> {
|
||||
try {
|
||||
// WordPress REST API : liste des posts récents, filtrée sur catégorie forex si dispo
|
||||
const res = await fetch(
|
||||
"https://investinglive.com/wp-json/wp/v2/posts?per_page=20&orderby=date&order=desc&_fields=id,title,link,date,excerpt,categories",
|
||||
"https://api.investinglive.com/api/homepage/articles",
|
||||
{
|
||||
next: { revalidate: 1800 },
|
||||
next: { revalidate: 900 },
|
||||
headers: { ...TE_HEADERS, "Accept": "application/json" },
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const posts = await res.json() as Array<{
|
||||
id: number;
|
||||
title: { rendered: string };
|
||||
link: string;
|
||||
date: string;
|
||||
excerpt: { rendered: string };
|
||||
const data = await res.json() as {
|
||||
LatestArticles?: Array<{
|
||||
Id: string;
|
||||
Title: string;
|
||||
Slug: string;
|
||||
Tldr?: string[] | string | null; // schéma incohérent selon le type d'article
|
||||
PublishedOn: string;
|
||||
Category?: { Name: string; Slug: string };
|
||||
}>;
|
||||
|
||||
};
|
||||
const posts = data.LatestArticles;
|
||||
if (!Array.isArray(posts)) return [];
|
||||
|
||||
return posts.slice(0, 20).map(post => {
|
||||
const title = post.title?.rendered?.replace(/<[^>]+>/g, "").replace(/’/g, "'").replace(/“/g, '"').replace(/”/g, '"').trim() ?? "";
|
||||
const summary = post.excerpt?.rendered?.replace(/<[^>]+>/g, "").trim().slice(0, 250);
|
||||
const title = post.Title?.trim() ?? "";
|
||||
const summary = (Array.isArray(post.Tldr) ? post.Tldr.join(" ") : post.Tldr ?? "").slice(0, 250);
|
||||
const catSlug = post.Category?.Slug ?? "news";
|
||||
const url = `https://investinglive.com/${catSlug}/${post.Slug}/`;
|
||||
const combined = `${title} ${summary ?? ""}`;
|
||||
const { impacts, categories } = applyRules(combined);
|
||||
return {
|
||||
id: `il-${post.id}`,
|
||||
id: `il-${post.Id}`,
|
||||
title,
|
||||
url: post.link,
|
||||
url,
|
||||
source: "InvestingLive",
|
||||
publishedAt: parseDate(post.date),
|
||||
publishedAt: parseDate(post.PublishedOn),
|
||||
summary,
|
||||
impacts,
|
||||
categories,
|
||||
|
||||
+90
-99
@@ -99,15 +99,21 @@ const MEETING_TITLES: Partial<Record<Currency, string>> = {
|
||||
|
||||
// ── Extraction des champs (nommage hétérogène selon les CB) ───────────────────
|
||||
|
||||
// .github/scripts/fetch-rate-data.mjs écrit toujours "midpoint", quelle que soit
|
||||
// la devise (CME/Investing.com/InvestingLive écrivent tous le même schéma). Les
|
||||
// noms par devise ci-dessous ("current_target", "cash_rate_target"…) datent de
|
||||
// l'ancienne source rateprobability.com (remplacée par le commit 2673686) et ne
|
||||
// sont plus jamais écrits par le pipeline actuel — d'où le "0.00%" affiché pour
|
||||
// toute devise autre que USD/NZD (les deux seules à retomber sur "midpoint").
|
||||
function getCurrentRate(ccy: Currency, today: Record<string, unknown>): number {
|
||||
if (typeof today["midpoint"] === "number") return today["midpoint"] as number;
|
||||
switch (ccy) {
|
||||
case "USD": return (today["midpoint"] as number) ?? 0;
|
||||
case "EUR": return (today["ecb_main_refinancing"] as number) ?? (today["ecb_deposit_facility"] as number) ?? 0;
|
||||
case "GBP": return (today["current_target"] as number) ?? 0;
|
||||
case "JPY": return (today["current_target"] as number) ?? 0;
|
||||
case "CAD": return (today["Overnight Rate Target"] as number) ?? 0;
|
||||
case "AUD": return (today["cash_rate_target"] as number) ?? 0;
|
||||
case "NZD": return (today["Official Cash Rate (OCR)"] as number) ?? (today["current_target"] as number) ?? (today["midpoint"] as number) ?? 0;
|
||||
case "NZD": return (today["Official Cash Rate (OCR)"] as number) ?? (today["current_target"] as number) ?? 0;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
@@ -118,56 +124,79 @@ function getAsOf(today: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
|
||||
// ── SNB meeting dates (published par la SNB, trimestrielles) ─────────────────
|
||||
// Mise à jour annuelle : mars, juin, septembre, décembre
|
||||
// ── Calendriers officiels par banque centrale ─────────────────────────────────
|
||||
// Utilisés pour les CB sans page Investing.com dédiée (ou dont le fallback IL
|
||||
// n'a qu'un seul point "year-end") : on construit une vraie courbe multi-réunions
|
||||
// à partir des dates réelles + de l'estimation InvestingLive (proba/direction).
|
||||
// Sources officielles, à mettre à jour quand chaque banque publie son calendrier
|
||||
// suivant (généralement 1x/an) :
|
||||
// SNB : snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions
|
||||
// RBNZ : rbnz.govt.nz
|
||||
// ECB : ecb.europa.eu/press/calendars/mgcgc (jour 2 = décision + conf. presse)
|
||||
// BoE : bankofengland.co.uk/monetary-policy/upcoming-mpc-dates
|
||||
// BoJ : boj.or.jp/en/mopo/mpmsche_minu (2027 pas encore publié à l'écriture de ceci)
|
||||
// BoC : bankofcanada.ca (annonce annuelle du calendrier, pas encore publié pour 2027)
|
||||
// RBA : rba.gov.au/schedules-events/board-meeting-schedules.html (jour 2 = décision)
|
||||
|
||||
const SNB_MEETINGS: string[] = [
|
||||
// 2026
|
||||
"2026-06-19",
|
||||
"2026-09-25",
|
||||
"2026-12-11",
|
||||
// 2027
|
||||
"2027-03-18",
|
||||
"2027-06-17",
|
||||
"2027-09-23",
|
||||
"2027-12-09",
|
||||
"2026-06-19", "2026-09-25", "2026-12-11",
|
||||
"2027-03-18", "2027-06-17", "2027-09-23", "2027-12-09",
|
||||
];
|
||||
|
||||
// ── RBNZ meeting dates (7 par an) ─────────────────────────────────────────────
|
||||
// Source officielle : rbnz.govt.nz — mise à jour annuelle
|
||||
|
||||
const RBNZ_MEETINGS: string[] = [
|
||||
// 2026
|
||||
"2026-07-09",
|
||||
"2026-08-19",
|
||||
"2026-10-14",
|
||||
"2026-11-25",
|
||||
// 2027
|
||||
"2027-02-24",
|
||||
"2027-04-09",
|
||||
"2027-05-26",
|
||||
"2027-07-14",
|
||||
"2027-08-18",
|
||||
"2027-10-13",
|
||||
"2027-11-24",
|
||||
"2026-07-09", "2026-08-19", "2026-10-14", "2026-11-25",
|
||||
"2027-02-24", "2027-04-09", "2027-05-26", "2027-07-14",
|
||||
"2027-08-18", "2027-10-13", "2027-11-24",
|
||||
];
|
||||
|
||||
// Construit un CBRatePath NZD depuis InvestingLive + calendrier RBNZ officiel
|
||||
function buildRBNZPath(il: ILExpectationsMap, currentRate: number): CBRatePath | null {
|
||||
const nzdData = il["NZD"];
|
||||
if (!nzdData) return null;
|
||||
const ECB_MEETINGS: string[] = [
|
||||
"2026-07-23", "2026-09-10", "2026-10-29", "2026-12-17",
|
||||
"2027-02-04", "2027-03-18", "2027-04-29", "2027-06-10",
|
||||
"2027-07-22", "2027-09-09", "2027-10-28", "2027-12-16",
|
||||
];
|
||||
|
||||
const BOE_MEETINGS: string[] = [
|
||||
"2026-07-30", "2026-09-17", "2026-11-05", "2026-12-17",
|
||||
"2027-02-04", "2027-03-18", "2027-04-29", "2027-06-17",
|
||||
"2027-07-29", "2027-09-16", "2027-11-04", "2027-12-16",
|
||||
];
|
||||
|
||||
const BOJ_MEETINGS: string[] = [
|
||||
"2026-07-31", "2026-09-18", "2026-10-30", "2026-12-18",
|
||||
];
|
||||
|
||||
const BOC_MEETINGS: string[] = [
|
||||
"2026-07-15", "2026-09-02", "2026-10-28", "2026-12-09",
|
||||
];
|
||||
|
||||
const RBA_MEETINGS: string[] = [
|
||||
"2026-08-11", "2026-09-29", "2026-11-03", "2026-12-08",
|
||||
"2027-02-09", "2027-03-23", "2027-05-04", "2027-06-22",
|
||||
"2027-08-10", "2027-09-28", "2027-11-02", "2027-12-14",
|
||||
];
|
||||
|
||||
// Construit un CBRatePath depuis un calendrier officiel + l'estimation InvestingLive
|
||||
// (proba/direction de la prochaine réunion, biais year-end pour les suivantes).
|
||||
function buildOfficialCalendarPath(
|
||||
currency: Currency,
|
||||
officialMeetings: string[],
|
||||
il: ILExpectationsMap,
|
||||
currentRate: number,
|
||||
): CBRatePath | null {
|
||||
const ilData = il[currency];
|
||||
if (!ilData) return null;
|
||||
|
||||
const nowIso = new Date().toISOString().slice(0, 10);
|
||||
const upcomingMeetings = RBNZ_MEETINGS.filter(d => d >= nowIso);
|
||||
const upcomingMeetings = officialMeetings.filter(d => d >= nowIso);
|
||||
if (upcomingMeetings.length === 0) return null;
|
||||
|
||||
const yearEndIsCut = nzdData.bpsYearEnd < 0;
|
||||
const yearEndIsCut = ilData.bpsYearEnd < 0;
|
||||
|
||||
const meetings: RateProbMeeting[] = upcomingMeetings.map((dateIso, i) => {
|
||||
const isNext = i === 0;
|
||||
const probMovePct = isNext ? nzdData.nextMeetingProbPct : 0;
|
||||
const probMovePct = isNext ? ilData.nextMeetingProbPct : 0;
|
||||
const probIsCut = isNext
|
||||
? (nzdData.nextMeetingIsNoChange ? yearEndIsCut : !nzdData.nextMeetingIsHike)
|
||||
? (ilData.nextMeetingIsNoChange ? yearEndIsCut : !ilData.nextMeetingIsHike)
|
||||
: yearEndIsCut;
|
||||
const changeBps = isNext ? (probMovePct > 50 ? (probIsCut ? -25 : 25) : 0) : 0;
|
||||
const impliedRate = isNext && probMovePct > 50
|
||||
@@ -182,56 +211,8 @@ function buildRBNZPath(il: ILExpectationsMap, currentRate: number): CBRatePath |
|
||||
);
|
||||
|
||||
return {
|
||||
currency: "NZD",
|
||||
asOf: nzdData.publishedDate,
|
||||
currentRate,
|
||||
meetings,
|
||||
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
|
||||
yearEndImplied: meetings.at(-1)?.impliedRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Construit un CBRatePath CHF depuis les données InvestingLive (probabilités OIS-équivalent)
|
||||
function buildSNBPath(il: ILExpectationsMap, currentRate: number): CBRatePath | null {
|
||||
const chfData = il["CHF"];
|
||||
if (!chfData) return null;
|
||||
|
||||
const nowIso = new Date().toISOString().slice(0, 10);
|
||||
const upcomingMeetings = SNB_MEETINGS.filter(d => d >= nowIso);
|
||||
if (upcomingMeetings.length === 0) return null;
|
||||
|
||||
// Direction par défaut : bpsYearEnd > 0 = hausse, < 0 = baisse
|
||||
const yearEndIsHike = chfData.bpsYearEnd >= 0;
|
||||
|
||||
const meetings: RateProbMeeting[] = upcomingMeetings.map((dateIso, i) => {
|
||||
const isNext = i === 0;
|
||||
const probMovePct = isNext ? chfData.nextMeetingProbPct : 0;
|
||||
// Si "no change" à la prochaine réunion, la direction vient du biais year-end
|
||||
const probIsCut = isNext
|
||||
? (chfData.nextMeetingIsNoChange ? !yearEndIsHike : !chfData.nextMeetingIsHike)
|
||||
: !yearEndIsHike;
|
||||
const changeBps = isNext ? (probIsCut ? -chfData.bpsYearEnd : chfData.bpsYearEnd) : 0;
|
||||
const impliedRate = isNext && probMovePct > 50
|
||||
? currentRate + (probIsCut ? -0.25 : 0.25)
|
||||
: currentRate;
|
||||
|
||||
return {
|
||||
label: dateIso.slice(0, 7), // "2026-06"
|
||||
dateIso,
|
||||
impliedRate,
|
||||
probMovePct,
|
||||
probIsCut,
|
||||
changeBps,
|
||||
};
|
||||
});
|
||||
|
||||
const peakMeeting = meetings.reduce((best, m) =>
|
||||
m.probMovePct > best.probMovePct ? m : best, meetings[0]
|
||||
);
|
||||
|
||||
return {
|
||||
currency: "CHF",
|
||||
asOf: chfData.publishedDate,
|
||||
currency,
|
||||
asOf: ilData.publishedDate,
|
||||
currentRate,
|
||||
meetings,
|
||||
peakMeeting: peakMeeting.probMovePct > 0 ? peakMeeting : null,
|
||||
@@ -362,18 +343,28 @@ export async function fetchAllCBPaths(): Promise<RateProbData> {
|
||||
}
|
||||
}
|
||||
|
||||
// CHF/SNB : Investing.com n'a pas de page SNB → InvestingLive seule source.
|
||||
if (!data["CHF"] && ilData["CHF"]) {
|
||||
const snbPath = buildSNBPath(ilData, 0.00);
|
||||
if (snbPath) data["CHF"] = snbPath;
|
||||
}
|
||||
|
||||
// NZD/RBNZ : Investing.com n'a pas de page RBNZ → InvestingLive + calendrier RBNZ.
|
||||
// Si les données JSON ont ≤ 1 réunion (buildILFallback n'en met qu'une) → reconstruire.
|
||||
if ((!data["NZD"] || data["NZD"].meetings.length <= 1) && ilData["NZD"]) {
|
||||
const rate = data["NZD"]?.currentRate || 2.25; // RBNZ OCR juin 2026
|
||||
const rbnzPath = buildRBNZPath(ilData, rate);
|
||||
if (rbnzPath) data["NZD"] = rbnzPath;
|
||||
// Investing.com n'a de Rate Monitor que pour la Fed (USD) — toutes les autres
|
||||
// devises retombent sur le fallback InvestingLive, qui n'a qu'un seul point
|
||||
// "year-end" (buildILFallback dans fetch-rate-data.mjs). On reconstruit ici une
|
||||
// vraie courbe multi-réunions à partir du calendrier officiel de chaque CB +
|
||||
// de l'estimation InvestingLive (proba/direction), comme déjà fait pour NZD/CHF.
|
||||
const OFFICIAL_CALENDARS: Partial<Record<Currency, { meetings: string[]; fallbackRate: number }>> = {
|
||||
CHF: { meetings: SNB_MEETINGS, fallbackRate: 0.00 },
|
||||
NZD: { meetings: RBNZ_MEETINGS, fallbackRate: 2.25 },
|
||||
EUR: { meetings: ECB_MEETINGS, fallbackRate: 2.15 },
|
||||
GBP: { meetings: BOE_MEETINGS, fallbackRate: 3.75 },
|
||||
JPY: { meetings: BOJ_MEETINGS, fallbackRate: 0.75 },
|
||||
CAD: { meetings: BOC_MEETINGS, fallbackRate: 2.25 },
|
||||
AUD: { meetings: RBA_MEETINGS, fallbackRate: 4.35 },
|
||||
};
|
||||
for (const [ccyStr, cal] of Object.entries(OFFICIAL_CALENDARS)) {
|
||||
const ccy = ccyStr as Currency;
|
||||
if (!cal) continue;
|
||||
// Reconstruit si aucune donnée, ou si le fallback IL n'a mis qu'un seul point.
|
||||
if ((data[ccy] && data[ccy]!.meetings.length > 1) || !ilData[ccy]) continue;
|
||||
const rate = data[ccy]?.currentRate || cal.fallbackRate;
|
||||
const path = buildOfficialCalendarPath(ccy, cal.meetings, ilData, rate);
|
||||
if (path) data[ccy] = path;
|
||||
}
|
||||
|
||||
// Enrichissement IL : fusion proba première réunion + deltas hebdo
|
||||
|
||||
+96
-1
@@ -17,6 +17,9 @@
|
||||
|
||||
import type { Currency } from "./types";
|
||||
import type { EventCategory } from "@/app/api/calendar/route";
|
||||
import { CALENDAR_COUNTRIES, TE_NAME_TO_CURRENCY, TE_NAME_TO_CODE } from "./calendar-countries";
|
||||
import type { WideCalendarEvent } from "./fxstreetCalendar";
|
||||
import { classifyOtherTitle } from "./calendar-taxonomy";
|
||||
|
||||
// ── Country → Currency ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,7 +50,7 @@ function teCategory(cat: string, eventName?: string): EventCategory {
|
||||
if (/retail\s+sales|core\s+retail|household\s+spending/.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|\badp\b|jolts|job\s+openings|job\s+quits|ism\s+\w+\s+employ/.test(c)) return "employment";
|
||||
return "other";
|
||||
return classifyOtherTitle(eventName ?? "");
|
||||
}
|
||||
|
||||
// ── Importance (1/2/3) → impact ───────────────────────────────────────────────
|
||||
@@ -267,6 +270,98 @@ export async function fetchTEInflationForecasts(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Fetch élargi (45 pays) ─────────────────────────────────────────────────
|
||||
// Contrairement à fetchTECalendarHTML (limité aux 8 devises majeures pour
|
||||
// les besoins de macro/route.ts), cette variante couvre tout CALENDAR_COUNTRIES
|
||||
// en fetchant chaque page /{slug}/calendar en parallèle (même pattern que le
|
||||
// cas spécial Suisse ci-dessus, généralisé). Utilisée par le calendrier économique
|
||||
// pour se rapprocher de la couverture de tradingeconomics.com/calendar.
|
||||
|
||||
function parseCalendarHTMLWide(html: string): WideCalendarEvent[] {
|
||||
const events: WideCalendarEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
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_NAME_TO_CURRENCY[country.toLowerCase()];
|
||||
const code = TE_NAME_TO_CODE[country.toLowerCase()];
|
||||
if (!ccy || !code) continue; // pays hors CALENDAR_COUNTRIES
|
||||
|
||||
const dateMatch = body.match(/class=' (\d{4}-\d{2}-\d{2})'/);
|
||||
if (!dateMatch) continue;
|
||||
const dateStr = dateMatch[1];
|
||||
|
||||
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);
|
||||
|
||||
const titleMatch = body.match(/<a\s+class='calendar-event'[^>]*>([^<]+)<\/a>/);
|
||||
const title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : decodeHTMLEntities(eventAttr);
|
||||
|
||||
const refMatch = body.match(/class="calendar-reference"\s*>([^<]*)</);
|
||||
const ref = refMatch ? refMatch[1].trim() : "";
|
||||
|
||||
const actual = extractText(body, "actual");
|
||||
const previous = extractText(body, "previous");
|
||||
const consensus = extractText(body, "consensus");
|
||||
const forecast = extractText(body, "forecast");
|
||||
|
||||
events.push({
|
||||
id: `te_${id}`,
|
||||
date: isoDate,
|
||||
currency: ccy,
|
||||
countryCode: code,
|
||||
category: teCategory(category, eventAttr),
|
||||
title: ref ? `${title} ${ref}` : title,
|
||||
impact: teImpact(importance),
|
||||
actual,
|
||||
forecast: consensus ?? forecast,
|
||||
previous,
|
||||
isPublished: actual !== null || evDate < now,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Variante mono-pays : évite de fetcher les ~45 pages de fetchTECalendarWide
|
||||
// quand on n'a besoin que d'un seul pays (ex. new-zealand, absent du G20 donc
|
||||
// absent de fetchTECalendarHTML — cf. centralBankGovernance.ts RBNZ).
|
||||
export async function fetchTECalendarForCountry(teSlug: string, fromDate?: string, toDate?: string): Promise<WideCalendarEvent[]> {
|
||||
const qs = fromDate && toDate ? `?startDate=${fromDate}&endDate=${toDate}` : "";
|
||||
const html = await fetchOneTEPage(`https://tradingeconomics.com/${teSlug}/calendar${qs}`);
|
||||
if (!html) return [];
|
||||
return parseCalendarHTMLWide(html);
|
||||
}
|
||||
|
||||
export async function fetchTECalendarWide(fromDate?: string, toDate?: string): Promise<WideCalendarEvent[]> {
|
||||
const qs = fromDate && toDate ? `?startDate=${fromDate}&endDate=${toDate}` : "";
|
||||
const pages = await Promise.all(
|
||||
CALENDAR_COUNTRIES.map(c => fetchOneTEPage(`https://tradingeconomics.com/${c.teSlug}/calendar${qs}`))
|
||||
);
|
||||
|
||||
const allEvents: WideCalendarEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const html of pages) {
|
||||
if (!html) continue;
|
||||
for (const ev of parseCalendarHTMLWide(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;
|
||||
}
|
||||
|
||||
// ── Paid API (when TRADING_ECONOMICS_API_KEY is set) ─────────────────────────
|
||||
|
||||
export async function fetchTECalendar(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ["papaparse"],
|
||||
serverComponentsExternalPackages: ["papaparse", "pdf-parse", "pdfjs-dist"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Generated
+232
@@ -15,6 +15,7 @@
|
||||
"next": "14.2.5",
|
||||
"openai": "^4.52.7",
|
||||
"papaparse": "^5.4.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"recharts": "^2.12.7",
|
||||
@@ -276,6 +277,205 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -4975,6 +5175,38 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
@@ -16,6 +16,7 @@
|
||||
"next": "14.2.5",
|
||||
"openai": "^4.52.7",
|
||||
"papaparse": "^5.4.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"recharts": "^2.12.7",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,46 @@
|
||||
' Lance le dashboard Forex en PWA : demarre "npm run dev" si besoin (silencieux,
|
||||
' attend que le serveur reponde), puis ouvre l'app Chrome installee.
|
||||
' Cible ce script depuis le raccourci Bureau / Menu Demarrer a la place de
|
||||
' chrome_proxy.exe directement, pour ne plus tomber sur une page blanche
|
||||
' quand le serveur de dev n'est pas deja lance.
|
||||
|
||||
projectDir = "C:\Users\capug\Documents\Private\Investissements\Trading\Code\forex-dashboard"
|
||||
logDir = projectDir & "\logs"
|
||||
serverUrl = "http://localhost:3000/"
|
||||
chromeExe = "C:\Program Files\Google\Chrome\Application\chrome_proxy.exe"
|
||||
chromeArgs = " --profile-directory=""Profile 4"" --app-id=hbblfifohofgngfbjbiimbbcimepbdcb"
|
||||
maxWaitSec = 30
|
||||
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
|
||||
If Not fso.FolderExists(logDir) Then fso.CreateFolder(logDir)
|
||||
|
||||
Function IsServerUp(url)
|
||||
On Error Resume Next
|
||||
Dim http
|
||||
IsServerUp = False
|
||||
Set http = CreateObject("MSXML2.XMLHTTP")
|
||||
http.Open "GET", url, False
|
||||
http.Send
|
||||
If Err.Number = 0 And http.Status >= 200 And http.Status < 500 Then
|
||||
IsServerUp = True
|
||||
End If
|
||||
Err.Clear
|
||||
On Error Goto 0
|
||||
End Function
|
||||
|
||||
If Not IsServerUp(serverUrl) Then
|
||||
WshShell.CurrentDirectory = projectDir
|
||||
cmd = "cmd /c npm run dev >> """ & logDir & "\dashboard.log"" 2>&1"
|
||||
WshShell.Run cmd, 0, False
|
||||
|
||||
waited = 0
|
||||
Do While waited < maxWaitSec
|
||||
WScript.Sleep 1000
|
||||
waited = waited + 1
|
||||
If IsServerUp(serverUrl) Then Exit Do
|
||||
Loop
|
||||
End If
|
||||
|
||||
WshShell.Run """" & chromeExe & """" & chromeArgs, 1, False
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = join(__dirname, "../.env.local");
|
||||
const TOKEN = process.env.VERCEL_TOKEN;
|
||||
const PROJECT_ID = process.env.VERCEL_PROJECT_ID;
|
||||
const TEAM_ID = process.env.VERCEL_TEAM_ID;
|
||||
|
||||
if (!TOKEN || !PROJECT_ID || !TEAM_ID) {
|
||||
console.error("Missing VERCEL_TOKEN, VERCEL_PROJECT_ID or VERCEL_TEAM_ID");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lines = readFileSync(envFile, "utf8").split("\n");
|
||||
const envVars = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (key) envVars.push({ key, value });
|
||||
}
|
||||
|
||||
console.log(`Found ${envVars.length} variables to push...`);
|
||||
|
||||
for (const { key, value } of envVars) {
|
||||
const res = await fetch(
|
||||
`https://api.vercel.com/v10/projects/${PROJECT_ID}/env?teamId=${TEAM_ID}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
value,
|
||||
type: "encrypted",
|
||||
target: ["production", "preview"],
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.key) {
|
||||
console.log(` ✓ ${key}`);
|
||||
} else if (data.error?.code === "ENV_ALREADY_EXISTS") {
|
||||
console.log(` ~ ${key} (already exists, skipping)`);
|
||||
} else {
|
||||
console.log(` ✗ ${key}: ${data.error?.message ?? JSON.stringify(data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nDone. Now deploying to production...");
|
||||
@@ -0,0 +1,13 @@
|
||||
' Lance le dashboard Forex (npm run dev) en arriere-plan, sans fenetre visible.
|
||||
' Copie de ce fichier dans le dossier Demarrage de Windows (shell:startup)
|
||||
' pour un lancement automatique a chaque connexion.
|
||||
projectDir = "C:\Users\capug\Documents\Private\Investissements\Trading\Code\forex-dashboard"
|
||||
logDir = projectDir & "\logs"
|
||||
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
If Not fso.FolderExists(logDir) Then fso.CreateFolder(logDir)
|
||||
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.CurrentDirectory = projectDir
|
||||
cmd = "cmd /c npm run dev >> """ & logDir & "\dashboard.log"" 2>&1"
|
||||
WshShell.Run cmd, 0, False
|
||||
@@ -0,0 +1,19 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const b = await chromium.launch();
|
||||
const p = await b.newPage();
|
||||
await p.setViewportSize({width: 589, height: 900});
|
||||
await p.goto('http://localhost:3099/');
|
||||
await p.waitForTimeout(9000);
|
||||
await p.evaluate(() => window.scrollTo(0, 2200));
|
||||
await p.waitForTimeout(400);
|
||||
await p.screenshot({path: 'C:/Users/capug/AppData/Local/Temp/scroll2200.png'});
|
||||
await p.evaluate(() => window.scrollTo(0, 4200));
|
||||
await p.waitForTimeout(400);
|
||||
await p.screenshot({path: 'C:/Users/capug/AppData/Local/Temp/scroll4200.png'});
|
||||
await p.evaluate(() => window.scrollTo(0, 6500));
|
||||
await p.waitForTimeout(400);
|
||||
await p.screenshot({path: 'C:/Users/capug/AppData/Local/Temp/scroll6500.png'});
|
||||
await b.close();
|
||||
console.log('done');
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user