import { NextResponse } from "next/server"; import { COT_CODES } from "@/lib/constants"; import type { Currency } from "@/lib/types"; const SODA_BASE = "https://publicreporting.cftc.gov/resource"; const CODES_LIST = Object.values(COT_CODES).map(c => `'${c}'`).join(","); const WHERE = `cftc_contract_market_code in(${CODES_LIST}) AND futonly_or_combined='FutOnly'`; const ORDER = "report_date_as_yyyy_mm_dd DESC"; const LIMIT = 250; // 8 devises × 26 semaines = 208 lignes max // ── Types ───────────────────────────────────────────────────────────────────── export interface CotWeek { weekDate: string; net: number; longPct: number; shortPct: number; totalLev: number; deltaNet: number | null; // changement net semaine en semaine (depuis l'API) deltaLong: number | null; // contrats longs ajoutés/retirés deltaShort: number | null; // contrats shorts ajoutés/retirés } export interface CotHistory { tff: Record; // Leveraged Money — Hedge Funds legacy: Record; // Non-Commercial — tous spéculateurs } // ── Cache ───────────────────────────────────────────────────────────────────── let _cache: { data: CotHistory; ts: number } | null = null; function cacheTtl(): number { const d = new Date(); const daysUntilFriday = (5 - d.getUTCDay() + 7) % 7 || 7; d.setUTCDate(d.getUTCDate() + daysUntilFriday); d.setUTCHours(15, 30, 0, 0); return Math.min(d.getTime() - Date.now(), 4 * 24 * 3600_000); } // ── Helpers ─────────────────────────────────────────────────────────────────── function codeMap(): Record { return Object.fromEntries( (Object.entries(COT_CODES) as [Currency, string][]).map(([ccy, code]) => [code, ccy]) ); } function initByCcy(): Record> { return Object.fromEntries(Object.keys(COT_CODES).map(k => [k, new Map()])); } function toWeeks(maps: Record>): Record { const result = {} as Record; for (const [ccy, m] of Object.entries(maps)) { result[ccy as Currency] = Array.from(m.values()) .sort((a, b) => b.weekDate.localeCompare(a.weekDate)) .slice(0, 26); } return result; } function int(v: string | undefined): number { const n = parseInt(v ?? "0", 10); return isNaN(n) ? 0 : n; } // ── Parseurs ────────────────────────────────────────────────────────────────── interface TffRow { cftc_contract_market_code: string; report_date_as_yyyy_mm_dd: string; lev_money_positions_long: string; lev_money_positions_short: string; change_in_lev_money_long: string; change_in_lev_money_short: string; } function parseTff(rows: TffRow[]): Record { const codes = codeMap(); const maps = initByCcy(); for (const row of rows) { const ccy = codes[row.cftc_contract_market_code]; if (!ccy) continue; const longs = int(row.lev_money_positions_long); const shorts = int(row.lev_money_positions_short); const total = longs + shorts; const dL = int(row.change_in_lev_money_long); const dS = int(row.change_in_lev_money_short); const weekDate = row.report_date_as_yyyy_mm_dd?.slice(0, 10); if (!weekDate) continue; maps[ccy].set(weekDate, { weekDate, net: longs - shorts, longPct: total > 0 ? Math.round(longs / total * 100) : 50, shortPct: total > 0 ? Math.round(shorts / total * 100) : 50, totalLev: total, deltaNet: dL - dS, deltaLong: dL, deltaShort: dS, }); } return toWeeks(maps); } interface LegacyRow { cftc_contract_market_code: string; report_date_as_yyyy_mm_dd: string; noncomm_positions_long_all: string; noncomm_positions_short_all:string; change_in_noncomm_long_all: string; change_in_noncomm_short_all:string; } function parseLegacy(rows: LegacyRow[]): Record { const codes = codeMap(); const maps = initByCcy(); for (const row of rows) { const ccy = codes[row.cftc_contract_market_code]; if (!ccy) continue; const longs = int(row.noncomm_positions_long_all); const shorts = int(row.noncomm_positions_short_all); const total = longs + shorts; const dL = int(row.change_in_noncomm_long_all); const dS = int(row.change_in_noncomm_short_all); const weekDate = row.report_date_as_yyyy_mm_dd?.slice(0, 10); if (!weekDate) continue; maps[ccy].set(weekDate, { weekDate, net: longs - shorts, longPct: total > 0 ? Math.round(longs / total * 100) : 50, shortPct: total > 0 ? Math.round(shorts / total * 100) : 50, totalLev: total, deltaNet: dL - dS, deltaLong: dL, deltaShort: dS, }); } return toWeeks(maps); } // ── Route ───────────────────────────────────────────────────────────────────── async function sodaFetch(dataset: string): Promise { const url = `${SODA_BASE}/${dataset}.json?$where=${encodeURIComponent(WHERE)}&$limit=${LIMIT}&$order=${encodeURIComponent(ORDER)}`; const res = await fetch(url, { cache: "no-store", headers: { "Accept": "application/json" } }); if (!res.ok) throw new Error(`SODA ${dataset} failed: ${res.status}`); return res.json(); } export async function GET() { if (_cache && Date.now() - _cache.ts < cacheTtl()) { return NextResponse.json(_cache.data); } try { const [tffRows, legacyRows] = await Promise.all([ sodaFetch("gpe5-46if"), // TFF Futures Only sodaFetch("6dca-aqww"), // Legacy Futures Only ]); const data: CotHistory = { tff: parseTff(tffRows as TffRow[]), legacy: parseLegacy(legacyRows as LegacyRow[]), }; _cache = { data, ts: Date.now() }; return NextResponse.json(data); } catch (err) { return NextResponse.json({ error: String(err) }, { status: 502 }); } }