mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
fix: cache no-store sur routes API, EUR emploi, NZD OIS (RBNZ), ReportTab charts 750px
- app/api/*/route.ts : export const dynamic = force-dynamic + Cache-Control no-store - lib/tecpi.ts : regex emploi EUR (grew/rose/fell), fallback body HTML, unite %/percent - lib/rateprobability.ts : RBNZ_MEETINGS 2026-2027, buildRBNZPath (mirrors buildSNBPath) CHF/NZD skippes du loop Investing.com (SNB/RBNZ rate monitor = 404) Taux RBNZ 2.25% (etait 3.25%) - components/ReportTab.tsx : hauteur charts 400 -> 750px (SPX, VIX, DXY, Gold) - lib/tradingeconomics.ts, lib/types.ts, lib/newsfeed.ts : correctifs associes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import { inflateRawSync } from "zlib";
|
||||
import { COT_CODES } from "@/lib/constants";
|
||||
import type { Currency, CotEntry } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// ── CFTC Traders in Financial Futures (TFF) — fichier annuel ZIP ──────────────
|
||||
// URL : https://www.cftc.gov/files/dea/history/fut_fin_txt_YYYY.zip
|
||||
// Contient toutes les semaines de l'année en ordre décroissant.
|
||||
@@ -167,6 +169,8 @@ function parseCOT(csv: string): Record<string, CotEntry> {
|
||||
longsDelta: prev !== null ? cur.hfLongs - prev.hfLongs : null,
|
||||
shortsDelta: prev !== null ? cur.hfShorts - prev.hfShorts : null,
|
||||
amNetDelta: prev !== null ? amNet - (prev.amLongs - prev.amShorts) : null,
|
||||
amLongsDelta: prev !== null ? cur.amLongs - prev.amLongs : null,
|
||||
amShortsDelta: prev !== null ? cur.amShorts - prev.amShorts : null,
|
||||
weekDate: cur.weekDate,
|
||||
prevWeekDate: prev?.weekDate ?? null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchTEBondYields } from "@/lib/tebonds";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// ── FRED (spreads crédit + rendements) ───────────────────────────────────────
|
||||
// VIX et S&P500 ne passent plus par FRED (lag 1-2j) → Yahoo Finance (temps réel)
|
||||
|
||||
@@ -294,5 +296,5 @@ export async function GET() {
|
||||
// Compat
|
||||
copper: null,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { FRED_SERIES } from "@/lib/constants";
|
||||
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 { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// ── Myfxbook Community Outlook API ────────────────────────────────────────────
|
||||
// Source : https://www.myfxbook.com/community/outlook
|
||||
// Auth : login.json → session token → get-community-outlook.json
|
||||
|
||||
@@ -333,9 +333,15 @@ function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boo
|
||||
return (
|
||||
<div className={`rounded-xl border ${borderCls} ${bgCls} overflow-hidden ${isPriority ? "ring-1 ring-offset-0 ring-amber-500/20" : ""}`}>
|
||||
<div className="p-3">
|
||||
{/* Heure · Source · Catégorie · Prioritaire */}
|
||||
{/* Heure · Analyse · Source · Catégorie · Prioritaire */}
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<span className="text-[9px] text-white whitespace-nowrap shrink-0">{formatRelativeTime(item.publishedAt)}</span>
|
||||
{!secondary && (
|
||||
<button onClick={() => setExpanded(e => !e)}
|
||||
className="text-[9px] text-slate-400 hover:text-white px-1.5 py-0.5 rounded-full border border-slate-600/50 transition-colors whitespace-nowrap shrink-0">
|
||||
{expanded ? "▲" : "▼ Analyse"}
|
||||
</button>
|
||||
)}
|
||||
<span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400"}`}>
|
||||
{item.source}
|
||||
</span>
|
||||
@@ -350,8 +356,8 @@ function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boo
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre + Analyse (bouton en fin de ligne) */}
|
||||
<div className="flex items-start gap-2 mb-1.5">
|
||||
{/* Titre seul */}
|
||||
<div className="flex items-start gap-1 mb-1.5">
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-start gap-1 flex-1 min-w-0">
|
||||
<span className={`text-[12px] leading-snug font-medium group-hover:text-white transition-colors ${secondary ? "text-slate-400" : "text-slate-200"}`}>
|
||||
@@ -359,12 +365,6 @@ function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boo
|
||||
</span>
|
||||
<ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" />
|
||||
</a>
|
||||
{!secondary && (
|
||||
<button onClick={() => setExpanded(e => !e)}
|
||||
className="text-[9px] text-slate-400 hover:text-white px-1.5 py-0.5 rounded-full border border-slate-600/50 transition-colors whitespace-nowrap shrink-0 mt-0.5">
|
||||
{expanded ? "▲" : "▼ Analyse"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.summary && !secondary && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { DriverData } from "@/lib/types";
|
||||
import type { FxWeeklyEntry } from "@/app/api/fx-weekly/route";
|
||||
import type { CotHistory } from "@/app/api/cot-history/route";
|
||||
import { CURRENCY_META } from "@/lib/constants";
|
||||
import { TvMiniChart, TvAdvancedChart } from "@/components/TvChart";
|
||||
import { TvAdvancedChart } from "@/components/TvChart";
|
||||
|
||||
interface Props {
|
||||
calEvents: CalendarEvent[];
|
||||
@@ -509,12 +509,12 @@ export default function ReportTab({ calEvents, drivers, cotHistory }: Props) {
|
||||
<div className="h-px flex-1 bg-sky-500/30" />
|
||||
</div>
|
||||
|
||||
{/* Macro overview : S&P, VIX, DXY, Or */}
|
||||
{/* Macro overview : S&P, VIX, DXY, Or — bougies interactives avec outils de dessin */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TvMiniChart symbol="SP:SPX" label="S&P 500 · Daily" dateRange="12M" height={200} />
|
||||
<TvMiniChart symbol="TVC:VIX" label="VIX · Daily" dateRange="12M" height={200} />
|
||||
<TvMiniChart symbol="TVC:DXY" label="DXY Dollar Index · Weekly" dateRange="60M" height={200} />
|
||||
<TvMiniChart symbol="TVC:GOLD" label="Or (XAU/USD) · Weekly" dateRange="60M" height={200} />
|
||||
<TvAdvancedChart symbol="SP:SPX" label="S&P 500" interval="D" height={750} />
|
||||
<TvAdvancedChart symbol="TVC:VIX" label="VIX" interval="D" height={750} />
|
||||
<TvAdvancedChart symbol="TVC:DXY" label="DXY Dollar Index" interval="W" height={750} />
|
||||
<TvAdvancedChart symbol="TVC:GOLD" label="Or (XAU/USD)" interval="W" height={750} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
|
||||
@@ -178,6 +178,7 @@ function PairRow({ pairName, sym, base, quote, showVol }: {
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
export default function SentimentPairsTab({ symbols }: Props) {
|
||||
const [showVol, setShowVol] = useState(true);
|
||||
const [showContrariens, setShowContrariens] = useState(false);
|
||||
|
||||
const symMap: Record<string, MyfxSymbol> = {};
|
||||
for (const s of symbols ?? []) symMap[s.name] = s;
|
||||
@@ -221,14 +222,21 @@ export default function SentimentPairsTab({ symbols }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Résumé signaux contrarien */}
|
||||
{/* Résumé signaux contrariens — compact / déroulant */}
|
||||
{contrarians.length > 0 && (
|
||||
<div className="bg-amber-500/8 border border-amber-500/20 rounded-xl p-3">
|
||||
<p className="text-[10px] font-semibold text-amber-400 uppercase tracking-wider mb-2">
|
||||
<div className="border border-amber-500/20 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowContrariens(s => !s)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-amber-500/8 hover:bg-amber-500/12 transition-colors"
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-amber-400 uppercase tracking-wider">
|
||||
⚡ {contrarians.length} signal{contrarians.length > 1 ? "s" : ""} contrarien{contrarians.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{contrarians.slice(0, 8).map(p => {
|
||||
</span>
|
||||
<span className="text-amber-400/60 text-[10px]">{showContrariens ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{showContrariens && (
|
||||
<div className="flex flex-wrap gap-2 px-3 py-2.5 bg-amber-500/5">
|
||||
{contrarians.slice(0, 12).map(p => {
|
||||
const dir = p.sym!.longPercentage >= 70 ? "short" : "long";
|
||||
return (
|
||||
<div key={p.std} className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-[11px] font-medium ${
|
||||
@@ -239,12 +247,13 @@ export default function SentimentPairsTab({ symbols }: Props) {
|
||||
<span>{CURRENCY_META[p.base]?.flag}{CURRENCY_META[p.quote]?.flag}</span>
|
||||
<span className="font-bold">{p.std}</span>
|
||||
<span className="text-[10px] opacity-70">
|
||||
{p.sym!.longPercentage}%L → signal {dir === "short" ? "↓ SELL" : "↑ BUY"}
|
||||
{p.sym!.longPercentage}%L → {dir === "short" ? "↓ SELL" : "↑ BUY"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -87,10 +87,10 @@ export function TvAdvancedChart({
|
||||
hide_legend: false,
|
||||
allow_symbol_change: false,
|
||||
calendar: false,
|
||||
hide_volume: true,
|
||||
hide_volume: false,
|
||||
isTransparent: true,
|
||||
save_image: true,
|
||||
drawings_access: { type: "all", tools: [{ name: "Regression Trend" }] },
|
||||
drawings_access: { type: "all" },
|
||||
container_id: containerId,
|
||||
},
|
||||
height
|
||||
|
||||
+43
-8
@@ -562,7 +562,32 @@ function parseRssItems(xml: string, source: string): NewsItem[] {
|
||||
if (!title || !url.startsWith("http")) continue;
|
||||
|
||||
const combined = `${title} ${summary ?? ""}`;
|
||||
const { impacts, categories } = applyRules(combined);
|
||||
const bodyResult = applyRules(combined);
|
||||
const titleResult = applyRules(title);
|
||||
|
||||
// Mêmes filtres que ZeroHedge :
|
||||
// - person cats uniquement si nom dans le titre
|
||||
// - "Crise" uniquement si mot-clé de crise dans le titre
|
||||
// - si uniquement person cats → titre doit avoir mot-clé macro
|
||||
const PERSON_CATS_RSS = new Set(["Discours BC", "Chef d'État"]);
|
||||
const CRISIS_TITLE_KW_RSS = /\b(bank|banking|financial|crisis|crash|collapse|systemic|meltdown|turmoil|default|contagion|insolvency|bail.?out|credit crunch|debt crisis)\b/i;
|
||||
const MACRO_TITLE_RSS = /\b(rate|rates|inflation|gdp|economy|monetary|bond|yield|forex|currency|dollar|euro|pound|yen|franc|tariff|recession|deficit|trade war|rate cut|rate hike|interest)\b/i;
|
||||
|
||||
const categories = bodyResult.categories.filter(c => {
|
||||
if (PERSON_CATS_RSS.has(c)) return titleResult.categories.includes(c);
|
||||
if (c === "Crise") return CRISIS_TITLE_KW_RSS.test(title);
|
||||
return true;
|
||||
});
|
||||
const impacts = bodyResult.impacts;
|
||||
if (impacts.length === 0 && categories.length === 0) continue;
|
||||
|
||||
const onlyPersonCats = categories.length > 0 && categories.every(c => PERSON_CATS_RSS.has(c));
|
||||
if (onlyPersonCats && !MACRO_TITLE_RSS.test(title)) continue;
|
||||
|
||||
// Si aucun impact devise spécifique : le titre doit mentionner l'une de nos 8 devises/BC
|
||||
// Évite Corée du Sud, Philippines, Turquie etc. qui déclenchent "Décision Taux" sans rapport
|
||||
const OUR_CB_KW = /\b(fed|fomc|ecb|boe|boj|rba|rbnz|snb|boc|powell|lagarde|bailey|ueda|bullock|orr|jordan|macklem|dollar|euro|pound|sterling|yen|franc|loonie|aussie|kiwi|usd|eur|gbp|jpy|chf|cad|aud|nzd|g7|g10|forex)\b/i;
|
||||
if (impacts.length === 0 && !OUR_CB_KW.test(title)) continue;
|
||||
|
||||
items.push({
|
||||
id: `rss-${Buffer.from(url).toString("base64").slice(0, 12)}`,
|
||||
@@ -788,19 +813,29 @@ async function fetchZeroHedgeNews(): Promise<NewsItem[]> {
|
||||
const summary = plainText.slice(0, 300);
|
||||
const analysisText = plainText.slice(0, 800);
|
||||
|
||||
// Filtre de pertinence forex : garder seulement si une règle matche.
|
||||
// IMPORTANT : "Discours BC" et "Chef d'État" (déclenchées par des noms propres)
|
||||
// ne sont valides que si le nom apparaît dans le TITRE — évite "Bailey" dans
|
||||
// un article social UK dont le body parle aussi de la "pound" ou de l'"economy".
|
||||
// Filtre de pertinence forex ZeroHedge (3 niveaux) :
|
||||
// 1. "Discours BC" / "Chef d'État" uniquement si nom dans le TITRE
|
||||
// 2. "Crise" uniquement si titre contient mot-clé de crise financière explicite
|
||||
// (évite les articles d'opinion avec doom-language dans le body)
|
||||
// 3. Si seule rétention = catégorie personne, titre doit avoir mot-clé macro
|
||||
const bodyResult = applyRules(`${title} ${analysisText}`);
|
||||
const titleResult = applyRules(title);
|
||||
const PERSON_CATS = new Set(["Discours BC", "Chef d'État"]);
|
||||
const categories = bodyResult.categories.filter(
|
||||
c => !PERSON_CATS.has(c) || titleResult.categories.includes(c)
|
||||
);
|
||||
const CRISIS_TITLE_KW = /\b(bank|banking|financial|crisis|crash|collapse|systemic|meltdown|turmoil|default|contagion|insolvency|bail.?out|credit crunch|debt crisis)\b/i;
|
||||
const categories = bodyResult.categories.filter(c => {
|
||||
if (PERSON_CATS.has(c)) return titleResult.categories.includes(c);
|
||||
if (c === "Crise") return CRISIS_TITLE_KW.test(title);
|
||||
return true;
|
||||
});
|
||||
const impacts = bodyResult.impacts;
|
||||
if (impacts.length === 0 && categories.length === 0) continue;
|
||||
|
||||
// Si toutes les catégories viennent uniquement de noms propres, exiger un
|
||||
// mot-clé macro dans le titre (rate, inflation, gdp, etc.)
|
||||
const MACRO_TITLE = /\b(rate|rates|inflation|gdp|economy|monetary|bond|yield|forex|currency|dollar|euro|pound|yen|franc|tariff|recession|deficit|trade war|rate cut|rate hike|interest)\b/i;
|
||||
const onlyPersonCats = categories.length > 0 && categories.every(c => PERSON_CATS.has(c));
|
||||
if (onlyPersonCats && !MACRO_TITLE.test(title)) continue;
|
||||
|
||||
const dateStr = dateM?.[1]?.trim() ?? "";
|
||||
items.push({
|
||||
id: `zh-${Buffer.from(url).toString("base64").slice(0, 12)}`,
|
||||
|
||||
+98
-3
@@ -46,6 +46,7 @@ export interface CBRatePath {
|
||||
ilDelta?: ILWeeklyDelta; // delta vs article IL semaine précédente
|
||||
prevMeetings?: RateProbMeeting[]; // réunions semaine précédente (snapshot RP)
|
||||
prevWeekDate?: string; // date du snapshot semaine précédente
|
||||
history?: Array<{ date: string; meetings: RateProbMeeting[] }>; // snapshots hebdo accumulés
|
||||
}
|
||||
|
||||
export type RateProbData = Partial<Record<Currency, CBRatePath>>;
|
||||
@@ -81,7 +82,7 @@ function getCurrentRate(ccy: Currency, today: Record<string, unknown>): number {
|
||||
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) ?? 0;
|
||||
case "NZD": return (today["Official Cash Rate (OCR)"] as number) ?? (today["current_target"] as number) ?? (today["midpoint"] as number) ?? 0;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
@@ -107,6 +108,64 @@ const SNB_MEETINGS: string[] = [
|
||||
"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",
|
||||
];
|
||||
|
||||
// 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 nowIso = new Date().toISOString().slice(0, 10);
|
||||
const upcomingMeetings = RBNZ_MEETINGS.filter(d => d >= nowIso);
|
||||
if (upcomingMeetings.length === 0) return null;
|
||||
|
||||
const yearEndIsCut = nzdData.bpsYearEnd < 0;
|
||||
|
||||
const meetings: RateProbMeeting[] = upcomingMeetings.map((dateIso, i) => {
|
||||
const isNext = i === 0;
|
||||
const probMovePct = isNext ? nzdData.nextMeetingProbPct : 0;
|
||||
const probIsCut = isNext
|
||||
? (nzdData.nextMeetingIsNoChange ? yearEndIsCut : !nzdData.nextMeetingIsHike)
|
||||
: yearEndIsCut;
|
||||
const changeBps = isNext ? (probMovePct > 50 ? (probIsCut ? -25 : 25) : 0) : 0;
|
||||
const impliedRate = isNext && probMovePct > 50
|
||||
? parseFloat((currentRate + (probIsCut ? -0.25 : 0.25)).toFixed(4))
|
||||
: currentRate;
|
||||
|
||||
return { label: dateIso.slice(0, 7), dateIso, impliedRate, probMovePct, probIsCut, changeBps };
|
||||
});
|
||||
|
||||
const peakMeeting = meetings.reduce((best, m) =>
|
||||
m.probMovePct > best.probMovePct ? m : best, meetings[0]
|
||||
);
|
||||
|
||||
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"];
|
||||
@@ -174,6 +233,16 @@ function loadCachedRPBody(ccy: string, _slug: string): Record<string, unknown> |
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function loadHistorySnapshots(): Array<{ date: string; raw: Record<string, unknown> }> {
|
||||
try {
|
||||
const filePath = join(process.cwd(), "data", "rate-probabilities.json");
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { snapshots?: Array<{ data: Record<string, unknown>; fetchedAt: string }> };
|
||||
if (!parsed.snapshots?.length) return [];
|
||||
return parsed.snapshots.map(s => ({ date: s.fetchedAt.slice(0, 10), raw: s.data }));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function loadPrevWeekCachedBody(ccy: string): { body: Record<string, unknown>; date: string } | null {
|
||||
try {
|
||||
const filePath = join(process.cwd(), "data", "rate-probabilities.json");
|
||||
@@ -249,13 +318,39 @@ export async function fetchAllCBPaths(): Promise<RateProbData> {
|
||||
}
|
||||
}
|
||||
|
||||
// CHF/SNB : Investing.com rate monitor couvre SNB → déjà dans le JSON si dispo.
|
||||
// InvestingLive reste en backup si le JSON n'a pas CHF.
|
||||
// Historique multi-semaines (snapshots accumulés par GitHub Actions)
|
||||
const historySnaps = loadHistorySnapshots();
|
||||
if (historySnaps.length) {
|
||||
for (const ccy of CB_KEYS) {
|
||||
const path = data[ccy];
|
||||
if (!path) continue;
|
||||
const history: CBRatePath["history"] = [];
|
||||
for (const snap of historySnaps) {
|
||||
const entry = snap.raw[ccy] as Record<string, unknown> | undefined;
|
||||
if (!entry) continue;
|
||||
const snapPath = parseCBBody(ccy, entry);
|
||||
if (snapPath?.meetings.length) {
|
||||
history.push({ date: snap.date, meetings: snapPath.meetings });
|
||||
}
|
||||
}
|
||||
if (history.length) data[ccy] = { ...path, history };
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Enrichissement IL : deltas hebdo pour toutes les devises
|
||||
for (const [ccyStr, ilEntry] of Object.entries(ilData)) {
|
||||
const ccy = ccyStr as keyof RateProbData;
|
||||
|
||||
+34
-9
@@ -702,10 +702,10 @@ export async function fetchTEGDPGrowthRate(): Promise<CoreCPIMap> {
|
||||
// USD : /united-states/non-farm-payrolls → en milliers, MoM
|
||||
// GBP : /united-kingdom/employment-change → en milliers, MoM (3 mois glissants)
|
||||
// AUD : /australia/employment-change → en personnes → /1000
|
||||
// EUR : /euro-area/employment-change → QoQ%
|
||||
// EUR : /euro-area/employment-change → QoQ% converti en milliers via le niveau total
|
||||
|
||||
export interface TEEmploymentEntry {
|
||||
value: number; // k pour USD/GBP/AUD, QoQ% pour EUR
|
||||
value: number; // milliers de personnes (toutes devises, EUR converti depuis le %)
|
||||
prev: number | null;
|
||||
}
|
||||
|
||||
@@ -734,12 +734,16 @@ export async function fetchTEEmploymentChange(): Promise<Partial<Record<Currency
|
||||
if (!res.ok) return null;
|
||||
const html = await res.text();
|
||||
|
||||
// Current value from meta description
|
||||
// Current value — meta description (préféré) OU body HTML si absent
|
||||
const metaM = html.match(/name=["']description["'][^>]*content=["']([^"']+)["']/i)
|
||||
?? html.match(/content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
const desc = metaM?.[1] ?? "";
|
||||
const incrM = desc.match(/increased\s+by\s+([\d,]+\.?\d*)/i);
|
||||
const decrM = desc.match(/decreased\s+by\s+([\d,]+\.?\d*)/i);
|
||||
// Fallback : chercher le passage clé directement dans le body (supprime les balises)
|
||||
const bodyText = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
|
||||
const desc = metaM?.[1] ?? bodyText.slice(0, 3000); // body limité à 3k chars
|
||||
|
||||
// Verbes positifs/négatifs larges (grew/rose/fell/dropped/etc.)
|
||||
const incrM = desc.match(/(?:increased|grew|rose|gained|expanded|climbed)\s+by\s+([\d,]+\.?\d*)/i);
|
||||
const decrM = desc.match(/(?:decreased|fell|dropped|declined|contracted|shrank|slipped)\s+by\s+([\d,]+\.?\d*)/i);
|
||||
if (!incrM && !decrM) return null;
|
||||
|
||||
const sign = incrM ? 1 : -1;
|
||||
@@ -747,6 +751,7 @@ export async function fetchTEEmploymentChange(): Promise<Partial<Record<Currency
|
||||
const curRaw = sign * absVal;
|
||||
|
||||
// Find previous from table: groups of (value, prev, unit-label)
|
||||
// Pour EUR : la colonne unité contient "%" pas "percent" → vérifier les deux
|
||||
const tdVals: string[] = [];
|
||||
const tdRe = /<td[^>]*>([^<]+)<\/td>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -754,14 +759,34 @@ export async function fetchTEEmploymentChange(): Promise<Partial<Record<Currency
|
||||
|
||||
let prevRaw: number | null = null;
|
||||
for (let i = 0; i < tdVals.length - 2; i++) {
|
||||
const a = parseFloat(tdVals[i].replace(/,/g, ""));
|
||||
const b = parseFloat(tdVals[i + 1].replace(/,/g, ""));
|
||||
const a = parseFloat(tdVals[i].replace(/[,%]/g, ""));
|
||||
const b = parseFloat(tdVals[i + 1].replace(/[,%]/g, ""));
|
||||
const c = tdVals[i + 2].toLowerCase();
|
||||
if (isNaN(a) || isNaN(b) || !c.includes(unitKw)) continue;
|
||||
const unitMatch = unitKw === "percent"
|
||||
? (c.includes("percent") || c.includes("%"))
|
||||
: c.includes(unitKw);
|
||||
if (isNaN(a) || isNaN(b) || !unitMatch) continue;
|
||||
const tol = Math.max(1, Math.abs(curRaw) * 0.02);
|
||||
if (Math.abs(a - curRaw) <= tol) { prevRaw = b; break; }
|
||||
}
|
||||
|
||||
// EUR : la page donne un % QoQ → convertir en Δ milliers de personnes.
|
||||
// Niveau total d'emploi dans le body ("to 176.308 million") → Δk = niveau_k × %/100.
|
||||
// Fallback hardcodé ≈ 176 000k (zone euro Q1 2026) si le niveau n'est pas parseable.
|
||||
if (unitKw === "percent") {
|
||||
const lvlM = desc.match(/to\s+([\d.]+)\s*(million|thousand)/i)
|
||||
?? bodyText.match(/to\s+([\d.]+)\s*(million|thousand)/i);
|
||||
let levelK = 176000; // fallback ≈ zone euro
|
||||
if (lvlM) {
|
||||
const lvlNum = parseFloat(lvlM[1]);
|
||||
levelK = /million/i.test(lvlM[2]) ? lvlNum * 1000 : lvlNum;
|
||||
}
|
||||
return [ccy, {
|
||||
value: parseFloat((levelK * curRaw / 100).toFixed(1)),
|
||||
prev: prevRaw !== null ? parseFloat((levelK * prevRaw / 100).toFixed(1)) : null,
|
||||
}] as [Currency, TEEmploymentEntry];
|
||||
}
|
||||
|
||||
return [ccy, {
|
||||
value: parseFloat((curRaw / divisor).toFixed(precision)),
|
||||
prev: prevRaw !== null ? parseFloat((prevRaw / divisor).toFixed(precision)) : null,
|
||||
|
||||
@@ -42,11 +42,11 @@ function teCategory(cat: string, eventName?: string): EventCategory {
|
||||
if (/\bpmi\b|purchasing\s+managers/.test(c)) return "pmi";
|
||||
if (/interest\s+rate|monetary\s+policy|rate\s+decision|bank\s+rate/.test(c)) return "policy_rate";
|
||||
if (/speech|speaks?|testimony|press\s+conf|central\s+bank/.test(c)) return "cb_speech";
|
||||
if (/inflation|cpi|hicp|consumer\s+price|ppi/.test(c)) return "inflation";
|
||||
if (/inflation|cpi|hicp|consumer\s+price|ppi|producer\s+price/.test(c)) return "inflation";
|
||||
if (/\bgdp\b|gross\s+domestic|growth\s+rate/.test(c)) return "gdp";
|
||||
if (/retail\s+sales|core\s+retail/.test(c)) return "retail_sales";
|
||||
if (/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/.test(c)) return "employment";
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,8 @@ export interface CotEntry {
|
||||
longsDelta: number | null; // Δ longs HF (+= ajout de longs)
|
||||
shortsDelta: number | null; // Δ shorts HF (+= ajout de shorts)
|
||||
amNetDelta: number | null; // Δ net AM
|
||||
amLongsDelta: number | null; // Δ longs AM
|
||||
amShortsDelta: number | null; // Δ shorts AM
|
||||
// Métadonnées
|
||||
weekDate: string;
|
||||
prevWeekDate: string | null;
|
||||
|
||||
Reference in New Issue
Block a user