diff --git a/app/api/cot/route.ts b/app/api/cot/route.ts index 97d5c86..fd722b6 100644 --- a/app/api/cot/route.ts +++ b/app/api/cot/route.ts @@ -1,89 +1,122 @@ import { NextResponse } from "next/server"; import { COT_CODES } from "@/lib/constants"; -import type { Currency } from "@/lib/types"; +import type { Currency, CotEntry } from "@/lib/types"; -// CFTC CSV URL — updated weekly on Fridays -const CFTC_URL = - "https://www.cftc.gov/files/dea/history/fut_fin_txt_2024.zip"; -// Current year CSV (plain text, no zip) -const CFTC_CURRENT = - "https://www.cftc.gov/sites/default/files/files/dea/cotarchives/2024/futures/FinFutWk062824.txt"; +// ── CFTC Traders in Financial Futures (TFF) — format legacy CSV sans header ── +// URL : https://www.cftc.gov/dea/newcot/FinFutWk.txt (mis à jour chaque vendredi) +// +// 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) +// 15 Lev_Money_Positions_Short_All +// 16 Lev_Money_Positions_Spreading_All +// ... -// In-memory cache (server lifetime) -let cotCache: { data: Record; ts: number } | null = null; -const TTL = 7 * 24 * 3600_000; // 1 week +const CFTC_URL = "https://www.cftc.gov/dea/newcot/FinFutWk.txt"; +const IDX_CODE = 3; +const IDX_LEV_LONG = 14; +const IDX_LEV_SHORT = 15; +const IDX_DATE = 2; + +// In-memory cache (1 semaine) +let _cache: { data: Record; ts: number } | null = null; +const TTL = 7 * 24 * 3600_000; + +export type { CotEntry } from "@/lib/types"; export async function GET() { - if (cotCache && Date.now() - cotCache.ts < TTL) { - return NextResponse.json(cotCache.data); + if (_cache && Date.now() - _cache.ts < TTL) { + return NextResponse.json(_cache.data); } try { - // Fetch latest COT "Disaggregated" or "Financial" futures CSV - // The public URL pattern for the most recent weekly file: - const now = new Date(); - const year = now.getFullYear(); - const csvUrl = `https://www.cftc.gov/files/dea/history/fut_fin_txt_${year}.zip`; - - // Simpler approach: use the non-compressed annual file (available for current year) - const res = await fetch( - `https://www.cftc.gov/dea/newcot/FinFutWk.txt`, - { next: { revalidate: 86400 * 7 } } - ); - + const res = await fetch(CFTC_URL, { + next: { revalidate: 86400 * 7 }, + headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" }, + }); if (!res.ok) { - return NextResponse.json( - { error: `CFTC fetch failed: ${res.status}`, note: "COT data may be unavailable temporarily." }, - { status: 502 } - ); + return NextResponse.json({ error: `CFTC fetch failed: ${res.status}` }, { status: 502 }); } - const text = await res.text(); + const text = await res.text(); const result = parseCOT(text); - cotCache = { data: result, ts: Date.now() }; + + _cache = { data: result, ts: Date.now() }; return NextResponse.json(result); } catch (err) { return NextResponse.json({ error: String(err) }, { status: 502 }); } } -function parseCOT(csv: string): Record { - const lines = csv.split("\n"); - if (lines.length < 2) return {}; - - const header = lines[0].split(",").map((h) => h.replace(/"/g, "").trim()); - const result: Record = {}; - +function parseCOT(csv: string): Record { + const lines = csv.split("\n"); const targetCodes = new Set(Object.values(COT_CODES)); + const result: Record = {}; - for (let i = 1; i < lines.length; i++) { - const row = lines[i].split(",").map((v) => v.replace(/"/g, "").trim()); - if (row.length < 10) continue; + for (const line of lines) { + if (!line.trim()) continue; - const codeIdx = header.indexOf("CFTC_Contract_Market_Code"); - const longIdx = header.indexOf("NonComm_Positions_Long_All"); - const shortIdx = header.indexOf("NonComm_Positions_Short_All"); + // Split respectant les guillemets + const cols = splitCsvLine(line); + if (cols.length < 16) continue; - if (codeIdx < 0 || longIdx < 0 || shortIdx < 0) continue; - const code = row[codeIdx]; - if (!targetCodes.has(code)) continue; + const code = cols[IDX_CODE]?.trim(); + if (!code || !targetCodes.has(code)) continue; - const longs = parseInt(row[longIdx] ?? "0", 10); - const shorts = parseInt(row[shortIdx] ?? "0", 10); - const total = longs + shorts; - const net = longs - shorts; + 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 currency = (Object.entries(COT_CODES) as [Currency, string][]).find( - ([, c]) => c === code - )?.[0]; + const total = longs + shorts; + const net = longs - shorts; + const weekDate = cols[IDX_DATE]?.trim() ?? ""; + + const currency = (Object.entries(COT_CODES) as [Currency, string][]) + .find(([, c]) => c === code)?.[0]; if (!currency) continue; result[currency] = { net, - longPct: total > 0 ? Math.round((longs / total) * 100) : 50, + longPct: total > 0 ? Math.round((longs / total) * 100) : 50, shortPct: total > 0 ? Math.round((shorts / total) * 100) : 50, + totalLev: total, + weekDate, }; } 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; + } + } + result.push(current); + return result; +} diff --git a/app/api/fx/route.ts b/app/api/fx/route.ts index b836d11..89a53fb 100644 --- a/app/api/fx/route.ts +++ b/app/api/fx/route.ts @@ -1,65 +1,125 @@ import { NextResponse } from "next/server"; -// AV primary (real-time) → Frankfurter fallback (ECB daily) -// AV free plan: 25 req/day, 5 req/min -// With revalidate:86400, server fetches each URL at most once per day → 7 calls/day total -const CURRENCIES = ["EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]; +const CURRENCIES = ["EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "SEK"]; const AV_BASE = "https://www.alphavantage.co/query"; -export async function GET() { - const avKey = process.env.ALPHA_VANTAGE_KEY; - if (avKey) { - const result = await fetchAV(avKey); - if (result) return NextResponse.json(result); - } - return fetchFrankfurter(); +// ICE DXY official weights (USD as base, weights sum to 1) +// EUR and GBP are quote currencies in their conventional pairs (EURUSD, GBPUSD) +// so their rates from Frankfurter/AV (USD→CCY) are already the inverse → positive exponents +const DXY_WEIGHTS = { + EUR: 0.576, + JPY: 0.136, + GBP: 0.119, + CAD: 0.091, + SEK: 0.042, + CHF: 0.036, +}; + +function computeDxy(rates: Record): number | null { + const required = ["EUR", "GBP", "JPY", "CAD", "CHF", "SEK"]; + if (required.some((ccy) => rates[ccy] == null || Number.isNaN(rates[ccy]))) return null; + const { EUR, GBP, JPY, CAD, CHF, SEK } = rates; + return parseFloat( + (50.14348112 * + Math.pow(EUR, DXY_WEIGHTS.EUR) * + Math.pow(JPY, DXY_WEIGHTS.JPY) * + Math.pow(GBP, DXY_WEIGHTS.GBP) * + Math.pow(CAD, DXY_WEIGHTS.CAD) * + Math.pow(SEK, DXY_WEIGHTS.SEK) * + Math.pow(CHF, DXY_WEIGHTS.CHF) + ).toFixed(2) + ); } -async function fetchAV(apiKey: string) { - // Sequential (not parallel) to respect AV's 5 req/min limit +// ── Yahoo Finance — DX=F (ICE Dollar Index Futures, temps réel) ────────────── +async function fetchYahooDXY(): Promise<{ value: number | null; delta: number | null }> { + try { + const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent("DX=F")}?interval=1d&range=2d`; + const res = await fetch(url, { + next: { revalidate: 300 }, + headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" }, + }); + if (!res.ok) return { value: null, delta: null }; + const data = await res.json(); + const meta = data?.chart?.result?.[0]?.meta as { + regularMarketPrice?: number; + chartPreviousClose?: number; + regularMarketPreviousClose?: number; + previousClose?: number; + } | undefined; + const current = meta?.regularMarketPrice ?? null; + const prevClose = meta?.chartPreviousClose + ?? meta?.regularMarketPreviousClose + ?? meta?.previousClose + ?? null; + if (current == null) return { value: null, delta: null }; + const delta = prevClose != null ? parseFloat((current - prevClose).toFixed(2)) : null; + return { value: parseFloat(current.toFixed(2)), delta }; + } catch { return { value: null, delta: null }; } +} + +// ── Alpha Vantage — taux FX (cache 5 min) ──────────────────────────────────── +async function fetchAVRates(apiKey: string): Promise | null> { const rates: Record = {}; for (const ccy of CURRENCIES) { try { const url = `${AV_BASE}?function=CURRENCY_EXCHANGE_RATE&from_currency=USD&to_currency=${ccy}&apikey=${apiKey}`; - const res = await fetch(url, { next: { revalidate: 86400 } }); // 24h server cache + const res = await fetch(url, { next: { revalidate: 300 } }); if (!res.ok) continue; const json = await res.json(); const rate = json?.["Realtime Currency Exchange Rate"]?.["5. Exchange Rate"]; if (rate) rates[ccy] = parseFloat(rate); - } catch { /* skip on error */ } + } catch { /* skip */ } } - if (Object.keys(rates).length < 4) return null; // too many failures → fall back - // DXY = 50.14 × EURUSD^-0.576 × USDJPY^0.136 × GBPUSD^-0.119 × USDCAD^0.091 × USDCHF^0.036 - // AV from_currency=USD → e=USD/EUR, j=JPY/USD, g=USD/GBP, c=CAD/USD, ch=CHF/USD - // ⟹ EURUSD=1/e → e^0.576 ; USDJPY=j → j^0.136 ; GBPUSD=1/g → g^0.119 - // USDCAD=c → c^0.091 ; USDCHF=ch → ch^0.036 - const e = rates.EUR, g = rates.GBP, j = rates.JPY, c = rates.CAD, ch = rates.CHF; - const dxy = (e && g && j && c && ch) - ? parseFloat((50.14348112 * Math.pow(e,0.576) * Math.pow(j,0.136) * Math.pow(g,0.119) * Math.pow(c,0.091) * Math.pow(ch,0.036)).toFixed(2)) - : null; - return { rates, dxy, base: "USD", source: "alphavantage", timestamp: Date.now() }; + return Object.keys(rates).length >= 4 ? rates : null; } -async function fetchFrankfurter() { +// ── Frankfurter (ECB daily fixing) — fallback ───────────────────────────────── +async function fetchFrankfurterRates(): Promise<{ rates: Record; date: string } | null> { try { - const res = await fetch("https://api.frankfurter.app/latest?from=USD", { next: { revalidate: 86400 } }); - if (!res.ok) throw new Error(`Frankfurter ${res.status}`); - const data = await res.json(); - const rates = data.rates as Record; - - // DXY approximé depuis les taux ECB (même formule que la branche AV) - // rates.X = "1 USD = X unités" — même convention que AV - const e = rates.EUR, g = rates.GBP, j = rates.JPY, c = rates.CAD, ch = rates.CHF; - // DXY = 50.14 × EURUSD^-0.576 × USDJPY^0.136 × GBPUSD^-0.119 × USDCAD^0.091 × USDCHF^0.036 - // Frankfurter from=USD → e=USD/EUR, j=JPY/USD, g=USD/GBP, c=CAD/USD, ch=CHF/USD - // ⟹ EURUSD=1/e → e^0.576 ; USDJPY=j → j^0.136 ; GBPUSD=1/g → g^0.119 - // USDCAD=c → c^0.091 ; USDCHF=ch → ch^0.036 - const dxy = (e && g && j && c && ch) - ? parseFloat((50.14348112 * Math.pow(e,0.576) * Math.pow(j,0.136) * Math.pow(g,0.119) * Math.pow(c,0.091) * Math.pow(ch,0.036)).toFixed(2)) - : null; - - return NextResponse.json({ rates, dxy, base: "USD", source: "frankfurter", date: data.date }); - } catch (err) { - return NextResponse.json({ error: String(err) }, { status: 502 }); - } + const res = await fetch("https://api.frankfurter.app/latest?from=USD", { next: { revalidate: 300 } }); + if (!res.ok) return null; + const data = await res.json(); + return { rates: data.rates as Record, date: data.date as string }; + } catch { return null; } +} + +// ── GET ─────────────────────────────────────────────────────────────────────── +export async function GET() { + // Fetch Yahoo DXY in parallel with AV rates setup + const [yahooDxy] = await Promise.all([fetchYahooDXY()]); + + const avKey = process.env.ALPHA_VANTAGE_KEY; + let rates: Record = {}; + let source = "none"; + let date: string | undefined; + + if (avKey) { + const avRates = await fetchAVRates(avKey); + if (avRates) { rates = avRates; source = "alphavantage"; } + } + if (Object.keys(rates).length < 4) { + const ff = await fetchFrankfurterRates(); + if (ff) { rates = ff.rates; source = "frankfurter"; date = ff.date; } + } + + // DXY : source directe Yahoo Finance (futures DX=F), proxy calculé en fallback + const dxy = yahooDxy.value ?? computeDxy(rates); + const dxyDelta = yahooDxy.delta ?? null; + + if (dxy === null) { + return NextResponse.json({ error: "Unable to compute DXY — données FX insuffisantes" }, { status: 502 }); + } + + return NextResponse.json({ + rates, + dxy, + dxyDelta, + dxySource: yahooDxy.value != null ? "Yahoo Finance DX=F" : "ICE proxy calculé", + basket: ["EUR", "JPY", "GBP", "CAD", "SEK", "CHF"], + base: "USD", + source, + ...(date && { date }), + timestamp: Date.now(), + }); } diff --git a/app/api/rate-probabilities/route.ts b/app/api/rate-probabilities/route.ts new file mode 100644 index 0000000..583d540 --- /dev/null +++ b/app/api/rate-probabilities/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { fetchAllCBPaths } from "@/lib/rateprobability"; +import type { RateProbData } from "@/lib/rateprobability"; + +export type { RateProbData, CBRatePath, RateProbMeeting } from "@/lib/rateprobability"; + +export interface RateProbabilitiesResponse { + data: RateProbData; + fetchedAt: string; +} + +export async function GET() { + const data = await fetchAllCBPaths(); + return NextResponse.json( + { data, fetchedAt: new Date().toISOString() } satisfies RateProbabilitiesResponse, + { headers: { "Cache-Control": "s-maxage=3600, stale-while-revalidate=7200" } } + ); +} diff --git a/app/api/sentiment/route.ts b/app/api/sentiment/route.ts index 0de17d7..951c511 100644 --- a/app/api/sentiment/route.ts +++ b/app/api/sentiment/route.ts @@ -1,66 +1,146 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; -// OANDA v20 API — position book (% long/short by pair) -const OANDA_BASE = "https://api-fxtrade.oanda.com/v3"; +// ── Myfxbook Community Outlook API ──────────────────────────────────────────── +// Source : https://www.myfxbook.com/community/outlook +// Auth : login.json → session token → get-community-outlook.json +// Session TTL : ~24h ; on la garde en mémoire le temps du process server. -const MAJOR_PAIRS = [ - "EUR_USD", "GBP_USD", "USD_JPY", "USD_CHF", - "USD_CAD", "AUD_USD", "NZD_USD", - "EUR_GBP", "EUR_JPY", "GBP_JPY", - "AUD_JPY", "CAD_JPY", "NZD_JPY", -]; +const MYFXBOOK_BASE = "https://www.myfxbook.com/api"; -export async function GET(req: NextRequest) { - const { searchParams } = new URL(req.url); - const pair = searchParams.get("pair"); +// Server-side session cache +let _session: string | null = null; +let _sessionTs = 0; +const SESSION_TTL = 20 * 3600_000; // 20h - const apiKey = process.env.OANDA_API_KEY; - if (!apiKey) { +// Data cache (1h) +let _cache: { data: MyfxbookSentiment; ts: number } | null = null; +const DATA_TTL = 3600_000; + +interface MyfxbookSymbol { + name: string; // "EURUSD" + longPercentage: number; + shortPercentage: number; + longVolume: number; + shortVolume: number; + longPositions: number; + shortPositions: number; + totalPositions: number; +} + +interface MyfxbookSentiment { + symbols: MyfxbookSymbol[]; + source: "myfxbook"; + timestamp: number; +} + +// ── Map pair → base currency (long = haussier base) ───────────────────────── +// Pour les paires USD/* on inverse (short = haussier base non-USD) +const PAIR_TO_CCY: Record = { + EURUSD: { ccy: "EUR", inverse: false }, + GBPUSD: { ccy: "GBP", inverse: false }, + USDJPY: { ccy: "JPY", inverse: true }, + USDCHF: { ccy: "CHF", inverse: true }, + USDCAD: { ccy: "CAD", inverse: true }, + AUDUSD: { ccy: "AUD", inverse: false }, + NZDUSD: { ccy: "NZD", inverse: false }, + XAUUSD: { ccy: "XAU", inverse: false }, +}; + +// ── Login ───────────────────────────────────────────────────────────────────── + +async function login(): Promise { + const email = process.env.MYFXBOOK_EMAIL; + const password = process.env.MYFXBOOK_PASSWORD; + if (!email || !password) return null; + + try { + const url = `${MYFXBOOK_BASE}/login.json?email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return null; + const data = await res.json(); + if (data.error) { + console.error("[sentiment] Myfxbook login error:", data.message); + return null; + } + _session = data.session; + _sessionTs = Date.now(); + return data.session; + } catch (e) { + console.error("[sentiment] Myfxbook login exception:", e); + return null; + } +} + +async function getSession(): Promise { + if (_session && Date.now() - _sessionTs < SESSION_TTL) return _session; + return login(); +} + +// ── Fetch community outlook ─────────────────────────────────────────────────── + +async function fetchOutlook(session: string): Promise { + try { + const url = `${MYFXBOOK_BASE}/get-community-outlook.json?session=${session}`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return null; + const data = await res.json(); + if (data.error) { + // Session expired → force re-login next time + if (data.message?.toLowerCase().includes("session")) _session = null; + return null; + } + return (data.symbols ?? []) as MyfxbookSymbol[]; + } catch { + return null; + } +} + +// ── GET ─────────────────────────────────────────────────────────────────────── + +export async function GET() { + // Return cached data if fresh + if (_cache && Date.now() - _cache.ts < DATA_TTL) { + return NextResponse.json(_cache.data); + } + + const session = await getSession(); + if (!session) { return NextResponse.json( - { error: "OANDA_API_KEY not configured. Add it to .env.local." }, + { error: "MYFXBOOK_EMAIL / MYFXBOOK_PASSWORD manquants dans .env.local — créez un compte gratuit sur myfxbook.com" }, { status: 503 } ); } - const pairsToFetch = pair ? [pair] : MAJOR_PAIRS; - const results: Record = {}; + let symbols = await fetchOutlook(session); - await Promise.allSettled( - pairsToFetch.map(async (p) => { - try { - const res = await fetch( - `${OANDA_BASE}/instruments/${p}/positionBook?time=current`, - { - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - next: { revalidate: 3600 }, - } - ); - if (!res.ok) return; - const data = await res.json(); - const buckets: { price: string; longCountPercent: string; shortCountPercent: string }[] = - data?.positionBook?.buckets ?? []; + // Session expired → try once more with fresh login + if (!symbols) { + _session = null; + const fresh = await login(); + if (fresh) symbols = await fetchOutlook(fresh); + } - let totalLong = 0; - let totalShort = 0; - for (const b of buckets) { - totalLong += parseFloat(b.longCountPercent ?? "0"); - totalShort += parseFloat(b.shortCountPercent ?? "0"); - } - const total = totalLong + totalShort; - if (total === 0) return; - results[p] = { - pair: p, - longPct: Math.round((totalLong / total) * 100), - shortPct: Math.round((totalShort / total) * 100), - }; - } catch { - // silently skip unavailable pairs - } - }) - ); + if (!symbols) { + return NextResponse.json({ error: "Myfxbook community outlook unavailable" }, { status: 502 }); + } - return NextResponse.json({ pairs: results, source: "OANDA", timestamp: Date.now() }); + const result: MyfxbookSentiment = { symbols, source: "myfxbook", timestamp: Date.now() }; + _cache = { data: result, ts: Date.now() }; + return NextResponse.json(result); +} + +// ── Helper interne : traduit symbols[] en {CCY: {longPct, shortPct}} ───────── +function symbolsToCurrencyMap(symbols: MyfxbookSymbol[]): Record { + const result: Record = {}; + for (const sym of symbols) { + const mapping = PAIR_TO_CCY[sym.name]; + if (!mapping) continue; + const { ccy, inverse } = mapping; + result[ccy] = { + pair: sym.name, + longPct: inverse ? sym.shortPercentage : sym.longPercentage, + shortPct: inverse ? sym.longPercentage : sym.shortPercentage, + }; + } + return result; } diff --git a/app/page.tsx b/app/page.tsx index 9e7487a..b34fde3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,47 +1,146 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import { RefreshCw, TrendingUp, AlertTriangle, Zap, Database } from "lucide-react"; +import { RefreshCw, Zap, Database } from "lucide-react"; import { CURRENCIES, CURRENCY_META } from "@/lib/constants"; -import type { Currency, DriverData } from "@/lib/types"; +import type { Currency, DriverData, SentimentEntry, CotEntry } from "@/lib/types"; +import type { RateProbData } from "@/lib/rateprobability"; import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache"; import CurrencyCard from "@/components/CurrencyCard"; import DriversBar from "@/components/DriversBar"; +import CalendarTab from "@/components/CalendarTab"; +import SentimentPairsTab from "@/components/SentimentPairsTab"; +import type { CalendarEvent } from "@/app/api/calendar/route"; const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000"); export default function Dashboard() { - const [drivers, setDrivers] = useState(null); + const [drivers, setDrivers] = useState(null); const [expectations, setExpectations] = useState | null>(null); - const [yields, setYields] = useState<{ yields: Record; spreads: Record } | null>(null); - const [lastRefresh, setLastRefresh] = useState(new Date()); - const [loading, setLoading] = useState(true); + const [yields, setYields] = useState<{ yields: Record; spreads: Record } | null>(null); + const [sentiment, setSentiment] = useState | null>(null); + const [cot, setCot] = useState | null>(null); + const [calEvents, setCalEvents] = useState([]); + const [nextWeekAvail, setNextWeekAvail] = useState(false); + const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs">("dashboard"); + const [rawSymbols, setRawSymbols] = useState | null>(null); + const [rateProbabilities, setRateProbabilities] = useState(null); + const [lastRefresh, setLastRefresh] = useState(new Date()); + const [loading, setLoading] = useState(true); const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]); - const [driversFromCache, setDriversFromCache] = useState(false); - const [driversCacheAge, setDriversCacheAge] = useState(null); + const [driversFromCache, setDriversFromCache] = useState(false); + const [driversCacheAge, setDriversCacheAge] = useState(null); + + // ── Sentiment multi-paires Myfxbook → {CCY: {longPct, shortPct, pair}} ────── + // Pour chaque devise, on calcule le % "long CCY" en moyenne pondérée (par volume) + // sur toutes les paires disponibles où cette devise apparaît (base ou cotation). + // - Si CCY est la BASE (ex: EUR dans EURUSD) → longPct = sym.longPercentage + // - Si CCY est la COTATION (ex: JPY dans USDJPY) → longPct = sym.shortPercentage + // (être short la paire = être long la monnaie de cotation) + function parseSentimentSymbols(symbols: Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }>): Record { + // base → long base currency; quote → long = short base = long quote + // Format: { base: "EUR", quote: "USD" } + const PAIR_DEF: Record = { + // Majeures + EURUSD: { base: "EUR", quote: "USD" }, + GBPUSD: { base: "GBP", quote: "USD" }, + USDJPY: { base: "USD", quote: "JPY" }, + USDCHF: { base: "USD", quote: "CHF" }, + USDCAD: { base: "USD", quote: "CAD" }, + AUDUSD: { base: "AUD", quote: "USD" }, + NZDUSD: { base: "NZD", quote: "USD" }, + // Crosses EUR + EURJPY: { base: "EUR", quote: "JPY" }, + EURGBP: { base: "EUR", quote: "GBP" }, + EURCHF: { base: "EUR", quote: "CHF" }, + EURCAD: { base: "EUR", quote: "CAD" }, + EURAUD: { base: "EUR", quote: "AUD" }, + EURNZD: { base: "EUR", quote: "NZD" }, + // Crosses GBP + GBPJPY: { base: "GBP", quote: "JPY" }, + GBPCHF: { base: "GBP", quote: "CHF" }, + GBPCAD: { base: "GBP", quote: "CAD" }, + GBPAUD: { base: "GBP", quote: "AUD" }, + GBPNZD: { base: "GBP", quote: "NZD" }, + // Crosses AUD + AUDJPY: { base: "AUD", quote: "JPY" }, + AUDCAD: { base: "AUD", quote: "CAD" }, + AUDCHF: { base: "AUD", quote: "CHF" }, + AUDNZD: { base: "AUD", quote: "NZD" }, + // Crosses CAD + CADJPY: { base: "CAD", quote: "JPY" }, + // Crosses CHF + CHFJPY: { base: "CHF", quote: "JPY" }, + // Crosses NZD + NZDJPY: { base: "NZD", quote: "JPY" }, + NZDCAD: { base: "NZD", quote: "CAD" }, + NZDCHF: { base: "NZD", quote: "CHF" }, + }; + + const OUR_CCYS = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]; + const longWeighted: Record = {}; + const totalPos: Record = {}; + const pairCount: Record = {}; + + for (const sym of symbols) { + const def = PAIR_DEF[sym.name]; + if (!def || sym.totalPositions <= 0) continue; + const { base, quote } = def; + + // Base currency : long la paire = long la base + if (OUR_CCYS.includes(base)) { + longWeighted[base] = (longWeighted[base] ?? 0) + sym.longPercentage * sym.totalPositions; + totalPos[base] = (totalPos[base] ?? 0) + sym.totalPositions; + pairCount[base] = (pairCount[base] ?? 0) + 1; + } + // Quote currency : long la paire = short la cotation → long cotation = shortPercentage + if (OUR_CCYS.includes(quote)) { + longWeighted[quote] = (longWeighted[quote] ?? 0) + sym.shortPercentage * sym.totalPositions; + totalPos[quote] = (totalPos[quote] ?? 0) + sym.totalPositions; + pairCount[quote] = (pairCount[quote] ?? 0) + 1; + } + } + + const result: Record = {}; + for (const ccy of OUR_CCYS) { + const total = totalPos[ccy] ?? 0; + if (total === 0) continue; + const n = pairCount[ccy] ?? 1; + const longPct = Math.round(longWeighted[ccy] / total); + // Label : "DXY (7 paires)" pour USD, "EUR (6 paires)" pour EUR, etc. + const label = ccy === "USD" ? `DXY (${n} paires)` : `${ccy} (${n} paire${n > 1 ? "s" : ""})`; + result[ccy] = { pair: label, longPct, shortPct: 100 - longPct }; + } + + return result; + } const refresh = useCallback(async () => { setLoading(true); try { - const [driversRes, expectRes, yieldsRes, fxRes] = await Promise.allSettled([ + const [driversRes, expectRes, yieldsRes, fxRes, sentimentRes, cotRes, calRes, rateProbRes] = await Promise.allSettled([ fetch("/api/drivers").then((r) => r.json()), fetch("/api/expectations").then((r) => r.json()), fetch("/api/yields").then((r) => r.json()), fetch("/api/fx").then((r) => r.json()), + fetch("/api/sentiment").then((r) => r.json()), + fetch("/api/cot").then((r) => r.json()), + fetch("/api/calendar").then((r) => r.json()), + fetch("/api/rate-probabilities").then((r) => r.json()), ]); - // ── Drivers (marchés globaux) ────────────────────────────────────────── + // ── Drivers ─────────────────────────────────────────────────────────── if (driversRes.status === "fulfilled" && !driversRes.value?.error) { const driversData = driversRes.value as DriverData; if (fxRes.status === "fulfilled" && fxRes.value?.dxy != null) { - driversData.dxy = fxRes.value.dxy; + driversData.dxy = fxRes.value.dxy; + driversData.dxyDelta = fxRes.value.dxyDelta ?? null; } setDrivers(driversData); setDriversFromCache(false); setDriversCacheAge(null); saveCache("drivers", driversData); } else { - // Fallback localStorage const cached = loadCache("drivers"); if (cached) { setDrivers(cached.data); @@ -68,6 +167,38 @@ export default function Dashboard() { if (cached && cached.data) setYields(cached.data); } + // ── Sentiment Myfxbook ──────────────────────────────────────────────── + if (sentimentRes.status === "fulfilled" && !sentimentRes.value?.error && sentimentRes.value?.symbols) { + const syms = sentimentRes.value.symbols as Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }>; + setRawSymbols(syms); + const mapped = parseSentimentSymbols(syms); + setSentiment(mapped); + saveCache("sentiment", mapped); + } else { + const cached = loadCache>("sentiment"); + if (cached) setSentiment(cached.data); + } + + // ── COT CFTC ───────────────────────────────────────────────────────── + if (cotRes.status === "fulfilled" && !cotRes.value?.error && Object.keys(cotRes.value ?? {}).length > 0) { + setCot(cotRes.value as Record); + saveCache("cot", cotRes.value); + } else { + const cached = loadCache>("cot"); + if (cached) setCot(cached.data); + } + + // ── Calendrier économique ───────────────────────────────────────────── + if (calRes.status === "fulfilled" && Array.isArray(calRes.value?.events)) { + setCalEvents(calRes.value.events as CalendarEvent[]); + setNextWeekAvail(calRes.value.nextWeekAvail === true); + } + + // ── Probabilités de taux (rateprobability.com OIS) ─────────────────── + if (rateProbRes.status === "fulfilled" && rateProbRes.value?.data) { + setRateProbabilities(rateProbRes.value.data as RateProbData); + } + setLastRefresh(new Date()); } finally { setLoading(false); @@ -98,9 +229,6 @@ export default function Dashboard() {

Forex Macro Dashboard

-

- USD · EUR · GBP · JPY · CHF · CAD · AUD · NZD — v8.0 -

@@ -115,7 +243,7 @@ export default function Dashboard() {
{driversFromCache && driversCacheAge && ( - + cache {driversCacheAge} @@ -134,47 +262,79 @@ export default function Dashboard() {
- {/* Global drivers bar */} - {drivers && } - - {/* 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} -
- ))} -
- )} - - {/* Currency cards grid */} -
- {CURRENCIES.map((currency) => ( - + {/* Tab navigation */} +
+ {(["dashboard", "calendar", "pairs"] as const).map((tab) => ( + ))}
+ {/* Global drivers bar — visible sur les deux onglets */} + {drivers && } + + {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} +
+ ))} +
+ )} + + {/* Currency cards grid */} +
+ {CURRENCIES.map((currency) => ( + + ))} +
+ + )} + + {activeTab === "calendar" && ( + + )} + + {activeTab === "pairs" && ( + + )} + {/* Footer */}