mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-17 14:38:05 +00:00
auto: sync 2026-06-01 23:24
This commit is contained in:
+87
-54
@@ -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 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> {
|
||||
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 }> = {};
|
||||
|
||||
function parseCOT(csv: string): Record<string, CotEntry> {
|
||||
const lines = csv.split("\n");
|
||||
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(row[longIdx] ?? "0", 10);
|
||||
const shorts = parseInt(row[shortIdx] ?? "0", 10);
|
||||
const total = longs + shorts;
|
||||
const net = longs - shorts;
|
||||
const longs = parseInt(cols[IDX_LEV_LONG]?.trim() ?? "0", 10);
|
||||
const shorts = parseInt(cols[IDX_LEV_SHORT]?.trim() ?? "0", 10);
|
||||
if (isNaN(longs) || isNaN(shorts)) continue;
|
||||
|
||||
const currency = (Object.entries(COT_CODES) as [Currency, string][]).find(
|
||||
([, c]) => c === code
|
||||
)?.[0];
|
||||
const total = longs + shorts;
|
||||
const net = longs - shorts;
|
||||
const weekDate = cols[IDX_DATE]?.trim() ?? "";
|
||||
|
||||
const currency = (Object.entries(COT_CODES) as [Currency, string][])
|
||||
.find(([, c]) => c === code)?.[0];
|
||||
if (!currency) continue;
|
||||
|
||||
result[currency] = {
|
||||
net,
|
||||
longPct: total > 0 ? Math.round((longs / total) * 100) : 50,
|
||||
longPct: total > 0 ? Math.round((longs / total) * 100) : 50,
|
||||
shortPct: total > 0 ? Math.round((shorts / total) * 100) : 50,
|
||||
totalLev: total,
|
||||
weekDate,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Gère les champs entourés de guillemets doubles dans un CSV */
|
||||
function splitCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === "," && !inQuotes) {
|
||||
result.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
result.push(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
+103
-43
@@ -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 data = await res.json();
|
||||
const rates = data.rates as Record<string, number>;
|
||||
const res = await fetch("https://api.frankfurter.app/latest?from=USD", { next: { revalidate: 300 } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return { rates: data.rates as Record<string, number>, date: data.date as string };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// 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;
|
||||
// ── GET ───────────────────────────────────────────────────────────────────────
|
||||
export async function GET() {
|
||||
// Fetch Yahoo DXY in parallel with AV rates setup
|
||||
const [yahooDxy] = await Promise.all([fetchYahooDXY()]);
|
||||
|
||||
return NextResponse.json({ rates, dxy, base: "USD", source: "frankfurter", date: data.date });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: String(err) }, { status: 502 });
|
||||
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" } }
|
||||
);
|
||||
}
|
||||
+133
-53
@@ -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 },
|
||||
}
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const buckets: { price: string; longCountPercent: string; shortCountPercent: string }[] =
|
||||
data?.positionBook?.buckets ?? [];
|
||||
// Session expired → try once more with fresh login
|
||||
if (!symbols) {
|
||||
_session = null;
|
||||
const fresh = await login();
|
||||
if (fresh) symbols = await fetchOutlook(fresh);
|
||||
}
|
||||
|
||||
let totalLong = 0;
|
||||
let totalShort = 0;
|
||||
for (const b of buckets) {
|
||||
totalLong += parseFloat(b.longCountPercent ?? "0");
|
||||
totalShort += parseFloat(b.shortCountPercent ?? "0");
|
||||
}
|
||||
const total = totalLong + totalShort;
|
||||
if (total === 0) return;
|
||||
results[p] = {
|
||||
pair: p,
|
||||
longPct: Math.round((totalLong / total) * 100),
|
||||
shortPct: Math.round((totalShort / total) * 100),
|
||||
};
|
||||
} catch {
|
||||
// silently skip unavailable pairs
|
||||
}
|
||||
})
|
||||
);
|
||||
if (!symbols) {
|
||||
return NextResponse.json({ error: "Myfxbook community outlook unavailable" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ pairs: results, source: "OANDA", timestamp: Date.now() });
|
||||
const result: MyfxbookSentiment = { symbols, source: "myfxbook", timestamp: Date.now() };
|
||||
_cache = { data: result, ts: Date.now() };
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
// ── Helper interne : traduit symbols[] en {CCY: {longPct, shortPct}} ─────────
|
||||
function symbolsToCurrencyMap(symbols: MyfxbookSymbol[]): Record<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,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user