feat: dashboard v8 — COT, FX Weekly, Report, TvChart, Sentiment, News

Ajout des onglets COT, Weekly Report, TvChart. Refonte Sentiment, News,
Calendar, Drivers. Nouvelles API cot-history et fx-weekly. Intégration FinancialJuice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Capucine Gest
2026-06-08 14:33:23 +02:00
co-authored by Claude Sonnet 4.6
parent e11d61eb4d
commit 1a2ea02b22
15 changed files with 2284 additions and 389 deletions
+174
View File
@@ -0,0 +1,174 @@
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<Currency, CotWeek[]>; // Leveraged Money — Hedge Funds
legacy: Record<Currency, CotWeek[]>; // 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<string, Currency> {
return Object.fromEntries(
(Object.entries(COT_CODES) as [Currency, string][]).map(([ccy, code]) => [code, ccy])
);
}
function initByCcy<T>(): Record<string, Map<string, T>> {
return Object.fromEntries(Object.keys(COT_CODES).map(k => [k, new Map<string, T>()]));
}
function toWeeks(maps: Record<string, Map<string, CotWeek>>): Record<Currency, CotWeek[]> {
const result = {} as Record<Currency, CotWeek[]>;
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<Currency, CotWeek[]> {
const codes = codeMap();
const maps = initByCcy<CotWeek>();
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<Currency, CotWeek[]> {
const codes = codeMap();
const maps = initByCcy<CotWeek>();
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<unknown[]> {
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 });
}
}
+24 -6
View File
@@ -31,21 +31,38 @@ 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;
// In-memory cache — expire le vendredi suivant à 15h30 UTC (publication CFTC)
// TTL max 4 jours pour garantir refresh chaque semaine
let _cache: { data: Record<string, unknown>; ts: number; weekDate: string } | null = null;
function nextCftcRelease(): number {
const now = new Date();
const d = new Date(now);
// Prochain vendredi 15:30 UTC
const daysUntilFriday = (5 - d.getUTCDay() + 7) % 7 || 7;
d.setUTCDate(d.getUTCDate() + daysUntilFriday);
d.setUTCHours(15, 30, 0, 0);
return d.getTime();
}
function cacheTtl(): number {
const ttlToRelease = nextCftcRelease() - Date.now();
// max 4 jours pour éviter de bloquer sur de vieilles données
return Math.min(ttlToRelease, 4 * 24 * 3600_000);
}
export type { CotEntry } from "@/lib/types";
export async function GET() {
if (_cache && Date.now() - _cache.ts < TTL) {
if (_cache && Date.now() - _cache.ts < cacheTtl()) {
return NextResponse.json(_cache.data);
}
try {
const res = await fetch(CFTC_URL, {
next: { revalidate: 86400 * 7 },
next: { revalidate: 86400 }, // revalidate quotidien — CFTC sort chaque vendredi
headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" },
cache: "no-store", // forcer fetch frais pour l'in-memory cache ci-dessus
});
if (!res.ok) {
return NextResponse.json({ error: `CFTC fetch failed: ${res.status}` }, { status: 502 });
@@ -54,7 +71,8 @@ export async function GET() {
const text = await res.text();
const result = parseCOT(text);
_cache = { data: result, ts: Date.now() };
const weekDate = Object.values(result as Record<string, CotEntry>)[0]?.weekDate ?? "";
_cache = { data: result, ts: Date.now(), weekDate };
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 502 });
+124
View File
@@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
// Calcule la performance hebdomadaire G10 en % vs USD
// en comparant le vendredi de la semaine en cours au vendredi précédent
export interface FxWeeklyEntry {
ccy: string;
pct: number; // % change vs USD, positif = a apprécié
current: number | null; // taux actuel (unités par USD)
prev: number | null; // taux semaine précédente
}
export interface FxWeeklyData {
weekFrom: string; // "YYYY-MM-DD" — début de semaine (lundi)
weekTo: string; // "YYYY-MM-DD" — fin de semaine (vendredi)
prevFri: string; // "YYYY-MM-DD" — vendredi précédent
currencies: FxWeeklyEntry[];
}
const G10_CCYS = ["EUR","GBP","JPY","CHF","CAD","AUD","NZD"];
// DXY basket weights pour USD
const DXY_WEIGHTS: Record<string, number> = {
EUR: 0.576, JPY: 0.136, GBP: 0.119, CAD: 0.091, SEK: 0.042, CHF: 0.036,
};
let _cache: { data: FxWeeklyData; ts: number; key: string } | null = null;
const TTL = 3600_000; // 1h
// Renvoie le vendredi de la semaine complétée la plus récente.
// Dimanche → vendredi d'avant-hier (la semaine lun-ven vient de se terminer).
// Lundi…jeudi → vendredi de la semaine précédente (la semaine courante n'est pas finie).
// Vendredi → aujourd'hui. Samedi → hier.
function lastFriday(from?: Date): Date {
const d = from ? new Date(from) : new Date();
const day = d.getDay(); // 0=dim … 6=sam
const sub = (day - 5 + 7) % 7; // 0 si ven, 1 si sam, 2 si dim, 3 si lun, …, 6 si jeu
d.setDate(d.getDate() - sub);
d.setHours(0, 0, 0, 0);
return d;
}
function prevFridayFrom(fri: Date): Date {
const d = new Date(fri);
d.setDate(d.getDate() - 7);
return d;
}
function toISO(d: Date): string { return d.toISOString().slice(0, 10); }
async function fetchRates(date: string): Promise<Record<string, number> | null> {
try {
const ccys = [...G10_CCYS, "SEK"].join(",");
const res = await fetch(
`https://api.frankfurter.app/${date}?from=USD&to=${ccys}`,
{ next: { revalidate: 3600 }, headers: { "Accept": "application/json" } }
);
if (!res.ok) return null;
const json = await res.json() as { rates?: Record<string, number> };
return json.rates ?? null;
} catch { return null; }
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// Optionnel : ?weekTo=YYYY-MM-DD pour forcer la semaine
const forcedFriday = searchParams.get("weekTo");
const thisFri = forcedFriday ? new Date(forcedFriday) : lastFriday();
const prevFri = prevFridayFrom(thisFri);
const cacheKey = toISO(thisFri);
if (_cache && _cache.key === cacheKey && Date.now() - _cache.ts < TTL) {
return NextResponse.json(_cache.data);
}
const [currRates, prevRates] = await Promise.all([
fetchRates(toISO(thisFri)),
fetchRates(toISO(prevFri)),
]);
if (!currRates || !prevRates) {
return NextResponse.json({ error: "Frankfurter unavailable" }, { status: 502 });
}
// Calcul % pour chaque devise G10
const entries: FxWeeklyEntry[] = G10_CCYS.map(ccy => {
const curr = currRates[ccy] ?? null;
const prev = prevRates[ccy] ?? null;
let pct = 0;
if (curr && prev && prev > 0) {
// rate = units of CCY per 1 USD
// Si rate diminue → CCY apprécie → pct positif
pct = ((prev - curr) / prev) * 100;
}
return { ccy, pct: Math.round(pct * 10) / 10, current: curr, prev };
});
// USD : inverse pondéré du panier DXY
let usdPct = 0;
let totalWeight = 0;
for (const [ccy, w] of Object.entries(DXY_WEIGHTS)) {
const entry = entries.find(e => e.ccy === ccy);
if (entry) { usdPct -= entry.pct * w; totalWeight += w; }
}
if (totalWeight > 0) usdPct /= totalWeight;
entries.unshift({ ccy: "USD", pct: Math.round(usdPct * 10) / 10, current: 1, prev: 1 });
// Trier du plus fort au plus faible
entries.sort((a, b) => b.pct - a.pct);
// Lundi de la semaine thisFri
const monday = new Date(thisFri);
monday.setDate(thisFri.getDate() - 4);
const data: FxWeeklyData = {
weekFrom: toISO(monday),
weekTo: toISO(thisFri),
prevFri: toISO(prevFri),
currencies: entries,
};
_cache = { data, ts: Date.now(), key: cacheKey };
return NextResponse.json(data);
}
+23 -1
View File
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
}
let body: {
mode: "cb_analysis" | "expert_opinion" | "summary" | "divergence";
mode: "cb_analysis" | "expert_opinion" | "summary" | "divergence" | "report_ccy";
currency?: string;
data?: unknown;
userInput?: string;
@@ -72,6 +72,28 @@ ${JSON.stringify(data, null, 2)}
Explique en 3 phrases : pourquoi cette configuration est significative et quelle action de trading elle suggère.`;
break;
case "report_ccy": {
const d = data as {
weeklyPct?: string; weekFrom?: string; weekTo?: string;
cotNetTff?: number; cotDeltaTff?: number; cotLongPctTff?: number;
cotNetLegacy?: number; cotDeltaLegacy?: number;
yieldCurrent?: number; yieldDelta?: number;
hint?: string;
};
const weekRange = d.weekFrom && d.weekTo ? `${d.weekFrom} au ${d.weekTo}` : "de la semaine";
userMessage = `Rédige le paragraphe d'analyse hebdomadaire pour ${currency} (semaine du ${weekRange}).
Données disponibles :
- Performance hebdomadaire vs USD : ${d.weeklyPct ?? "N/D"}
- COT Hedge Funds (TFF) : net ${d.cotNetTff ?? "N/D"} contrats, variation semaine : ${d.cotDeltaTff != null ? (d.cotDeltaTff > 0 ? "+" : "") + d.cotDeltaTff : "N/D"}, % long : ${d.cotLongPctTff ?? "N/D"}%
- COT Non-Commercial (Legacy) : net ${d.cotNetLegacy ?? "N/D"} contrats, variation : ${d.cotDeltaLegacy != null ? (d.cotDeltaLegacy > 0 ? "+" : "") + d.cotDeltaLegacy : "N/D"}
- Rendement obligataire : ${d.yieldCurrent != null ? d.yieldCurrent + "%" : "N/D"}${d.yieldDelta != null ? (d.yieldDelta > 0 ? "+" : "") + d.yieldDelta + "%" : "N/D"})
${d.hint ? `- Contexte additionnel : ${d.hint}` : ""}
Rédige un paragraphe fluide de 80 à 120 mots, style analyste macro professionnel. Structure : (1) performance et catalyseurs de la semaine, (2) lecture institutionnelle (COT), (3) niveau ou seuil clé à surveiller. Pas de bullet points — texte continu.`;
break;
}
case "summary":
default:
userMessage = `Génère une synthèse macro hebdomadaire pour ${currency} basée sur ces données :
+2 -2
View File
@@ -5,7 +5,7 @@ import type { NewsItem } from "@/lib/newsfeed";
export type { NewsItem } from "@/lib/newsfeed";
let _cache: { data: NewsItem[]; ts: number } | null = null;
const TTL = 30 * 60_000; // 30 min
const TTL = 5 * 60_000; // 5 min — actualités fraîches
export async function GET() {
if (_cache && Date.now() - _cache.ts < TTL) {
@@ -17,6 +17,6 @@ export async function GET() {
return NextResponse.json(
{ items, fetchedAt: new Date().toISOString() },
{ headers: { "Cache-Control": "s-maxage=1800, stale-while-revalidate=3600" } }
{ headers: { "Cache-Control": "s-maxage=300, stale-while-revalidate=600" } }
);
}
+37 -5
View File
@@ -12,8 +12,11 @@ import CalendarTab from "@/components/CalendarTab";
import SentimentPairsTab from "@/components/SentimentPairsTab";
import YieldsTab from "@/components/YieldsTab";
import NewsTab from "@/components/NewsTab";
import CotTab from "@/components/CotTab";
import ReportTab from "@/components/ReportTab";
import type { CalendarEvent } from "@/app/api/calendar/route";
import type { NewsItem } from "@/app/api/news/route";
import type { CotHistory } from "@/app/api/cot-history/route";
const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000");
@@ -25,10 +28,12 @@ export default function Dashboard() {
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" | "yields" | "news">("dashboard");
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields" | "news" | "cot" | "report">("dashboard");
const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
const [newsLoading, setNewsLoading] = useState(false);
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }> | null>(null);
const [cotHistory, setCotHistory] = useState<CotHistory | null>(null);
const [cotLoading, setCotLoading] = useState(false);
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; longVolume: number; shortVolume: number; longPositions: number; shortPositions: number; totalPositions: number; avgLongPrice?: number; avgShortPrice?: number }> | null>(null);
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
@@ -174,7 +179,7 @@ export default function Dashboard() {
// ── 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 }>;
const syms = sentimentRes.value.symbols as Array<{ name: string; longPercentage: number; shortPercentage: number; longVolume: number; shortVolume: number; longPositions: number; shortPositions: number; totalPositions: number; avgLongPrice?: number; avgShortPrice?: number }>;
setRawSymbols(syms);
const mapped = parseSentimentSymbols(syms);
setSentiment(mapped);
@@ -233,6 +238,23 @@ export default function Dashboard() {
if (activeTab === "news" && newsItems.length === 0) refreshNews();
}, [activeTab, newsItems.length, refreshNews]);
const refreshCotHistory = useCallback(async () => {
setCotLoading(true);
try {
const res = await fetch("/api/cot-history");
if (res.ok) {
const json = await res.json();
if (!json.error) setCotHistory(json as CotHistory);
}
} finally {
setCotLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === "cot" && !cotHistory) refreshCotHistory();
}, [activeTab, cotHistory, refreshCotHistory]);
const handleDivergenceUpdate = useCallback((currency: Currency, score: number) => {
setActiveDivergences((prev) => {
const filtered = prev.filter((d) => d.currency !== currency);
@@ -292,7 +314,7 @@ export default function Dashboard() {
{/* Tab navigation */}
<div className="flex gap-0 border-b border-slate-800 mb-4">
{(["dashboard", "calendar", "pairs", "yields", "news"] as const).map((tab) => (
{(["dashboard", "calendar", "pairs", "yields", "news", "cot", "report"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -306,7 +328,9 @@ export default function Dashboard() {
: tab === "calendar" ? "📅 Calendrier"
: tab === "pairs" ? "↕ Paires"
: tab === "yields" ? "📈 Yields 10Y"
: "📰 Actualités"}
: tab === "news" ? "📰 Actualités"
: tab === "cot" ? "📊 COT"
: "📋 Rapport"}
</button>
))}
</div>
@@ -371,6 +395,14 @@ export default function Dashboard() {
<NewsTab items={newsItems} loading={newsLoading} onRefresh={refreshNews} />
)}
{activeTab === "cot" && (
<CotTab history={cotHistory} loading={cotLoading} />
)}
{activeTab === "report" && (
<ReportTab calEvents={calEvents} drivers={drivers} cotHistory={cotHistory} />
)}
{/* Legend */}
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center gap-5 flex-wrap text-[10px] text-slate-600">
<div className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" /> Haussier / Sous-évalué</div>