diff --git a/app/api/cot/route.ts b/app/api/cot/route.ts index 358765b..c49c1b4 100644 --- a/app/api/cot/route.ts +++ b/app/api/cot/route.ts @@ -1,44 +1,39 @@ import { NextResponse } from "next/server"; +import { inflateRawSync } from "zlib"; import { COT_CODES } from "@/lib/constants"; import type { Currency, CotEntry } from "@/lib/types"; -// ── CFTC Traders in Financial Futures (TFF) — format legacy CSV sans header ── -// URL : https://www.cftc.gov/dea/newcot/FinFutWk.txt (mis à jour chaque vendredi) +// ── 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. +// On extrait les 2 dernières semaines par devise pour calculer les deltas. // // Colonnes (0-based, séparées par virgule) : // 0 Market_and_Exchange_Names // 1 As_of_Date_In_Form_YYMMDD // 2 Report_Date_as_YYYY-MM-DD // 3 CFTC_Contract_Market_Code -// 4 CFTC_Market_Code -// 5 CFTC_Region_Code -// 6 CFTC_Commodity_Code -// 7 Open_Interest_All -// 8 Dealer_Positions_Long_All -// 9 Dealer_Positions_Short_All -// 10 Dealer_Positions_Spreading_All // 11 Asset_Mgr_Positions_Long_All // 12 Asset_Mgr_Positions_Short_All -// 13 Asset_Mgr_Positions_Spreading_All -// 14 Lev_Money_Positions_Long_All ← hedge funds (positions spéculatives) +// 14 Lev_Money_Positions_Long_All ← hedge funds // 15 Lev_Money_Positions_Short_All -// 16 Lev_Money_Positions_Spreading_All -// ... -const CFTC_URL = "https://www.cftc.gov/dea/newcot/FinFutWk.txt"; -const IDX_CODE = 3; +const IDX_CODE = 3; +const IDX_DATE = 2; +const IDX_AM_LONG = 11; +const IDX_AM_SHORT = 12; const IDX_LEV_LONG = 14; const IDX_LEV_SHORT = 15; -const IDX_DATE = 2; + +function cftcZipUrl(): string { + return `https://www.cftc.gov/files/dea/history/fut_fin_txt_${new Date().getFullYear()}.zip`; +} // In-memory cache — expire le vendredi suivant à 15h30 UTC (publication CFTC) -// TTL max 4 jours pour garantir refresh chaque semaine -let _cache: { data: Record; ts: number; weekDate: string } | null = null; +let _cache: { data: Record; ts: number } | null = null; function nextCftcRelease(): number { - const now = new Date(); - const d = new Date(now); - // Prochain vendredi 15:30 UTC + const d = new Date(); const daysUntilFriday = (5 - d.getUTCDay() + 7) % 7 || 7; d.setUTCDate(d.getUTCDate() + daysUntilFriday); d.setUTCHours(15, 30, 0, 0); @@ -46,9 +41,7 @@ function nextCftcRelease(): number { } function cacheTtl(): number { - const ttlToRelease = nextCftcRelease() - Date.now(); - // max 4 jours pour éviter de bloquer sur de vieilles données - return Math.min(ttlToRelease, 4 * 24 * 3600_000); + return Math.min(nextCftcRelease() - Date.now(), 4 * 24 * 3600_000); } export type { CotEntry } from "@/lib/types"; @@ -59,81 +52,138 @@ export async function GET() { } try { - const res = await fetch(CFTC_URL, { - next: { revalidate: 86400 }, // revalidate quotidien — CFTC sort chaque vendredi + const res = await fetch(cftcZipUrl(), { + cache: "no-store", headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" }, - cache: "no-store", // forcer fetch frais pour l'in-memory cache ci-dessus }); if (!res.ok) { return NextResponse.json({ error: `CFTC fetch failed: ${res.status}` }, { status: 502 }); } - const text = await res.text(); - const result = parseCOT(text); + const zipBuf = Buffer.from(await res.arrayBuffer()); + const text = extractFirstFileFromZip(zipBuf); + if (!text) return NextResponse.json({ error: "ZIP parse failed" }, { status: 502 }); - const weekDate = Object.values(result as Record)[0]?.weekDate ?? ""; - _cache = { data: result, ts: Date.now(), weekDate }; + const result = parseCOT(text); + _cache = { data: result, ts: Date.now() }; return NextResponse.json(result); } catch (err) { return NextResponse.json({ error: String(err) }, { status: 502 }); } } +// ── ZIP parser minimal (format PKZIP, méthode 8 = deflate) ─────────────────── + +function extractFirstFileFromZip(buf: Buffer): string | null { + try { + // Find End-of-Central-Directory (PK\x05\x06) + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) { + if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) { + eocd = i; break; + } + } + if (eocd < 0) return null; + + const localHdrOffset = buf.readUInt32LE(buf.readUInt32LE(eocd + 16) + 42); + const lfnLen = buf.readUInt16LE(localHdrOffset + 26); + const lexLen = buf.readUInt16LE(localHdrOffset + 28); + const dataStart = localHdrOffset + 30 + lfnLen + lexLen; + const compSize = buf.readUInt32LE(localHdrOffset + 18); + const method = buf.readUInt16LE(localHdrOffset + 8); + + const compressed = buf.slice(dataStart, dataStart + compSize); + const decompressed = method === 8 + ? inflateRawSync(compressed) + : compressed; // stored (method 0) + + return decompressed.toString("utf8"); + } catch { + return null; + } +} + +// ── Parseur CSV TFF ────────────────────────────────────────────────────────── + +type RawWeek = { + hfLongs: number; hfShorts: number; + amLongs: number; amShorts: number; + weekDate: string; +}; + function parseCOT(csv: string): Record { - const lines = csv.split("\n"); + const lines = csv.split("\n"); const targetCodes = new Set(Object.values(COT_CODES)); - const result: Record = {}; + const raw: Record = {}; for (const line of lines) { if (!line.trim()) continue; - - // Split respectant les guillemets const cols = splitCsvLine(line); if (cols.length < 16) continue; const code = cols[IDX_CODE]?.trim(); if (!code || !targetCodes.has(code)) continue; - const longs = parseInt(cols[IDX_LEV_LONG]?.trim() ?? "0", 10); - const shorts = parseInt(cols[IDX_LEV_SHORT]?.trim() ?? "0", 10); - if (isNaN(longs) || isNaN(shorts)) continue; + const hfLongs = parseInt(cols[IDX_LEV_LONG]?.trim() ?? "0", 10); + const hfShorts = parseInt(cols[IDX_LEV_SHORT]?.trim() ?? "0", 10); + const amLongs = parseInt(cols[IDX_AM_LONG]?.trim() ?? "0", 10); + const amShorts = parseInt(cols[IDX_AM_SHORT]?.trim() ?? "0", 10); + if (isNaN(hfLongs) || isNaN(hfShorts) || isNaN(amLongs) || isNaN(amShorts)) continue; - const total = longs + shorts; - const net = longs - shorts; const weekDate = cols[IDX_DATE]?.trim() ?? ""; + if (!raw[code]) raw[code] = []; + // Keep only the 2 most-recent weeks (file is in descending date order) + if (raw[code].length < 2) raw[code].push({ hfLongs, hfShorts, amLongs, amShorts, weekDate }); + } + const result: Record = {}; + + for (const [code, weeks] of Object.entries(raw)) { const currency = (Object.entries(COT_CODES) as [Currency, string][]) .find(([, c]) => c === code)?.[0]; - if (!currency) continue; + if (!currency || weeks.length === 0) continue; + + const cur = weeks[0]; + const prev = weeks[1] ?? null; + + const hfTotal = cur.hfLongs + cur.hfShorts; + const hfNet = cur.hfLongs - cur.hfShorts; + const amTotal = cur.amLongs + cur.amShorts; + const amNet = cur.amLongs - cur.amShorts; result[currency] = { - net, - longPct: total > 0 ? Math.round((longs / total) * 100) : 50, - shortPct: total > 0 ? Math.round((shorts / total) * 100) : 50, - totalLev: total, - weekDate, + net: hfNet, + hfLongs: cur.hfLongs, + hfShorts: cur.hfShorts, + longPct: hfTotal > 0 ? Math.round((cur.hfLongs / hfTotal) * 100) : 50, + shortPct: hfTotal > 0 ? Math.round((cur.hfShorts / hfTotal) * 100) : 50, + totalLev: hfTotal, + amNet, + amLongs: cur.amLongs, + 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, + weekDate: cur.weekDate, + prevWeekDate: prev?.weekDate ?? null, }; } return result; } -/** Gère les champs entourés de guillemets doubles dans un CSV */ function splitCsvLine(line: string): string[] { const result: string[] = []; let current = ""; let inQuotes = false; - for (let i = 0; i < line.length; i++) { const ch = line[i]; - if (ch === '"') { - inQuotes = !inQuotes; - } else if (ch === "," && !inQuotes) { - result.push(current); - current = ""; - } else { - current += ch; - } + if (ch === '"') { inQuotes = !inQuotes; } + else if (ch === "," && !inQuotes) { result.push(current); current = ""; } + else { current += ch; } } result.push(current); return result; diff --git a/app/api/drivers/route.ts b/app/api/drivers/route.ts index 28dc5bc..46e4b68 100644 --- a/app/api/drivers/route.ts +++ b/app/api/drivers/route.ts @@ -47,33 +47,6 @@ async function yahooQuote(symbol: string): Promise { } catch { return empty; } } -// ── Stooq (Or XAU/USD, Argent XAG/USD — gratuit, sans clé, quasi temps réel) ─ -// delta = Close - Open = variation intraday vs ouverture de session - -async function stooqMetal(symbol: string): Promise { - const empty: FredResult = { value: null, delta: null, deltaPct: null }; - try { - const url = `https://stooq.com/q/l/?s=${symbol}&f=sd2t2ohlcv&h&e=csv`; - const res = await fetch(url, { - next: { revalidate: 300 }, // cache 5 min - headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" }, - }); - if (!res.ok) return empty; - const text = await res.text(); - const lines = text.trim().split("\n"); - if (lines.length < 2) return empty; - const cols = lines[1].split(","); - // CSV header: Symbol,Date,Time,Open,High,Low,Close,Volume - const open = parseFloat(cols[3]); - const close = parseFloat(cols[6]); - if (isNaN(close)) return empty; - const delta = !isNaN(open) ? parseFloat((close - open).toFixed(2)) : null; - const deltaPct = (!isNaN(open) && open > 0) - ? parseFloat(((close - open) / open * 100).toFixed(2)) - : null; - return { value: parseFloat(close.toFixed(2)), delta, deltaPct }; - } catch { return empty; } -} // ── Binance (Bitcoin — gratuit, sans clé, temps réel) ──────────────────────── // ticker/price = prix spot instantané (plus précis que ticker/24hr lastPrice) @@ -110,36 +83,150 @@ async function coingeckoBTC(): Promise<{ value: number | null; change24h: number } catch { return { value: null, change24h: null }; } } +// ── investing.com — BTC/USD temps réel (data-test attributes, cache 1 min) ──── +// Sélecteurs stables : data-test="instrument-price-last/change/change-percent" +async function investingBTC(): Promise<{ value: number | null; delta: number | null; deltaPct: number | null }> { + const empty = { value: null, delta: null, deltaPct: null }; + try { + const res = await fetch("https://www.investing.com/crypto/bitcoin/btc-usd", { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + }, + next: { revalidate: 60 }, + }); + if (!res.ok) return empty; + const html = await res.text(); + + // "61,307.0" → 61307 + const priceMatch = html.match(/data-test="instrument-price-last">([^<]+)/); + const deltaMatch = html.match(/data-test="instrument-price-change">([^<]+)/); + // "(-0.75%)" → -0.75 + const pctMatch = html.match(/data-test="instrument-price-change-percent">\(([^)%]+)%\)/); + + if (!priceMatch) return empty; + const value = parseFloat(priceMatch[1].replace(/,/g, "")); + if (isNaN(value)) return empty; + + const delta = deltaMatch ? parseFloat(deltaMatch[1].replace(/,/g, "")) : null; + const deltaPct = pctMatch ? parseFloat(pctMatch[1]) + : delta !== null && value > 0 ? parseFloat(((delta / (value - delta)) * 100).toFixed(2)) + : null; + + return { + value: Math.round(value), + delta: delta !== null && !isNaN(delta) ? Math.round(delta) : null, + deltaPct: deltaPct !== null && !isNaN(deltaPct) ? deltaPct : null, + }; + } catch { return empty; } +} + +// ── Business Insider Markets — WTI & S&P 500 (JSON inline, cache 1 min) ────── +// JSON pattern dans le HTML : "currentValue":XX.XX et "previousClose":XX.XX +async function biMarket(url: string): Promise<{ value: number | null; delta: number | null; deltaPct: number | null }> { + const empty = { value: null, delta: null, deltaPct: null }; + try { + const res = await fetch(url, { + headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }, + next: { revalidate: 60 }, + }); + if (!res.ok) return empty; + const html = await res.text(); + const curMatch = html.match(/"currentValue":([\d.]+)/); + const prevMatch = html.match(/"previousClose":([\d.]+)/); + if (!curMatch) return empty; + const value = parseFloat(curMatch[1]); + const prev = prevMatch ? parseFloat(prevMatch[1]) : null; + if (isNaN(value)) return empty; + const delta = prev !== null ? parseFloat((value - prev).toFixed(2)) : null; + const deltaPct = delta !== null && prev !== null && prev > 0 + ? parseFloat(((delta / prev) * 100).toFixed(2)) : null; + return { value: parseFloat(value.toFixed(2)), delta, deltaPct }; + } catch { return empty; } +} + +// ── abcbourse.com — Brent spot temps réel (Six Financial Information) ──────── +// Sélecteurs HTML stables : id="lastcx" (cours), id="veille" (clôture J-1), id="varcx" (%) +async function abcbourseBrent(): Promise<{ value: number | null; delta: number | null; deltaPct: number | null }> { + const empty = { value: null, delta: null, deltaPct: null }; + try { + const res = await fetch("https://www.abcbourse.com/cotation/XBRUSDu", { + headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }, + next: { revalidate: 60 }, + }); + if (!res.ok) return empty; + const html = await res.text(); + + const priceMatch = html.match(/id="lastcx">([^<]+)/); + const prevMatch = html.match(/id="veille">([^<]+)/); + const pctMatch = html.match(/id="varcx"[^>]*>([^<]+)/); + + if (!priceMatch) return empty; + + // "96,70 $" → 96.70 + const value = parseFloat(priceMatch[1].replace(/[^\d,]/g, "").replace(",", ".")); + if (isNaN(value)) return empty; + + const prev = prevMatch ? parseFloat(prevMatch[1].replace(",", ".").trim()) : null; + const delta = prev !== null && !isNaN(prev) ? parseFloat((value - prev).toFixed(2)) : null; + + let deltaPct: number | null = null; + if (pctMatch) { + // + = "+" ; − ou − = "−" + const pctStr = pctMatch[1] + .replace(/[Bb];/g, "+") + .replace(/−|−/g, "-") + .replace("%", "") + .replace(",", ".") + .trim(); + const pctNum = parseFloat(pctStr); + if (!isNaN(pctNum)) deltaPct = pctNum; + } else if (delta !== null && prev !== null && prev > 0) { + deltaPct = parseFloat(((delta / prev) * 100).toFixed(2)); + } + + return { value, delta, deltaPct }; + } catch { return empty; } +} + // ── GET ─────────────────────────────────────────────────────────────────────── export async function GET() { const fredKey = process.env.FRED_API_KEY; if (!fredKey) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 }); - // 1. Indices — Yahoo Finance (temps réel, cache 5 min) - // ^VIX = CBOE Volatility Index | ^GSPC = S&P 500 - const [vixQ, sp500Q] = await Promise.all([ + // 1. Indices — Business Insider (JSON inline, cache 1 min) + fallback Yahoo Finance + // VIX reste Yahoo (Business Insider n'a pas VIX) + const [vixQ, sp500Raw] = await Promise.all([ yahooQuote("^VIX"), - yahooQuote("^GSPC"), + biMarket("https://markets.businessinsider.com/index/s%26p_500"), ]); + const sp500Q = sp500Raw.value !== null ? sp500Raw : await yahooQuote("^GSPC"); - // Pétrole — Stooq futures (quasi temps réel, cache 5 min, sans clé API) - // cl.f = WTI NYMEX | cb.f = Brent ICE → delta = variation intraday vs ouverture - const [brentQ, wtiQ] = await Promise.all([ - stooqMetal("cb.f"), - stooqMetal("cl.f"), + // Brent — abcbourse.com (Six Financial Information, temps réel, cache 1 min) + // Fallback : Yahoo Finance BZ=F si le scraping échoue + // WTI — Business Insider (JSON inline, cache 1 min) + fallback Yahoo Finance CL=F + const [brentRaw, wtiRaw] = await Promise.all([ + abcbourseBrent(), + biMarket("https://markets.businessinsider.com/commodities/oil-price?type=wti"), ]); + const brentQ = brentRaw.value !== null ? brentRaw : await yahooQuote("BZ=F").then(q => ({ + value: q.value, delta: q.delta, deltaPct: q.deltaPct, + })); + const wtiQ = wtiRaw.value !== null ? wtiRaw : await yahooQuote("CL=F"); - // 2. Métaux précieux — Stooq (quasi temps réel, cache 5 min, sans clé API) - // delta = variation intraday vs ouverture de session - const [goldQ, silverQ] = await Promise.all([ - stooqMetal("xauusd"), - stooqMetal("xagusd"), + // 2. Métaux précieux — Business Insider (JSON inline, cache 1 min) + fallback Yahoo Finance + const [goldRaw, silverRaw] = await Promise.all([ + biMarket("https://markets.businessinsider.com/commodities/gold-price"), + biMarket("https://markets.businessinsider.com/commodities/silver-price"), ]); + const goldQ = goldRaw.value !== null ? goldRaw : await yahooQuote("GC=F"); + const silverQ = silverRaw.value !== null ? silverRaw : await yahooQuote("SI=F"); - // 3. Bitcoin — Binance (temps réel), fallback CoinGecko - const btcBin = await binanceBTC(); - const btcCg = btcBin.value === null ? await coingeckoBTC() : { value: null, change24h: null }; + // 3. BTC/USD — investing.com (data-test attrs, cache 1 min) + fallback Binance/CoinGecko + const btcRaw = await investingBTC(); + const btcBin = btcRaw.value === null ? await binanceBTC() : { value: null, change24h: null }; + const btcCg = btcRaw.value === null && btcBin.value === null ? await coingeckoBTC() : { value: null, change24h: null }; // 4. FRED — spreads crédit (cache 1h) // Yields 10Y — TE bonds (cache 1h, données du jour) @@ -160,8 +247,9 @@ export async function GET() { sp500: sp500Q.value, sp500Change: sp500Q.delta, sp500ChangePct: sp500Q.deltaPct, - btc: btcBin.value ?? btcCg.value, + btc: btcRaw.value ?? btcBin.value ?? btcCg.value, btcChange24h: btcBin.change24h ?? btcCg.change24h, + btcDeltaPct: btcRaw.deltaPct, // Crédit (FRED, bps) hySpread: hyRaw != null ? Math.round(hyRaw * 100) : null, igSpread: igRaw != null ? Math.round(igRaw * 100) : null, @@ -190,15 +278,19 @@ export async function GET() { us10y, us2y, curveSlope: us10y !== null && us2y !== null ? Math.round((us10y - us2y) * 100) : null, - // Commodités — Or/Argent intraday (Stooq), Pétrole j-1 (FRED) - gold: goldQ.value, - goldDelta: goldQ.delta, - silver: silverQ.value, - silverDelta: silverQ.delta, - brent: brentQ.value, - brentDelta: brentQ.delta, + // Commodités — Business Insider (cache 1 min) + fallback Yahoo Finance + gold: goldQ.value, + goldDelta: goldQ.delta, + goldDeltaPct: goldQ.deltaPct, + silver: silverQ.value, + silverDelta: silverQ.delta, + silverDeltaPct: silverQ.deltaPct, + brent: brentQ.value, + brentDelta: brentQ.delta, + brentDeltaPct: brentQ.deltaPct, wti: wtiQ.value, wtiDelta: wtiQ.delta, + wtiDeltaPct: wtiQ.deltaPct, // Compat copper: null, timestamp: Date.now(), diff --git a/app/api/macro/route.ts b/app/api/macro/route.ts index e137958..8ea5dee 100644 --- a/app/api/macro/route.ts +++ b/app/api/macro/route.ts @@ -5,7 +5,7 @@ import cpiOverridesRaw from "@/data/cpi_overrides.json"; import rateDecisionsRaw from "@/data/rate_decisions.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 } from "@/lib/tecpi"; +import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate, fetchTESTIRRate, fetchTEEmploymentChange } from "@/lib/tecpi"; import { fetchTEInflationForecasts } from "@/lib/tradingeconomics"; const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations"; @@ -625,14 +625,14 @@ function toIndicatorYoY(obs: Obs[], periods = 12): IndicatorResult { */ function toIndicatorDeltaK(obs: Obs[], personsToK: boolean): IndicatorResult { if (obs.length < 2) return null; - const raw = obs[0].value - obs[1].value; - const valK = personsToK - ? parseFloat((raw / 1000).toFixed(1)) - : parseFloat(raw.toFixed(1)); + const toK = (v: number) => personsToK ? parseFloat((v / 1000).toFixed(1)) : parseFloat(v.toFixed(1)); + const valK = toK(obs[0].value - obs[1].value); + // Période précédente : delta obs[1]-obs[2] si disponible + const prevK = obs.length >= 3 ? toK(obs[1].value - obs[2].value) : null; return { value: valK, - prev: null, - surprise: valK, // surprise = la valeur elle-même (signe = direction) + prev: prevK, + surprise: prevK !== null ? parseFloat((valK - prevK).toFixed(1)) : valK, trend: valK > 0 ? "up" : valK < 0 ? "down" : "flat", lastUpdated: obs[0].date, }; @@ -1109,7 +1109,7 @@ export async function GET(req: NextRequest) { // Remplace séries FRED stale/erronées. // Nouvelles données : cpiYoY headline, cpiCoreMoM (pages individuelles), ppiMoM { - const [teCoreMap, teMoMMap, teYoYMap, teCoreMoMMap, teCoreIdxMap, tePPIMap, teCorePages, teYoYPages, teAUDComm, teGDPMap, teUneMap, teSTIRMap] = await Promise.all([ + const [teCoreMap, teMoMMap, teYoYMap, teCoreMoMMap, teCoreIdxMap, tePPIMap, teCorePages, teYoYPages, teAUDComm, teGDPMap, teUneMap, teSTIRMap, teEmpMap] = await Promise.all([ fetchTECoreInflation(), fetchTEMoMInflation(), fetchTEInflationYoY(), @@ -1122,6 +1122,7 @@ export async function GET(req: NextRequest) { fetchTEGDPGrowthRate(), fetchTEUnemploymentRate(), fetchTESTIRRate(), + fetchTEEmploymentChange(), ]); const teCore = teCoreMap[currency]; @@ -1286,6 +1287,25 @@ export async function GET(req: NextRequest) { } } + // Employment Change — TE pages individuelles (USD NFP, GBP MoM, AUD MoM, EUR QoQ%) + // Remplace FRED : prev était null (toIndicatorDeltaK ne calculait pas le delta précédent) + // EUR/GBP : pas de série FRED disponible → seule source + { + const teEmp = teEmpMap[currency]; + if (teEmp) { + const surprise = teEmp.prev !== null + ? parseFloat((teEmp.value - teEmp.prev).toFixed(1)) + : teEmp.value; + indicators.employment = { + value: teEmp.value, + prev: teEmp.prev, + surprise, + trend: teEmp.value > 0 ? "up" : teEmp.value < 0 ? "down" : "flat", + lastUpdated: null, + }; + } + } + // AUD Commodity Prices YoY if (teAUDComm) { const surprise = teAUDComm.prev !== null diff --git a/app/api/yields/route.ts b/app/api/yields/route.ts index 523d6c1..c691c0b 100644 --- a/app/api/yields/route.ts +++ b/app/api/yields/route.ts @@ -1,13 +1,43 @@ import { NextResponse } from "next/server"; import { fetchTEBondYields } from "@/lib/tebonds"; +// Variation % d'un pair FX vs clôture J-1 (Yahoo Finance, cache 5 min) +// Valeur positive = devise X plus forte vs USD (ou USD plus fort si pair inversé) +async function fxChangePct(symbol: string, invert = false): Promise { + try { + const res = await fetch( + `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=2d`, + { next: { revalidate: 300 }, headers: { "User-Agent": "Mozilla/5.0" } } + ); + if (!res.ok) return null; + const meta = (await res.json())?.chart?.result?.[0]?.meta as { regularMarketPrice?: number; chartPreviousClose?: number } | undefined; + const cur = meta?.regularMarketPrice ?? null; + const prev = meta?.chartPreviousClose ?? null; + if (cur === null || prev === null || prev === 0) return null; + const pct = (cur - prev) / prev * 100; + return parseFloat((invert ? -pct : pct).toFixed(3)); + } catch { return null; } +} + // 10Y sovereign yields — source unique : tradingeconomics.com/bonds (HTML statique) // Remplace les sources précédentes (FRED DGS10 + IRLTLT01XXM156N mensuel + ECB/BoE APIs) // qui avaient des décalages allant de 1 jour (FRED daily) à 1 mois (FRED monthly JPY/CHF/AUD/NZD). // TE bonds = données du jour pour les 8 devises, cache 1h. export async function GET() { - const bondData = await fetchTEBondYields(); + const [bondData, fxResults] = await Promise.all([ + fetchTEBondYields(), + Promise.all([ + fxChangePct("EURUSD=X"), // EUR: positif = EUR fort + fxChangePct("GBPUSD=X"), // GBP: positif = GBP fort + fxChangePct("USDJPY=X", true), // JPY: inversé (USD/JPY haut = JPY faible) + fxChangePct("USDCHF=X", true), // CHF: inversé + fxChangePct("USDCAD=X", true), // CAD: inversé + fxChangePct("AUDUSD=X"), // AUD: positif = AUD fort + fxChangePct("NZDUSD=X"), // NZD: positif = NZD fort + ]), + ]); + const [eurFx, gbpFx, jpyFx, chfFx, cadFx, audFx, nzdFx] = fxResults; const yields: Record = { USD: bondData.USD?.yield10y ?? null, @@ -42,5 +72,17 @@ export async function GET() { } } - return NextResponse.json({ yields, spreads, dayDeltas, timestamp: Date.now() }); + // Variation FX journalière par devise (positif = devise forte vs USD) + const fxDayPct: Record = { + USD: 0, + EUR: eurFx, + GBP: gbpFx, + JPY: jpyFx, + CHF: chfFx, + CAD: cadFx, + AUD: audFx, + NZD: nzdFx, + }; + + return NextResponse.json({ yields, spreads, dayDeltas, fxDayPct, timestamp: Date.now() }); } diff --git a/app/page.tsx b/app/page.tsx index c4c3a38..f787bce 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,9 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import { RefreshCw, Zap, Database, Activity, Maximize2, Minimize2 } from "lucide-react"; +import { RefreshCw, Zap, Database, Activity, Maximize2, Minimize2, X, BarChart2 } from "lucide-react"; import { CURRENCIES, CURRENCY_META } from "@/lib/constants"; -import type { Currency, DriverData, SentimentEntry, CotEntry } from "@/lib/types"; +import type { Currency, DriverData, SentimentEntry, CotEntry, MacroSection } from "@/lib/types"; import type { RateProbData } from "@/lib/rateprobability"; import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache"; import CurrencyCard from "@/components/CurrencyCard"; @@ -23,7 +23,7 @@ const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600 export default function Dashboard() { const [drivers, setDrivers] = useState(null); const [expectations, setExpectations] = useState | null>(null); - const [yields, setYields] = useState<{ yields: Record; spreads: Record; dayDeltas?: Record } | null>(null); + const [yields, setYields] = useState<{ yields: Record; spreads: Record; dayDeltas?: Record; fxDayPct?: Record } | null>(null); const [sentiment, setSentiment] = useState | null>(null); const [cot, setCot] = useState | null>(null); const [calEvents, setCalEvents] = useState([]); @@ -41,6 +41,13 @@ export default function Dashboard() { const [driversFromCache, setDriversFromCache] = useState(false); const [driversCacheAge, setDriversCacheAge] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); + const [macroSection, setMacroSection] = useState("all"); + const [comparisonOpen, setComparisonOpen] = useState(false); + const [comparisonSection, setComparisonSection] = useState>("inflation"); + const [comparisonCurrencies, setComparisonCurrencies] = useState("all"); + const [focusCurrency, setFocusCurrency] = useState("all"); + const [globalMacroSlide, setGlobalMacroSlide] = useState<"mon"|"infl"|"cro"|"empl">("mon"); + const [macroSyncEnabled, setMacroSyncEnabled] = useState(false); // ── Sentiment multi-paires Myfxbook → {CCY: {longPct, shortPct, pair}} ────── // Pour chaque devise, on calcule le % "long CCY" en moyenne pondérée (par volume) @@ -297,12 +304,16 @@ export default function Dashboard() {
{divergenceCount > 0 && ( -
+
+ )}
@@ -363,30 +374,204 @@ export default function Dashboard() { {activeTab === "dashboard" && ( <> - {/* Active divergences summary */} - {activeDivergences.length > 0 && ( -
- {activeDivergences - .sort((a, b) => Math.abs(b.score) - Math.abs(a.score)) - .map(({ currency, score }) => ( -
- - {CURRENCY_META[currency].flag} {currency} SD:{score > 0 ? "+" : ""}{score} + {/* ── Barre de contrôle : Sync + Comparer uniquement ────────────── */} +
+ + + {/* Badges actifs (section filtre + focus devise) */} + {macroSection !== "all" && ( +
+ + {{ inflation:"📊 Inflation", pmi:"🏭 PMI", employment:"👷 Emploi", gdp:"📈 PIB", policy:"🏦 Politique" }[macroSection]} + + +
+ )} + {focusCurrency !== "all" && ( +
+ {CURRENCY_META[focusCurrency].flag} {focusCurrency} + +
+ )} +
+ + {/* ── Comparison panel ──────────────────────────────────────────── */} + {comparisonOpen && (() => { + type IndSnap = { value: number | null; trend: string | null; surprise: number | null } | null; + type CacheData = { indicators: Record }; + const COMP_SECTIONS: { id: Exclude; label: string }[] = [ + { id: "inflation", label: "📊 Inflation" }, + { id: "pmi", label: "🏭 PMI" }, + { id: "employment", label: "👷 Emploi" }, + { id: "gdp", label: "📈 PIB" }, + { id: "policy", label: "🏦 Politique" }, + ]; + const SECTION_FIELDS: Record, { key: string; label: string; unit?: string; inv?: boolean }[]> = { + inflation: [{ key:"cpiYoY", label:"CPI YoY", unit:"%" }, { key:"cpiCore", label:"Core CPI", unit:"%" }, { key:"cpiMoM", label:"CPI MoM", unit:"%" }], + pmi: [{ key:"pmiComposite", label:"PMI Comp." }, { key:"pmiMfg", label:"PMI Mfg" }, { key:"pmiServices", label:"PMI Svc" }], + employment: [{ key:"unemployment", label:"Chômage", unit:"%", inv:true }, { key:"employment", label:"Emploi", unit:"k" }], + gdp: [{ key:"gdp", label:"PIB QoQ", unit:"%" }, { key:"retailSales", label:"Retail Sales", unit:"%" }], + policy: [{ key:"policyRate", label:"Taux dir.", unit:"%" }], + }; + const fields = SECTION_FIELDS[comparisonSection]; + const activeCurrencies = comparisonCurrencies === "all" ? CURRENCIES : comparisonCurrencies; + const rows = activeCurrencies.map(c => { + const cached = loadCache(`macro_${c}`); + const inds = cached?.data?.indicators ?? {}; + return { currency: c, inds }; + }); + const trendColor = (t: string | null, inv = false) => { + if (!t) return "text-slate-500"; + return (t === "up") !== inv ? "text-emerald-400" : "text-red-400"; + }; + const surpriseColor = (s: number | null, inv = false) => { + if (s === null) return "text-slate-500"; + return (s > 0) !== inv ? "text-emerald-400" : "text-red-400"; + }; + const toggleCurrency = (c: Currency) => { + setComparisonCurrencies(prev => { + const current = prev === "all" ? [...CURRENCIES] : [...prev]; + const next = current.includes(c) ? current.filter(x => x !== c) : [...current, c]; + const result = next.length === CURRENCIES.length ? "all" : next.length === 0 ? "all" : next; + // 1 devise sélectionnée → zoom sur cette carte dans la grille + if (Array.isArray(result) && result.length === 1) setFocusCurrency(result[0]); + else setFocusCurrency("all"); + return result; + }); + }; + const resetCurrencies = () => { setComparisonCurrencies("all"); setFocusCurrency("all"); }; + return ( +
+ {/* Header */} +
+ Comparaison + +
+ {/* Config row */} +
+ {/* Section picker */} +
+ {COMP_SECTIONS.map(s => ( + + ))}
- ))} -
- )} +
+ {/* Currency picker — Toutes = tableau complet | 1 seule = zoom carte */} +
+ {Array.isArray(comparisonCurrencies) && comparisonCurrencies.length === 1 && ( + zoom ↓ + )} + + {CURRENCIES.map(c => { + const active = comparisonCurrencies === "all" || comparisonCurrencies.includes(c); + return ( + + ); + })} +
+
+ {/* Table */} +
+ + + + + {fields.map(f => ( + + ))} + + + + {rows.map(({ currency: c, inds }) => ( + + + {fields.map(f => { + const ind = inds[f.key] as IndSnap; + const val = ind?.value ?? null; + const fmtVal = val !== null ? `${val % 1 === 0 ? val : val.toFixed(2)}${f.unit ?? ""}` : "—"; + const tArrow = ind?.trend === "up" ? "↑" : ind?.trend === "down" ? "↓" : ""; + return ( + + ); + })} + + ))} + +
Devise{f.label}
+ {CURRENCY_META[c].flag} {c} + + + {fmtVal} + + {tArrow && ( + {tArrow} + )} + {ind?.surprise !== null && ind?.surprise !== undefined && ( + + {ind.surprise > 0 ? "▲" : "▼"} + + )} +
+
+
+ ); + })()} {/* Currency cards grid */} -
- {CURRENCIES.map((currency) => ( +
+ {CURRENCIES.filter(c => focusCurrency === "all" || c === focusCurrency).map((currency) => ( ))}
@@ -411,7 +600,7 @@ export default function Dashboard() { )} {activeTab === "yields" && ( - + )} {activeTab === "news" && ( diff --git a/components/CurrencyCard.tsx b/components/CurrencyCard.tsx index c8603f7..76fca96 100644 --- a/components/CurrencyCard.tsx +++ b/components/CurrencyCard.tsx @@ -1,22 +1,23 @@ "use client"; -import { useEffect, useState, useCallback, useMemo } from "react"; +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { TrendingUp, TrendingDown, Minus, Loader2, Database, BarChart2, Activity, Target, Zap, Eye, Layers, - ChevronRight, ArrowUpRight, ArrowDownRight, AlertTriangle, + ChevronRight, ArrowUpRight, ArrowDownRight, AlertTriangle, Info, } from "lucide-react"; import { - AreaChart, Area, LineChart, Line, ResponsiveContainer, - Tooltip, XAxis, + AreaChart, Area, LineChart, Line, BarChart, Bar, Cell, + ResponsiveContainer, Tooltip, XAxis, YAxis, ReferenceLine, } from "recharts"; 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 { Currency, BiasPhase, RateExpectation, MacroSection } from "@/lib/types"; import type { CBRatePath, ILWeeklyDelta } from "@/lib/rateprobability"; import type { SentimentEntry, CotEntry } from "@/lib/types"; +import type { CalendarEvent } from "@/app/api/calendar/route"; import NarrativeButton from "./NarrativeButton"; // ─── Types internes ─────────────────────────────────────────────────────────── @@ -53,10 +54,15 @@ interface Props { cot: CotEntry | null; ratePath: CBRatePath | null; onDivergenceUpdate: (currency: Currency, score: number) => void; + calEvents?: CalendarEvent[]; + macroSection?: MacroSection; + syncMacroSlide?: "mon" | "infl" | "cro" | "empl"; + onMacroSlideChange?: (id: "mon" | "infl" | "cro" | "empl") => void; } type Tab = "overview" | "mispricing" | "focus"; type SignalDir = "bullish" | "bearish" | "neutral" | "warning"; +type SliderBlock = "news" | "ois" | "cot" | "signal"; // ─── Helpers de style ───────────────────────────────────────────────────────── @@ -165,7 +171,7 @@ function SurpriseIndexBadge({ value }: { value: number }) { const bgBar = value > 20 ? "bg-emerald-500" : value < -20 ? "bg-red-500" : "bg-slate-500"; return (
- ESI + forecast → +1 ; actual < forecast → −1 ; égal → 0. Le chômage est inversé (hausse = mauvaise nouvelle → −1). Résultat = moyenne des signes × 100.\n\nPlage : −100 à +100. Seuils : > +20 vert (majorité de beats) ; < −20 rouge (majorité de misses).\n\nLimites : non pondéré (un petit beat = un gros beat), non daté (une donnée vieille compte autant qu'une récente)."}>ESI
@@ -232,21 +238,24 @@ function RpArrow({ // ─── Sous-composants ───────────────────────────────────────────────────────── -function MacroBlock({ title, children }: { title: string; children: React.ReactNode }) { +function MacroBlock({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { return (
-
{title}
+
+
{title}
+ {action} +
{children}
); } function IRow({ - label, ind, unit = "", consensus, surpriseVsCons, tooltip, info, invertSurprise = false, + label, ind, unit = "", consensus, surpriseVsCons, tooltip, info, invertSurprise = false, isNew = false, }: { label: string; ind: Ind | null; unit?: string; consensus?: number | null; surpriseVsCons?: number | null; - tooltip?: string | null; info?: string | null; invertSurprise?: boolean; + tooltip?: string | null; info?: string | null; invertSurprise?: boolean; isNew?: boolean; }) { const value = ind?.value ?? null; const prev = ind?.prev ?? null; @@ -269,6 +278,9 @@ function IRow({
{label} + {isNew && ( + NEW + )} {info && ( i @@ -316,10 +328,278 @@ function SignalBar({ strength, direction }: { strength: number; direction: Signa ); } +// ─── OIS Enhanced Block ─────────────────────────────────────────────────────── +// Bloc OIS enrichi : summary (Current Rate → Expected, Next Meeting, Most Likely/Alt) +// + 3 onglets graphiques : Rate Curve / Implied Points / Scénarios + +function OISEnhancedBlock({ ratePath }: { ratePath: CBRatePath }) { + const [chartTab, setChartTab] = useState<"curve" | "implied" | "scenarios">("curve"); + + const { currentRate, meetings, yearEndImplied, ilDelta, ilCurrent } = ratePath; + if (!meetings.length) return null; + + const m0 = meetings[0]; + const bpsYE = yearEndImplied !== null ? Math.round((yearEndImplied - currentRate) * 100) : null; + const bpsCls = bpsYE === null ? "text-slate-400" : bpsYE < 0 ? "text-sky-400" : bpsYE > 0 ? "text-red-400" : "text-slate-400"; + + // Expected move at next meeting + const isMoveExpected = m0.probMovePct >= 50; + const moveLabel = isMoveExpected ? (m0.probIsCut ? "Cut" : "Hike") : "Hold"; + const moveCls = isMoveExpected ? (m0.probIsCut ? "text-sky-400" : "text-red-400") : "text-slate-400"; + const moveIcon = isMoveExpected ? (m0.probIsCut ? "↓" : "↑") : "="; + + // Probability-weighted expected bps at next meeting + const expectedBps = Math.round((m0.probMovePct / 100) * (m0.impliedRate - currentRate) * 10000) / 100; + + // Most Likely / Alternative scenarios at next meeting + const mlIsMove = m0.probMovePct >= 50; + const mlRate = mlIsMove ? m0.impliedRate : currentRate; + const mlProb = mlIsMove ? m0.probMovePct : 100 - m0.probMovePct; + const altRate = mlIsMove ? currentRate : m0.impliedRate; + const altProb = mlIsMove ? 100 - m0.probMovePct : m0.probMovePct; + const altIsMove = !mlIsMove; + + // Chart data (limit to 10 meetings) + const chartMeetings = meetings.slice(0, 10); + + const rateCurveData = chartMeetings.map(m => ({ + label: m.label, + current: +m.impliedRate.toFixed(3), + ...(ilDelta ? { weekAgo: +(m.impliedRate + ilDelta.bpsDelta / 100).toFixed(3) } : {}), + })); + + const impliedPtsData = chartMeetings.map(m => ({ + label: m.label, + bps: +m.changeBps.toFixed(1), + })); + + const scenariosData = meetings.slice(0, 8).map(m => ({ + label: m.label, + dateIso: m.dateIso, + rate: +m.impliedRate.toFixed(3), + prob: m.probMovePct, + isCut: m.probIsCut, + })); + + // Y-axis domain for Rate Curve + const allRates = rateCurveData.flatMap(d => [d.current, d.weekAgo].filter((v): v is number => v !== undefined)); + allRates.push(currentRate); + const minR = Math.min(...allRates); + const maxR = Math.max(...allRates); + const yMargin = Math.max(0.05, (maxR - minR) * 0.25); + + return ( +
+ + {/* ── Summary header ────────────────────────────────────────────────── */} +
+ {/* Title */} +
+
+ OIS · Futures + LIVE +
+ au {ratePath.asOf} +
+ + {/* 3-column rates */} +
+
+
Current Rate
+
{currentRate.toFixed(2)}%
+
+
+
Expected
+
{yearEndImplied?.toFixed(2) ?? "—"}%
+
+
+
Next Meeting
+
{m0.label}
+
+
+ + {/* Expected Move + Change bps + Δ fin an */} +
+
+
Expected Move
+
+ {moveIcon}{moveLabel} +
+
+
+
Change (bps)
+
+ {expectedBps > 0 ? "+" : ""}{expectedBps.toFixed(2)} +
+
+ {bpsYE !== null && ( +
+
Δ fin an
+
+ {bpsYE > 0 ? "+" : ""}{bpsYE}bps +
+
+ )} +
+ + {/* Most Likely / Alternative probability bars */} +
+
+ Most Likely + {mlIsMove ? moveIcon : "="} + {mlRate.toFixed(2)}% +
+
+
+ {Math.round(mlProb)}% +
+
+ Alternative + {altIsMove ? moveIcon : "="} + {altRate.toFixed(2)}% +
+
+
+ {Math.round(altProb)}% +
+
+
+ + {/* ── Chart section ─────────────────────────────────────────────────── */} +
+ {/* Tab buttons */} +
+ {([ { id: "curve" as const, label: "Rate Curve" }, { id: "implied" as const, label: "Implied Pts" }, { id: "scenarios" as const, label: "Scénarios" } ]).map(t => ( + + ))} +
+ + {/* Chart 1 — Implied Interest Rate Curve */} + {chartTab === "curve" && ( +
+
+ Courbe de taux implicite{ilDelta ? " · blanc = actuel · bleu = sem. préc. (approx)" : ""} +
+ + + + v.toFixed(2)} /> + [`${v.toFixed(3)}%`, name === "current" ? "Actuel" : "Sem. préc."]} + /> + + {ilDelta && ( + + )} + + +
+ )} + + {/* Chart 2 — Implied Points (bps cumulatifs par réunion) */} + {chartTab === "implied" && ( +
+
Implied Points (bps par réunion)
+ + + + `${v}bps`} /> + + [`${v > 0 ? "+" : ""}${v.toFixed(1)}bps`, "Implied"]} + /> + + {impliedPtsData.map((entry, i) => ( + 0 ? "#4ade80" : "#475569"} fillOpacity={0.8} /> + ))} + + + +
+ )} + + {/* Chart 3 — Scénarios : taux implicite par réunion (barres horizontales) */} + {chartTab === "scenarios" && ( +
+
Taux implicite par réunion
+
+ {(() => { + const maxR2 = Math.max(...scenariosData.map(d => d.rate), currentRate); + const minR2 = Math.min(...scenariosData.map(d => d.rate), currentRate) - 0.05; + const range = maxR2 - minR2 || 0.25; + return scenariosData.map(d => { + const barW = Math.max(5, Math.min(100, ((d.rate - minR2) / range) * 100)); + const isDown = d.rate < currentRate - 0.001; + const isUp = d.rate > currentRate + 0.001; + const barCl = isDown ? "bg-sky-500/70" : isUp ? "bg-red-500/70" : "bg-amber-500/60"; + const isPeak = ratePath.peakMeeting?.dateIso === d.dateIso; + return ( +
+ + {d.label}{isPeak ? "●" : ""} + +
+
+
+ {d.rate.toFixed(2)}% + {d.prob.toFixed(0)}% +
+ ); + }); + })()} +
+
+ )} +
+ + {/* ── IL footer (analyste InvestingLive) ────────────────────────────── */} + {ilCurrent && ( +
+ + Analyste · {ilCurrent.articleDate} ·{" "} + + {ilCurrent.isNoChange ? "Hold" : `${ilCurrent.isCut ? "" : "+"}${ilCurrent.bpsYearEnd}bps`} + + + {ratePath.ilDelta && (Math.abs(ratePath.ilDelta.probDelta) >= 3 || Math.abs(ratePath.ilDelta.bpsDelta) >= 5) && ( + + Δsem: + + + + )} +
+ )} +
+ ); +} + // ─── Composant principal ────────────────────────────────────────────────────── export default function CurrencyCard({ - currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate + currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate, + calEvents, macroSection, syncMacroSlide, onMacroSlideChange, }: Props) { const meta = CURRENCY_META[currency]; @@ -342,6 +622,20 @@ export default function CurrencyCard({ const [inflFilter, setInflFilter] = useState<"all" | "mom" | "yoy">("mom"); const [expandedSig, setExpandedSig] = useState(null); const [divergenceOpen, setDivergenceOpen] = useState(false); + const [showCotInfo, setShowCotInfo] = useState(false); + const [showAllMeetings, setShowAllMeetings] = useState(false); + const [showYield10Y, setShowYield10Y] = useState(false); + const [showRecentNews, setShowRecentNews] = useState(false); + const [showUpcoming, setShowUpcoming] = useState(false); + const [sliderBlock, setSliderBlock] = useState("ois"); + const [sliderDir, setSliderDir] = useState<1|-1>(1); + type MacroSlide = "mon" | "infl" | "cro" | "empl"; + type SignauxSlide = "ois" | "cot" | "sent"; + const [macroSlide, setMacroSlide] = useState("mon"); + const [macroSlideDir, setMacroSlideDir] = useState<1|-1>(1); + const prevSyncMacroRef = useRef(syncMacroSlide ?? "mon"); + const [signauxSlide, setSignauxSlide] = useState("ois"); + const [signauxSlideDir, setSignauxSlideDir] = useState<1|-1>(1); // ── Data fetch ─────────────────────────────────────────────────────────────── const load = useCallback(async () => { @@ -450,20 +744,47 @@ export default function CurrencyCard({ direction: SignalDir; strength: number; icon: React.ReactNode; }[] = []; - // COT CFTC — Leveraged Money (hedge funds), source : CFTC FinFutWk.txt - // Logique : "follow smart money" — majorité long HF = bullish, majorité short = bearish - // ≠ Retail Myfxbook (contrarian) qui est géré séparément dans le signal "sentiment" + // COT CFTC — Analyse flux AM (hedging) vs HF (spéculation) + momentum hebdomadaire + // Logique : qui domine le flux ET dans quelle direction évolue chaque groupe if (cot) { - const cotDir: SignalDir = cot.longPct > 60 ? "bullish" // HF majoritairement long = bullish - : cot.shortPct > 60 ? "bearish" // HF majoritairement short = bearish - : cot.net > 0 ? "bullish" : cot.net < 0 ? "bearish" : "neutral"; - const imbalance = Math.abs(cot.longPct - cot.shortPct); + const hfIsShort = cot.net < 0; + const amDominates = Math.abs(cot.amNet) > Math.abs(cot.net); + const hfLongsGrowing = cot.longsDelta !== null && cot.longsDelta > 0; + const hfShortsReducing= cot.shortsDelta !== null && cot.shortsDelta < 0; + const hfShortsGrowing = cot.shortsDelta !== null && cot.shortsDelta > 0; + const hfLongsReducing = cot.longsDelta !== null && cot.longsDelta < 0; + // Direction du signal = momentum, pas position absolue + let cotDir: SignalDir; + if (amDominates) { + cotDir = cot.amNet > 0 ? "bullish" : "bearish"; + } else if (hfIsShort) { + cotDir = (hfLongsGrowing || hfShortsReducing) ? "neutral" : "bearish"; + } else { + cotDir = (hfShortsGrowing || hfLongsReducing) ? "neutral" : "bullish"; + } + // Tendance HF en mots + let hfTrend = ""; + if (hfIsShort) { + if (hfLongsGrowing && hfShortsReducing) hfTrend = "retournement en cours (L↑ S↓)"; + else if (hfLongsGrowing) hfTrend = "accumulation de longs (L↑)"; + else if (hfShortsReducing) hfTrend = "couverture de shorts (S↓)"; + else hfTrend = "shorts stables"; + } else { + if (hfShortsGrowing && hfLongsReducing) hfTrend = "retournement baissier (S↑ L↓)"; + else if (hfShortsGrowing) hfTrend = "ajout de shorts (S↑)"; + else if (hfLongsReducing) hfTrend = "réduction de longs (L↓)"; + else hfTrend = "longs stables"; + } + const hfImbalance = Math.abs(cot.longPct - cot.shortPct); + const momentumBoost = cot.netDelta !== null ? Math.min(30, Math.abs(cot.netDelta) / 500) : 0; mispricingSignals.push({ - id: "cot", label: "COT Hedge Funds (CFTC)", + id: "cot", label: "COT CFTC — Flux & Momentum", direction: cotDir, - value: `${cot.longPct.toFixed(0)}% L / ${cot.shortPct.toFixed(0)}% S`, - detail: `Leveraged Money CFTC : ${cot.longPct.toFixed(0)}% long vs ${cot.shortPct.toFixed(0)}% short (imbalance ${imbalance.toFixed(0)}%, net ${cot.net > 0 ? "+" : ""}${(cot.net / 1000).toFixed(0)}k). ${cotDir === "bullish" ? "Hedge funds majoritairement longs → signal institutionnel haussier." : cotDir === "bearish" ? "Hedge funds majoritairement shorts → signal institutionnel baissier." : "Positioning institutionnel équilibré."}`, - strength: Math.min(100, imbalance * 2), + value: amDominates + ? `AM ${cot.amNet > 0 ? "+" : ""}${(cot.amNet/1000).toFixed(0)}k domine` + : `HF ${cot.net > 0 ? "+" : ""}${(cot.net/1000).toFixed(0)}k domine`, + detail: `AM (hedging) : net ${cot.amNet > 0 ? "+" : ""}${(cot.amNet/1000).toFixed(0)}k${cot.amNetDelta !== null ? ` Δ${cot.amNetDelta > 0 ? "+" : ""}${(cot.amNetDelta/1000).toFixed(0)}k` : ""}. HF (spécu) : net ${cot.net > 0 ? "+" : ""}${(cot.net/1000).toFixed(0)}k — ${hfTrend}. Flux dominant : ${amDominates ? "AM (institutionnel)" : "HF (spéculatif)"}.`, + strength: Math.min(100, hfImbalance * 1.5 + momentumBoost), icon: , }); } @@ -574,6 +895,145 @@ export default function CurrencyCard({ { 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 !== ""); + const goToSlide = useCallback((id: SliderBlock) => { + const order: SliderBlock[] = ["news", "ois", "cot", "signal"]; + setSliderBlock(prev => { + setSliderDir(order.indexOf(id) >= order.indexOf(prev) ? 1 : -1); + return id; + }); + }, []); + + const goToMacroSlide = useCallback((id: "mon"|"infl"|"cro"|"empl") => { + const order = ["mon", "infl", "cro", "empl"] as const; + setMacroSlide(prev => { + setMacroSlideDir(order.indexOf(id) >= order.indexOf(prev) ? 1 : -1); + return id; + }); + onMacroSlideChange?.(id); + }, [onMacroSlideChange]); + + // Sync depuis une autre carte : calculer la direction par rapport à la valeur précédente + useEffect(() => { + if (syncMacroSlide === undefined) return; + const order = ["mon", "infl", "cro", "empl"] as const; + const prev = prevSyncMacroRef.current; + if (syncMacroSlide !== prev) { + setMacroSlideDir(order.indexOf(syncMacroSlide) >= order.indexOf(prev) ? 1 : -1); + setMacroSlide(syncMacroSlide); + prevSyncMacroRef.current = syncMacroSlide; + } + }, [syncMacroSlide]); + + const goToSignauxSlide = useCallback((id: SignauxSlide) => { + const order: SignauxSlide[] = ["ois", "cot", "sent"]; + setSignauxSlide(prev => { + setSignauxSlideDir(order.indexOf(id) >= order.indexOf(prev) ? 1 : -1); + return id; + }); + }, []); + + // ── Recent published events for this currency (last 72h, non-low impact) ───── + const recentEvents = useMemo(() => { + if (!calEvents?.length) return []; + const cutoff = Date.now() - 72 * 3600 * 1000; + return calEvents + .filter(e => + e.currency === currency && + e.isPublished && + e.actual !== null && + e.impact !== "low" && + new Date(e.date).getTime() >= cutoff + ) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 3); + }, [calEvents, currency]); + + // ── Upcoming events for this currency (next 3 days, not yet published) ──────── + const upcomingEvents = useMemo(() => { + if (!calEvents?.length) return []; + const now = Date.now(); + const horizon = now + 3 * 24 * 3600 * 1000; + return calEvents + .filter(e => + e.currency === currency && + !e.isPublished && + e.impact !== "low" && + new Date(e.date).getTime() >= now && + new Date(e.date).getTime() <= horizon + ) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + }, [calEvents, currency]); + + // ── Categories with a real publication in the last 7 days for this currency + // Uses the calendar event date (not lastUpdated scraping timestamp). + const recentCalCategories = useMemo(() => { + if (!calEvents?.length) return new Set(); + const cutoff = Date.now() - 7 * 24 * 3600 * 1000; + const cats = new Set(); + for (const e of calEvents) { + if ( + e.currency === currency && + e.isPublished && + e.actual !== null && e.actual !== "" && + new Date(e.date).getTime() >= cutoff + ) { + cats.add(e.category); + } + } + return cats; + }, [calEvents, currency]); + + // Maps indicator key → EventCategory (matches calendar route types) + const IND_CAT: Record = { + cpiYoY: "inflation", cpiCore: "inflation", cpiMoM: "inflation", + cpiCoreMoM: "inflation", ppiMoM: "inflation", + pmiMfg: "pmi", pmiServices: "pmi", pmiComposite: "pmi", + unemployment: "employment", employment: "employment", + gdp: "gdp", + retailSales: "retail_sales", + policyRate: "policy_rate", + }; + const indIsNew = (key: string) => recentCalCategories.has(IND_CAT[key] ?? "__none__"); + + // ── 5-dimension signal summary (header dots) ────────────────────────────────── + const oisSignalDir: SignalDir = (() => { + if (!ratePath?.peakMeeting) return "neutral"; + return ratePath.peakMeeting.probIsCut ? "bearish" : "bullish"; + })(); + const cotSignalDir = (mispricingSignals.find(s => s.id === "cot")?.direction ?? "neutral") as SignalDir; + const sentSignalDir = (mispricingSignals.find(s => s.id === "sentiment")?.direction ?? "neutral") as SignalDir; + const realRateDir: SignalDir = (() => { + const cpiH = inds?.cpiYoY?.value ?? null; + if (policyRateValue === null || cpiH === null) return "neutral"; + const rr = policyRateValue - cpiH; + return rr < 0 ? "bearish" : rr > 1.5 ? "bullish" : "neutral"; + })(); + const signalSummary: { key: string; abbr: string; dir: SignalDir }[] = [ + { key: "ois", abbr: "OIS", dir: oisSignalDir }, + { key: "mac", abbr: "Fonda.", dir }, + { key: "cot", abbr: "COT", dir: cotSignalDir }, + { key: "sent", abbr: "Sent", dir: sentSignalDir }, + { key: "real", abbr: "Réel", dir: realRateDir }, + ]; + + // Auto-select first available slider block when data changes + useEffect(() => { + const available: SliderBlock[] = []; + if (recentEvents.length > 0) available.push("news"); + if (ratePath && ratePath.meetings.length > 0) available.push("ois"); + if (cot) available.push("cot"); + if (divergenceSignal !== "neutral") available.push("signal"); + if (!available.length) return; + setSliderBlock(prev => available.includes(prev) ? prev : available[0]); + }, [recentEvents.length, ratePath, cot, divergenceSignal]); + + // ── macroSection filtering helpers ─────────────────────────────────────────── + const ms = macroSection ?? "all"; + const showInflation = ms === "all" || ms === "inflation"; + const showPmi = ms === "all" || ms === "pmi"; + const showEmployment = ms === "all" || ms === "employment"; + const showGdp = ms === "all" || ms === "gdp" || ms === "pmi"; + // ── Tabs config ────────────────────────────────────────────────────────────── const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: "overview", label: "Aperçu", icon: }, @@ -583,7 +1043,7 @@ export default function CurrencyCard({ // ── Render ──────────────────────────────────────────────────────────────────── return ( -
+
{/* ── Header ──────────────────────────────────────────────────────────── */}
@@ -600,17 +1060,7 @@ export default function CurrencyCard({
{meta.cbShort} · {meta.name}
-
- {loading - ? - : <> -
- {macroScore > 0 ? "+" : ""}{macroScore} -
-
score
- - } -
+ {loading && }
{/* Phase pill + mispricing + ESI + cache */} @@ -618,14 +1068,6 @@ export default function CurrencyCard({ {phaseLabel(phase)} - {mispricingSignals.length > 0 && ( - - - {mispricDir === "bullish" ? "▲" : mispricDir === "bearish" ? "▼" : "—"} {avgStr} - - signaux - - )} {esi !== null && } {fromCache && cacheAge && ( @@ -633,8 +1075,160 @@ export default function CurrencyCard({ )}
+ {/* 5-dimension signal summary */} + {!loading && ( +
+ {signalSummary.map(s => ( +
+
+ {s.abbr} +
+ ))} +
+ {recentEvents.length > 0 && ( + + )} + {upcomingEvents.length > 0 && ( + + )} +
+
+ )}
+ {/* ── News flash panel ────────────────────────────────────────────────── */} + + {showRecentNews && recentEvents.length > 0 && ( + +
+
+ + Données publiées récemment + +
+
+ {recentEvents.map(e => { + const aNum = e.actual ? parseFloat(e.actual) : null; + const fNum = e.forecast ? parseFloat(e.forecast) : null; + const surp = aNum !== null && fNum !== null && !isNaN(aNum) && !isNaN(fNum) ? aNum - fNum : null; + const isBeat = surp !== null && surp > 0; + const isMiss = surp !== null && surp < 0; + return ( +
+ + {e.title} + + {e.actual && A: {e.actual}} + {e.forecast && F: {e.forecast}} + {isBeat && } + {isMiss && } + +
+ ); + })} +
+
+
+ )} +
+ + {/* ── Upcoming events panel ───────────────────────────────────────────── */} + + {showUpcoming && upcomingEvents.length > 0 && ( + +
+
+ + À venir — 3 prochains jours + +
+
+ {upcomingEvents.map(e => { + const fNum = e.forecast ? parseFloat(e.forecast) : null; + const pNum = e.previous ? parseFloat(e.previous) : null; + const diff = fNum !== null && pNum !== null && !isNaN(fNum) && !isNaN(pNum) ? fNum - pNum : null; + const isUp = diff !== null && diff > 0; + const isDown = diff !== null && diff < 0; + const eventDate = new Date(e.date); + const dayLabel = eventDate.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" }); + const timeLabel = eventDate.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }); + return ( +
+ +
+
+ {e.title} +
+
+ {dayLabel} {timeLabel} + {e.forecast && ( + + F: {e.forecast} + + )} + {e.previous && ( + + Préc: {e.previous} + + )} + {diff !== null && ( + + {isUp ? "↑" : isDown ? "↓" : "="}{" "} + {diff > 0 ? "+" : ""}{diff.toFixed(2).replace(/\.?0+$/, "")} + + )} +
+
+
+ ); + })} +
+
+
+ )} +
+ {/* ── Tab bar ─────────────────────────────────────────────────────────── */}
{TABS.map((t) => ( @@ -665,7 +1259,7 @@ export default function CurrencyCard({
{/* ── Tab content ─────────────────────────────────────────────────────── */} -
+
- {/* ── OIS block ───────────────────────────────────────────── */} - {ratePath && ratePath.meetings.length > 0 && (() => { - const yearEnd2026 = `${new Date().getFullYear()}-12-31`; - const meets2026 = ratePath.meetings.filter(m => m.dateIso <= yearEnd2026); - const bpsYE = ratePath.yearEndImplied !== null - ? Math.round((ratePath.yearEndImplied - ratePath.currentRate) * 100) : null; - const bpsCls = bpsYE === null ? "text-slate-400" - : bpsYE < 0 ? "text-sky-400" : bpsYE > 0 ? "text-red-400" : "text-slate-400"; - return ( -
- - {/* ── SECTION 1 : SOFR / OIS live ─────────────────────── */} -
- {/* header */} -
-
- OIS · SOFR Futures - LIVE -
- au {ratePath.asOf} -
- - {/* taux actuel → fin d'an */} -
-
-
Taux actuel
-
{ratePath.currentRate.toFixed(2)}%
-
-
-
-
Fin d'an (Dec)
-
- {ratePath.yearEndImplied?.toFixed(2) ?? "—"}% -
-
- {bpsYE !== null && ( -
-
Δ bps
-
- {bpsYE > 0 ? "+" : ""}{bpsYE} - bps -
-
- )} -
- - {/* chart probabilités */} - - ({ label: m.label, prob: m.probMovePct }))} - margin={{ top: 2, right: 0, left: 0, bottom: 0 }} - > - - - [`${v.toFixed(0)}%`, "Prob. move"]} - /> - - - - {/* tableau des réunions 2026 */} - {meets2026.length > 0 && ( -
- {meets2026.map(m => { - const isPeak = m.dateIso === ratePath.peakMeeting?.dateIso; - const dir = m.probMovePct < 20 ? "HOLD" - : m.probIsCut ? (m.probMovePct >= 50 ? "CUT" : "CUT?") - : (m.probMovePct >= 50 ? "HIKE" : "HIKE?"); - const dirCls = m.probMovePct < 20 ? "text-slate-600" - : m.probIsCut ? "text-sky-400" : "text-red-400"; - return ( -
- - {isPeak && }{m.label} - - {dir} - = 20 ? "text-slate-300" : "text-slate-600"}`}> - {m.probMovePct.toFixed(0)}% - - {m.impliedRate.toFixed(3)}% -
- ); - })} -
- )} + {/* ── Divergence macro / marché ───────────────────────────── */} + {(dir !== "neutral" || mispricDir !== "neutral") && ( +
+ {/* Macro */} +
+ Fond. + + {dir === "bullish" ? "↑" : dir === "bearish" ? "↓" : "—"} + + + {macroScore > 0 ? "+" : ""}{macroScore} + +
+ {/* Verdict central */} +
+
+ {bothOpposed ? "⚠ Divergence" : bothAligned ? (divergenceSignal === "bullish" ? "✓ Convergence ↑" : "✓ Convergence ↓") : dir !== "neutral" ? "Macro seule" : "Signaux seuls"}
- - {/* ── SECTION 2 : Analyste G. Dellamotta ──────────────── */} - {ratePath.ilCurrent && ( -
-
- Analyste · G. Dellamotta - {ratePath.ilCurrent.articleDate} -
-
- {/* bps year-end analyste */} -
-
Fin d'an (IL)
-
- {ratePath.ilCurrent.isCut ? "" : "+"}{ratePath.ilCurrent.bpsYearEnd} - bps -
-
- {/* prob next meeting analyste */} -
-
Proch. réunion
-
- {ratePath.ilCurrent.isNoChange - ? {ratePath.ilCurrent.probPct.toFixed(0)}% Hold - : - {ratePath.ilCurrent.probPct.toFixed(0)}% {ratePath.ilCurrent.isCut ? "Cut" : "Hike"} - - } -
-
- {/* delta hebdo */} - {ratePath.ilDelta && (Math.abs(ratePath.ilDelta.probDelta) >= 3 || Math.abs(ratePath.ilDelta.bpsDelta) >= 5) && ( -
-
vs sem. préc.
-
- - -
-
- )} -
+ {bothOpposed && ( +
+ Fond. {dir === "bullish" ? "haussier" : "baissier"} · Marché {mispricDir === "bullish" ? "haussier" : "baissier"}
)}
- ); - })()} - - {/* Divergence / convergence signal — expandable */} - {divergenceSignal !== "neutral" && ( -
- - - {divergenceOpen && ( - -
- {/* Macro indicators */} -
-
- Fondamentaux macro - score {macroScore > 0 ? "+" : ""}{macroScore} -
-
- {macroContributors.map(c => ( -
-
- 0 ? "text-emerald-400" : c.sig < 0 ? "text-red-400" : "text-slate-600"}> - {c.sig > 0 ? "↑" : c.sig < 0 ? "↓" : "→"} - - {c.label} -
- 0 ? "text-emerald-400" : c.sig < 0 ? "text-red-400" : "text-slate-500"}`}> - {c.value} - -
- ))} -
-
- {/* Market signals */} - {mispricingSignals.length > 0 && ( -
-
- Signaux marché - - {bullCount}↑ {bearCount}↓ - -
-
- {mispricingSignals.map(s => ( -
-
- - {s.direction === "bullish" ? "↑" : s.direction === "bearish" ? "↓" : "→"} - - {s.label} -
- - {s.value} - -
- ))} -
-
- )} -
-
- )} -
+ {/* Marché / Signaux */} +
+ Marché + + {mispricDir === "bullish" ? "↑" : mispricDir === "bearish" ? "↓" : "—"} + + OIS/COT/Sent +
)} - {/* Politique Monétaire */} - - -
-
- - 10Y Yield -
-
- - {yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"} - - {spread10Y !== null && ( - 0 ? "text-emerald-500" : "text-red-500"}`}> - ({spread10Y > 0 ? "+" : ""}{spread10Y}bps vs US) - - )} -
-
- {curveSpread !== null && ( -
-
- - Courbe (10Y−CT) + {/* ── Slider macro : Mon. / Infl. / Cro. / Empl. ───────────── */} + {(() => { + const macroTabs: { id: "mon"|"infl"|"cro"|"empl"; label: string }[] = [ + { id: "mon", label: "Mon." }, + { id: "infl", label: "Infl." }, + { id: "cro", label: "Cro." }, + { id: "empl", label: "Empl." }, + ]; + return ( +
+ {/* Micro-buttons */} +
+ {macroTabs.map(t => ( + + ))}
- - {curveSpread > 0 ? "+" : ""}{curveSpread}bps{curveInverted ? " ⚠" : ""} - -
- )} - {/* STIR 3M — Taux interbancaire court terme */} - {inds?.stir3m?.value != null && (() => { - const stir = inds.stir3m!.value!; - const stirPrev = inds.stir3m!.prev; - const stirTrend = inds.stir3m!.trend; - // Spread STIR - Taux directeur - const stirSpread = policyRateValue !== null - ? parseFloat((stir - policyRateValue).toFixed(2)) : null; - // Signal : spread positif = marché price des hausses (hawkish) - const stirSig: SignalDir = - stirTrend === "up" ? "bearish" // hausse STIR = crédit plus cher/risqué - : stirTrend === "down" ? "bullish" // baisse STIR = crédit plus sûr/souple - : "neutral"; - const spreadSig: SignalDir = - stirSpread !== null && stirSpread > 0.1 ? "bullish" // STIR > CT = hawkish bias - : stirSpread !== null && stirSpread < -0.1 ? "bearish" // STIR < CT = dovish bias - : "neutral"; - return ( - <> -
-
- - STIR 3M -
-
- - {stir.toFixed(2)}% - - {stirTrend && ( - - {stirTrend === "up" ? "↑" : stirTrend === "down" ? "↓" : "→"} - {stirPrev !== null ? ` ${stirPrev.toFixed(2)}%` : ""} - + {/* Fixed-height sliding container */} +
+ + ({ x: d > 0 ? "100%" : "-100%", opacity: 0 }), + center: { x: "0%", opacity: 1 }, + exit: (d: number) => ({ x: d > 0 ? "-100%" : "100%", opacity: 0 }), + }} + initial="enter" + animate="center" + exit="exit" + transition={{ type: "tween", duration: 0.22, ease: "easeInOut" }} + className="absolute inset-0 overflow-hidden" + > + + {/* MON. — Politique Monétaire */} + {macroSlide === "mon" && ( + + + {(() => { + const cpiH = inds?.cpiYoY?.value ?? null; + if (policyRateValue === null || cpiH === null) return null; + const rr = parseFloat((policyRateValue - cpiH).toFixed(2)); + const rDir: SignalDir = rr < 0 ? "bearish" : rr > 1.5 ? "bullish" : "neutral"; + return ( +
+
Taux réel (CT−CPI)
+ {rr > 0 ? "+" : ""}{rr}% +
+ ); + })()} + {curveSpread !== null && ( +
+
Courbe (10Y−CT)
+ {curveSpread > 0 ? "+" : ""}{curveSpread}bps{curveInverted ? " ⚠" : ""} +
+ )} + {yield10Y !== null && ( +
+
10Y Yield
+
+ {yield10Y.toFixed(2)}% + {spread10Y !== null && 0 ? "text-emerald-500" : "text-red-500"}`}>({spread10Y > 0 ? "+" : ""}{spread10Y}bps)} +
+
+ )} +
)} -
-
- {stirSpread !== null && ( -
-
- - Spread STIR−CT -
- 0 ? "STIR > taux directeur → marché price des hausses" : stirSpread < 0 ? "STIR < taux directeur → marché price des baisses" : "STIR ≈ taux directeur"} - > - {stirSpread > 0 ? "+" : ""}{Math.round(stirSpread * 100)}bps - -
- )} - - ); - })()} - {(() => { - // Taux réel = Taux directeur − CPI YoY headline (= Inflation Rate YoY) - // On n'utilise PAS Core CPI comme fallback : Core < Headline → gonflerait le taux réel - const cpiHeadline = inds?.cpiYoY?.value ?? null; - if (policyRateValue === null || cpiHeadline === null) return null; - const realRate = parseFloat((policyRateValue - cpiHeadline).toFixed(2)); - const rDir: SignalDir = realRate < 0 ? "bearish" : realRate > 1.5 ? "bullish" : "neutral"; - return ( -
-
- - Taux réel (CT−CPI) -
- - {realRate > 0 ? "+" : ""}{realRate}% - + + {/* INFL. — Inflation */} + {macroSlide === "infl" && ( + + + + + + + )} + + {/* CRO. — Croissance */} + {macroSlide === "cro" && ( + + + + + + + + )} + + {/* EMPL. — Emploi */} + {macroSlide === "empl" && ( + + + + + )} + + +
- ); - })()} - - - {/* Inflation avec filtre MoM/YoY */} -
-
- Inflation -
- {(["all", "mom", "yoy"] as const).map((v) => ( - - ))}
-
- - {(inflFilter === "all" || inflFilter === "mom") && ( - <> - - - { - const isQoQ = (inds?.cpiCoreMoM as (Ind & { isQoQ?: boolean }) | null)?.isQoQ; - return isQoQ ? "Core CPI QoQ" : "Core CPI MoM"; - })()} - ind={inds?.cpiCoreMoM ?? null} - unit="%" consensus={fc?.cpiCoreMoM ?? null} - info="=Core Inflation Rate MoM" - tooltip={(() => { - const raw = (inds?.cpiCoreMoM as (Ind & { _raw?: { last: number; prev: number; refMonth: string } }) | null)?._raw; - return raw ? `Index: last=${raw.last} prev=${raw.prev} ref=${raw.refMonth}` : null; - })()} - /> - - )} - - {(inflFilter === "all" || inflFilter === "yoy") && ( - <> - { - const fd = (inds?.cpiCore as (Ind & { _finalForecast?: number; _finalDelta?: number }) | null); - const base = "=Core Inflation Rate YoY"; - if (fd?._finalForecast === undefined) return base; - const d = fd._finalDelta ?? 0; - return `${base} • Final prévu : ${fd._finalForecast?.toFixed(1)}% (${d > 0 ? "↑" : d < 0 ? "↓" : "="})`; - })()} - /> - { - const fd = (inds?.cpiYoY as (Ind & { _finalForecast?: number; _finalDelta?: number }) | null); - const base = "=Inflation Rate YoY"; - if (fd?._finalForecast === undefined) return base; - const d = fd._finalDelta ?? 0; - return `${base} • Final prévu : ${fd._finalForecast?.toFixed(1)}% (${d > 0 ? "↑" : d < 0 ? "↓" : "="})`; - })()} - /> - {inds?.commodityPricesYoY && ( - - )} - - )} -
- - {/* Croissance */} - - - - - - - {/* Emploi */} - - - - - - {/* PMI détail */} - - - - + ); + })()} )} {/* ════ SIGNAUX / MISPRICING ════════════════════════════════════ */} {activeTab === "mispricing" && ( <> - {/* Biais global */} -
-
-
Biais Signaux
-
- {mispricDir === "bullish" ? `${currency} Haussier` : mispricDir === "bearish" ? `${currency} Baissier` : "Signal Mixte"} -
-
-
-
Force moy.
-
{avgStr}
-
-
- - {/* Signal list */} -
- {mispricingSignals.length === 0 && ( -
Données insuffisantes pour calculer les signaux
- )} - {mispricingSignals.map((sig) => ( -
- + ))}
- - - - {expandedSig === sig.id && ( + )} +
0 ? "h-[460px]" : "h-[310px]"} overflow-hidden rounded-xl transition-all duration-300`}> + ({ x: d > 0 ? "100%" : "-100%", opacity: 0 }), + center: { x: "0%", opacity: 1 }, + exit: (d: number) => ({ x: d > 0 ? "-100%" : "100%", opacity: 0 }), + }} + initial="enter" + animate="center" + exit="exit" + transition={{ type: "tween", duration: 0.22, ease: "easeInOut" }} + className="absolute inset-0 overflow-hidden" > -
-

{sig.detail}

+ + {/* OIS — nouveau bloc enrichi */} + {signauxSlide === "ois" && ratePath && ratePath.meetings.length > 0 && ( + + )} + + {/* COT */} + {signauxSlide === "cot" && cot && (() => { + const hfIsShort = cot.net < 0; + const amDominates = Math.abs(cot.amNet) > Math.abs(cot.net); + const hfLongsGrowing = cot.longsDelta !== null && cot.longsDelta > 0; + const hfShortsReducing= cot.shortsDelta !== null && cot.shortsDelta < 0; + const hfShortsGrowing = cot.shortsDelta !== null && cot.shortsDelta > 0; + const hfLongsReducing = cot.longsDelta !== null && cot.longsDelta < 0; + + let hfTrend = ""; + if (hfIsShort) { + if (hfLongsGrowing && hfShortsReducing) hfTrend = "retournement en cours (L↑ S↓)"; + else if (hfLongsGrowing) hfTrend = "accumulation de longs (L↑)"; + else if (hfShortsReducing) hfTrend = "couverture de shorts (S↓)"; + else hfTrend = "exposition baissière stable"; + } else { + if (hfShortsGrowing && hfLongsReducing) hfTrend = "retournement baissier (S↑ L↓)"; + else if (hfShortsGrowing) hfTrend = "ajout de shorts (S↑)"; + else if (hfLongsReducing) hfTrend = "réduction de longs (L↓)"; + else hfTrend = "exposition haussière stable"; + } + + // Poids relatif AM vs HF (barre de dominance) + const totalAbs = Math.abs(cot.amNet) + Math.abs(cot.net); + const amPct = totalAbs > 0 ? Math.round(Math.abs(cot.amNet) / totalAbs * 100) : 50; + const hfPct = 100 - amPct; + const dFmt = (v: number) => `${v > 0 ? "+" : ""}${(v / 1000).toFixed(1)}k`; + + return ( +
+ {/* ① Header */} +
+
+ + COT CFTC · {cot.weekDate} + +
+ {cot.prevWeekDate ? `vs ${cot.prevWeekDate}` : ""} +
+ + {/* ⓘ Tooltip explicatif */} + {showCotInfo && ( +
+

AM (Asset Managers) — fonds pension, souverains, assurances. Prennent des positions pour couvrir des expositions réelles (hedging). Leur flux pilote la devise à moyen terme.

+

HF (Hedge Funds / CTAs) — spéculation directionnelle à court terme. Réactifs aux catalyseurs macro. Retournements rapides possibles.

+

Lire : Net = longs − shorts · Δ sem = variation vs semaine précédente · L% = part de longs dans le total. Un HF net short avec des longs qui augmentent (Δ L↑) signale un potentiel retournement haussier.

+
+ )} + + {/* ② Signal de synthèse — lecture immédiate en premier */} + {(() => { + const hfSignalColor = + (hfLongsGrowing && hfIsShort) || (hfShortsReducing && hfIsShort) + ? "text-emerald-400 border-emerald-500/20 bg-emerald-500/5" + : (hfShortsGrowing && !hfIsShort) || (hfLongsReducing && !hfIsShort) + ? "text-red-400 border-red-500/20 bg-red-500/5" + : "text-slate-300 border-slate-600/30 bg-slate-800/30"; + return ( +
+
+ → HF {hfIsShort ? "net short" : "net long"} — {hfTrend}
- - )} - +
+ {amDominates + ? `AM domine le flux · ${cot.amNet > 0 ? "hedge haussier" : "hedge baissier"} (${dFmt(cot.amNet)})` + : `HF domine le flux · net ${dFmt(cot.net)}` + } +
+
+ ); + })()} + + {/* ③ Flux hebdomadaire HF — ΔL/ΔS en priorité */} +
+
Mouvement HF · semaine
+
+
+
Δ Longs
+
0 ? "text-emerald-400" : "text-red-400" + }`}> + {cot.longsDelta !== null + ? `${cot.longsDelta > 0 ? "+" : ""}${(cot.longsDelta/1000).toFixed(1)}k${cot.longsDelta > 0 ? "↑" : "↓"}` + : "—" + } +
+
{(cot.hfLongs/1000).toFixed(1)}k acc.
+
+
+
+
Δ Shorts
+
0 ? "text-red-400" : "text-emerald-400" + }`}> + {cot.shortsDelta !== null + ? `${cot.shortsDelta > 0 ? "+" : ""}${(cot.shortsDelta/1000).toFixed(1)}k${cot.shortsDelta > 0 ? "↑" : "↓"}` + : "—" + } +
+
{(cot.hfShorts/1000).toFixed(1)}k acc.
+
+
+
+ + {/* ④ Dominance AM vs HF — poids + direction explicite */} +
+ {/* Barre de poids */} +
+
+
+
+ {/* AM */} +
+ AM · {amPct}% du flux + + 0 ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"}`}> + {cot.amNet > 0 ? "LONG" : "SHORT"} + + 0 ? "text-emerald-400" : "text-red-400"}`}> + {dFmt(cot.amNet)} + + {cot.amNetDelta !== null && ( + Δ{dFmt(cot.amNetDelta)}{cot.amNetDelta > 0 ? "↑" : "↓"} + )} + +
+ {/* HF */} +
+ HF · {hfPct}% du flux + + 0 ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"}`}> + {cot.net > 0 ? "LONG" : "SHORT"} + + 0 ? "text-emerald-400" : "text-red-400"}`}> + {dFmt(cot.net)} + + {cot.netDelta !== null && ( + Δ{dFmt(cot.netDelta)}{cot.netDelta > 0 ? "↑" : "↓"} + )} + +
+
- ))} -
+ ); + })()} + + {/* Sentiment */} + {signauxSlide === "sent" && sentiment && (() => { + const sentDir = sentiment.longPct < 30 ? "bullish" : sentiment.longPct > 70 ? "bearish" : "neutral"; + const sentCls = sentDir === "bullish" ? "text-emerald-400" : sentDir === "bearish" ? "text-red-400" : "text-slate-400"; + const sentBg = sentDir === "bullish" ? "border-emerald-500/20 bg-emerald-500/5" : sentDir === "bearish" ? "border-red-500/20 bg-red-500/5" : "border-slate-700/40 bg-slate-800/20"; + return ( +
+
+ + Sentiment Retail (DXM) · {sentiment.pair} +
+
+
+
Long
+
{sentiment.longPct.toFixed(0)}%
+
+
+
+
+
+
+
LongShort
+
+
+
Short
+
{sentiment.shortPct.toFixed(0)}%
+
+
+
+
+ {sentDir === "bullish" + ? `Signal contrarian HAUSSIER — majorité short (${sentiment.shortPct.toFixed(0)}%)` + : sentDir === "bearish" + ? `Signal contrarian BAISSIER — majorité long (${sentiment.longPct.toFixed(0)}%)` + : "Sentiment équilibré — pas de signal contrarian"} +
+
+ {sentDir === "bullish" ? "Retail survendu → opportunité d'achat" + : sentDir === "bearish" ? "Retail suracheté → opportunité de vente" + : "Attendre un déséquilibre >70/30"} +
+
+
+ ); + })()} + + + +
+
+ ); + })()} )} @@ -1217,23 +1719,7 @@ export default function CurrencyCard({ } - {/* Autres indicateurs (référence) */} - - {(phase === "easing" || phase === "dovish_pause") - ? <> - - - - - : <> - - - - - } - - - {/* Taux directeur */} +{/* Taux directeur */} {yield10Y !== null && ( diff --git a/components/DriversBar.tsx b/components/DriversBar.tsx index b731552..ac12d74 100644 --- a/components/DriversBar.tsx +++ b/components/DriversBar.tsx @@ -95,18 +95,16 @@ export default function DriversBar({ drivers }: Props) { const { vix, vixDelta, sp500, sp500ChangePct, - btc, btcChange24h, + btc, btcDeltaPct, btcChange24h, hySpread, igSpread, us10y, us2y, curveSlope, - gold, goldDelta, - silver, silverDelta, - brent, brentDelta, - wti, wtiDelta, + gold, goldDeltaPct, + silver, silverDeltaPct, + brent, brentDelta, brentDeltaPct, + wti, wtiDelta, wtiDeltaPct, } = drivers; - const dxy = (drivers as DriverData & { dxy?: number | null }).dxy ?? null; - const dxyDelta = (drivers as DriverData & { dxyDelta?: number | null }).dxyDelta ?? null; - const riskOff = (vix ?? 0) > 25 || (hySpread ?? 0) > 500; + const riskOff = (vix ?? 0) > 25 || (hySpread ?? 0) > 500; return (
@@ -133,9 +131,10 @@ export default function DriversBar({ drivers }: Props) { accent={(vix ?? 0) > 25 ? "red" : undefined} tooltip="Clôture actuelle − clôture précédente (Yahoo Finance)." /> - + tooltip="% vs clôture J-1 (Business Insider, cache 1 min). Fallback : Yahoo Finance." /> + {/* ── Crédit ────────────────────────────────────────────── */}
@@ -146,11 +145,9 @@ export default function DriversBar({ drivers }: Props) { - {/* ── FX / Taux ─────────────────────────────────────────── */} + {/* ── Taux ──────────────────────────────────────────────── */}
- - + - - - - + + + 0 ? "+" : ""}${brentDelta.toFixed(2)} $` : ""}`} /> + 0 ? "+" : ""}${wtiDelta.toFixed(2)} $` : ""}`} />
diff --git a/components/NewsTab.tsx b/components/NewsTab.tsx index 6b9b17b..6e04d68 100644 --- a/components/NewsTab.tsx +++ b/components/NewsTab.tsx @@ -93,8 +93,12 @@ export default function NewsTab({ items, loading, onRefresh }: Props) { if (priorityOnly && !isPriorityItem(item)) return false; if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false; if (filterCat !== "ALL" && !item.categories.includes(filterCat)) return false; - if (filterDir !== "all" && filterCcy !== "ALL") { - if (!item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir)) return false; + if (filterDir !== "all") { + if (filterCcy !== "ALL") { + if (!item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir)) return false; + } else { + if (!item.impacts.some(i => i.direction === filterDir)) return false; + } } return true; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -251,8 +255,8 @@ export default function NewsTab({ items, loading, onRefresh }: Props) { {filterCcy !== "ALL" && ` · ${CCY_FLAGS[filterCcy]} ${filterCcy}`} {filterCat !== "ALL" && ` · ${filterCat}`} - {(filterCcy !== "ALL" || filterCat !== "ALL" || filterDir !== "all") && ( - diff --git a/components/YieldsTab.tsx b/components/YieldsTab.tsx index 1e7a9fd..5352880 100644 --- a/components/YieldsTab.tsx +++ b/components/YieldsTab.tsx @@ -15,6 +15,7 @@ interface YieldsData { interface Props { yieldsData: YieldsData | null; + fxDayPct?: Record | null; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -54,7 +55,7 @@ function carryLabel(bps: number): { label: string; color: string } { // ── Component ───────────────────────────────────────────────────────────────── -export default function YieldsTab({ yieldsData }: Props) { +export default function YieldsTab({ yieldsData, fxDayPct }: Props) { if (!yieldsData) { return (
@@ -99,21 +100,35 @@ export default function YieldsTab({ yieldsData }: Props) { const topCarry = carryPairs.slice(0, 6); const tightPairs = [...carryPairs].sort((a, b) => a.bps - b.bps).slice(0, 4); - // Divergences : devises dont la direction journalière est opposée à la majorité + // Divergences yield/FX : yield et devise vont dans des directions opposées + // Yield ↑ + devise ↓ → obligations vendues MAIS devise sous pression = signal de stress + // Yield ↓ + devise ↑ → obligations achetées MAIS devise forte = safe-haven ou anomalie + // Seuils : yield ≥ 2bp (0.02%) ET FX ≥ 0.1% pour filtrer le bruit const divergences = useMemo(() => { - const deltas = sorted.map(([ccy]) => ({ ccy, d: dayDeltas[ccy] ?? 0 })); - const rising = deltas.filter(x => x.d > 0.001).length; - const falling = deltas.filter(x => x.d < -0.001).length; - const majority = rising >= falling ? "up" : "down"; + const YIELD_THRESHOLD = 0.02; // 2 basis points minimum + const FX_THRESHOLD = 0.1; // 0.1% FX move minimum + const result: { ccy: string; yieldDelta: number; fxPct: number; type: "stress" | "safehaven" }[] = []; - return deltas - .filter(x => { - if (majority === "up" && x.d < -0.001) return true; - if (majority === "down" && x.d > 0.001) return true; - return false; - }) - .sort((a, b) => Math.abs(b.d) - Math.abs(a.d)); - }, [sorted, dayDeltas]); + for (const [ccy] of sorted) { + const yd = dayDeltas[ccy] ?? null; + const fx = fxDayPct?.[ccy] ?? null; + if (yd === null || fx === null) continue; + if (Math.abs(yd) < YIELD_THRESHOLD || Math.abs(fx) < FX_THRESHOLD) continue; + + const yieldUp = yd > 0; + const fxUp = fx > 0; + + if (yieldUp && !fxUp) { + // Yield monte, devise baisse → signal de stress / pression vendeuse sur dettes + result.push({ ccy, yieldDelta: yd, fxPct: fx, type: "stress" }); + } else if (!yieldUp && fxUp) { + // Yield baisse, devise monte → safe-haven ou anomalie (ex: JPY, CHF) + result.push({ ccy, yieldDelta: yd, fxPct: fx, type: "safehaven" }); + } + } + + return result.sort((a, b) => Math.abs(b.yieldDelta) + Math.abs(b.fxPct) - (Math.abs(a.yieldDelta) + Math.abs(a.fxPct))); + }, [sorted, dayDeltas, fxDayPct]); // Movers du jour (triés par amplitude de variation) const movers = useMemo(() => { @@ -276,42 +291,58 @@ export default function YieldsTab({ yieldsData }: Props) { {/* ── Divergences + Movers du jour ────────────────────────────────────── */}
- {/* Divergences */} + {/* Divergences yield/FX */}
-

- ⚡ Divergences du jour +

+ ⚡ Divergences Yield / FX

- {divergences.length === 0 ? ( +

+ Yield et devise vont dans des sens opposés — signal d'anomalie ou de stress +

+ {!fxDayPct ? ( +

Données FX non disponibles.

+ ) : divergences.length === 0 ? (

- Aucune divergence notable — tous les yields bougent dans la même direction. + Aucune divergence — yield et devise évoluent de façon cohérente ce jour.

) : ( <> -
- {divergences.map(({ ccy, d }) => ( -
-
+
+ {divergences.map(({ ccy, yieldDelta, fxPct, type }) => ( +
+
{CCY_FLAGS[ccy]} - {ccy} - {CCY_COUNTRY[ccy]} + {ccy}
-
- {deltaIcon(d)} - {d >= 0 ? "+" : ""}{d.toFixed(3)}% - 0 - ? "bg-green-50 text-green-700 border-green-200" - : "bg-red-50 text-red-700 border-red-200" +
+ {/* Yield delta */} + + {deltaIcon(yieldDelta)} + yield + {yieldDelta >= 0 ? "+" : ""}{yieldDelta.toFixed(3)}% + + vs + {/* FX delta */} + + {deltaIcon(fxPct)} + FX + {fxPct >= 0 ? "+" : ""}{fxPct.toFixed(2)}% + + {/* Badge type */} + - {d > 0 ? "↑ contre tendance" : "↓ contre tendance"} + {type === "stress" ? "⚠ stress dette" : "🛡 safe-haven"}
))}

- Ces devises bougent à contre-courant des autres ce jour. - Signal potentiel de réévaluation ou d'actualité macro spécifique. + Stress dette : yield ↑ + devise ↓ (obligations vendues, devise sous pression).
+ Safe-haven : yield ↓ + devise ↑ (capitaux attirés malgré taux bas).

)} diff --git a/lib/newsfeed.ts b/lib/newsfeed.ts index 646f3f0..2f58184 100644 --- a/lib/newsfeed.ts +++ b/lib/newsfeed.ts @@ -845,7 +845,15 @@ export async function fetchAllNews(): Promise { return true; }); - deduped.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); + const PRIORITY_SET = new Set(["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État", "Probabilités Taux"]); + const isPrio = (item: NewsItem) => item.categories.some(c => PRIORITY_SET.has(c)); + + deduped.sort((a, b) => { + const pa = isPrio(a) ? 1 : 0; + const pb = isPrio(b) ? 1 : 0; + if (pb !== pa) return pb - pa; + return b.publishedAt.localeCompare(a.publishedAt); + }); return deduped; } diff --git a/lib/tecpi.ts b/lib/tecpi.ts index 6597dbd..2edad7b 100644 --- a/lib/tecpi.ts +++ b/lib/tecpi.ts @@ -697,3 +697,80 @@ export async function fetchTEGDPGrowthRate(): Promise { return {}; } } + +// ── Employment Change — pages individuelles TradingEconomics ───────────────── +// USD : /united-states/non-farm-payrolls → en milliers, MoM +// GBP : /united-kingdom/employment-change → en milliers, MoM (3 mois glissants) +// AUD : /australia/employment-change → en personnes → /1000 +// EUR : /euro-area/employment-change → QoQ% + +export interface TEEmploymentEntry { + value: number; // k pour USD/GBP/AUD, QoQ% pour EUR + prev: number | null; +} + +const TE_EMP_CONFIG: Partial> = { + USD: { slug: "united-states/non-farm-payrolls", unitKw: "thousand", divisor: 1, precision: 0 }, + GBP: { slug: "united-kingdom/employment-change", unitKw: "thousand", divisor: 1, precision: 0 }, + AUD: { slug: "australia/employment-change", unitKw: "person", divisor: 1000, precision: 1 }, + EUR: { slug: "euro-area/employment-change", unitKw: "percent", divisor: 1, precision: 2 }, +}; + +export async function fetchTEEmploymentChange(): Promise>> { + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + }; + + const entries = await Promise.all( + (Object.entries(TE_EMP_CONFIG) as [Currency, NonNullable][]) + .map(async ([ccy, { slug, unitKw, divisor, precision }]) => { + try { + const res = await fetch(`https://tradingeconomics.com/${slug}`, { + next: { revalidate: 21600 }, + headers, + }); + if (!res.ok) return null; + const html = await res.text(); + + // Current value from meta description + 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); + if (!incrM && !decrM) return null; + + const sign = incrM ? 1 : -1; + const absVal = parseFloat((incrM?.[1] ?? decrM![1]).replace(/,/g, "")); + const curRaw = sign * absVal; + + // Find previous from table: groups of (value, prev, unit-label) + const tdVals: string[] = []; + const tdRe = /]*>([^<]+)<\/td>/g; + let m: RegExpExecArray | null; + while ((m = tdRe.exec(html)) !== null) tdVals.push(m[1].trim()); + + let prevRaw: number | null = null; + for (let i = 0; i < tdVals.length - 2; i++) { + const a = parseFloat(tdVals[i].replace(/,/g, "")); + const b = parseFloat(tdVals[i + 1].replace(/,/g, "")); + const c = tdVals[i + 2].toLowerCase(); + if (isNaN(a) || isNaN(b) || !c.includes(unitKw)) continue; + const tol = Math.max(1, Math.abs(curRaw) * 0.02); + if (Math.abs(a - curRaw) <= tol) { prevRaw = b; break; } + } + + return [ccy, { + value: parseFloat((curRaw / divisor).toFixed(precision)), + prev: prevRaw !== null ? parseFloat((prevRaw / divisor).toFixed(precision)) : null, + }] as [Currency, TEEmploymentEntry]; + } catch { return null; } + }) + ); + + const result: Partial> = {}; + for (const e of entries) if (e) result[e[0]] = e[1]; + return result; +} diff --git a/lib/types.ts b/lib/types.ts index ea2aaab..82c38cf 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -110,7 +110,8 @@ export interface DriverData { sp500Change: number | null; // pts vs clôture j-1 sp500ChangePct: number | null; // % vs clôture j-1 btc: number | null; // BTC/USD - btcChange24h: number | null; // % variation 24h (CoinGecko) + btcChange24h: number | null; // % variation 24h (legacy) + btcDeltaPct: number | null; // % vs clôture J-1 (Business Insider) // Crédit hySpread: number | null; igSpread: number | null; @@ -121,14 +122,18 @@ export interface DriverData { us2y: number | null; curveSlope: number | null; // Commodités (avec delta vs session précédente) - gold: number | null; - goldDelta: number | null; - silver: number | null; - silverDelta: number | null; - brent: number | null; - brentDelta: number | null; + gold: number | null; + goldDelta: number | null; + goldDeltaPct: number | null; + silver: number | null; + silverDelta: number | null; + silverDeltaPct: number | null; + brent: number | null; + brentDelta: number | null; + brentDeltaPct: number | null; wti: number | null; wtiDelta: number | null; + wtiDeltaPct: number | null; // Compat copper: number | null; } @@ -144,10 +149,28 @@ export interface SentimentEntry { pair: string; } +export type MacroSection = "all" | "inflation" | "pmi" | "employment" | "gdp" | "policy"; + export interface CotEntry { - net: number; - longPct: number; - shortPct: number; - totalLev: number; - weekDate: string; + // HF — Leveraged Money (spéculation directionnelle, hedge funds / CTAs) + net: number; // longs - shorts + hfLongs: number; // contrats long bruts + hfShorts: number; // contrats short bruts + longPct: number; // % longs / total HF + shortPct: number; // % shorts / total HF + totalLev: number; // total contrats HF + // AM — Asset Manager (hedging institutionnel, fonds pension / souverains) + amNet: number; + amLongs: number; + amShorts: number; + amLongPct: number; // % longs / total AM + amTotal: number; + // Δ semaine précédente (null si pas de données J-7) + netDelta: number | null; // Δ net HF + longsDelta: number | null; // Δ longs HF (+= ajout de longs) + shortsDelta: number | null; // Δ shorts HF (+= ajout de shorts) + amNetDelta: number | null; // Δ net AM + // Métadonnées + weekDate: string; + prevWeekDate: string | null; }