auto: sync 2026-06-01 23:24

This commit is contained in:
Capucine Gest
2026-06-01 23:24:32 +02:00
parent eb416be780
commit c1b3e54c28
13 changed files with 1537 additions and 307 deletions
+133 -53
View File
@@ -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;
}