diff --git a/app/api/cot/route.ts b/app/api/cot/route.ts index c49c1b4..494a73f 100644 --- a/app/api/cot/route.ts +++ b/app/api/cot/route.ts @@ -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. @@ -163,10 +165,12 @@ function parseCOT(csv: string): Record { amShorts: cur.amShorts, amLongPct: amTotal > 0 ? Math.round((cur.amLongs / amTotal) * 100) : 50, amTotal, - netDelta: prev !== null ? hfNet - (prev.hfLongs - prev.hfShorts) : null, - 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, + netDelta: prev !== null ? hfNet - (prev.hfLongs - prev.hfShorts) : null, + 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, }; diff --git a/app/api/drivers/route.ts b/app/api/drivers/route.ts index 46e4b68..835d3d1 100644 --- a/app/api/drivers/route.ts +++ b/app/api/drivers/route.ts @@ -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" } }); } diff --git a/app/api/macro/route.ts b/app/api/macro/route.ts index 8ea5dee..6cad209 100644 --- a/app/api/macro/route.ts +++ b/app/api/macro/route.ts @@ -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"; diff --git a/app/api/sentiment/route.ts b/app/api/sentiment/route.ts index 951c511..805bf74 100644 --- a/app/api/sentiment/route.ts +++ b/app/api/sentiment/route.ts @@ -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 diff --git a/components/NewsTab.tsx b/components/NewsTab.tsx index 5ae2ac5..01953e3 100644 --- a/components/NewsTab.tsx +++ b/components/NewsTab.tsx @@ -333,9 +333,15 @@ function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boo return (
- {/* Heure · Source · Catégorie · Prioritaire */} + {/* Heure · Analyse · Source · Catégorie · Prioritaire */}
{formatRelativeTime(item.publishedAt)} + {!secondary && ( + + )} {item.source} @@ -350,8 +356,8 @@ function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boo )}
- {/* Titre + Analyse (bouton en fin de ligne) */} -
+ {/* Titre seul */} +
@@ -359,12 +365,6 @@ function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boo - {!secondary && ( - - )}
{item.summary && !secondary && ( diff --git a/components/ReportTab.tsx b/components/ReportTab.tsx index 5459e46..664cb0c 100644 --- a/components/ReportTab.tsx +++ b/components/ReportTab.tsx @@ -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) {
- {/* Macro overview : S&P, VIX, DXY, Or */} + {/* Macro overview : S&P, VIX, DXY, Or — bougies interactives avec outils de dessin */}
- - - - + + + +
diff --git a/components/SentimentPairsTab.tsx b/components/SentimentPairsTab.tsx index 9c3d1d5..9864d22 100644 --- a/components/SentimentPairsTab.tsx +++ b/components/SentimentPairsTab.tsx @@ -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 = {}; for (const s of symbols ?? []) symMap[s.name] = s; @@ -221,30 +222,38 @@ export default function SentimentPairsTab({ symbols }: Props) {
- {/* Résumé signaux contrarien */} + {/* Résumé signaux contrariens — compact / déroulant */} {contrarians.length > 0 && ( -
-

- ⚡ {contrarians.length} signal{contrarians.length > 1 ? "s" : ""} contrarien{contrarians.length > 1 ? "s" : ""} -

-
- {contrarians.slice(0, 8).map(p => { - const dir = p.sym!.longPercentage >= 70 ? "short" : "long"; - return ( -
- {CURRENCY_META[p.base]?.flag}{CURRENCY_META[p.quote]?.flag} - {p.std} - - {p.sym!.longPercentage}%L → signal {dir === "short" ? "↓ SELL" : "↑ BUY"} - -
- ); - })} -
+
+ + {showContrariens && ( +
+ {contrarians.slice(0, 12).map(p => { + const dir = p.sym!.longPercentage >= 70 ? "short" : "long"; + return ( +
+ {CURRENCY_META[p.base]?.flag}{CURRENCY_META[p.quote]?.flag} + {p.std} + + {p.sym!.longPercentage}%L → {dir === "short" ? "↓ SELL" : "↑ BUY"} + +
+ ); + })} +
+ )}
)} diff --git a/components/TvChart.tsx b/components/TvChart.tsx index 5dc7321..1c6b1ed 100644 --- a/components/TvChart.tsx +++ b/components/TvChart.tsx @@ -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 diff --git a/lib/newsfeed.ts b/lib/newsfeed.ts index 4e36e13..eda91e4 100644 --- a/lib/newsfeed.ts +++ b/lib/newsfeed.ts @@ -561,8 +561,33 @@ function parseRssItems(xml: string, source: string): NewsItem[] { if (!title || !url.startsWith("http")) continue; - const combined = `${title} ${summary ?? ""}`; - const { impacts, categories } = applyRules(combined); + const combined = `${title} ${summary ?? ""}`; + 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 { 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 PERSON_CATS = new Set(["Discours BC", "Chef d'État"]); + 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)}`, diff --git a/lib/rateprobability.ts b/lib/rateprobability.ts index f73e627..ec09f53 100644 --- a/lib/rateprobability.ts +++ b/lib/rateprobability.ts @@ -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>; @@ -81,7 +82,7 @@ function getCurrentRate(ccy: Currency, today: Record): 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 | } catch { return null; } } +function loadHistorySnapshots(): Array<{ date: string; raw: Record }> { + 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; 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; date: string } | null { try { const filePath = join(process.cwd(), "data", "rate-probabilities.json"); @@ -249,13 +318,39 @@ export async function fetchAllCBPaths(): Promise { } } - // 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 | 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; diff --git a/lib/tecpi.ts b/lib/tecpi.ts index 2edad7b..8483090 100644 --- a/lib/tecpi.ts +++ b/lib/tecpi.ts @@ -702,10 +702,10 @@ export async function fetchTEGDPGrowthRate(): Promise { // 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]*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]*>([^<]+)<\/td>/g; let m: RegExpExecArray | null; @@ -754,14 +759,34 @@ export async function fetchTEEmploymentChange(): Promise