mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
auto: sync 2026-06-01 23:24
This commit is contained in:
+82
-49
@@ -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<string, unknown>; 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<string, unknown>; 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 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<string, unknown> {
|
||||
function parseCOT(csv: string): Record<string, CotEntry> {
|
||||
const lines = csv.split("\n");
|
||||
if (lines.length < 2) return {};
|
||||
|
||||
const header = lines[0].split(",").map((h) => h.replace(/"/g, "").trim());
|
||||
const result: Record<string, { net: number; longPct: number; shortPct: number }> = {};
|
||||
|
||||
const targetCodes = new Set(Object.values(COT_CODES));
|
||||
const result: Record<string, CotEntry> = {};
|
||||
|
||||
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(cols[IDX_LEV_LONG]?.trim() ?? "0", 10);
|
||||
const shorts = parseInt(cols[IDX_LEV_SHORT]?.trim() ?? "0", 10);
|
||||
if (isNaN(longs) || isNaN(shorts)) continue;
|
||||
|
||||
const longs = parseInt(row[longIdx] ?? "0", 10);
|
||||
const shorts = parseInt(row[shortIdx] ?? "0", 10);
|
||||
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];
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
+104
-44
@@ -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<string, number>): 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<Record<string, number> | null> {
|
||||
const rates: Record<string, number> = {};
|
||||
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<string, number>; 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 res = await fetch("https://api.frankfurter.app/latest?from=USD", { next: { revalidate: 300 } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const rates = data.rates as Record<string, number>;
|
||||
|
||||
// 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 });
|
||||
return { rates: data.rates as Record<string, number>, 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<string, number> = {};
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" } }
|
||||
);
|
||||
}
|
||||
+130
-50
@@ -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<string, { ccy: string; inverse: boolean }> = {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
if (_session && Date.now() - _sessionTs < SESSION_TTL) return _session;
|
||||
return login();
|
||||
}
|
||||
|
||||
// ── Fetch community outlook ───────────────────────────────────────────────────
|
||||
|
||||
async function fetchOutlook(session: string): Promise<MyfxbookSymbol[] | null> {
|
||||
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<string, { longPct: number; shortPct: number; pair: string }> = {};
|
||||
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 },
|
||||
// Session expired → try once more with fresh login
|
||||
if (!symbols) {
|
||||
_session = null;
|
||||
const fresh = await login();
|
||||
if (fresh) symbols = await fetchOutlook(fresh);
|
||||
}
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const buckets: { price: string; longCountPercent: string; shortCountPercent: string }[] =
|
||||
data?.positionBook?.buckets ?? [];
|
||||
|
||||
let totalLong = 0;
|
||||
let totalShort = 0;
|
||||
for (const b of buckets) {
|
||||
totalLong += parseFloat(b.longCountPercent ?? "0");
|
||||
totalShort += parseFloat(b.shortCountPercent ?? "0");
|
||||
if (!symbols) {
|
||||
return NextResponse.json({ error: "Myfxbook community outlook unavailable" }, { status: 502 });
|
||||
}
|
||||
const total = totalLong + totalShort;
|
||||
if (total === 0) return;
|
||||
results[p] = {
|
||||
pair: p,
|
||||
longPct: Math.round((totalLong / total) * 100),
|
||||
shortPct: Math.round((totalShort / total) * 100),
|
||||
|
||||
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<string, { longPct: number; shortPct: number; pair: string }> {
|
||||
const result: Record<string, { longPct: number; shortPct: number; pair: string }> = {};
|
||||
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,
|
||||
};
|
||||
} catch {
|
||||
// silently skip unavailable pairs
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ pairs: results, source: "OANDA", timestamp: Date.now() });
|
||||
return result;
|
||||
}
|
||||
|
||||
+171
-11
@@ -1,12 +1,16 @@
|
||||
"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");
|
||||
|
||||
@@ -14,34 +18,129 @@ export default function Dashboard() {
|
||||
const [drivers, setDrivers] = useState<DriverData | null>(null);
|
||||
const [expectations, setExpectations] = useState<Record<string, unknown> | null>(null);
|
||||
const [yields, setYields] = useState<{ yields: Record<string, number | null>; spreads: Record<string, number | null> } | null>(null);
|
||||
const [sentiment, setSentiment] = useState<Record<string, SentimentEntry> | null>(null);
|
||||
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
|
||||
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
|
||||
const [nextWeekAvail, setNextWeekAvail] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs">("dashboard");
|
||||
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }> | null>(null);
|
||||
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]);
|
||||
const [driversFromCache, setDriversFromCache] = useState(false);
|
||||
const [driversCacheAge, setDriversCacheAge] = useState<string | null>(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<string, SentimentEntry> {
|
||||
// base → long base currency; quote → long = short base = long quote
|
||||
// Format: { base: "EUR", quote: "USD" }
|
||||
const PAIR_DEF: Record<string, { base: string; quote: string }> = {
|
||||
// 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<string, number> = {};
|
||||
const totalPos: Record<string, number> = {};
|
||||
const pairCount: Record<string, number> = {};
|
||||
|
||||
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<string, SentimentEntry> = {};
|
||||
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.dxyDelta = fxRes.value.dxyDelta ?? null;
|
||||
}
|
||||
setDrivers(driversData);
|
||||
setDriversFromCache(false);
|
||||
setDriversCacheAge(null);
|
||||
saveCache("drivers", driversData);
|
||||
} else {
|
||||
// Fallback localStorage
|
||||
const cached = loadCache<DriverData>("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<Record<string, SentimentEntry>>("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<string, CotEntry>);
|
||||
saveCache("cot", cotRes.value);
|
||||
} else {
|
||||
const cached = loadCache<Record<string, CotEntry>>("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() {
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
Forex Macro Dashboard
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
USD · EUR · GBP · JPY · CHF · CAD · AUD · NZD — v8.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -115,7 +243,7 @@ export default function Dashboard() {
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{driversFromCache && driversCacheAge && (
|
||||
<span className="flex items-center gap-0.5 text-amber-500" title="Marchés affichés depuis le cache local — API indisponible">
|
||||
<span className="flex items-center gap-0.5 text-amber-500" title="Marchés affichés depuis le cache local">
|
||||
<Database size={11} />
|
||||
<span>cache {driversCacheAge}</span>
|
||||
</span>
|
||||
@@ -134,9 +262,28 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Global drivers bar */}
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-0 border-b border-gray-200 mb-4">
|
||||
{(["dashboard", "calendar", "pairs"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{tab === "dashboard" ? "Dashboard" : tab === "calendar" ? "📅 Calendrier" : "↕ Paires"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global drivers bar — visible sur les deux onglets */}
|
||||
{drivers && <DriversBar drivers={drivers} />}
|
||||
|
||||
{activeTab === "dashboard" && (
|
||||
<>
|
||||
{/* Active divergences summary */}
|
||||
{activeDivergences.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
@@ -166,15 +313,28 @@ export default function Dashboard() {
|
||||
currency={currency}
|
||||
expectations={expectations}
|
||||
yields={yields}
|
||||
sentiment={sentiment?.[currency] ?? null}
|
||||
cot={cot?.[currency] ?? null}
|
||||
ratePath={rateProbabilities?.[currency] ?? null}
|
||||
onDivergenceUpdate={handleDivergenceUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "calendar" && (
|
||||
<CalendarTab events={calEvents} loading={loading} nextWeekAvail={nextWeekAvail} />
|
||||
)}
|
||||
|
||||
{activeTab === "pairs" && (
|
||||
<SentimentPairsTab symbols={rawSymbols} />
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-6 text-center text-xs text-gray-400 space-y-1">
|
||||
<p>
|
||||
Sources: FRED · ECB · BoE · BoC · CFTC · Frankfurter · OANDA · investinglive.com
|
||||
Sources: FRED · ECB · BoE · BoC · CFTC · Frankfurter · Myfxbook · ForexFactory
|
||||
</p>
|
||||
<p>
|
||||
LLM: Groq (Llama 3.1) · Données à titre informatif uniquement — pas de conseil financier
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Loader2, Calendar } from "lucide-react";
|
||||
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
|
||||
import type { Currency } from "@/lib/types";
|
||||
import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||
|
||||
interface Props {
|
||||
events: CalendarEvent[];
|
||||
loading: boolean;
|
||||
nextWeekAvail: boolean; // nextweek.json disponible sur le CDN FF
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
employment: "Emploi",
|
||||
pmi: "PMI",
|
||||
policy_rate: "Taux directeur",
|
||||
cb_speech: "Discours BC",
|
||||
inflation: "Inflation",
|
||||
gdp: "PIB",
|
||||
retail_sales: "Ventes détail",
|
||||
trade_balance: "Balance comm.",
|
||||
};
|
||||
|
||||
const IMPACT_COLOR: Record<string, string> = {
|
||||
high: "bg-red-500",
|
||||
medium: "bg-amber-400",
|
||||
low: "bg-gray-300",
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function isoToLocalDate(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): { day: string; time: string } {
|
||||
const d = new Date(iso);
|
||||
const day = d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "short" });
|
||||
const time = d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||
return { day, time };
|
||||
}
|
||||
|
||||
function fmtDayLabel(dateStr: string): string {
|
||||
return new Date(dateStr + "T12:00:00").toLocaleDateString("fr-FR", {
|
||||
weekday: "long", day: "numeric", month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nextMonday(): Date {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + (day === 0 ? 1 : 8 - day));
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ── Week bounds ───────────────────────────────────────────────────────────────
|
||||
|
||||
function getWeekBounds() {
|
||||
const nm = nextMonday();
|
||||
|
||||
const currentStart = new Date(nm);
|
||||
currentStart.setDate(nm.getDate() - 7);
|
||||
const currentEnd = new Date(nm);
|
||||
currentEnd.setDate(nm.getDate() - 1);
|
||||
|
||||
const nextEnd = new Date(nm);
|
||||
nextEnd.setDate(nm.getDate() + 6);
|
||||
|
||||
const next2Start = new Date(nm);
|
||||
next2Start.setDate(nm.getDate() + 7);
|
||||
const next2End = new Date(nm);
|
||||
next2End.setDate(nm.getDate() + 13);
|
||||
|
||||
const fmt = (d: Date) => d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
|
||||
return {
|
||||
currentWeekLabel: `${fmt(currentStart)} – ${fmt(currentEnd)}`,
|
||||
nextWeekLabel: `${fmt(nm)} – ${fmt(nextEnd)}`,
|
||||
next2WeekLabel: `${fmt(next2Start)} – ${fmt(next2End)}`,
|
||||
next2StartLabel: fmt(next2Start),
|
||||
nextMondayIso: nm.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function ImpactDot({ impact }: { impact: string }) {
|
||||
return <span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${IMPACT_COLOR[impact] ?? "bg-gray-300"}`} />;
|
||||
}
|
||||
|
||||
function EventRow({ ev, isChild, expanded, onToggle }: {
|
||||
ev: CalendarEvent; isChild: boolean; expanded: boolean; onToggle: () => void;
|
||||
}) {
|
||||
const { day, time } = fmtDate(ev.date);
|
||||
const meta = CURRENCY_META[ev.currency];
|
||||
|
||||
const rowCls = [
|
||||
"border-b border-gray-100 hover:bg-gray-50 transition-colors",
|
||||
isChild ? "bg-gray-50/70" : "",
|
||||
!ev.isPublished && ev.impact === "high" ? "border-l-2 border-l-red-400" : "",
|
||||
!ev.isPublished && ev.impact === "medium" ? "border-l-2 border-l-amber-400" : "",
|
||||
ev.isPublished ? "opacity-70" : "",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<tr className={rowCls}>
|
||||
<td className="py-2 px-3 whitespace-nowrap">
|
||||
<div className="text-xs font-medium text-gray-700">{day}</div>
|
||||
<div className="text-[10px] text-gray-400">{time}</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 whitespace-nowrap">
|
||||
<span className="text-sm">{meta?.flag}</span>
|
||||
<span className="ml-1 text-xs font-semibold text-gray-700">{ev.currency}</span>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<button
|
||||
onClick={ev.isGroupParent ? onToggle : undefined}
|
||||
className={`flex items-center gap-1 text-left text-sm ${ev.isGroupParent ? "cursor-pointer font-medium text-gray-800 hover:text-blue-600" : "text-gray-600"} ${isChild ? "pl-4 text-[11px]" : ""}`}
|
||||
>
|
||||
{ev.isGroupParent && (expanded ? <ChevronDown size={12} className="text-gray-400 flex-shrink-0" /> : <ChevronRight size={12} className="text-gray-400 flex-shrink-0" />)}
|
||||
{isChild && <span className="text-gray-300 mr-1">↳</span>}
|
||||
{ev.title}
|
||||
</button>
|
||||
<div className="text-[9px] text-gray-400 mt-0.5 pl-4">{CATEGORY_LABELS[ev.category]}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<span className="text-xs text-gray-500 tabular-nums">{ev.previous ?? "—"}</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
{ev.forecast
|
||||
? <span className="text-xs font-medium text-blue-600 tabular-nums">{ev.forecast}</span>
|
||||
: <span className="text-xs text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
{ev.actual
|
||||
? <span className={`text-xs font-semibold tabular-nums ${ev.isPublished ? "text-gray-800" : "text-gray-400"}`}>{ev.actual}</span>
|
||||
: <span className="text-xs text-gray-200">—</span>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<ImpactDot impact={ev.impact} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type WeekTab = "current" | "next" | "next2" | "all";
|
||||
|
||||
export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
|
||||
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [showLow, setShowLow] = useState(false);
|
||||
const [weekTab, setWeekTab] = useState<WeekTab>("all");
|
||||
const [fromDate, setFromDate] = useState<string>(todayIso());
|
||||
|
||||
const { currentWeekLabel, nextWeekLabel, next2WeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
|
||||
|
||||
const toggle = (groupKey: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupKey)) next.delete(groupKey); else next.add(groupKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Filtrage
|
||||
const filtered = useMemo(() => {
|
||||
return events.filter((ev) => {
|
||||
if (filterCcy !== "ALL" && ev.currency !== filterCcy) return false;
|
||||
if (!showLow && ev.impact === "low") return false;
|
||||
if (ev.isGroupChild && ev.groupKey && !expanded.has(ev.groupKey)) return false;
|
||||
// Filtre semaine
|
||||
if (weekTab === "current" && ev.week !== "current") return false;
|
||||
if (weekTab === "next" && ev.week !== "next") return false;
|
||||
if (weekTab === "next2" && ev.week !== "next2") return false;
|
||||
// Filtre date depuis
|
||||
const evDate = isoToLocalDate(ev.date);
|
||||
if (evDate < fromDate) return false;
|
||||
return true;
|
||||
});
|
||||
}, [events, filterCcy, showLow, expanded, weekTab, fromDate]);
|
||||
|
||||
// Grouper par jour
|
||||
const days: string[] = [];
|
||||
const dayMap: Record<string, CalendarEvent[]> = {};
|
||||
for (const ev of filtered) {
|
||||
const d = isoToLocalDate(ev.date);
|
||||
if (!dayMap[d]) { dayMap[d] = []; days.push(d); }
|
||||
dayMap[d].push(ev);
|
||||
}
|
||||
days.sort();
|
||||
|
||||
// Compteurs par semaine pour les onglets
|
||||
const countCurrent = events.filter(e => e.week === "current").length;
|
||||
const countNext = events.filter(e => e.week === "next").length;
|
||||
const countNext2 = events.filter(e => e.week === "next2").length;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">Calendrier économique</h2>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">Sources : ForexFactory · FRED · Banques centrales</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-gray-500 cursor-pointer">
|
||||
<input type="checkbox" checked={showLow} onChange={(e) => setShowLow(e.target.checked)} className="w-3 h-3" />
|
||||
Impact faible
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Onglets semaine ──────────────────────────────────────────────────── */}
|
||||
<div className="flex gap-0 border-b border-gray-200 bg-gray-50/50">
|
||||
{([
|
||||
["all", "Tout", null, null],
|
||||
["current","Sem. en cours", currentWeekLabel, countCurrent],
|
||||
["next", "Sem. prochaine", nextWeekLabel, countNext],
|
||||
["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2],
|
||||
] as [WeekTab, string, string | null, number | null][]).map(([tab, label, sub, count]) => {
|
||||
const isActive = weekTab === tab;
|
||||
const disabled = tab === "next" && !nextWeekAvail && countNext === 0;
|
||||
const noData = typeof count === "number" && count === 0 && tab !== "all";
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => !disabled && setWeekTab(tab)}
|
||||
disabled={disabled}
|
||||
className={`px-3 py-2.5 text-xs font-medium border-b-2 transition-colors text-left ${
|
||||
isActive ? "border-blue-500 text-blue-600 bg-white" :
|
||||
disabled ? "border-transparent text-gray-300 cursor-not-allowed" :
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
{tab === "next2" && countNext2 > 0 && (
|
||||
<span className="text-[8px] bg-amber-100 text-amber-700 px-1 rounded">FRED</span>
|
||||
)}
|
||||
</div>
|
||||
{sub && (
|
||||
<div className={`text-[9px] mt-0.5 ${isActive ? "text-blue-400" : disabled ? "text-gray-300" : "text-gray-400"}`}>
|
||||
{disabled ? "Dispo lundi (retry auto)" : sub}
|
||||
</div>
|
||||
)}
|
||||
{tab !== "all" && typeof count === "number" && (
|
||||
<div className={`text-[9px] ${noData ? "text-gray-300" : "text-gray-400"}`}>
|
||||
{count} événement{count !== 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Filtre devise + date ──────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-gray-100">
|
||||
{/* Date depuis */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Calendar size={11} className="text-gray-400" />
|
||||
<span className="text-[10px] text-gray-500">Depuis</span>
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
className="text-[10px] border border-gray-200 rounded px-1.5 py-0.5 text-gray-700 focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFromDate(todayIso())}
|
||||
className="text-[9px] text-blue-500 hover:text-blue-700 underline"
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||
|
||||
{/* Filtre devise */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => setFilterCcy("ALL")}
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${filterCcy === "ALL" ? "bg-gray-800 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
|
||||
>
|
||||
Tout
|
||||
</button>
|
||||
{CURRENCIES.map((ccy) => (
|
||||
<button
|
||||
key={ccy}
|
||||
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
|
||||
className={`flex items-center gap-0.5 px-2 py-0.5 rounded-full text-[10px] font-medium ${filterCcy === ccy ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
|
||||
>
|
||||
{CURRENCY_META[ccy].flag} {ccy}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Table ────────────────────────────────────────────────────────────── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin text-gray-300" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-sm text-gray-400">Aucun événement pour cette sélection</p>
|
||||
{weekTab === "next" && !nextWeekAvail && (
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
ForexFactory ne publie la semaine prochaine que du lundi au vendredi.<br />
|
||||
Données disponibles dans quelques heures.
|
||||
</p>
|
||||
)}
|
||||
{fromDate > todayIso() && (
|
||||
<button onClick={() => setFromDate(todayIso())} className="mt-2 text-[10px] text-blue-500 underline">
|
||||
Revenir à aujourd'hui
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
|
||||
<th className="py-2 px-3 text-left">Date / Heure</th>
|
||||
<th className="py-2 px-2 text-left">Devise</th>
|
||||
<th className="py-2 px-3 text-left">Événement</th>
|
||||
<th className="py-2 px-3 text-right">Précédent</th>
|
||||
<th className="py-2 px-3 text-right">Prévision</th>
|
||||
<th className="py-2 px-3 text-right">Actuel</th>
|
||||
<th className="py-2 px-3 text-center">Impact</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(() => {
|
||||
const rows: React.ReactNode[] = [];
|
||||
let lastWeek: "current" | "next" | "next2" | null = null;
|
||||
for (const day of days) {
|
||||
const dayEvents = dayMap[day];
|
||||
if (!dayEvents?.length) continue;
|
||||
const w = dayEvents[0].week;
|
||||
// Séparateur de semaine
|
||||
if (weekTab === "all" && w !== lastWeek) {
|
||||
lastWeek = w;
|
||||
const weekBanners: Record<string, string> = {
|
||||
current: `📅 Semaine en cours — ${currentWeekLabel}`,
|
||||
next: `📅 Semaine prochaine — ${nextWeekLabel}`,
|
||||
next2: `📅 À partir du ${next2StartLabel} — réunions CB + données économiques`,
|
||||
};
|
||||
rows.push(
|
||||
<tr key={`wsep_${w}`} className={w === "next2" ? "bg-amber-600" : "bg-indigo-600"}>
|
||||
<td colSpan={7} className="px-4 py-1.5 text-[10px] font-bold text-white uppercase tracking-widest">
|
||||
{weekBanners[w] ?? w}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
// Séparateur de jour
|
||||
rows.push(
|
||||
<tr key={`dsep_${day}`} className="bg-blue-50">
|
||||
<td colSpan={7} className="px-3 py-1.5 text-[10px] font-semibold text-blue-700 capitalize">
|
||||
{fmtDayLabel(day)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
for (const ev of dayEvents) {
|
||||
rows.push(
|
||||
<EventRow
|
||||
key={ev.id}
|
||||
ev={ev}
|
||||
isChild={ev.isGroupChild}
|
||||
expanded={ev.groupKey ? expanded.has(ev.groupKey) : false}
|
||||
onToggle={() => ev.groupKey && toggle(ev.groupKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500 inline-block" /> Impact élevé</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400 inline-block" /> Impact moyen</span>
|
||||
<span>· Cliquer sur une ligne groupée pour voir les sous-indicateurs</span>
|
||||
<span>· Prévision = consensus marché avant publication</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+259
-36
@@ -2,19 +2,34 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp, Loader2, Database } from "lucide-react";
|
||||
import { CURRENCY_META } from "@/lib/constants";
|
||||
import { CURRENCY_META, COUNTRY_PROFILES } from "@/lib/constants";
|
||||
import { biasLabel, biasColor, calcMacroScore } from "@/lib/scoring";
|
||||
import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
|
||||
import type { Currency, BiasPhase, RateExpectation } from "@/lib/types";
|
||||
import type { CBRatePath } from "@/lib/rateprobability";
|
||||
import type { SentimentEntry, CotEntry } from "@/lib/types";
|
||||
import NarrativeButton from "./NarrativeButton";
|
||||
|
||||
interface Ind { value: number | null; prev: number | null; surprise: number | null; trend: "up"|"down"|"flat"|null; lastUpdated: string | null }
|
||||
interface MacroData { currency: string; indicators: Record<string, Ind | null>; fetchedAt: string }
|
||||
interface MacroForecasts {
|
||||
cpi: number | null; cpiSurprise: number | null;
|
||||
unemployment: number | null; unemploymentSurprise: number | null;
|
||||
pmiMfg: number | null; pmiMfgSurprise: number | null;
|
||||
pmiSvc: number | null; pmiSvcSurprise: number | null;
|
||||
pmiComposite: number | null; pmiCompositeSurprise: number | null;
|
||||
retailSales: number | null; retailSalesSurprise: number | null;
|
||||
gdp: number | null; gdpSurprise: number | null;
|
||||
employment: number | null; employmentSurprise: number | null;
|
||||
}
|
||||
interface MacroData { currency: string; indicators: Record<string, Ind | null>; forecasts?: MacroForecasts | null; fetchedAt: string }
|
||||
|
||||
interface Props {
|
||||
currency: Currency;
|
||||
expectations: Record<string, unknown> | null;
|
||||
yields: { yields: Record<string, number | null>; spreads: Record<string, number | null> } | null;
|
||||
sentiment: SentimentEntry | null;
|
||||
cot: CotEntry | null;
|
||||
ratePath: CBRatePath | null;
|
||||
onDivergenceUpdate: (currency: Currency, score: number) => void;
|
||||
}
|
||||
|
||||
@@ -26,19 +41,30 @@ const PHASES: Record<BiasPhase, { label: string; color: string }> = {
|
||||
transition: { label: "🟠 Transition", color: "text-orange-500" },
|
||||
};
|
||||
|
||||
function SectionHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 pt-1.5 pb-0.5">
|
||||
<span className="text-[8px] font-bold text-gray-300 uppercase tracking-widest whitespace-nowrap">{label}</span>
|
||||
<div className="flex-1 h-px bg-gray-100" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendIcon({ trend }: { trend: "up"|"down"|"flat"|null }) {
|
||||
if (trend === "up") return <TrendingUp size={11} className="text-green-500 flex-shrink-0" />;
|
||||
if (trend === "down") return <TrendingDown size={11} className="text-red-500 flex-shrink-0" />;
|
||||
return <Minus size={11} className="text-gray-300 flex-shrink-0" />;
|
||||
}
|
||||
|
||||
function Row({ label, ind, unit = "", invertSurprise = false, warn = false, consensus = null }: {
|
||||
label: string; ind: Ind | null; unit?: string; invertSurprise?: boolean; warn?: boolean; consensus?: number | null;
|
||||
function Row({ label, ind, unit = "", invertSurprise = false, warn = false, consensus = null, surpriseVsCons = null }: {
|
||||
label: string; ind: Ind | null; unit?: string; invertSurprise?: boolean; warn?: boolean;
|
||||
consensus?: number | null; // consensus à venir (upcoming)
|
||||
surpriseVsCons?: number | null; // actual − consensus si ≤5j post-release
|
||||
}) {
|
||||
const value = ind?.value ?? null;
|
||||
const prev = ind?.prev ?? null;
|
||||
|
||||
// Colorer la valeur actuelle selon la direction du mouvement
|
||||
// Colorer la valeur actuelle selon la direction du mouvement vs période précédente
|
||||
const s = ind?.surprise ?? null;
|
||||
const effectiveS = invertSurprise && s !== null ? -s : s;
|
||||
const valCls =
|
||||
@@ -50,6 +76,14 @@ function Row({ label, ind, unit = "", invertSurprise = false, warn = false, cons
|
||||
const fmt = (v: number | null) =>
|
||||
v !== null ? `${v.toFixed(2)}${unit}` : "—";
|
||||
|
||||
// Coloration de la surprise vs consensus (inversion pour chômage/unemployment)
|
||||
const effSurprise = invertSurprise && surpriseVsCons !== null ? -surpriseVsCons : surpriseVsCons;
|
||||
const surpriseCls = effSurprise === null ? ""
|
||||
: effSurprise > 0 ? "text-green-600"
|
||||
: effSurprise < 0 ? "text-red-600"
|
||||
: "text-gray-500";
|
||||
const surpriseArrow = effSurprise === null ? "" : effSurprise > 0 ? "▲" : effSurprise < 0 ? "▼" : "▬";
|
||||
|
||||
return (
|
||||
<div className="py-1.5 border-b border-gray-50 last:border-0">
|
||||
{/* Ligne 1 : label + valeur actuelle publiée */}
|
||||
@@ -64,23 +98,34 @@ function Row({ label, ind, unit = "", invertSurprise = false, warn = false, cons
|
||||
{fmt(value)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Ligne 2 : précédent + consensus marché */}
|
||||
{/* Ligne 2 : précédent + consensus à venir OU surprise post-publication */}
|
||||
<div className="flex items-center justify-between pl-5 mt-0.5">
|
||||
<span className="text-[10px] text-gray-400 tabular-nums">
|
||||
Préc. <span className="text-gray-500 font-medium">{fmt(prev)}</span>
|
||||
</span>
|
||||
{surpriseVsCons !== null ? (
|
||||
// Surprise vs consensus (≤5 jours post-release)
|
||||
<span className="text-[10px] tabular-nums">
|
||||
<span className="text-gray-400">Surpr. </span>
|
||||
<span className={`font-medium ${surpriseCls}`}>
|
||||
{surpriseArrow}{surpriseVsCons > 0 ? "+" : ""}{surpriseVsCons.toFixed(2)}{unit}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
// Consensus à venir (upcoming)
|
||||
<span className="text-[10px] text-gray-400 tabular-nums">
|
||||
Cons.
|
||||
{consensus !== null
|
||||
? <span className="text-blue-500 font-medium">{fmt(consensus)}</span>
|
||||
: <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CurrencyCard({ currency, expectations, yields, onDivergenceUpdate }: Props) {
|
||||
export default function CurrencyCard({ currency, expectations, yields, sentiment, cot, ratePath, onDivergenceUpdate }: Props) {
|
||||
const meta = CURRENCY_META[currency];
|
||||
const [data, setData] = useState<MacroData | null>(null);
|
||||
const [phase, setPhase] = useState<BiasPhase>("hawkish_pause");
|
||||
@@ -152,13 +197,14 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge
|
||||
}, [expectations, currency, meta.cbShort]);
|
||||
|
||||
const inds = data?.indicators;
|
||||
const fc = data?.forecasts ?? null; // ForexFactory forecasts
|
||||
|
||||
// Build a minimal indicator object for scoring
|
||||
const forScoring = {
|
||||
policyRate: { value: inds?.policyRate?.value ?? null, prev: inds?.policyRate?.prev ?? null, consensus: null, surprise: inds?.policyRate?.surprise ?? null, trend: inds?.policyRate?.trend ?? null, lastUpdated: "" },
|
||||
cpiCore: { value: inds?.cpiCore?.value ?? null, prev: inds?.cpiCore?.prev ?? null, consensus: null, surprise: inds?.cpiCore?.surprise ?? null, trend: inds?.cpiCore?.trend ?? null, lastUpdated: "" },
|
||||
pmiMfg: { value: null, prev: null, consensus: null, surprise: null, trend: null, lastUpdated: "" },
|
||||
pmiServices: { value: null, prev: null, consensus: null, surprise: null, trend: null, lastUpdated: "" },
|
||||
pmiMfg: { value: inds?.pmiMfg?.value ?? null, prev: inds?.pmiMfg?.prev ?? null, consensus: null, surprise: inds?.pmiMfg?.surprise ?? null, trend: inds?.pmiMfg?.trend ?? null, lastUpdated: "" },
|
||||
pmiServices: { value: inds?.pmiServices?.value ?? null, prev: inds?.pmiServices?.prev ?? null, consensus: null, surprise: inds?.pmiServices?.surprise ?? null, trend: inds?.pmiServices?.trend ?? null, lastUpdated: "" },
|
||||
gdp: { value: inds?.gdp?.value ?? null, prev: inds?.gdp?.prev ?? null, consensus: null, surprise: inds?.gdp?.surprise ?? null, trend: inds?.gdp?.trend ?? null, lastUpdated: "" },
|
||||
retailSales: { value: inds?.retailSales?.value ?? null, prev: inds?.retailSales?.prev ?? null, consensus: null, surprise: inds?.retailSales?.surprise ?? null, trend: inds?.retailSales?.trend ?? null, lastUpdated: "" },
|
||||
unemployment: { value: inds?.unemployment?.value ?? null, prev: inds?.unemployment?.prev ?? null, consensus: null, surprise: inds?.unemployment?.surprise ?? null, trend: inds?.unemployment?.trend ?? null, lastUpdated: "" },
|
||||
@@ -173,6 +219,30 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge
|
||||
const spread10Y = yields?.spreads[currency] ?? null;
|
||||
const borderCls = macroScore >= 4 ? "border-green-200" : macroScore <= -4 ? "border-red-200" : "border-gray-200";
|
||||
|
||||
// Consensus = taux attendu à la prochaine réunion CB
|
||||
// Priorité : ratePath (OIS temps réel) > rateExp (snapshot statique)
|
||||
const rateConsensus = (() => {
|
||||
const rate = inds?.policyRate?.value ?? null;
|
||||
if (rate === null) return null;
|
||||
// OIS live
|
||||
if (ratePath && ratePath.meetings.length > 0) {
|
||||
const next = ratePath.meetings[0];
|
||||
if (next.probMovePct > 50) {
|
||||
return next.probIsCut
|
||||
? parseFloat((rate - 0.25).toFixed(2))
|
||||
: parseFloat((rate + 0.25).toFixed(2));
|
||||
}
|
||||
return parseFloat(rate.toFixed(2));
|
||||
}
|
||||
// Fallback snapshot statique (CHF / si rateprobability indispo)
|
||||
if (!rateExp) return null;
|
||||
const desc = rateExp.prob_desc.toLowerCase();
|
||||
if (desc.includes("no change")) return parseFloat(rate.toFixed(2));
|
||||
if (rateExp.direction === "cut" && rateExp.prob_pct > 50) return parseFloat((rate - 0.25).toFixed(2));
|
||||
if (rateExp.direction === "hike" && rateExp.prob_pct > 50) return parseFloat((rate + 0.25).toFixed(2));
|
||||
return parseFloat(rate.toFixed(2));
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={`bg-white border rounded-xl overflow-hidden ${borderCls}`}>
|
||||
{/* Header */}
|
||||
@@ -206,49 +276,202 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate expectation pill */}
|
||||
{rateExp && (
|
||||
{/* ── Probabilités OIS (rateprobability.com) ─────────────────────────── */}
|
||||
{ratePath && ratePath.meetings.length > 0 ? (
|
||||
<div className="mx-4 mb-2 px-3 py-2 bg-gray-50 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[9px] font-semibold text-gray-400 uppercase tracking-wider">OIS · marchés</span>
|
||||
<span className="text-[9px] text-gray-400">au {ratePath.asOf}</span>
|
||||
</div>
|
||||
|
||||
{/* Pic + taux fin d'année */}
|
||||
{ratePath.peakMeeting && (
|
||||
<div className="flex items-center justify-between text-[10px] mb-1.5">
|
||||
<span>
|
||||
<span className="text-gray-500">Pic </span>
|
||||
<span className="font-semibold text-gray-800">{ratePath.peakMeeting.label}</span>
|
||||
<span className={`ml-1 font-bold ${ratePath.peakMeeting.probIsCut ? "text-green-600" : "text-red-500"}`}>
|
||||
{ratePath.peakMeeting.probMovePct.toFixed(0)}%
|
||||
{ratePath.peakMeeting.probIsCut ? " ▼" : " ▲"}
|
||||
{ratePath.peakMeeting.changeBps > 0.5 &&
|
||||
<span className="font-normal text-gray-400"> +{ratePath.peakMeeting.changeBps.toFixed(0)}bps</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
{ratePath.yearEndImplied !== null && (
|
||||
<span className="text-gray-400 text-[9px]">
|
||||
fin d'an {ratePath.yearEndImplied.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline réunion par réunion */}
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{ratePath.meetings.slice(0, 6).map(m => (
|
||||
<div key={m.dateIso} className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-gray-400 w-11 shrink-0 tabular-nums">{m.label}</span>
|
||||
<div className="flex-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${m.probIsCut ? "bg-green-400" : "bg-red-400"}`}
|
||||
style={{ width: `${m.probMovePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[9px] w-7 text-right tabular-nums shrink-0 ${m.probMovePct >= 50 ? "font-bold text-gray-800" : "text-gray-400"}`}>
|
||||
{m.probMovePct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : rateExp ? (
|
||||
/* Fallback snapshot statique (ex: CHF sans données OIS) */
|
||||
<div className="mx-4 mb-2 px-3 py-1.5 bg-gray-50 rounded-lg text-xs leading-snug">
|
||||
<span className="font-semibold">{rateExp.direction === "hike" ? "▲" : "▼"} {rateExp.bps} bps</span>
|
||||
<span className="relative group inline-flex items-center ml-0.5 cursor-help align-middle">
|
||||
<span className="text-[9px] text-gray-400 border border-gray-300 rounded-full w-3 h-3 flex items-center justify-center leading-none select-none">i</span>
|
||||
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 hidden group-hover:block bg-gray-800 text-white text-[10px] rounded px-2 py-1.5 w-56 z-50 leading-snug shadow-lg whitespace-normal">
|
||||
1 bp (basis point) = 0,01% de taux. Variation cumulée attendue d'ici fin d'année selon les marchés (OIS / futures de taux). Distinct de la probabilité à la prochaine réunion ci-dessous.
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-gray-500"> · {rateExp.prob_pct}% prob. </span>
|
||||
<span className={`font-medium ${rateExp.prob_desc.includes("no change") || rateExp.prob_desc.includes("sans") ? "text-gray-600" : rateExp.direction === "hike" ? "text-red-600" : "text-green-600"}`}>
|
||||
<span className={`font-medium ${rateExp.prob_desc.includes("no change") ? "text-gray-600" : rateExp.direction === "hike" ? "text-red-600" : "text-green-600"}`}>
|
||||
{rateExp.prob_desc}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Core indicators (always visible) */}
|
||||
{/* ── Indicateurs macro — organisation "prisme" ─────────────────────── */}
|
||||
<div className="px-4 pb-2">
|
||||
<Row label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" />
|
||||
<Row label="CPI (MoM%)" ind={inds?.cpiCore ?? null} unit="%" />
|
||||
<Row label="PIB (QoQ %)" ind={inds?.gdp ?? null} unit="%" />
|
||||
<Row label="Chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise />
|
||||
|
||||
{/* Expanded indicators */}
|
||||
{expanded && (
|
||||
<>
|
||||
<Row label="PMI Mfg" ind={null} warn />
|
||||
<Row label="PMI Services" ind={null} warn />
|
||||
<Row label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" warn={!inds?.retailSales} />
|
||||
<Row label="Emploi (MoM%)" ind={inds?.employment ?? null} unit="%" warn={!inds?.employment} />
|
||||
{/* 10Y yield */}
|
||||
<div className="flex items-center justify-between py-1.5 text-xs">
|
||||
<span className="text-gray-500">10Y Yield</span>
|
||||
<span className="font-semibold text-gray-800 tabular-nums">
|
||||
{/* ── POLITIQUE MONÉTAIRE ─────────────────────────────────────────── */}
|
||||
<SectionHeader label="Politique monétaire" />
|
||||
<Row label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" consensus={rateConsensus} />
|
||||
<div className="flex items-center justify-between py-1.5 text-xs border-b border-gray-50">
|
||||
<span className="text-gray-500 text-xs">10Y Yield</span>
|
||||
<span className="font-semibold text-gray-800 tabular-nums text-xs">
|
||||
{yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"}
|
||||
{spread10Y !== null && (
|
||||
<span className={`ml-1 text-[10px] ${spread10Y > 0 ? "text-green-600" : "text-red-600"}`}>
|
||||
({spread10Y > 0 ? "+" : ""}{spread10Y}bps)
|
||||
({spread10Y > 0 ? "+" : ""}{spread10Y}bps vs US)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── INFLATION ───────────────────────────────────────────────────── */}
|
||||
<SectionHeader label="Inflation" />
|
||||
<Row label="CPI Core YoY" ind={inds?.cpiCore ?? null} unit="%" consensus={fc?.cpi ?? null} surpriseVsCons={fc?.cpiSurprise ?? null} />
|
||||
<Row label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" />
|
||||
|
||||
{/* ── CROISSANCE ──────────────────────────────────────────────────── */}
|
||||
<SectionHeader label="Croissance" />
|
||||
<Row label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} surpriseVsCons={fc?.gdpSurprise ?? null} />
|
||||
<Row label="PMI Composite" ind={inds?.pmiComposite ?? null} warn={!inds?.pmiComposite} consensus={fc?.pmiComposite ?? null} surpriseVsCons={fc?.pmiCompositeSurprise ?? null} />
|
||||
|
||||
{/* ── EMPLOI ──────────────────────────────────────────────────────── */}
|
||||
<SectionHeader label="Emploi" />
|
||||
{/* Variation emploi = NFP/Employment Change en milliers — ex: +115k = 115 000 emplois créés */}
|
||||
<Row label="Variation emploi" ind={inds?.employment ?? null} unit="k" warn={!inds?.employment} consensus={fc?.employment ?? null} surpriseVsCons={fc?.employmentSurprise ?? null} />
|
||||
<Row label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise consensus={fc?.unemployment ?? null} surpriseVsCons={fc?.unemploymentSurprise ?? null} />
|
||||
|
||||
{/* ── Données supplémentaires (expanded) ──────────────────────────── */}
|
||||
{expanded && (
|
||||
<>
|
||||
{/* PMI détail */}
|
||||
<SectionHeader label="PMI détail" />
|
||||
<Row label="PMI Mfg" ind={inds?.pmiMfg ?? null} warn={!inds?.pmiMfg} consensus={fc?.pmiMfg ?? null} surpriseVsCons={fc?.pmiMfgSurprise ?? null} />
|
||||
<Row label="PMI Services" ind={inds?.pmiServices ?? null} warn={!inds?.pmiServices} consensus={fc?.pmiSvc ?? null} surpriseVsCons={fc?.pmiSvcSurprise ?? null} />
|
||||
<Row label="Ventes détail" ind={inds?.retailSales ?? null} unit="%" warn={!inds?.retailSales} consensus={fc?.retailSales ?? null} surpriseVsCons={fc?.retailSalesSurprise ?? null} />
|
||||
|
||||
{/* Géopolitique */}
|
||||
<SectionHeader label="Géopolitique" />
|
||||
{inds?.tradeBalance ? (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-gray-50">
|
||||
<span className="text-gray-500 text-xs">Balance comm.</span>
|
||||
<span className={`text-xs font-semibold tabular-nums ${(inds.tradeBalance.value ?? 0) >= 0 ? "text-green-600" : "text-red-500"}`}>
|
||||
{(inds.tradeBalance.value ?? 0) >= 0 ? "+" : ""}{inds.tradeBalance.value?.toFixed(1)}B
|
||||
<span className="text-[9px] text-gray-400 font-normal ml-0.5">
|
||||
{(inds.tradeBalance.value ?? 0) >= 0 ? " surplus" : " déficit"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Profil énergie + matières premières */}
|
||||
{(() => {
|
||||
const profile = COUNTRY_PROFILES[currency];
|
||||
if (!profile) return null;
|
||||
const energyColor = profile.energy === "exporter" ? "text-green-700 bg-green-50" : profile.energy === "importer" ? "text-red-700 bg-red-50" : "text-gray-600 bg-gray-100";
|
||||
const energyLabel = profile.energy === "exporter" ? "🛢 Export. énergie" : profile.energy === "importer" ? "⚡ Import. énergie" : "⚖ Énergie ~neutre";
|
||||
return (
|
||||
<div className="py-1.5 border-b border-gray-50 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${energyColor}`}>{energyLabel}</span>
|
||||
<span className="text-[9px] text-gray-400 text-right leading-tight max-w-[55%]">{profile.energyNote}</span>
|
||||
</div>
|
||||
{profile.commodities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{profile.commodities.map(c => (
|
||||
<span key={c} className="text-[8px] bg-amber-50 text-amber-700 border border-amber-200 px-1.5 py-0.5 rounded-full">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Sentiment & Positionnement ──────────────────────────────── */}
|
||||
<SectionHeader label="Sentiment & Positionnement" />
|
||||
{sentiment ? (
|
||||
<div className="py-1.5 border-b border-gray-50">
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-gray-500 text-[10px]">{sentiment.pair} · Myfxbook</span>
|
||||
<span className="text-[10px] tabular-nums">
|
||||
<span className="text-green-600 font-semibold">{sentiment.longPct}% L</span>
|
||||
<span className="text-gray-300 mx-0.5">/</span>
|
||||
<span className="text-red-500 font-semibold">{sentiment.shortPct}% S</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Barre visuelle long/short */}
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-green-400 transition-all" style={{ width: `${sentiment.longPct}%` }} />
|
||||
<div className="bg-red-400 flex-1" />
|
||||
</div>
|
||||
{/* Signal contrarien */}
|
||||
{(sentiment.longPct >= 70 || sentiment.shortPct >= 70) && (
|
||||
<p className={`text-[9px] mt-0.5 font-medium ${sentiment.longPct >= 70 ? "text-red-500" : "text-green-600"}`}>
|
||||
{sentiment.longPct >= 70 ? "⚠ Retail très long — signal contrarien baissier" : "⚠ Retail très short — signal contrarien haussier"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 text-[10px] text-gray-300 border-b border-gray-50">— (Myfxbook indisponible)</div>
|
||||
)}
|
||||
|
||||
{/* ── COT CFTC ────────────────────────────────────────────────── */}
|
||||
<div className="pt-1 pb-0.5">
|
||||
<span className="text-[9px] font-semibold text-gray-400 uppercase tracking-wider">COT — Hedge Funds</span>
|
||||
</div>
|
||||
{cot ? (
|
||||
<div className="py-1.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-gray-500 text-[10px]">Lev. Money · {cot.weekDate}</span>
|
||||
<span className="text-[10px] tabular-nums">
|
||||
<span className="text-green-600 font-semibold">{cot.longPct}% L</span>
|
||||
<span className="text-gray-300 mx-0.5">/</span>
|
||||
<span className="text-red-500 font-semibold">{cot.shortPct}% S</span>
|
||||
<span className="text-gray-400 ml-1">({cot.net > 0 ? "+" : ""}{cot.net.toLocaleString("fr-FR")})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-green-400 transition-all" style={{ width: `${cot.longPct}%` }} />
|
||||
<div className="bg-red-400 flex-1" />
|
||||
</div>
|
||||
{/* Divergence COT vs Sentiment */}
|
||||
{sentiment && Math.abs(cot.longPct - sentiment.longPct) >= 20 && (
|
||||
<p className="text-[9px] mt-0.5 text-amber-600 font-medium">
|
||||
⚡ Divergence COT/Retail : {Math.abs(cot.longPct - sentiment.longPct)}pts
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 text-[10px] text-gray-300">— (CFTC indisponible)</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { CURRENCY_META } from "@/lib/constants";
|
||||
import type { Currency } from "@/lib/types";
|
||||
|
||||
interface MyfxSymbol {
|
||||
name: string;
|
||||
longPercentage: number;
|
||||
shortPercentage: number;
|
||||
totalPositions: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
symbols: MyfxSymbol[] | null;
|
||||
}
|
||||
|
||||
// Toutes les 28 combinaisons (C(8,2)) des 8 devises — base/quote dans l'ordre standard Forex
|
||||
const PAIRS: { base: Currency; quote: Currency; std: string }[] = [
|
||||
// Majeures USD
|
||||
{ base: "EUR", quote: "USD", std: "EURUSD" },
|
||||
{ base: "GBP", quote: "USD", std: "GBPUSD" },
|
||||
{ base: "USD", quote: "JPY", std: "USDJPY" },
|
||||
{ base: "USD", quote: "CHF", std: "USDCHF" },
|
||||
{ base: "USD", quote: "CAD", std: "USDCAD" },
|
||||
{ base: "AUD", quote: "USD", std: "AUDUSD" },
|
||||
{ base: "NZD", quote: "USD", std: "NZDUSD" },
|
||||
// Crosses EUR
|
||||
{ base: "EUR", quote: "GBP", std: "EURGBP" },
|
||||
{ base: "EUR", quote: "JPY", std: "EURJPY" },
|
||||
{ base: "EUR", quote: "CHF", std: "EURCHF" },
|
||||
{ base: "EUR", quote: "CAD", std: "EURCAD" },
|
||||
{ base: "EUR", quote: "AUD", std: "EURAUD" },
|
||||
{ base: "EUR", quote: "NZD", std: "EURNZD" },
|
||||
// Crosses GBP
|
||||
{ base: "GBP", quote: "JPY", std: "GBPJPY" },
|
||||
{ base: "GBP", quote: "CHF", std: "GBPCHF" },
|
||||
{ base: "GBP", quote: "CAD", std: "GBPCAD" },
|
||||
{ base: "GBP", quote: "AUD", std: "GBPAUD" },
|
||||
{ base: "GBP", quote: "NZD", std: "GBPNZD" },
|
||||
// Crosses AUD
|
||||
{ base: "AUD", quote: "JPY", std: "AUDJPY" },
|
||||
{ base: "AUD", quote: "CAD", std: "AUDCAD" },
|
||||
{ base: "AUD", quote: "CHF", std: "AUDCHF" },
|
||||
{ base: "AUD", quote: "NZD", std: "AUDNZD" },
|
||||
// Crosses CAD
|
||||
{ base: "CAD", quote: "JPY", std: "CADJPY" },
|
||||
// Crosses CHF
|
||||
{ base: "CHF", quote: "JPY", std: "CHFJPY" },
|
||||
// Crosses NZD
|
||||
{ base: "NZD", quote: "JPY", std: "NZDJPY" },
|
||||
{ base: "NZD", quote: "CAD", std: "NZDCAD" },
|
||||
{ base: "NZD", quote: "CHF", std: "NZDCHF" },
|
||||
// Croisée manquante CAD/CHF
|
||||
{ base: "CAD", quote: "CHF", std: "CADCHF" },
|
||||
];
|
||||
|
||||
// Groupes pour l'affichage
|
||||
const GROUPS = [
|
||||
{ label: "Majeures USD", pairs: ["EURUSD","GBPUSD","USDJPY","USDCHF","USDCAD","AUDUSD","NZDUSD"] },
|
||||
{ label: "Crosses EUR", pairs: ["EURGBP","EURJPY","EURCHF","EURCAD","EURAUD","EURNZD"] },
|
||||
{ label: "Crosses GBP", pairs: ["GBPJPY","GBPCHF","GBPCAD","GBPAUD","GBPNZD"] },
|
||||
{ label: "Crosses AUD/NZD",pairs: ["AUDJPY","AUDCAD","AUDCHF","AUDNZD","NZDJPY","NZDCAD","NZDCHF"] },
|
||||
{ label: "Crosses CAD/CHF",pairs: ["CADJPY","CHFJPY","CADCHF"] },
|
||||
];
|
||||
|
||||
function SentimentBar({ longPct }: { longPct: number }) {
|
||||
const isContrarian = longPct >= 70 || longPct <= 30;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-[120px]">
|
||||
<span className={`text-[10px] tabular-nums font-medium w-8 text-right ${isContrarian ? "text-amber-600 font-bold" : "text-green-600"}`}>
|
||||
{longPct}%
|
||||
</span>
|
||||
<div className="flex h-2 w-20 rounded-full overflow-hidden">
|
||||
<div className="bg-green-400 transition-all" style={{ width: `${longPct}%` }} />
|
||||
<div className="bg-red-400 flex-1" />
|
||||
</div>
|
||||
<span className={`text-[10px] tabular-nums font-medium w-8 ${isContrarian ? "text-amber-600 font-bold" : "text-red-500"}`}>
|
||||
{100 - longPct}%
|
||||
</span>
|
||||
{isContrarian && (
|
||||
<span className="text-[9px] text-amber-500 font-semibold">⚠</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SentimentPairsTab({ symbols }: Props) {
|
||||
const symMap: Record<string, MyfxSymbol> = {};
|
||||
for (const s of symbols ?? []) symMap[s.name] = s;
|
||||
|
||||
const pairMap: Record<string, { base: Currency; quote: Currency }> = {};
|
||||
for (const p of PAIRS) pairMap[p.std] = { base: p.base, quote: p.quote };
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<h2 className="text-sm font-semibold text-gray-800">Sentiment retail — toutes les paires</h2>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
Source : Myfxbook Community Outlook · {symbols ? `${Object.keys(symMap).length} paires disponibles` : "chargement…"}
|
||||
· Long = retail haussier sur la devise de base · ⚠ = signal contrarien (>70% ou <30%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
{/* Group header */}
|
||||
<div className="px-4 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-[9px] font-semibold text-gray-500 uppercase tracking-wider">{group.label}</span>
|
||||
</div>
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="text-[9px] text-gray-400 uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="py-1.5 px-4 text-left w-32">Paire</th>
|
||||
<th className="py-1.5 px-4 text-left">Long L / Short S</th>
|
||||
<th className="py-1.5 px-4 text-right w-28">Positions totales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.pairs.map((pairName) => {
|
||||
const def = pairMap[pairName];
|
||||
const sym = symMap[pairName];
|
||||
const baseMeta = def ? CURRENCY_META[def.base] : null;
|
||||
const quoteMeta = def ? CURRENCY_META[def.quote] : null;
|
||||
|
||||
return (
|
||||
<tr key={pairName} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors">
|
||||
{/* Pair name */}
|
||||
<td className="py-2 px-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm leading-none">{baseMeta?.flag}</span>
|
||||
<span className="text-sm leading-none">{quoteMeta?.flag}</span>
|
||||
<span className="text-xs font-semibold text-gray-800">{pairName}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Sentiment bar */}
|
||||
<td className="py-2 px-4">
|
||||
{sym ? (
|
||||
<SentimentBar longPct={sym.longPercentage} />
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-300 italic">Non disponible sur Myfxbook</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Total positions */}
|
||||
<td className="py-2 px-4 text-right">
|
||||
{sym ? (
|
||||
<span className="text-[10px] text-gray-500 tabular-nums">
|
||||
{sym.totalPositions.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-200">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400">
|
||||
Long % = % des positions retail haussières sur la devise de base de la paire · Données Myfxbook Community Outlook
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-13
@@ -1,36 +1,36 @@
|
||||
[
|
||||
{
|
||||
"updated_at": "2026-05-29",
|
||||
"note": "Valeurs manuelles depuis investing.com — utilisées quand FRED/DBnomics sont en retard. L'override est appliqué uniquement si sa date est plus récente que la source automatique.",
|
||||
"note": "Valeurs YoY% manuelles depuis investing.com — utilisées quand FRED/DBnomics sont en retard. L'override est appliqué uniquement si sa date est plus récente que la source automatique.",
|
||||
"overrides": {
|
||||
"JPY": {
|
||||
"cpiCore": {
|
||||
"value": 0.1,
|
||||
"prev": 0.4,
|
||||
"surprise": -0.3,
|
||||
"value": 3.6,
|
||||
"prev": 3.7,
|
||||
"surprise": -0.1,
|
||||
"trend": "down",
|
||||
"lastUpdated": "2026-05-22",
|
||||
"source": "investing.com — IPC Japon MoM% (avr. 2026, publié 22/05/2026)"
|
||||
"source": "investing.com — IPC Japon YoY% (avr. 2026, publié 22/05/2026)"
|
||||
}
|
||||
},
|
||||
"AUD": {
|
||||
"cpiCore": {
|
||||
"value": 1.4,
|
||||
"prev": 0.6,
|
||||
"surprise": 0.8,
|
||||
"trend": "up",
|
||||
"value": 2.4,
|
||||
"prev": 2.4,
|
||||
"surprise": 0.0,
|
||||
"trend": "flat",
|
||||
"lastUpdated": "2026-04-29",
|
||||
"source": "investing.com — IPC Australie QoQ% (T1 2026, publié 29/04/2026)"
|
||||
"source": "investing.com — IPC Australie YoY% (T1 2026, publié 29/04/2026)"
|
||||
}
|
||||
},
|
||||
"NZD": {
|
||||
"cpiCore": {
|
||||
"value": 0.9,
|
||||
"prev": 0.6,
|
||||
"value": 2.5,
|
||||
"prev": 2.2,
|
||||
"surprise": 0.3,
|
||||
"trend": "up",
|
||||
"lastUpdated": "2026-04-21",
|
||||
"source": "investing.com — IPC Nouvelle-Zélande QoQ% (T1 2026, publié 21/04/2026)"
|
||||
"source": "investing.com — IPC Nouvelle-Zélande YoY% (T1 2026, publié 21/04/2026)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-41
@@ -22,7 +22,8 @@ export const CURRENCY_META: Record<Currency, { name: string; flag: string; cb: s
|
||||
// Employment : séries LFEMTTTT*647S (niveaux en milliers → MoM% calculé localement)
|
||||
export const FRED_SERIES: Record<Currency, {
|
||||
policyRate: string | null;
|
||||
cpiCore: string | null;
|
||||
cpiCore: string | null; // CPI core (hors alim+énergie si dispo) → YoY
|
||||
cpiHeadline: string | null; // CPI headline (tous articles) → MoM (série différente de cpiCore !)
|
||||
gdp: string | null;
|
||||
retailSales: string | null;
|
||||
unemployment: string | null;
|
||||
@@ -30,67 +31,128 @@ export const FRED_SERIES: Record<Currency, {
|
||||
}> = {
|
||||
USD: {
|
||||
policyRate: "FEDFUNDS",
|
||||
cpiCore: "CPILFESL", // indice niveau → MoM%
|
||||
gdp: "GDPC1", // indice niveau → QoQ%
|
||||
retailSales: "USASLRTTO01GPSAM", // déjà MoM% — ex-MARTSSM44W72USS (niveau)
|
||||
cpiCore: "CPILFESL", // Core CPI (less food+energy) → YoY%
|
||||
cpiHeadline: "CPIAUCSL", // Headline CPI All Items SA → MoM% (série différente !)
|
||||
gdp: "GDPC1",
|
||||
retailSales: "USASLRTTO01GPSAM",
|
||||
unemployment: "UNRATE",
|
||||
employment: "PAYEMS", // niveau → MoM%
|
||||
employment: "PAYEMS",
|
||||
},
|
||||
EUR: {
|
||||
policyRate: "ECBDFR", // taux dépôt BCE
|
||||
cpiCore: "CP0000EZCCM086NEST", // HICP total EA composition variable → Index 2025=100 → MoM%
|
||||
gdp: null, // → Eurostat dans /api/macro
|
||||
retailSales: "DEUSLRTTO01GPSAM", // proxy Allemagne, déjà MoM%
|
||||
unemployment: null, // → Eurostat EA21 dans /api/macro
|
||||
policyRate: "ECBDFR",
|
||||
cpiCore: "CP0000EZCCM086NEST", // HICP total → YoY%
|
||||
cpiHeadline: null, // → Eurostat HICP (mêmes obs, MoM calculé)
|
||||
gdp: null,
|
||||
retailSales: "DEUSLRTTO01GPSAM",
|
||||
unemployment: null,
|
||||
employment: null,
|
||||
},
|
||||
GBP: {
|
||||
policyRate: null, // → BoE API dans /api/macro
|
||||
cpiCore: "GBRCPIALLMINMEI", // indice niveau → MoM%
|
||||
gdp: "NGDPRSAXDCGBQ", // Real GDP UK (BEA/ONS) indice niveau → QoQ%
|
||||
retailSales: "GBRSLRTTO01GPSAM", // déjà MoM%
|
||||
policyRate: null,
|
||||
cpiCore: "GBRCPIALLMINMEI", // Headline All Items → YoY (pas de série core mensuelle)
|
||||
cpiHeadline: "GBRCPIALLMINMEI", // Même série : headline → MoM depuis mêmes obs
|
||||
gdp: "NGDPRSAXDCGBQ",
|
||||
retailSales: "GBRSLRTTO01GPSAM",
|
||||
unemployment: "LRHUTTTTGBM156S",
|
||||
employment: null, // pas de série mensuelle FRED pour GBP
|
||||
employment: null,
|
||||
},
|
||||
JPY: {
|
||||
policyRate: "IR3TIB01JPM156N", // 3M interbank (IRSTCB01JPM156N stale 2023)
|
||||
cpiCore: null, // JPNCPIALLMINMEI stale depuis 2021 sur FRED
|
||||
gdp: "JPNRGDPEXP", // indice niveau → QoQ%
|
||||
retailSales: "JPNSLRTTO01GPSAM", // déjà MoM%
|
||||
policyRate: "IR3TIB01JPM156N",
|
||||
cpiCore: null, // DBnomics M.JP.PCPI_IX → YoY
|
||||
cpiHeadline: null, // DBnomics M.JP.PCPI_IX → MoM (mêmes obs)
|
||||
gdp: "JPNRGDPEXP",
|
||||
retailSales: "JPNSLRTTO01GPSAM",
|
||||
unemployment: "LRHUTTTTJPM156S",
|
||||
employment: "LFEMTTTTJPM647S", // niveau mensuel → MoM%
|
||||
employment: "LFEMTTTTJPM647S",
|
||||
},
|
||||
CHF: {
|
||||
policyRate: "IR3TIB01CHM156N", // 3M interbank — IRSTCB01CHM156N n'existe pas
|
||||
cpiCore: "CHECPICORMINMEI", // indice niveau → MoM%
|
||||
gdp: "CHNGDPNQDSMEI", // indice niveau → QoQ% (peut être stale)
|
||||
retailSales: "CHESLRTTO01GPSAM", // déjà MoM%
|
||||
unemployment: "LRHUTTTTCHQ156S", // trimestriel (LRHUTTTTCHM156S n'existe pas)
|
||||
employment: null, // pas de série FRED pour CHF
|
||||
policyRate: "IR3TIB01CHM156N",
|
||||
cpiCore: "CHECPICORMINMEI", // Core CPI → YoY
|
||||
cpiHeadline: "CHECPIALLMINMEI", // Headline CPI → MoM (si absent sur FRED → fallback core MoM)
|
||||
gdp: "CHNGDPNQDSMEI",
|
||||
retailSales: "CHESLRTTO01GPSAM",
|
||||
unemployment: "LRHUTTTTCHQ156S",
|
||||
employment: null,
|
||||
},
|
||||
CAD: {
|
||||
policyRate: "IR3TIB01CAM156N", // 3M interbank (IRSTCB01CAM156N stale 2023)
|
||||
cpiCore: "CANCPICORMINMEI", // indice niveau → MoM%
|
||||
gdp: "NGDPRSAXDCCAQ", // Real GDP Canada (BEA/StatCan) indice niveau → QoQ%
|
||||
retailSales: "CANSLRTTO01GPSAM", // déjà MoM%
|
||||
policyRate: "IR3TIB01CAM156N",
|
||||
cpiCore: "CANCPICORMINMEI", // Core CPI → YoY
|
||||
cpiHeadline: "CANCPIALLMINMEI", // Headline CPI → MoM (si absent → fallback core MoM)
|
||||
gdp: "NGDPRSAXDCCAQ",
|
||||
retailSales: "CANSLRTTO01GPSAM",
|
||||
unemployment: "LRHUTTTTCAM156S",
|
||||
employment: "LFEMTTTTCAM647S", // niveau mensuel → MoM%
|
||||
employment: "LFEMTTTTCAM647S",
|
||||
},
|
||||
AUD: {
|
||||
policyRate: "IR3TIB01AUM156N", // 3M interbank — IRSTCB01AUM156N n'existe pas
|
||||
cpiCore: "AUSCPIALLQINMEI", // trimestriel (Q = quarterly, ex-AUSCPIALLMINMEI inexistant)
|
||||
gdp: "NGDPRSAXDCAUQ", // Real GDP Australia (ABS) indice niveau → QoQ%
|
||||
retailSales: null, // pas de série mensuelle FRED pour AUD
|
||||
policyRate: "IR3TIB01AUM156N",
|
||||
cpiCore: "AUSCPIALLQINMEI", // trimestriel → YoY
|
||||
cpiHeadline: "AUSCPIALLQINMEI", // même série trimestrielle → QoQ (pas de MoM mensuel AUS)
|
||||
gdp: "NGDPRSAXDCAUQ",
|
||||
retailSales: null,
|
||||
unemployment: "LRHUTTTTAUM156S",
|
||||
employment: "LFEMTTTTAUM647S", // niveau mensuel → MoM%
|
||||
employment: "LFEMTTTTAUM647S",
|
||||
},
|
||||
NZD: {
|
||||
policyRate: "IR3TIB01NZM156N", // 3M interbank — IRSTCB01NZM156N n'existe pas
|
||||
cpiCore: "NZLCPIALLQINMEI", // trimestriel (Q = quarterly, ex-NZLCPIALLMINMEI inexistant)
|
||||
gdp: "NAEXKP01NZQ661S", // indice niveau → QoQ% (stale ~2023, best available)
|
||||
retailSales: null, // pas de série mensuelle FRED pour NZD
|
||||
policyRate: "IR3TIB01NZM156N",
|
||||
cpiCore: "NZLCPIALLQINMEI", // trimestriel → YoY
|
||||
cpiHeadline: "NZLCPIALLQINMEI", // même série trimestrielle → QoQ
|
||||
gdp: "NAEXKP01NZQ657S",
|
||||
retailSales: null,
|
||||
unemployment: "LRUNTTTTNZQ156S",
|
||||
employment: "LFEMTTTTNZQ647S", // niveau trimestriel → QoQ% (proxy)
|
||||
employment: "LFEMTTTTNZQ647S",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Profils pays : énergie et matières premières ──────────────────────────────
|
||||
// Données structurelles stables (mise à jour ~annuelle)
|
||||
// energy: position nette pétrole/gaz du pays vis-à-vis du monde
|
||||
// commodities: principales exportations de matières premières (impact forex notable)
|
||||
export interface CountryProfile {
|
||||
energy: "exporter" | "importer" | "neutral";
|
||||
energyNote: string; // description courte (tooltip)
|
||||
commodities: string[]; // matières clés en cas de choc prix
|
||||
}
|
||||
|
||||
export const COUNTRY_PROFILES: Record<Currency, CountryProfile> = {
|
||||
USD: {
|
||||
energy: "exporter",
|
||||
energyNote: "1er producteur mondial pétrole + gaz (EIA). Exportateur net depuis 2019.",
|
||||
commodities: ["Pétrole", "GNL", "Blé", "Soja", "Maïs"],
|
||||
},
|
||||
EUR: {
|
||||
energy: "importer",
|
||||
energyNote: "Import ~55% énergie (MENA, Russie réduit). Très sensible aux chocs pétrole.",
|
||||
commodities: ["Blé (FR, DE)", "Machines industrielles"],
|
||||
},
|
||||
GBP: {
|
||||
energy: "neutral",
|
||||
energyNote: "Mer du Nord en déclin. Production ≈ consommation (~neutre).",
|
||||
commodities: ["Services financiers"],
|
||||
},
|
||||
JPY: {
|
||||
energy: "importer",
|
||||
energyNote: "Import ~90% énergie. 3ème importateur GNL mondial. Très sensible Détroit d'Hormuz.",
|
||||
commodities: [],
|
||||
},
|
||||
CHF: {
|
||||
energy: "importer",
|
||||
energyNote: "Import ~75% énergie (gaz naturel Europe, pétrole OPEP).",
|
||||
commodities: [],
|
||||
},
|
||||
CAD: {
|
||||
energy: "exporter",
|
||||
energyNote: "3ème réserves mondiales pétrole (sables bitumineux Alberta). Export ~4 Mb/j.",
|
||||
commodities: ["Pétrole", "Gaz naturel", "Blé", "Potasse", "Bois d'œuvre"],
|
||||
},
|
||||
AUD: {
|
||||
energy: "exporter",
|
||||
energyNote: "2ème exportateur GNL mondial. Export charbon thermique + métallurgique.",
|
||||
commodities: ["Minerai de fer", "GNL", "Charbon", "Or", "Blé", "Cuivre"],
|
||||
},
|
||||
NZD: {
|
||||
energy: "importer",
|
||||
energyNote: "Import pétrole. Renouvelables ~85% électricité (hydro), indépendant localement.",
|
||||
commodities: ["Lait / Produits laitiers", "Viande bovine", "Bois", "Laine"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+9
-1
@@ -8,6 +8,14 @@ function signalFromSurprise(surprise: number | null): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Employment change est en milliers (Δk) → seuil 10k pour signal
|
||||
function signalFromDeltaK(deltaK: number | null): number {
|
||||
if (deltaK === null) return 0;
|
||||
if (deltaK > 10) return 1;
|
||||
if (deltaK < -10) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// §4 macro score: -16 to +16
|
||||
export function calcMacroScore(
|
||||
indicators: CurrencyIndicators,
|
||||
@@ -23,7 +31,7 @@ export function calcMacroScore(
|
||||
{ key: "gdp", signal: signalFromSurprise(indicators.gdp.surprise) },
|
||||
{ key: "retailSales", signal: signalFromSurprise(indicators.retailSales.surprise) },
|
||||
{ key: "unemployment", signal: signalFromSurprise(indicators.unemployment.surprise) * -1 }, // inversion : chômage bas = haussier
|
||||
{ key: "employment", signal: signalFromSurprise(indicators.employment.surprise) },
|
||||
{ key: "employment", signal: signalFromDeltaK(indicators.employment.surprise) },
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface DriverData {
|
||||
igSpread: number | null;
|
||||
// Taux & FX
|
||||
dxy: number | null;
|
||||
dxyDelta: number | null; // pts vs clôture précédente (Yahoo Finance DX=F)
|
||||
us10y: number | null;
|
||||
us2y: number | null;
|
||||
curveSlope: number | null;
|
||||
@@ -136,3 +137,17 @@ export interface FXRates {
|
||||
[pair: string]: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SentimentEntry {
|
||||
longPct: number;
|
||||
shortPct: number;
|
||||
pair: string;
|
||||
}
|
||||
|
||||
export interface CotEntry {
|
||||
net: number;
|
||||
longPct: number;
|
||||
shortPct: number;
|
||||
totalLev: number;
|
||||
weekDate: string;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user