mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
feat: retail sales MoM (TE scraping), smart cache, IL weekly delta arrows, OIS phase labels
- Retail Sales: switch from FRED OECD series (4-6w lag) to Trading Economics real-time scraping for all 8 currencies; label changed to "Retail Sales MoM" - Cache TTL: 24h → 1h base + 15min hot when ForexFactory detects recent high-impact event - Rate probability trend arrows: server-side IL weekly delta (current vs previous Dellamotta article) with per-metric thresholds (prob ≥3%/10%, bps ≥10/25) and colour coding (sky=dovish, amber=hawkish) - Cycle phase: replaced FRED trend heuristic with OIS-based logic using peakMeeting.probMovePct, probIsCut, and yearEndBps; dynamic descriptions include real market numbers - investinglive.ts: find both current + previous article to compute week-over-week delta server-side Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+86
-2
@@ -9,7 +9,7 @@ import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTE
|
||||
import { fetchTEInflationForecasts } from "@/lib/tradingeconomics";
|
||||
|
||||
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
|
||||
const REVALIDATE = 86400; // cache 24h
|
||||
const REVALIDATE = 3600; // cache 1h (les données FRED sont disponibles ~1h après publication)
|
||||
|
||||
// ── FRED ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -428,6 +428,49 @@ async function scrapeTeGdp(country: string): Promise<{ value: number | null; pre
|
||||
} catch { return empty; }
|
||||
}
|
||||
|
||||
// ── Trading Economics — Retail Sales MoM (country-list, toutes devises en une requête) ─────
|
||||
// Parse le tableau https://tradingeconomics.com/country-list/retail-sales-mom
|
||||
// Valeurs positives : <td data-heatmap-value="N">0.5</td>
|
||||
// Valeurs négatives : <td data-heatmap-value="N"><span class='te-value-negative'>-0.4</span></td>
|
||||
|
||||
let _teRetailMoMCache: { data: Map<string, { value: number; prev: number | null }>; ts: number } | null = null;
|
||||
|
||||
async function fetchTeRetailSalesMoM(): Promise<Map<string, { value: number; prev: number | null }>> {
|
||||
if (_teRetailMoMCache && Date.now() - _teRetailMoMCache.ts < 3_600_000) {
|
||||
return _teRetailMoMCache.data;
|
||||
}
|
||||
const result = new Map<string, { value: number; prev: number | null }>();
|
||||
const slugToCurrency: Record<string, string> = {
|
||||
"united-states": "USD", "euro-area": "EUR", "united-kingdom": "GBP",
|
||||
"japan": "JPY", "switzerland": "CHF", "canada": "CAD",
|
||||
"australia": "AUD", "new-zealand": "NZD",
|
||||
};
|
||||
try {
|
||||
const res = await fetch("https://tradingeconomics.com/country-list/retail-sales-mom", {
|
||||
next: { revalidate: 3600 },
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return result;
|
||||
const html = await res.text();
|
||||
// Regex robuste : capture slug + valeur courante + valeur précédente
|
||||
// Gère les valeurs négatives wrappées dans <span class='te-value-negative'>
|
||||
const re = /<a href='\/([^']+)\/retail-sales'>\s*[^<]+\s*<\/a><\/td>\s*<td[^>]*>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>\s*<td>(?:<span[^>]*>)?([-\d.]+)(?:<\/span>)?<\/td>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const currency = slugToCurrency[m[1]];
|
||||
if (currency) {
|
||||
result.set(currency, { value: parseFloat(m[2]), prev: parseFloat(m[3]) });
|
||||
}
|
||||
}
|
||||
} catch { /* retourner map vide si TE indisponible */ }
|
||||
_teRetailMoMCache = { data: result, ts: Date.now() };
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Trading Economics — Balance commerciale (niveau, normalisé en Milliards devise locale)
|
||||
// Format meta TE :
|
||||
// "X recorded a trade deficit/surplus of Y.YY EUR/USD/etc. Billion/Million in Month of Year"
|
||||
@@ -618,14 +661,39 @@ function parseTeF(s: string | null | undefined): number | null {
|
||||
|
||||
const _cache = new Map<string, { data: unknown; ts: number }>();
|
||||
|
||||
// TTL adaptatif : 15 min si un event à fort/moyen impact vient d'être publié dans les 2h
|
||||
// pour cette devise, sinon 1h.
|
||||
async function getSmartTtl(currency: string): Promise<number> {
|
||||
const BASE_TTL = 3_600_000; // 1h par défaut
|
||||
const HOT_TTL = 900_000; // 15min après publication récente
|
||||
const WINDOW_MS = 7_200_000; // fenêtre de 2h
|
||||
try {
|
||||
const events = await fetchFFThisWeek();
|
||||
const now = Date.now();
|
||||
const hasRecent = events.some(e => {
|
||||
const t = new Date(e.date).getTime();
|
||||
return (
|
||||
e.country === currency &&
|
||||
e.actual !== "" && // données publiées (actual rempli)
|
||||
(e.impact === "High" || e.impact === "Medium") &&
|
||||
t <= now && // passé (pas futur)
|
||||
now - t < WINDOW_MS // dans les 2h
|
||||
);
|
||||
});
|
||||
return hasRecent ? HOT_TTL : BASE_TTL;
|
||||
} catch { return BASE_TTL; }
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const currency = (new URL(req.url).searchParams.get("currency") ?? "").toUpperCase() as Currency;
|
||||
const series = FRED_SERIES[currency];
|
||||
if (!series) return NextResponse.json({ error: "Unknown currency" }, { status: 400 });
|
||||
|
||||
// TTL smart : 15 min si publication récente, sinon 1h
|
||||
const ttl = await getSmartTtl(currency);
|
||||
const cached = _cache.get(currency);
|
||||
const staleCache = cached ?? null;
|
||||
if (cached && Date.now() - cached.ts < 86_400_000) return NextResponse.json(cached.data);
|
||||
if (cached && Date.now() - cached.ts < ttl) return NextResponse.json(cached.data);
|
||||
|
||||
const key = process.env.FRED_API_KEY;
|
||||
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
|
||||
@@ -956,6 +1024,22 @@ export async function GET(req: NextRequest) {
|
||||
indicators.cpiMoM = toIndicatorPct(_cpiCoreObs);
|
||||
}
|
||||
|
||||
// ── Retail Sales MoM : override depuis Trading Economics (plus à jour que FRED OCDE) ────────
|
||||
{
|
||||
const teRS = await fetchTeRetailSalesMoM();
|
||||
const rs = teRS.get(currency);
|
||||
if (rs !== undefined) {
|
||||
const surprise = rs.prev !== null ? parseFloat((rs.value - rs.prev).toFixed(2)) : null;
|
||||
indicators.retailSales = {
|
||||
value: rs.value,
|
||||
prev: rs.prev,
|
||||
surprise,
|
||||
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") : null,
|
||||
lastUpdated: today,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── PMI (Mfg + Services + Composite) + consensus FF ──────────────────────────
|
||||
const toDateObj = new Date();
|
||||
toDateObj.setDate(toDateObj.getDate() + 21);
|
||||
|
||||
+131
-40
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
TrendingUp, TrendingDown, Minus, Loader2, Database,
|
||||
@@ -15,7 +15,7 @@ import { CURRENCY_META, COUNTRY_PROFILES } from "@/lib/constants";
|
||||
import { biasLabel, calcMacroScore } from "@/lib/scoring";
|
||||
import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
|
||||
import type { Currency, BiasPhase, RateExpectation } from "@/lib/types";
|
||||
import type { CBRatePath } from "@/lib/rateprobability";
|
||||
import type { CBRatePath, ILWeeklyDelta } from "@/lib/rateprobability";
|
||||
import type { SentimentEntry, CotEntry } from "@/lib/types";
|
||||
import NarrativeButton from "./NarrativeButton";
|
||||
|
||||
@@ -76,6 +76,48 @@ function phaseLabel(p: BiasPhase) {
|
||||
return "Transition";
|
||||
}
|
||||
|
||||
// ─── Phase depuis OIS (source primaire) ──────────────────────────────────────
|
||||
// Ordre de priorité :
|
||||
// 1. Resserrement actif : proba de hausse >60% ET bps annuels >+15
|
||||
// 2. Assouplissement actif: proba de coupe >60% OU bps annuels <−40
|
||||
// 3. Pause dovish : bps annuels ≤ −15 (coupes attendues, pas imminentes)
|
||||
// 4. Pause hawkish : bps annuels ≥ +20 OU biais sans coupe modérément pricé
|
||||
// 5. Transition : anticipations neutres (fin de cycle, CB en attente)
|
||||
function computePhaseFromOIS(ratePath: CBRatePath | null): BiasPhase | null {
|
||||
if (!ratePath?.peakMeeting) return null;
|
||||
const yearEndBps = ratePath.yearEndImplied !== null
|
||||
? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100)
|
||||
: 0;
|
||||
const { probMovePct, probIsCut } = ratePath.peakMeeting;
|
||||
if (!probIsCut && probMovePct > 60 && yearEndBps >= 15) return "tightening";
|
||||
if (probIsCut && (probMovePct > 60 || yearEndBps <= -40)) return "easing";
|
||||
if (yearEndBps <= -15) return "dovish_pause";
|
||||
if (yearEndBps >= 20 || (!probIsCut && probMovePct > 40 && yearEndBps > 5)) return "hawkish_pause";
|
||||
return "transition";
|
||||
}
|
||||
|
||||
// ─── Description contextuelle de la phase (avec vrais chiffres OIS) ──────────
|
||||
function phaseDescription(phase: BiasPhase, ratePath: CBRatePath | null): string {
|
||||
const yearEndBps = (ratePath?.yearEndImplied != null && ratePath?.currentRate != null)
|
||||
? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100)
|
||||
: null;
|
||||
const peak = ratePath?.peakMeeting;
|
||||
const bpsStr = yearEndBps !== null ? `${yearEndBps > 0 ? "+" : ""}${yearEndBps}bps fin d'an` : "";
|
||||
const probStr = peak ? `${peak.probMovePct.toFixed(0)}% de ${peak.probIsCut ? "coupe" : "hausse"} (${peak.label})` : "";
|
||||
switch (phase) {
|
||||
case "tightening":
|
||||
return `Cycle de resserrement actif — ${probStr}${bpsStr ? ` · ${bpsStr}` : ""}. Surveiller : inflation, emploi solide, PMI > 50.`;
|
||||
case "easing":
|
||||
return `Cycle d'assouplissement — ${probStr}${bpsStr ? ` · ${bpsStr}` : ""}. Surveiller : désinflation, ralentissement emploi, PMI < 50.`;
|
||||
case "dovish_pause":
|
||||
return `Pause dovish${bpsStr ? ` — ${bpsStr} anticipés` : ""}. Prochaine réunion stable mais coupes attendues plus tard. Surveiller : timing désinflation.`;
|
||||
case "hawkish_pause":
|
||||
return `Pause hawkish${bpsStr ? ` — ${bpsStr} anticipés` : ""}. CB en attente. Surveiller : regain d'inflation ou fort ralentissement.`;
|
||||
case "transition":
|
||||
return `Anticipations neutres${bpsStr ? ` (${bpsStr})` : ""}. CB données-dépendantes — tous les indicateurs comptent.`;
|
||||
}
|
||||
}
|
||||
|
||||
function scoreDir(score: number): SignalDir {
|
||||
if (score >= 3) return "bullish";
|
||||
if (score <= -3) return "bearish";
|
||||
@@ -165,6 +207,29 @@ function trendDir(t: "up"|"down"|"flat"|null): SignalDir {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
// ─── RpArrow : flèche de tendance pour probabilités de taux (delta IL hebdo) ──
|
||||
// delta = valeur_actuelle - valeur_précédent_article
|
||||
// isBearishIfUp = true si une hausse du delta est baissière pour la devise
|
||||
|
||||
function RpArrow({
|
||||
delta, isBearishIfPositive, suffix, strongT, modT,
|
||||
}: {
|
||||
delta: number; isBearishIfPositive: boolean; suffix: string; strongT: number; modT: number;
|
||||
}) {
|
||||
const abs = Math.abs(delta);
|
||||
if (abs < modT) return null;
|
||||
const strong = abs >= strongT;
|
||||
const up = delta > 0;
|
||||
const bearish = isBearishIfPositive ? up : !up;
|
||||
const color = bearish ? "text-sky-400" : "text-amber-400";
|
||||
const arrow = strong ? (up ? "↑↑" : "↓↓") : (up ? "↑" : "↓");
|
||||
return (
|
||||
<span className={`font-bold ${color}`}>
|
||||
{arrow}{up ? "+" : ""}{Math.abs(Math.round(abs * 10) / 10)}{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sous-composants ─────────────────────────────────────────────────────────
|
||||
|
||||
function MacroBlock({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
@@ -260,7 +325,15 @@ export default function CurrencyCard({
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
const [data, setData] = useState<MacroData | null>(null);
|
||||
const [phase, setPhase] = useState<BiasPhase>("hawkish_pause");
|
||||
// Phase dérivée des probabilités OIS (source primaire) ou du trend FRED (fallback)
|
||||
const phase = useMemo<BiasPhase>(() => {
|
||||
const oisPhase = computePhaseFromOIS(ratePath);
|
||||
if (oisPhase) return oisPhase;
|
||||
const rateInd = data?.indicators?.policyRate;
|
||||
if (rateInd?.trend === "up") return "tightening";
|
||||
if (rateInd?.trend === "down") return "easing";
|
||||
return "hawkish_pause";
|
||||
}, [ratePath, data]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rateExp, setRateExp] = useState<RateExpectation | null>(null);
|
||||
const [fromCache, setFromCache] = useState(false);
|
||||
@@ -293,21 +366,12 @@ export default function CurrencyCard({
|
||||
setFromCache(false);
|
||||
setCacheAge(null);
|
||||
saveCache(cacheKey, merged);
|
||||
|
||||
const rateInd = merged.indicators.policyRate;
|
||||
if (rateInd?.trend === "up") setPhase("tightening");
|
||||
else if (rateInd?.trend === "down") setPhase("easing");
|
||||
else setPhase("hawkish_pause");
|
||||
} catch {
|
||||
const cached = loadCache<MacroData>(cacheKey);
|
||||
if (cached) {
|
||||
setData(cached.data);
|
||||
setFromCache(true);
|
||||
setCacheAge(formatCacheDate(cached.savedAt));
|
||||
const rateInd = cached.data.indicators.policyRate;
|
||||
if (rateInd?.trend === "up") setPhase("tightening");
|
||||
else if (rateInd?.trend === "down") setPhase("easing");
|
||||
else setPhase("hawkish_pause");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -505,7 +569,7 @@ export default function CurrencyCard({
|
||||
{ label: "PMI Mfg", value: inds?.pmiMfg?.value != null ? `${inds.pmiMfg.value.toFixed(1)}` : "", sig: inds?.pmiMfg?.value != null ? (inds.pmiMfg.value > 50 ? 1 : -1) : 0 },
|
||||
{ label: "PMI Services", value: inds?.pmiServices?.value != null ? `${inds.pmiServices.value.toFixed(1)}` : "", sig: inds?.pmiServices?.value != null ? (inds.pmiServices.value > 50 ? 1 : -1) : 0 },
|
||||
{ label: "PIB QoQ", value: inds?.gdp?.value != null ? `${inds.gdp.value > 0 ? "+" : ""}${inds.gdp.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.gdp?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() },
|
||||
{ label: "Retail Sales", value: inds?.retailSales?.value != null ? `${inds.retailSales.value > 0 ? "+" : ""}${inds.retailSales.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.retailSales?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() },
|
||||
{ label: "Retail Sales MoM", value: inds?.retailSales?.value != null ? `${inds.retailSales.value > 0 ? "+" : ""}${inds.retailSales.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.retailSales?.surprise; return s == null ? 0 : s > 0.3 ? 1 : s < -0.3 ? -1 : 0; })() },
|
||||
{ label: "Chômage", value: inds?.unemployment?.value != null ? `${inds.unemployment.value.toFixed(2)}%` : "", sig: (() => { const s = inds?.unemployment?.surprise; return s == null ? 0 : s < -0.3 ? 1 : s > 0.3 ? -1 : 0; })() },
|
||||
{ label: "Emploi", value: inds?.employment?.value != null ? `${inds.employment.value > 0 ? "+" : ""}${inds.employment.value.toFixed(1)}k` : "", sig: (() => { const s = inds?.employment?.surprise; return s == null ? 0 : s > 10 ? 1 : s < -10 ? -1 : 0; })() },
|
||||
].filter(c => c.value !== "");
|
||||
@@ -641,23 +705,49 @@ export default function CurrencyCard({
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{ratePath.peakMeeting && (
|
||||
<div className="flex items-center justify-between mt-1 text-[10px]">
|
||||
<span className="text-slate-600">
|
||||
Pic : <span className="text-slate-400">{ratePath.peakMeeting.label}</span>
|
||||
</span>
|
||||
<span className={ratePath.peakMeeting.probIsCut ? "text-sky-400 font-bold" : "text-red-400 font-bold"}>
|
||||
{ratePath.peakMeeting.probMovePct.toFixed(0)}% {ratePath.peakMeeting.probIsCut ? "Cut" : "Hike"}
|
||||
</span>
|
||||
{ratePath.yearEndImplied !== null && (() => {
|
||||
const bps = Math.round((ratePath.yearEndImplied! - ratePath.currentRate) * 100);
|
||||
const cls = bps < 0 ? "text-sky-400 font-bold" : bps > 0 ? "text-red-400 font-bold" : "text-slate-500";
|
||||
return (
|
||||
<span className={cls} title={`Taux actuel: ${ratePath.currentRate.toFixed(2)}% → fin an: ${ratePath.yearEndImplied!.toFixed(2)}%`}>
|
||||
{bps > 0 ? "+" : ""}{bps}bps fin an
|
||||
<>
|
||||
<div className="flex items-center justify-between mt-1 text-[10px]">
|
||||
<span className="text-slate-600">
|
||||
Pic : <span className="text-slate-400">{ratePath.peakMeeting.label}</span>
|
||||
</span>
|
||||
<span className={ratePath.peakMeeting.probIsCut ? "text-sky-400 font-bold" : "text-red-400 font-bold"}>
|
||||
{ratePath.peakMeeting.probMovePct.toFixed(0)}% {ratePath.peakMeeting.probIsCut ? "Cut" : "Hike"}
|
||||
</span>
|
||||
{ratePath.yearEndImplied !== null && (() => {
|
||||
const bps = Math.round((ratePath.yearEndImplied! - ratePath.currentRate) * 100);
|
||||
const cls = bps < 0 ? "text-sky-400 font-bold" : bps > 0 ? "text-red-400 font-bold" : "text-slate-500";
|
||||
return (
|
||||
<span className={cls} title={`Taux actuel: ${ratePath.currentRate.toFixed(2)}% → fin an: ${ratePath.yearEndImplied!.toFixed(2)}%`}>
|
||||
{bps > 0 ? "+" : ""}{bps}bps fin an
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/* ── Flèches de tendance vs article IL semaine précédente ── */}
|
||||
{ratePath.ilDelta && (Math.abs(ratePath.ilDelta.probDelta) >= 3 || Math.abs(ratePath.ilDelta.bpsDelta) >= 10) && (
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[9px] text-slate-600">
|
||||
<span title={`vs article du ${ratePath.ilDelta.prevDate}`}>
|
||||
vs sem. préc. :
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<RpArrow
|
||||
delta={ratePath.ilDelta.probDelta}
|
||||
isBearishIfPositive={ratePath.ilDelta.isCut}
|
||||
suffix="%"
|
||||
strongT={10}
|
||||
modT={3}
|
||||
/>
|
||||
{ratePath.ilDelta.bpsDelta !== 0 && (
|
||||
<RpArrow
|
||||
delta={ratePath.ilDelta.bpsDelta}
|
||||
isBearishIfPositive={true}
|
||||
suffix="bps"
|
||||
strongT={25}
|
||||
modT={10}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -933,7 +1023,7 @@ export default function CurrencyCard({
|
||||
<MacroBlock title="Croissance">
|
||||
<IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} surpriseVsCons={fc?.gdpSurprise ?? null} />
|
||||
<IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} surpriseVsCons={fc?.pmiCompositeSurprise ?? null} />
|
||||
<IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} surpriseVsCons={fc?.retailSalesSurprise ?? null} />
|
||||
<IRow label="Retail Sales MoM" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} surpriseVsCons={fc?.retailSalesSurprise ?? null} />
|
||||
</MacroBlock>
|
||||
|
||||
{/* Emploi */}
|
||||
@@ -1025,17 +1115,18 @@ export default function CurrencyCard({
|
||||
{/* ════ FOCUS DONNÉES ══════════════════════════════════════════════ */}
|
||||
{activeTab === "focus" && (
|
||||
<>
|
||||
{/* Context phase */}
|
||||
{/* Context phase — source : OIS / probabilités de taux (fallback : trend FRED) */}
|
||||
<div className={`rounded-xl border p-3 text-[11px] ${sigBg(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
|
||||
<div className={`font-semibold mb-1 ${sigColor(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
|
||||
Phase {phaseLabel(phase)}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`font-semibold ${sigColor(trendDir(phase === "tightening" ? "up" : phase === "easing" ? "down" : null))}`}>
|
||||
Phase {phaseLabel(phase)}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-600 italic">
|
||||
{ratePath ? "Source : OIS / probabilités" : "Source : trend taux FRED"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 leading-relaxed">
|
||||
{phase === "tightening" && "Surveiller les données soutenant une poursuite du resserrement : inflation, emploi solide, PMI expansionniste."}
|
||||
{phase === "easing" && "Surveiller les données justifiant des baisses : désinflation, ralentissement du marché de l'emploi, PMI en contraction."}
|
||||
{phase === "hawkish_pause" && "Surveiller les données qui pourraient forcer la main : regain d'inflation ou au contraire fort ralentissement."}
|
||||
{phase === "dovish_pause" && "Surveiller les signes de reprise permettant une normalisation de la politique monétaire."}
|
||||
{phase === "transition" && "Phase de transition — tous les indicateurs sont importants pour déterminer la direction future."}
|
||||
{phaseDescription(phase, ratePath)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1047,7 +1138,7 @@ export default function CurrencyCard({
|
||||
<FocusRow importance="critical"><IRow label="Variation emploi" ind={inds?.employment ?? null} unit="k" consensus={fc?.employment ?? null} /></FocusRow>
|
||||
<FocusRow importance="high"><IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} /></FocusRow>
|
||||
<FocusRow importance="high"><IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} /></FocusRow>
|
||||
<FocusRow importance="medium"><IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} /></FocusRow>
|
||||
<FocusRow importance="medium"><IRow label="Retail Sales MoM" ind={inds?.retailSales ?? null} unit="%" consensus={fc?.retailSales ?? null} /></FocusRow>
|
||||
</>
|
||||
: <>
|
||||
<FocusRow importance="critical"><IRow label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" consensus={fc?.cpiMoM ?? null} /></FocusRow>
|
||||
@@ -1070,7 +1161,7 @@ export default function CurrencyCard({
|
||||
: <>
|
||||
<FocusRow importance="medium"><IRow label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise /></FocusRow>
|
||||
<FocusRow importance="medium"><IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" /></FocusRow>
|
||||
<FocusRow importance="medium"><IRow label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" /></FocusRow>
|
||||
<FocusRow importance="medium"><IRow label="Retail Sales MoM" ind={inds?.retailSales ?? null} unit="%" /></FocusRow>
|
||||
</>
|
||||
}
|
||||
</MacroBlock>
|
||||
|
||||
+102
-66
@@ -36,28 +36,89 @@ export interface ILRateExpectation {
|
||||
|
||||
export type ILExpectationsMap = Partial<Record<Currency, ILRateExpectation>>;
|
||||
|
||||
// ── URL discovery ─────────────────────────────────────────────────────────────
|
||||
// Try the last 14 days to find the most recently published article.
|
||||
export interface ILExpectationsWithHistory {
|
||||
current: ILExpectationsMap;
|
||||
prev: ILExpectationsMap;
|
||||
prevDate: string | null;
|
||||
}
|
||||
|
||||
async function findLatestArticleUrl(): Promise<{ url: string; dateStr: string } | null> {
|
||||
for (let daysAgo = 0; daysAgo <= 14; daysAgo++) {
|
||||
const d = new Date(Date.now() - daysAgo * 86400000);
|
||||
const yyyymmdd = d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const url = `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
|
||||
next: { revalidate: 21600 }, // re-check every 6h
|
||||
});
|
||||
if (res.ok) return { url, dateStr: `${yyyymmdd.slice(0,4)}-${yyyymmdd.slice(4,6)}-${yyyymmdd.slice(6,8)}` };
|
||||
} catch { /* try next day */ }
|
||||
// ── URL discovery ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface ArticleRef { url: string; dateStr: string; daysAgo: number; }
|
||||
|
||||
async function tryUrl(daysAgo: number): Promise<ArticleRef | null> {
|
||||
const d = new Date(Date.now() - daysAgo * 86_400_000);
|
||||
const yyyymmdd = d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const dateStr = `${yyyymmdd.slice(0,4)}-${yyyymmdd.slice(4,6)}-${yyyymmdd.slice(6,8)}`;
|
||||
const url = `https://investinglive.com/news/how-have-interest-rate-expectations-changed-after-this-weeks-event-${yyyymmdd}/`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36" },
|
||||
next: { revalidate: 21600 },
|
||||
});
|
||||
return res.ok ? { url, dateStr, daysAgo } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Find current article (last 14 days) + previous article (14 days before current, up to 21 days)
|
||||
async function findArticleRefs(): Promise<{ current: ArticleRef | null; previous: ArticleRef | null }> {
|
||||
let current: ArticleRef | null = null;
|
||||
|
||||
for (let d = 0; d <= 14; d++) {
|
||||
const found = await tryUrl(d);
|
||||
if (found) { current = found; break; }
|
||||
}
|
||||
|
||||
if (!current) return { current: null, previous: null };
|
||||
|
||||
let previous: ArticleRef | null = null;
|
||||
// Start the day after the current article and look up to 21 days further back
|
||||
for (let d = current.daysAgo + 1; d <= current.daysAgo + 21; d++) {
|
||||
const found = await tryUrl(d);
|
||||
if (found) { previous = found; break; }
|
||||
}
|
||||
|
||||
return { current, previous };
|
||||
}
|
||||
|
||||
// ── Article fetch + parse ─────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAndParse(ref: ArticleRef): Promise<ILExpectationsMap> {
|
||||
try {
|
||||
const res = await fetch(ref.url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
next: { revalidate: 21600 },
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
|
||||
const html = await res.text();
|
||||
const jsonLdMatch = html.match(/"articleBody"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
||||
if (!jsonLdMatch) {
|
||||
console.warn("[IL] articleBody not found:", ref.url);
|
||||
return {};
|
||||
}
|
||||
|
||||
const body = jsonLdMatch[1]
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\u003c/g, "<")
|
||||
.replace(/\\u003e/g, ">");
|
||||
|
||||
const result = parseArticleBody(body, ref.dateStr);
|
||||
console.log(`[IL] Parsed ${Object.keys(result).length} CBs from article dated ${ref.dateStr}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("[IL] fetch error:", err);
|
||||
return {};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Article parser ─────────────────────────────────────────────────────────────
|
||||
// The structured data JSON-LD "articleBody" contains the raw text we need.
|
||||
|
||||
function parseArticleBody(text: string, publishedDate: string): ILExpectationsMap {
|
||||
const result: ILExpectationsMap = {};
|
||||
@@ -65,10 +126,6 @@ function parseArticleBody(text: string, publishedDate: string): ILExpectationsMa
|
||||
// Matches: "RBNZ: 75 bps (79% probability of rate hike at the next meeting)"
|
||||
// "Fed: 13 bps (99% probability of no change at the next meeting)"
|
||||
// "ECB: 53 bps (99% probability of rate cut at the next meeting)"
|
||||
// Note: bps value in the article is always unsigned — sign is inferred from direction.
|
||||
// rate hike → positive bps (rate going up)
|
||||
// rate cut → negative bps (rate going down)
|
||||
// no change → keep unsigned (residual expectation for later meetings)
|
||||
const linePattern = /([A-Za-z]+)\s*:\s*(-?\d+)\s*bps\s*\(\s*(\d+)%\s*probability\s+of\s+(rate\s+(?:hike|cut)|no\s+change)\s+at\s+the\s+next\s+meeting\)/gi;
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -83,15 +140,13 @@ function parseArticleBody(text: string, publishedDate: string): ILExpectationsMa
|
||||
const nextMeetingIsHike = !nextMeetingIsNoChange && direction.includes("hike");
|
||||
const nextMeetingIsCut = !nextMeetingIsNoChange && direction.includes("cut");
|
||||
|
||||
// Apply sign: if direction is cut and value is positive, negate it
|
||||
let bpsYearEnd = parseInt(bpsStr);
|
||||
if (nextMeetingIsCut && bpsYearEnd > 0) bpsYearEnd = -bpsYearEnd;
|
||||
|
||||
// Probability of change = 100 - probNoChange OR directProb if it's a hike/cut
|
||||
const nextMeetingProbPct = nextMeetingIsNoChange ? 100 - probPct : probPct;
|
||||
|
||||
result[ccy] = {
|
||||
currency: ccy,
|
||||
currency: ccy,
|
||||
nextMeetingProbPct,
|
||||
nextMeetingIsHike,
|
||||
nextMeetingIsNoChange,
|
||||
@@ -103,50 +158,31 @@ function parseArticleBody(text: string, publishedDate: string): ILExpectationsMa
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Main fetch ────────────────────────────────────────────────────────────────
|
||||
// ── Exports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Retourne uniquement l'article le plus récent (compatibilité descendante). */
|
||||
export async function fetchILExpectations(): Promise<ILExpectationsMap> {
|
||||
try {
|
||||
const found = await findLatestArticleUrl();
|
||||
if (!found) {
|
||||
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
|
||||
return {};
|
||||
}
|
||||
|
||||
const res = await fetch(found.url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
next: { revalidate: 21600 }, // cache 6h — new article only published after major events
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn("[IL] Fetch failed:", res.status);
|
||||
return {};
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
// Extract articleBody from JSON-LD structured data
|
||||
const jsonLdMatch = html.match(/"articleBody"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
||||
if (!jsonLdMatch) {
|
||||
console.warn("[IL] articleBody not found in JSON-LD");
|
||||
return {};
|
||||
}
|
||||
|
||||
const articleBody = jsonLdMatch[1]
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\u003c/g, "<")
|
||||
.replace(/\\u003e/g, ">");
|
||||
|
||||
const parsed = parseArticleBody(articleBody, found.dateStr);
|
||||
const count = Object.keys(parsed).length;
|
||||
console.log(`[IL] Parsed ${count} CBs from article dated ${found.dateStr}`);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error("[IL] error:", err);
|
||||
const { current } = await findArticleRefs();
|
||||
if (!current) {
|
||||
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
|
||||
return {};
|
||||
}
|
||||
return fetchAndParse(current);
|
||||
}
|
||||
|
||||
/** Retourne l'article courant ET l'article précédent pour calcul de delta semaine/semaine. */
|
||||
export async function fetchILExpectationsWithHistory(): Promise<ILExpectationsWithHistory> {
|
||||
const { current, previous } = await findArticleRefs();
|
||||
|
||||
if (!current) {
|
||||
console.warn("[IL] No recent rate-expectations article found (last 14 days)");
|
||||
return { current: {}, prev: {}, prevDate: null };
|
||||
}
|
||||
|
||||
const [currentData, prevData] = await Promise.all([
|
||||
fetchAndParse(current),
|
||||
previous ? fetchAndParse(previous) : Promise.resolve({} as ILExpectationsMap),
|
||||
]);
|
||||
|
||||
return { current: currentData, prev: prevData, prevDate: previous?.dateStr ?? null };
|
||||
}
|
||||
|
||||
+31
-9
@@ -3,7 +3,7 @@
|
||||
// Endpoint pattern: https://rateprobability.com/api/{cb}/latest
|
||||
|
||||
import type { Currency } from "./types";
|
||||
import { fetchILExpectations } from "./investinglive";
|
||||
import { fetchILExpectationsWithHistory } from "./investinglive";
|
||||
import type { ILExpectationsMap } from "./investinglive";
|
||||
|
||||
// ── Types publics ──────────────────────────────────────────────────────────────
|
||||
@@ -17,6 +17,13 @@ export interface RateProbMeeting {
|
||||
changeBps: number; // bps attendus à cette réunion (cumulatif)
|
||||
}
|
||||
|
||||
export interface ILWeeklyDelta {
|
||||
probDelta: number; // Δ nextMeetingProbPct (courant - semaine précédente)
|
||||
bpsDelta: number; // Δ bpsYearEnd (courant - semaine précédente)
|
||||
isCut: boolean; // contexte : le pic actuel est un cut
|
||||
prevDate: string; // date de l'article de référence (semaine précédente)
|
||||
}
|
||||
|
||||
export interface CBRatePath {
|
||||
currency: Currency;
|
||||
asOf: string; // "2026-05-31"
|
||||
@@ -24,6 +31,7 @@ export interface CBRatePath {
|
||||
meetings: RateProbMeeting[];
|
||||
peakMeeting: RateProbMeeting | null; // réunion avec proba max de mouvement
|
||||
yearEndImplied: number | null; // taux impliqué à la dernière réunion connue
|
||||
ilDelta?: ILWeeklyDelta; // delta vs article IL semaine précédente
|
||||
}
|
||||
|
||||
export type RateProbData = Partial<Record<Currency, CBRatePath>>;
|
||||
@@ -193,11 +201,14 @@ function buildSNBPath(il: ILExpectationsMap, currentRate: number): CBRatePath |
|
||||
// ── Fetch toutes les CB en parallèle ──────────────────────────────────────────
|
||||
|
||||
export async function fetchAllCBPaths(): Promise<RateProbData> {
|
||||
// rateprobability.com (7 CBs) + InvestingLive (tous CBs + CHF) en parallèle
|
||||
const [rpResults, ilData] = await Promise.all([
|
||||
// rateprobability.com (7 CBs) + InvestingLive (article courant + précédent) en parallèle
|
||||
const [rpResults, ilHistory] = await Promise.all([
|
||||
Promise.allSettled(CB_KEYS.map(([ccy, slug]) => fetchCBPath(ccy, slug))),
|
||||
fetchILExpectations(),
|
||||
fetchILExpectationsWithHistory(),
|
||||
]);
|
||||
const ilData = ilHistory.current;
|
||||
const ilPrev = ilHistory.prev;
|
||||
const prevDate = ilHistory.prevDate;
|
||||
|
||||
const data: RateProbData = {};
|
||||
|
||||
@@ -215,18 +226,29 @@ export async function fetchAllCBPaths(): Promise<RateProbData> {
|
||||
}
|
||||
|
||||
// Enrichir yearEndImplied avec bpsYearEnd de IL (Giuseppe Dellamotta — source humaine)
|
||||
// pour toutes les devises où IL a une donnée ET rateprobability.com a réussi.
|
||||
// Règle : pour les hikes (bpsYearEnd > 0) et cuts (bpsYearEnd < 0), mettre à jour.
|
||||
// Pour "no change" (bpsYearEnd proche de 0), garder la valeur rateprobability.com.
|
||||
// + calculer ilDelta (Δ vs article semaine précédente) pour les flèches de tendance.
|
||||
for (const [ccyStr, ilEntry] of Object.entries(ilData)) {
|
||||
const ccy = ccyStr as keyof RateProbData;
|
||||
const path = data[ccy];
|
||||
if (!path) continue;
|
||||
if (typeof ilEntry.bpsYearEnd !== "number") continue;
|
||||
if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 10) continue; // garder RP si "no change" + bps résiduel faible
|
||||
if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 10) continue;
|
||||
|
||||
const ilYearEnd = parseFloat((path.currentRate + ilEntry.bpsYearEnd / 100).toFixed(4));
|
||||
data[ccy] = { ...path, yearEndImplied: ilYearEnd };
|
||||
|
||||
// Delta semaine/semaine depuis l'article précédent de Giuseppe
|
||||
let ilDelta: import("./rateprobability").ILWeeklyDelta | undefined;
|
||||
const prevEntry = ilPrev[ccy];
|
||||
if (prevEntry && prevDate) {
|
||||
ilDelta = {
|
||||
probDelta: parseFloat((ilEntry.nextMeetingProbPct - prevEntry.nextMeetingProbPct).toFixed(1)),
|
||||
bpsDelta: ilEntry.bpsYearEnd - prevEntry.bpsYearEnd,
|
||||
isCut: !ilEntry.nextMeetingIsHike && !ilEntry.nextMeetingIsNoChange,
|
||||
prevDate,
|
||||
};
|
||||
}
|
||||
|
||||
data[ccy] = { ...path, yearEndImplied: ilYearEnd, ...(ilDelta ? { ilDelta } : {}) };
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user