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
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>
+138 -132
View File
@@ -9,7 +9,7 @@ import type { CalendarEvent } from "@/app/api/calendar/route";
interface Props {
events: CalendarEvent[];
loading: boolean;
nextWeekAvail: boolean; // nextweek.json disponible sur le CDN FF
nextWeekAvail: boolean;
}
const CATEGORY_LABELS: Record<string, string> = {
@@ -23,12 +23,6 @@ const CATEGORY_LABELS: Record<string, string> = {
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 {
@@ -37,9 +31,10 @@ function isoToLocalDate(iso: string): string {
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 };
return {
day: d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "short" }),
time: d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }),
};
}
function fmtDayLabel(dateStr: string): string {
@@ -54,31 +49,19 @@ function todayIso(): string {
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));
const d = new Date(now);
d.setDate(now.getDate() + (now.getDay() === 0 ? 1 : 8 - now.getDay()));
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 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)}`,
@@ -89,60 +72,76 @@ function getWeekBounds() {
};
}
// ── Sub-components ────────────────────────────────────────────────────────────
// ── Impact dot ────────────────────────────────────────────────────────────────
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"}`} />;
const cls = impact === "high" ? "bg-red-500"
: impact === "medium" ? "bg-amber-400"
: "bg-slate-600";
return <span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${cls}`} />;
}
// ── Event row ─────────────────────────────────────────────────────────────────
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(" ");
const borderCls = !ev.isPublished && ev.impact === "high" ? "border-l-2 border-l-red-500"
: !ev.isPublished && ev.impact === "medium" ? "border-l-2 border-l-amber-400"
: "border-l-2 border-l-transparent";
return (
<tr className={rowCls}>
<tr className={`border-b border-slate-800/60 hover:bg-slate-800/30 transition-colors ${borderCls} ${ev.isPublished ? "opacity-60" : ""} ${isChild ? "bg-slate-900/40" : ""}`}>
<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>
<div className="text-xs font-medium text-slate-300">{day}</div>
<div className="text-[10px] text-slate-600">{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>
<span className="ml-1 text-xs font-semibold text-slate-300">{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]" : ""}`}
className={`flex items-center gap-1 text-left text-[12px] ${
ev.isGroupParent
? "cursor-pointer font-medium text-slate-200 hover:text-amber-400"
: "text-slate-400"
} ${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.isGroupParent && (
expanded
? <ChevronDown size={12} className="text-slate-500 flex-shrink-0" />
: <ChevronRight size={12} className="text-slate-500 flex-shrink-0" />
)}
{isChild && <span className="text-slate-600 mr-1"></span>}
{ev.title}
</button>
<div className="text-[9px] text-gray-400 mt-0.5 pl-4">{CATEGORY_LABELS[ev.category]}</div>
<div className="text-[9px] text-slate-600 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>
<span className="text-xs text-slate-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>}
? <span className="text-xs font-medium text-amber-400 tabular-nums">{ev.forecast}</span>
: <span className="text-xs text-slate-700"></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>}
? <span className={`text-xs font-semibold tabular-nums ${ev.isPublished ? "text-slate-200" : "text-slate-500"}`}>{ev.actual}</span>
: <span className="text-xs text-slate-700"></span>}
</td>
<td className="py-2 px-3 text-center">
<ImpactDot impact={ev.impact} />
</td>
@@ -150,44 +149,38 @@ function EventRow({ ev, isChild, expanded, onToggle }: {
);
}
// ── Main component ────────────────────────────────────────────────────────────
// ── Main ──────────────────────────────────────────────────────────────────────
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 [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 { currentWeekLabel, nextWeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
void nextMondayIso;
const toggle = (groupKey: string) =>
setExpanded((prev) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(groupKey)) next.delete(groupKey); else next.add(groupKey);
next.has(groupKey) ? next.delete(groupKey) : 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]);
const filtered = useMemo(() => 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;
if (weekTab === "current" && ev.week !== "current") return false;
if (weekTab === "next" && ev.week !== "next") return false;
if (weekTab === "next2" && ev.week !== "next2") return false;
if (isoToLocalDate(ev.date) < 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) {
@@ -197,63 +190,66 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
}
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">
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100">
<div className="px-4 py-3 border-b border-slate-800">
<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>
<h2 className="text-sm font-semibold text-slate-200">Calendrier économique</h2>
<p className="text-[10px] text-slate-600 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" />
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer">
<input
type="checkbox"
checked={showLow}
onChange={e => setShowLow(e.target.checked)}
className="w-3 h-3 accent-amber-500"
/>
Impact faible
</label>
</div>
</div>
{/* ── Onglets semaine ──────────────────────────────────────────────────── */}
<div className="flex gap-0 border-b border-gray-200 bg-gray-50/50">
{/* Onglets semaine */}
<div className="flex gap-0 border-b border-slate-800 bg-slate-900/40">
{([
["all", "Tout", null, null],
["current","Sem. en cours", currentWeekLabel, countCurrent],
["next", "Sem. prochaine", nextWeekLabel, countNext],
["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2],
["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";
const isActive = weekTab === tab;
const disabled = tab === "next" && !nextWeekAvail && countNext === 0;
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"
isActive ? "border-amber-500 text-amber-400 bg-slate-900/60" :
disabled ? "border-transparent text-slate-700 cursor-not-allowed" :
"border-transparent text-slate-500 hover:text-slate-300 hover:bg-slate-800/40"
}`}
>
<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>
<span className="text-[8px] bg-amber-500/20 text-amber-400 border border-amber-500/30 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"}`}>
<div className={`text-[9px] mt-0.5 ${isActive ? "text-amber-500/70" : disabled ? "text-slate-700" : "text-slate-600"}`}>
{disabled ? "Dispo lundi (retry auto)" : sub}
</div>
)}
{tab !== "all" && typeof count === "number" && (
<div className={`text-[9px] ${noData ? "text-gray-300" : "text-gray-400"}`}>
<div className={`text-[9px] ${count === 0 ? "text-slate-700" : "text-slate-500"}`}>
{count} événement{count !== 1 ? "s" : ""}
</div>
)}
@@ -262,41 +258,47 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
})}
</div>
{/* ── Filtre devise + date ──────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-gray-100">
{/* Date depuis */}
{/* Filtre devise + date */}
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-slate-800 bg-slate-900/20">
<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>
<Calendar size={11} className="text-slate-600" />
<span className="text-[10px] text-slate-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"
onChange={e => setFromDate(e.target.value)}
className="text-[10px] bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-slate-300 focus:outline-none focus:border-amber-500/50"
/>
<button
onClick={() => setFromDate(todayIso())}
className="text-[9px] text-blue-500 hover:text-blue-700 underline"
className="text-[9px] text-amber-500 hover:text-amber-400 underline"
>
Aujourd&apos;hui
</button>
</div>
<div className="w-px h-4 bg-gray-200 shrink-0" />
<div className="w-px h-4 bg-slate-700 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"}`}
className={`px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors ${
filterCcy === "ALL"
? "bg-slate-200 text-slate-900"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
}`}
>
Tout
</button>
{CURRENCIES.map((ccy) => (
{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"}`}
className={`flex items-center gap-0.5 px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors ${
filterCcy === ccy
? "bg-amber-500 text-slate-900"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
}`}
>
{CURRENCY_META[ccy].flag} {ccy}
</button>
@@ -304,22 +306,22 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
</div>
</div>
{/* ── Table ────────────────────────────────────────────────────────────── */}
{/* Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={20} className="animate-spin text-gray-300" />
<Loader2 size={20} className="animate-spin text-slate-600" />
</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>
<p className="text-sm text-slate-500">Aucun événement pour cette sélection</p>
{weekTab === "next" && !nextWeekAvail && (
<p className="text-[10px] text-gray-400 mt-1">
<p className="text-[10px] text-slate-600 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">
<button onClick={() => setFromDate(todayIso())} className="mt-2 text-[10px] text-amber-500 underline">
Revenir à aujourd&apos;hui
</button>
)}
@@ -328,7 +330,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
<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">
<tr className="bg-slate-900/60 text-[10px] font-semibold text-slate-500 uppercase tracking-wider border-b border-slate-800">
<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>
@@ -341,35 +343,39 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
<tbody>
{(() => {
const rows: React.ReactNode[] = [];
let lastWeek: "current" | "next" | "next2" | null = null;
let lastWeek: string | null = null;
for (const day of days) {
const dayEvents = dayMap[day];
if (!dayEvents?.length) continue;
const w = dayEvents[0].week;
// Séparateur de semaine
// Séparateur 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`,
current: `Semaine en cours — ${currentWeekLabel}`,
next: `Semaine prochaine — ${nextWeekLabel}`,
next2: `À partir du ${next2StartLabel} — réunions BC + données`,
};
const isNext2 = w === "next2";
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}
<tr key={`wsep_${w}`} className={isNext2 ? "bg-amber-500/15" : "bg-indigo-500/15"}>
<td colSpan={7} className={`px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest ${isNext2 ? "text-amber-400" : "text-indigo-400"}`}>
📅 {weekBanners[w] ?? w}
</td>
</tr>
);
}
// Séparateur de jour
// Séparateur 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">
<tr key={`dsep_${day}`} className="bg-slate-800/50">
<td colSpan={7} className="px-3 py-1.5 text-[10px] font-semibold text-slate-400 capitalize">
{fmtDayLabel(day)}
</td>
</tr>
);
for (const ev of dayEvents) {
rows.push(
<EventRow
@@ -390,11 +396,11 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
)}
{/* Legend */}
<div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-500">
<div className="flex items-center gap-4 px-4 py-2.5 border-t border-slate-800 text-[10px] text-slate-600">
<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>
<span className="hidden sm:inline">· Cliquer sur une ligne groupée pour voir les sous-indicateurs</span>
<span className="hidden sm:inline">· Prévision = consensus marché avant publication</span>
</div>
</div>
);
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useState } from "react";
import {
ComposedChart, Bar, Cell, Line, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from "recharts";
import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp } from "lucide-react";
import type { Currency } from "@/lib/types";
import type { CotWeek, CotHistory } from "@/app/api/cot-history/route";
import { CURRENCY_META } from "@/lib/constants";
interface Props {
history: CotHistory | null;
loading: boolean;
}
const CURRENCIES: Currency[] = ["EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF", "USD"];
function formatNet(n: number): string {
const abs = Math.abs(n);
const sign = n >= 0 ? "+" : "-";
return abs >= 1000 ? `${sign}${(abs / 1000).toFixed(1)}k` : `${sign}${abs}`;
}
// ── Sparkline SVG ─────────────────────────────────────────────────────────────
function Sparkline({ weeks }: { weeks: CotWeek[] }) {
const pts = [...weeks].reverse().slice(-8);
if (pts.length < 2) return <div className="w-16 h-6 text-slate-700 text-[10px] flex items-center"></div>;
const vals = pts.map(w => w.net);
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const W = 64, H = 24, PAD = 2;
const points = vals.map((v, i) => {
const x = PAD + (i / (vals.length - 1)) * (W - PAD * 2);
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
return `${x},${y}`;
}).join(" ");
const latest = vals[vals.length - 1];
const color = latest >= 0 ? "#10b981" : "#ef4444";
const lastX = PAD + (W - PAD * 2);
const lastY = H - PAD - ((latest - min) / range) * (H - PAD * 2);
return (
<svg width={W} height={H} className="overflow-visible">
<polyline points={points} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" />
<circle cx={lastX} cy={lastY} r={2.5} fill={color} />
{min < 0 && max > 0 && (
<line
x1={PAD} x2={W - PAD}
y1={H - PAD - ((0 - min) / range) * (H - PAD * 2)}
y2={H - PAD - ((0 - min) / range) * (H - PAD * 2)}
stroke="#475569" strokeWidth={0.5} strokeDasharray="2 2"
/>
)}
</svg>
);
}
// ── Tooltip ───────────────────────────────────────────────────────────────────
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: Array<{ value: number; name: string }>;
label?: string;
}) {
if (!active || !payload?.length) return null;
const net = payload.find(p => p.name === "net")?.value ?? 0;
const longPct = payload.find(p => p.name === "longPct")?.value ?? 0;
const deltaNet = payload.find(p => p.name === "deltaNet")?.value;
return (
<div className="bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-xs shadow-xl space-y-0.5">
<p className="text-slate-400 font-medium">{label}</p>
<p className={`font-bold ${net >= 0 ? "text-emerald-400" : "text-red-400"}`}>Net : {formatNet(net)}</p>
<p className="text-slate-300">{longPct}% L / {100 - longPct}% S</p>
{deltaNet !== undefined && deltaNet !== null && (
<p className={`text-[11px] ${deltaNet > 0 ? "text-emerald-400" : deltaNet < 0 ? "text-red-400" : "text-slate-500"}`}>
Δ semaine : {formatNet(deltaNet)}
</p>
)}
</div>
);
}
// ── Carte devise ──────────────────────────────────────────────────────────────
function CurrencyCard({ ccy, weeks, selected, onClick }: {
ccy: Currency; weeks: CotWeek[]; selected: boolean; onClick: () => void;
}) {
const latest = weeks[0];
const d = latest?.deltaNet ?? null;
const meta = CURRENCY_META[ccy];
const bias = latest ? (latest.longPct > 60 ? "bull" : latest.shortPct > 60 ? "bear" : "neu") : "neu";
return (
<button
onClick={onClick}
className={`flex flex-col gap-1.5 p-3 rounded-xl border transition-all text-left w-full ${
selected
? "bg-slate-800/80 border-amber-500/50"
: "bg-slate-900/50 border-slate-800 hover:border-slate-600"
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-white">{meta?.flag} {ccy}</span>
{d !== null ? (
<span className={`flex items-center gap-0.5 text-[10px] font-semibold ${
d > 0 ? "text-emerald-400" : d < 0 ? "text-red-400" : "text-slate-500"
}`}>
{d > 0 ? <TrendingUp size={10} /> : d < 0 ? <TrendingDown size={10} /> : <Minus size={10} />}
{formatNet(d)}
</span>
) : <span className="text-[10px] text-slate-600"></span>}
</div>
<Sparkline weeks={weeks} />
{latest && (
<div className="flex items-center justify-between">
<span className={`text-[11px] font-semibold ${
bias === "bull" ? "text-emerald-400" : bias === "bear" ? "text-red-400" : "text-slate-400"
}`}>
{formatNet(latest.net)}
</span>
<span className="text-[10px] text-slate-500">{latest.longPct}%L</span>
</div>
)}
<div className="flex justify-center">
{selected
? <ChevronUp size={12} className="text-amber-400" />
: <ChevronDown size={12} className="text-slate-700" />}
</div>
</button>
);
}
// ── Composant principal ───────────────────────────────────────────────────────
export default function CotTab({ history, loading }: Props) {
const [selected, setSelected] = useState<Currency | null>(null);
const [mode, setMode] = useState<"tff" | "legacy">("tff");
if (loading) {
return <div className="flex items-center justify-center h-40 text-slate-500 text-sm">Chargement historique COT</div>;
}
if (!history || (!Object.keys(history.tff ?? {}).length && !Object.keys(history.legacy ?? {}).length)) {
return <div className="flex items-center justify-center h-40 text-slate-500 text-sm">Données COT indisponibles</div>;
}
const dataset = history[mode] ?? {};
const latestDate = (dataset.EUR ?? dataset.GBP ?? [])[0]?.weekDate ?? "";
const handleSelect = (ccy: Currency) => setSelected(prev => prev === ccy ? null : ccy);
const selWeeks = selected ? (dataset[selected] ?? []) : [];
const chartData = [...selWeeks].reverse().map(w => ({
label: w.weekDate.slice(5),
net: w.net,
longPct: w.longPct,
deltaNet: w.deltaNet,
fill: w.net >= 0 ? "#10b981" : "#ef4444",
}));
const w0 = selWeeks[0];
const d = w0?.deltaNet ?? null;
const MODE_LABELS = {
tff: { label: "Hedge Funds (TFF)", desc: "Leveraged Money — gestionnaires spéculatifs, fonds macro" },
legacy: { label: "Non-Commercial (Legacy)", desc: "Tous spéculateurs — traders non-commerciaux (méthode classique depuis 1986)" },
};
return (
<div className="space-y-3">
{/* En-tête + toggle */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">COT · CFTC</h2>
{latestDate && <span className="text-[11px] text-amber-400/80">Semaine du {latestDate}</span>}
</div>
{/* Toggle TFF / Legacy */}
<div className="flex items-center gap-1 bg-slate-900 border border-slate-800 rounded-lg p-0.5">
{(["tff", "legacy"] as const).map(m => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-all ${
mode === m
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "text-slate-500 hover:text-slate-300"
}`}
>
{MODE_LABELS[m].label}
</button>
))}
</div>
</div>
{/* Description du mode */}
<p className="text-[11px] text-slate-600">{MODE_LABELS[mode].desc}</p>
{/* Grille 8 cartes */}
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{CURRENCIES.map(ccy => (
<CurrencyCard
key={ccy}
ccy={ccy}
weeks={dataset[ccy] ?? []}
selected={selected === ccy}
onClick={() => handleSelect(ccy)}
/>
))}
</div>
{/* Panneau détail */}
{selected && chartData.length > 0 && (
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-4 space-y-3">
{/* Résumé */}
<div className="flex items-baseline gap-3 flex-wrap">
<span className="text-sm font-bold text-white">
{CURRENCY_META[selected]?.flag} {selected}
</span>
{w0 && (
<span className={`text-xs font-semibold ${w0.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
{formatNet(w0.net)} net · {w0.longPct}%L / {w0.shortPct}%S
</span>
)}
{d !== null && (
<span className={`text-xs ${d > 0 ? "text-emerald-400" : d < 0 ? "text-red-400" : "text-slate-500"}`}>
{d > 0 ? "▲" : "▼"} {formatNet(Math.abs(d))} Δ sem.
</span>
)}
{w0?.deltaLong !== null && w0?.deltaLong !== undefined && (
<span className="text-[11px] text-slate-500">
+L {formatNet(w0.deltaLong)} / +S {formatNet(w0.deltaShort ?? 0)}
</span>
)}
<span className="text-xs text-slate-600">{selWeeks.length} semaines</span>
</div>
{/* Chart */}
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="label" tick={{ fontSize: 9, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} interval="preserveStartEnd" />
<YAxis yAxisId="net" orientation="left" tick={{ fontSize: 9, fill: "#64748b" }} tickLine={false} axisLine={false} tickFormatter={v => `${(v/1000).toFixed(0)}k`} width={36} />
<YAxis yAxisId="pct" orientation="right" domain={[0,100]} tick={{ fontSize: 9, fill: "#64748b" }} tickLine={false} axisLine={false} tickFormatter={v=>`${v}%`} width={30} />
<Tooltip content={<ChartTooltip />} />
<ReferenceLine yAxisId="net" y={0} stroke="#475569" strokeWidth={1} />
<Bar yAxisId="net" dataKey="net" name="net" radius={[2,2,0,0]} maxBarSize={24} isAnimationActive={false}>
{chartData.map((e, i) => <Cell key={i} fill={e.fill} />)}
</Bar>
<Line yAxisId="pct" type="monotone" dataKey="longPct" name="longPct" stroke="#f59e0b" strokeWidth={1.5} dot={false} strokeDasharray="4 2" isAnimationActive={false} />
</ComposedChart>
</ResponsiveContainer>
{/* Tableau 6 dernières semaines avec deltas */}
<table className="w-full text-[11px] text-slate-400">
<thead>
<tr className="border-b border-slate-800 text-slate-600">
<th className="text-left pb-1">Semaine</th>
<th className="text-right pb-1">Net</th>
<th className="text-right pb-1">Δ Net</th>
<th className="text-right pb-1">Δ Longs</th>
<th className="text-right pb-1">Δ Shorts</th>
<th className="text-right pb-1">%L</th>
</tr>
</thead>
<tbody>
{selWeeks.slice(0, 6).map((w, i) => (
<tr key={w.weekDate} className={`border-b border-slate-800/40 ${i === 0 ? "text-white" : ""}`}>
<td className="py-1">{w.weekDate}</td>
<td className={`text-right font-medium ${w.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
{formatNet(w.net)}
</td>
<td className={`text-right text-[10px] ${
w.deltaNet === null ? "text-slate-600"
: w.deltaNet > 0 ? "text-emerald-400"
: w.deltaNet < 0 ? "text-red-400"
: "text-slate-500"
}`}>
{w.deltaNet !== null ? formatNet(w.deltaNet) : "—"}
</td>
<td className={`text-right text-[10px] ${
!w.deltaLong ? "text-slate-600" : w.deltaLong > 0 ? "text-emerald-400" : "text-red-400"
}`}>
{w.deltaLong !== null && w.deltaLong !== undefined ? formatNet(w.deltaLong) : "—"}
</td>
<td className={`text-right text-[10px] ${
!w.deltaShort ? "text-slate-600" : w.deltaShort > 0 ? "text-red-400" : "text-emerald-400"
}`}>
{w.deltaShort !== null && w.deltaShort !== undefined ? formatNet(w.deltaShort) : "—"}
</td>
<td className="text-right text-emerald-400">{w.longPct}%</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+78 -63
View File
@@ -14,7 +14,7 @@ function fmt(v: number | null, dec: number, unit = "") {
interface TooltipState { x: number; y: number }
function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, tooltip }: {
function Tile({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, tooltip, accent }: {
label: string;
value: number | null;
dec?: number;
@@ -23,6 +23,7 @@ function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, toolti
deltaPct?: boolean;
deltaDec?: number;
tooltip?: string;
accent?: "red" | "green" | "amber";
}) {
const [tip, setTip] = useState<TooltipState | null>(null);
const ref = useRef<HTMLDivElement>(null);
@@ -42,28 +43,30 @@ function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, toolti
? `${Math.abs(delta).toFixed(deltaDec ?? dec)}${deltaPct ? "%" : ""}`
: null;
const borderCls = accent === "red" ? "border-red-500/30 bg-red-500/5"
: accent === "green" ? "border-emerald-500/30 bg-emerald-500/5"
: accent === "amber" ? "border-amber-500/30 bg-amber-500/5"
: "border-slate-800/60 bg-slate-900/40";
return (
<>
<div
ref={ref}
onMouseEnter={tooltip ? show : undefined}
onMouseLeave={tooltip ? hide : undefined}
className={`flex items-center gap-1.5 shrink-0 ${tooltip ? "cursor-help" : ""}`}
className={`flex flex-col gap-0.5 px-3 py-2 rounded-lg border ${borderCls} ${tooltip ? "cursor-help" : ""} min-w-[72px]`}
>
<span className="text-slate-500 text-[11px]">{label}</span>
<span className="text-slate-100 font-semibold tabular-nums text-[11px]">
{fmt(value, dec, unit)}
</span>
{dFmt && (
<span className={`text-[10px] font-medium tabular-nums ${dColor}`}>
{dArrow}{dFmt}
<span className="text-slate-500 text-[10px] font-medium leading-none">{label}</span>
<div className="flex items-baseline gap-1">
<span className="text-slate-100 font-bold tabular-nums text-[13px] leading-none">
{fmt(value, dec, unit)}
</span>
)}
{tooltip && (
<span className="w-3 h-3 rounded-full border border-slate-700 text-slate-600 text-[7px] flex items-center justify-center leading-none select-none shrink-0">
i
</span>
)}
{dFmt && (
<span className={`text-[10px] font-medium tabular-nums leading-none ${dColor}`}>
{dArrow}{dFmt}
</span>
)}
</div>
</div>
{tip && typeof document !== "undefined" && createPortal(
@@ -80,8 +83,12 @@ function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, toolti
);
}
function VSep() {
return <div className="w-px h-3.5 bg-slate-700/60 shrink-0 mx-0.5" />;
function GroupLabel({ label }: { label: string }) {
return (
<span className="text-slate-600 text-[9px] font-semibold uppercase tracking-widest self-center shrink-0 hidden sm:block">
{label}
</span>
);
}
export default function DriversBar({ drivers }: Props) {
@@ -102,59 +109,67 @@ export default function DriversBar({ drivers }: Props) {
const riskOff = (vix ?? 0) > 25 || (hySpread ?? 0) > 500;
return (
<div className="mb-4 bg-slate-900 border border-slate-800 rounded-xl px-4 py-2.5 flex items-center gap-4 overflow-x-auto scrollbar-hide">
<div className="mb-4 bg-slate-950/60 border border-slate-800 rounded-xl p-3 space-y-2">
<span className="text-slate-500 font-semibold uppercase tracking-widest text-[10px] shrink-0">
DRIVERS GLOBAUX
</span>
{/* Ligne titre + alerte */}
<div className="flex items-center justify-between">
<span className="text-slate-500 font-semibold uppercase tracking-widest text-[10px]">
Drivers Globaux
</span>
{riskOff && (
<div className="flex items-center gap-1 bg-red-500/10 border border-red-500/20 rounded-full px-2 py-0.5">
<AlertTriangle size={10} className="text-red-400" />
<span className="text-[9px] font-semibold text-red-400">Risk-Off</span>
</div>
)}
</div>
{riskOff && (
<div className="flex items-center gap-1 shrink-0 bg-red-500/10 border border-red-500/20 rounded-full px-2 py-0.5">
<AlertTriangle size={10} className="text-red-400" />
<span className="text-[9px] font-semibold text-red-400">Risk-Off</span>
</div>
)}
{/* Grille responsive — 2 lignes sur desktop, s'adapte sur mobile */}
<div className="flex flex-wrap gap-2">
<VSep />
{/* ── Sentiment ─────────────────────────────────────────── */}
<GroupLabel label="Sentiment" />
<Tile label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1}
accent={(vix ?? 0) > 25 ? "red" : undefined}
tooltip="Clôture actuelle clôture précédente (Yahoo Finance)." />
<Tile label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2}
tooltip="% vs clôture précédente (Yahoo Finance)." />
<Tile label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2}
tooltip="Variation 24h (Binance / CoinGecko)." />
{/* Sentiment / Risk-On */}
<D label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1}
tooltip="Clôture actuelle clôture précédente (Yahoo Finance)." />
<D label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2}
tooltip="% vs clôture précédente (Yahoo Finance)." />
<D label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2}
tooltip="Variation 24h (Binance / CoinGecko)." />
{/* ── Crédit ────────────────────────────────────────────── */}
<div className="w-px bg-slate-800 self-stretch mx-0.5 hidden sm:block" />
<GroupLabel label="Crédit" />
<Tile label="HY Spread" value={hySpread} dec={0} unit=" bps"
accent={(hySpread ?? 0) > 500 ? "red" : (hySpread ?? 0) > 400 ? "amber" : undefined}
tooltip="High Yield spread vs Treasuries US. >500 bps = risk-off fort." />
<Tile label="IG Spread" value={igSpread} dec={0} unit=" bps"
tooltip="Investment Grade spread vs Treasuries US." />
<VSep />
{/* ── FX / Taux ─────────────────────────────────────────── */}
<div className="w-px bg-slate-800 self-stretch mx-0.5 hidden sm:block" />
<GroupLabel label="FX / Taux" />
<Tile label="DXY" value={dxy} dec={2} delta={dxyDelta} deltaDec={2}
tooltip="ICE Dollar Index Futures (DX=F) — Yahoo Finance, cache 5 min." />
<Tile
label="Crb 2-10" value={curveSlope} dec={0} unit=" bps"
accent={(curveSlope ?? 0) < -50 ? "amber" : undefined}
tooltip={`Spread US 10Y US 2Y. Négatif = courbe inversée.\nUS 10Y: ${us10y != null ? us10y.toFixed(2) + "%" : "N/A"} | US 2Y: ${us2y != null ? us2y.toFixed(2) + "%" : "N/A"}`}
/>
{/* Crédit */}
<D label="HY Spread" value={hySpread} dec={0} unit=" bps"
tooltip="High Yield spread vs Treasuries US. >500 bps = risk-off fort." />
<D label="IG Spread" value={igSpread} dec={0} unit=" bps"
tooltip="Investment Grade spread vs Treasuries US." />
<VSep />
{/* Taux & FX */}
<D label="DXY" value={dxy} dec={2} delta={dxyDelta} deltaDec={2}
tooltip="ICE Dollar Index Futures (DX=F) — Yahoo Finance, cache 5 min." />
<D
label="Crb 2-10" value={curveSlope} dec={0} unit=" bps"
tooltip={`Spread US 10Y US 2Y. Négatif = courbe inversée.\nUS 10Y: ${us10y != null ? us10y.toFixed(2) + "%" : "N/A"} | US 2Y: ${us2y != null ? us2y.toFixed(2) + "%" : "N/A"}`}
/>
<VSep />
{/* Commodités */}
<D label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1}
tooltip="Delta intraday closeopen (Stooq)." />
<D label="Argent $/oz" value={silver} dec={2} delta={silverDelta}
tooltip="Delta intraday closeopen (Stooq)." />
<D label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
tooltip="Delta intraday closeopen (Stooq)." />
<D label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
tooltip="Delta intraday closeopen (Stooq)." />
{/* ── Commodités ────────────────────────────────────────── */}
<div className="w-px bg-slate-800 self-stretch mx-0.5 hidden sm:block" />
<GroupLabel label="Commodités" />
<Tile label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1}
tooltip="Delta intraday closeopen (Stooq)." />
<Tile label="Argent $/oz" value={silver} dec={2} delta={silverDelta}
tooltip="Delta intraday closeopen (Stooq)." />
<Tile label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
tooltip="Delta intraday closeopen (Stooq)." />
<Tile label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
tooltip="Delta intraday closeopen (Stooq)." />
</div>
</div>
);
}
+46 -16
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect, useRef } from "react";
import {
ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus,
Loader2, Radio, AlertTriangle, Landmark, Globe, BarChart2, Zap,
@@ -71,21 +71,34 @@ interface Props {
}
export default function NewsTab({ items, loading, onRefresh }: Props) {
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
const [filterCat, setFilterCat] = useState<string | "ALL">("ALL");
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
const [filterCat, setFilterCat] = useState<string | "ALL">("ALL");
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
const [priorityOnly, setPriorityOnly] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Auto-refresh toutes les 5 minutes
useEffect(() => {
if (!autoRefresh) { if (intervalRef.current) clearInterval(intervalRef.current); return; }
intervalRef.current = setInterval(() => { onRefresh(); setLastRefreshAt(new Date()); }, 5 * 60_000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [autoRefresh, onRefresh]);
const isPriorityItem = (item: NewsItem) =>
item.categories.some(c => ["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État", "Probabilités Taux"].includes(c));
const filtered = useMemo(() => items.filter(item => {
if (priorityOnly && !isPriorityItem(item)) return false;
if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false;
if (filterCat !== "ALL" && !item.categories.includes(filterCat)) return false;
if (filterDir !== "all") {
const hasDir = filterCcy === "ALL"
? item.impacts.some(i => i.direction === filterDir)
: item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir);
if (!hasDir) return false;
if (filterDir !== "all" && filterCcy !== "ALL") {
if (!item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir)) return false;
}
return true;
}), [items, filterCcy, filterCat, filterDir]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [items, filterCcy, filterCat, filterDir, priorityOnly]);
// Catégories présentes dans le feed actuel
const activeCats = useMemo(() => {
@@ -125,15 +138,32 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
{/* ── Headline résumé par devise ──────────────────────────────────────── */}
{!loading && items.length > 0 && (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">
Biais actualités par devise
</span>
<button onClick={onRefresh} disabled={loading}
className="flex items-center gap-1 text-[9px] text-slate-600 hover:text-slate-400 disabled:opacity-50">
<RefreshCw size={9} className={loading ? "animate-spin" : ""} />
Actualiser
</button>
<div className="flex items-center gap-2">
{/* Bouton Prioritaires */}
<button onClick={() => setPriorityOnly(p => !p)}
className={`flex items-center gap-1 text-[9px] px-2.5 py-1 rounded-full font-semibold border transition-colors ${
priorityOnly ? "bg-amber-500/20 text-amber-400 border-amber-500/30" : "text-slate-500 border-slate-700/40 hover:text-slate-300"
}`}>
<Zap size={9} /> Prioritaires
</button>
{/* Auto-refresh */}
<button onClick={() => setAutoRefresh(a => !a)}
className={`flex items-center gap-1 text-[9px] px-2 py-1 rounded-full border transition-colors ${
autoRefresh ? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10" : "text-slate-600 border-slate-700/30"
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${autoRefresh ? "bg-emerald-500 animate-pulse" : "bg-slate-600"}`} />
{autoRefresh ? "Live 5min" : "Pause"}
</button>
<button onClick={() => { onRefresh(); setLastRefreshAt(new Date()); }} disabled={loading}
className="flex items-center gap-1 text-[9px] text-slate-600 hover:text-slate-400 disabled:opacity-50">
<RefreshCw size={9} className={loading ? "animate-spin" : ""} />
{lastRefreshAt ? lastRefreshAt.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }) : "Actualiser"}
</button>
</div>
</div>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{CCY_LIST.map(ccy => {
+552
View File
@@ -0,0 +1,552 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Printer, RefreshCw, Save, RotateCcw, Plus, Trash2, Sparkles, Loader2, Check } from "lucide-react";
import type { CalendarEvent } from "@/app/api/calendar/route";
import type { DriverData } from "@/lib/types";
import type { FxWeeklyEntry } from "@/app/api/fx-weekly/route";
import type { CotHistory } from "@/app/api/cot-history/route";
import { CURRENCY_META } from "@/lib/constants";
import { TvMiniChart, TvAdvancedChart } from "@/components/TvChart";
interface Props {
calEvents: CalendarEvent[];
drivers: DriverData | null;
cotHistory: CotHistory | null;
}
interface Theme { title: string; body: string }
interface ReportState {
weekLabel: string;
weekFrom: string;
weekTo: string;
author: string;
subtitle: string;
themes: Theme[];
currencies: Record<string, { pct: string; analysis: string; level: string }>;
notes: string;
}
const STORAGE_KEY = "forex-report-v2";
const G10 = ["USD","EUR","GBP","JPY","CHF","CAD","AUD","NZD"];
function fmtDate(iso: string) {
if (!iso) return "";
return new Date(iso + "T12:00:00").toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
function fmtShort(iso: string) {
if (!iso) return "";
return new Date(iso + "T12:00:00").toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
}
function defaultState(weekFrom = "", weekTo = ""): ReportState {
return {
weekLabel: weekFrom && weekTo ? `${fmtShort(weekFrom)}${fmtDate(weekTo)}` : "Semaine du … au …",
weekFrom, weekTo,
author: "Capucine · Forex Dashboard",
subtitle: "Analyse macro-fondamentale G10 · Marchés globaux",
themes: [{ title: "", body: "" }, { title: "", body: "" }, { title: "", body: "" }],
currencies: Object.fromEntries(G10.map(c => [c, { pct: "—", analysis: "", level: "" }])),
notes: "",
};
}
function save(state: ReportState) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { /**/ }
}
function load(): ReportState | null {
try { const r = localStorage.getItem(STORAGE_KEY); return r ? JSON.parse(r) : null; } catch { return null; }
}
// ── Composants UI ─────────────────────────────────────────────────────────────
function Field({ value, onChange, placeholder, multiline, className }: {
value: string; onChange: (v: string) => void; placeholder?: string;
multiline?: boolean; className?: string;
}) {
if (multiline) return (
<textarea value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder} rows={5}
className={`w-full bg-transparent resize-none outline-none placeholder-slate-700 ${className}`} />
);
return (
<input type="text" value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={`bg-transparent outline-none placeholder-slate-700 w-full ${className}`} />
);
}
function Pct({ val }: { val: string }) {
const n = parseFloat(val);
if (isNaN(n) || val === "—") return <span className="text-slate-500 font-mono text-sm"></span>;
const c = n > 0 ? "text-emerald-400" : n < 0 ? "text-red-400" : "text-slate-400";
return <span className={`font-mono font-bold text-sm ${c}`}>{n > 0 ? "+" : ""}{n.toFixed(1)}%</span>;
}
// ── Bouton Groq par devise ────────────────────────────────────────────────────
function AiButton({ ccy, weekFrom, weekTo, pct, cotHistory, onResult }: {
ccy: string; weekFrom: string; weekTo: string; pct: string;
cotHistory: CotHistory | null; onResult: (text: string) => void;
}) {
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const run = async () => {
setLoading(true); setErr(""); setDone(false);
try {
const tffWeeks = cotHistory?.tff?.[ccy as keyof typeof cotHistory.tff] ?? [];
const legacyWeeks = cotHistory?.legacy?.[ccy as keyof typeof cotHistory.legacy] ?? [];
const tff0 = tffWeeks[0];
const legacy0 = legacyWeeks[0];
const res = await fetch("/api/narrative", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "report_ccy",
currency: ccy,
data: {
weeklyPct: pct,
weekFrom, weekTo,
cotNetTff: tff0?.net,
cotDeltaTff: tff0?.deltaNet,
cotLongPctTff: tff0?.longPct,
cotNetLegacy: legacy0?.net,
cotDeltaLegacy: legacy0?.deltaNet,
},
}),
});
const json = await res.json();
if (json.error) throw new Error(json.error);
onResult(json.analysis ?? "");
setDone(true);
setTimeout(() => setDone(false), 3000);
} catch (e) {
setErr(String(e).replace(/^Error:\s*/i, "").slice(0, 60));
} finally { setLoading(false); }
};
return (
<div className="flex items-center gap-2">
<button onClick={run} disabled={loading}
className={`no-print flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-semibold transition-all ${
done ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" :
loading ? "bg-sky-500/10 text-sky-400 border border-sky-500/20 cursor-wait" :
"bg-sky-500/15 text-sky-400 border border-sky-500/25 hover:bg-sky-500/25"
}`}>
{loading ? <Loader2 size={10} className="animate-spin" />
: done ? <Check size={10} />
: <Sparkles size={10} />}
{loading ? "Génération…" : done ? "Injecté !" : "Générer avec IA"}
</button>
{err && <span className="text-[9px] text-red-400 truncate max-w-[140px]" title={err}> {err}</span>}
</div>
);
}
// ── Main ──────────────────────────────────────────────────────────────────────
export default function ReportTab({ calEvents, drivers, cotHistory }: Props) {
const [state, setState] = useState<ReportState>(() => load() ?? defaultState());
const [fxData, setFxData] = useState<FxWeeklyEntry[] | null>(null);
const [fxLoading, setFxLoad] = useState(false);
const [weekTo, setWeekTo] = useState("");
const [saved, setSaved] = useState(false);
const [showCharts, setShowCharts] = useState(false);
const loadFx = useCallback(async (override?: string) => {
setFxLoad(true);
try {
const url = override ? `/api/fx-weekly?weekTo=${override}` : "/api/fx-weekly";
const r = await fetch(url);
if (!r.ok) return;
const d = await r.json();
setFxData(d.currencies);
const newCcys = { ...state.currencies };
for (const e of d.currencies as FxWeeklyEntry[]) {
newCcys[e.ccy] = { ...newCcys[e.ccy], pct: e.pct > 0 ? `+${e.pct.toFixed(1)}%` : `${e.pct.toFixed(1)}%` };
}
setState(s => ({
...s,
weekFrom: d.weekFrom,
weekTo: d.weekTo,
weekLabel: `${fmtShort(d.weekFrom)}${fmtDate(d.weekTo)}`,
currencies: newCcys,
}));
} finally { setFxLoad(false); }
}, [state.currencies]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadFx(); }, []); // eslint-disable-line
const upd = (patch: Partial<ReportState>) =>
setState(s => { const n = { ...s, ...patch }; save(n); return n; });
const updCcy = (ccy: string, f: "pct" | "analysis" | "level", v: string) => {
const c = { ...state.currencies, [ccy]: { ...state.currencies[ccy], [f]: v } };
upd({ currencies: c });
};
const updTheme = (i: number, f: "title" | "body", v: string) =>
upd({ themes: state.themes.map((t, j) => j === i ? { ...t, [f]: v } : t) });
const handleSave = () => { save(state); setSaved(true); setTimeout(() => setSaved(false), 2000); };
// Devises triées par perf hebdo
const sorted = [...G10].sort((a, b) => {
const pa = parseFloat(state.currencies[a]?.pct ?? "0");
const pb = parseFloat(state.currencies[b]?.pct ?? "0");
return (isNaN(pb) ? 0 : pb) - (isNaN(pa) ? 0 : pa);
});
// Calendrier semaine suivante
const nextEvents = calEvents
.filter(e => e.week === "next" && e.impact !== "low" && !e.isGroupChild)
.sort((a, b) => a.date.localeCompare(b.date));
const calByDay: Record<string, CalendarEvent[]> = {};
for (const e of nextEvents) {
const d = e.date.slice(0, 10);
(calByDay[d] ??= []).push(e);
}
const calDays = Object.keys(calByDay).sort();
const pubDate = new Date().toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
return (
<div className="space-y-4">
{/* ── Contrôles ─────────────────────────────────────────────────────── */}
<div className="no-print flex items-center justify-between flex-wrap gap-3 bg-slate-900/60 border border-slate-800 rounded-xl px-4 py-3">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">📋 Rapport hebdomadaire</span>
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500">Vendredi clôture :</span>
<input type="date" value={weekTo} onChange={e => setWeekTo(e.target.value)}
className="text-[11px] bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-300 focus:outline-none focus:border-sky-500/50" />
<button onClick={() => loadFx(weekTo || undefined)} disabled={fxLoading}
className="flex items-center gap-1 text-[11px] bg-sky-500/15 text-sky-400 border border-sky-500/25 px-2 py-1 rounded hover:bg-sky-500/25 disabled:opacity-50">
<RefreshCw size={10} className={fxLoading ? "animate-spin" : ""} /> Charger
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => { setState(defaultState()); localStorage.removeItem(STORAGE_KEY); }}
className="flex items-center gap-1.5 text-[11px] text-slate-500 hover:text-slate-300 px-2 py-1.5 rounded border border-slate-800 hover:border-slate-700">
<RotateCcw size={11} /> Reset
</button>
<button onClick={handleSave}
className={`flex items-center gap-1.5 text-[11px] px-2 py-1.5 rounded border transition-all ${saved ? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30" : "text-slate-400 border-slate-800 hover:border-slate-600"}`}>
<Save size={11} /> {saved ? "Sauvegardé !" : "Sauvegarder"}
</button>
<button
onClick={() => setShowCharts(v => !v)}
className={`flex items-center gap-1.5 text-[11px] px-2 py-1.5 rounded border transition-all ${showCharts ? "bg-sky-500/20 text-sky-400 border-sky-500/30" : "text-slate-500 border-slate-800 hover:border-slate-600"}`}>
{showCharts ? "Masquer graphiques" : "Afficher graphiques TradingView"}
</button>
<button onClick={() => window.print()}
className="flex items-center gap-1.5 text-[11px] bg-sky-600/80 hover:bg-sky-600 text-white px-3 py-1.5 rounded font-medium">
<Printer size={11} /> Exporter PDF
</button>
</div>
</div>
{/* ══════════════════════════════════════════════════════════════════════
RAPPORT IMPRIMABLE
══════════════════════════════════════════════════════════════════════ */}
<div className="report-root font-sans">
{/* ── PAGE 1 : COUVERTURE ─────────────────────────────────────────── */}
<div className="report-page rp-cover bg-[#080c14] min-h-[297mm] flex flex-col p-10">
{/* Bande top */}
<div className="flex items-center justify-between mb-12">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-sky-500 flex items-center justify-center">
<span className="text-white font-black text-xs">FX</span>
</div>
<Field value={state.author} onChange={v => upd({ author: v })}
className="text-sky-400 text-xs font-semibold tracking-wide" placeholder="Auteur…" />
</div>
<span className="text-slate-600 text-[10px]">Publiée le {pubDate}</span>
</div>
{/* Titre central */}
<div className="flex-1 flex flex-col justify-center space-y-6">
<div className="space-y-1">
<p className="text-sky-500 text-xs uppercase tracking-[0.25em] font-semibold">Rapport Macro Weekly</p>
<Field value={state.weekLabel} onChange={v => upd({ weekLabel: v })}
className="text-white text-4xl font-black leading-tight tracking-tight block"
placeholder="Semaine du … au …" />
<Field value={state.subtitle} onChange={v => upd({ subtitle: v })}
className="text-slate-500 text-sm block mt-2" placeholder="Sous-titre…" />
</div>
{/* Barre de séparation animée */}
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-gradient-to-r from-sky-500 to-transparent" />
<div className="w-1.5 h-1.5 rounded-full bg-sky-500" />
</div>
{/* Classement G10 */}
{fxData && (
<div className="grid grid-cols-8 gap-2">
{sorted.map(ccy => {
const meta = CURRENCY_META[ccy as keyof typeof CURRENCY_META];
const n = parseFloat(state.currencies[ccy]?.pct ?? "0");
const c = n > 0 ? "#34d399" : n < 0 ? "#f87171" : "#94a3b8";
return (
<div key={ccy} className="flex flex-col items-center gap-1 p-2 rounded-lg bg-white/[0.03] border border-white/[0.06]">
<span className="text-xl leading-none">{meta?.flag}</span>
<span className="text-white text-xs font-bold">{ccy}</span>
<span className="font-mono font-bold text-xs" style={{ color: c }}>
{isNaN(n) ? "—" : `${n > 0 ? "+" : ""}${n.toFixed(1)}%`}
</span>
</div>
);
})}
</div>
)}
{/* Thèmes clés */}
<div className="space-y-2 mt-4">
<p className="text-slate-600 text-[9px] uppercase tracking-widest font-semibold no-print">Faits marquants de la semaine</p>
{state.themes.map((theme, i) => (
<div key={i} className="group relative flex gap-3 p-3 rounded-lg bg-white/[0.03] border-l-2 border-sky-500/60">
<div className="flex-1 space-y-0.5">
<Field value={theme.title} onChange={v => updTheme(i, "title", v)}
className="text-sky-400 text-[10px] font-bold uppercase tracking-wider"
placeholder="TITRE DU FAIT MARQUANT…" />
<Field value={theme.body} onChange={v => updTheme(i, "body", v)}
className="text-slate-400 text-xs" placeholder="Description courte et impact marché…" />
</div>
<button onClick={() => upd({ themes: state.themes.filter((_, j) => j !== i) })}
className="no-print opacity-0 group-hover:opacity-100 text-slate-700 hover:text-red-400 shrink-0 self-start mt-0.5">
<Trash2 size={11} />
</button>
</div>
))}
<button onClick={() => upd({ themes: [...state.themes, { title: "", body: "" }] })}
className="no-print flex items-center gap-1 text-[10px] text-slate-700 hover:text-sky-400 mt-1">
<Plus size={10} /> Ajouter un fait marquant
</button>
</div>
</div>
{/* Données drivers en bas */}
{drivers && (
<div className="mt-8 pt-4 border-t border-white/[0.05] grid grid-cols-4 gap-3">
{[
{ l: "VIX", v: drivers.vix?.toFixed(1), s: drivers.vix != null && drivers.vix > 25 ? "⚠" : "" },
{ l: "DXY", v: (drivers as {dxy?:number|null}).dxy?.toFixed(2) },
{ l: "Brent", v: drivers.brent ? `$${drivers.brent.toFixed(1)}` : null },
{ l: "US 10Y", v: drivers.us10y ? `${drivers.us10y.toFixed(2)}%` : null },
].map(({ l, v, s }) => (
<div key={l} className="flex items-center justify-between p-2 rounded-md bg-white/[0.03]">
<span className="text-slate-600 text-[10px]">{l}</span>
<span className="text-slate-300 text-[11px] font-semibold tabular-nums">{v ?? "—"} {s}</span>
</div>
))}
</div>
)}
</div>
{/* ── PAGE 23 : ANALYSES DEVISES ─────────────────────────────────── */}
<div className="report-page bg-[#080c14] min-h-[297mm] p-10 space-y-6">
{/* En-tête section */}
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-sky-500/30" />
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Actualité G10 · Analyses Devises</h2>
<div className="h-px flex-1 bg-sky-500/30" />
</div>
{/* Grille 2 colonnes */}
<div className="grid grid-cols-2 gap-4">
{sorted.map(ccy => {
const meta = CURRENCY_META[ccy as keyof typeof CURRENCY_META];
const entry = state.currencies[ccy];
const n = parseFloat(entry?.pct ?? "0");
const col = n > 0 ? "text-emerald-400" : n < 0 ? "text-red-400" : "text-slate-500";
const borderCol = n > 0 ? "border-emerald-500/30" : n < 0 ? "border-red-500/30" : "border-slate-700";
return (
<div key={ccy} className={`flex flex-col gap-3 p-4 rounded-xl bg-[#0f1623] border ${borderCol}`}>
{/* Header devise */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl leading-none">{meta?.flag}</span>
<div>
<div className="flex items-center gap-2">
<span className="text-white font-black text-sm">{ccy}</span>
<div className="no-print">
<Field value={entry?.pct ?? ""} onChange={v => updCcy(ccy, "pct", v)}
className={`font-mono font-bold text-sm w-16 ${col}`} placeholder="±0.0%" />
</div>
<span className={`print-only font-mono font-bold text-sm ${col}`}>
{isNaN(n) ? entry?.pct : `${n > 0 ? "+" : ""}${n.toFixed(1)}%`}
</span>
</div>
<span className="text-slate-600 text-[10px]">
{meta?.flag && ccy}
{cotHistory?.tff?.[ccy as keyof typeof cotHistory.tff]?.[0]?.net != null && (
<span className="ml-2">
COT {(cotHistory.tff[ccy as keyof typeof cotHistory.tff]?.[0]?.net ?? 0) > 0 ? "▲" : "▼"}
{" "}{((cotHistory.tff[ccy as keyof typeof cotHistory.tff]?.[0]?.net ?? 0) / 1000).toFixed(1)}k
</span>
)}
</span>
</div>
</div>
<AiButton
ccy={ccy}
weekFrom={state.weekFrom}
weekTo={state.weekTo}
pct={entry?.pct ?? "—"}
cotHistory={cotHistory}
onResult={v => updCcy(ccy, "analysis", v)}
/>
</div>
{/* Analyse */}
<Field value={entry?.analysis ?? ""} onChange={v => updCcy(ccy, "analysis", v)}
multiline
className="text-slate-300 text-xs leading-relaxed"
placeholder={`Analyse ${ccy} — cliquer ✨ pour générer avec l'IA, ou rédiger manuellement…`} />
{/* Niveau clé */}
<div className="flex items-center gap-2 pt-1 border-t border-white/[0.05]">
<span className="text-slate-600 text-[10px] shrink-0">Niveau clé :</span>
<Field value={entry?.level ?? ""} onChange={v => updCcy(ccy, "level", v)}
className="text-sky-400 text-[11px] font-mono" placeholder="ex: 1.1700 résistance…" />
</div>
</div>
);
})}
</div>
<p className="text-slate-700 text-[9px] text-center pt-4">{state.weekLabel} · {state.author}</p>
</div>
{/* ── PAGE 3 : GRAPHIQUES TRADINGVIEW ─────────────────────────────── */}
{showCharts && (
<div className="report-page bg-[#080c14] min-h-[297mm] p-10 space-y-6">
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-sky-500/30" />
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Vue d&apos;ensemble · Marchés Globaux</h2>
<div className="h-px flex-1 bg-sky-500/30" />
</div>
{/* Macro overview : S&P, VIX, DXY, US10Y */}
<div className="grid grid-cols-2 gap-4">
<TvAdvancedChart symbol="SP:SPX" label="S&P 500 · Weekly" interval="W" height={220} />
<TvAdvancedChart symbol="TVC:VIX" label="VIX · Daily" interval="D" height={220} />
<TvAdvancedChart symbol="TVC:DXY" label="DXY Dollar Index · Weekly" interval="W" height={220} />
<TvAdvancedChart symbol="TVC:US10Y" label="US 10Y Yield · Weekly" interval="W" height={220} />
</div>
<div className="flex items-center gap-4 pt-2">
<div className="h-px flex-1 bg-sky-500/30" />
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Currency Charts · G10 Weekly</h2>
<div className="h-px flex-1 bg-sky-500/30" />
</div>
{/* 8 currency mini charts */}
<div className="grid grid-cols-4 gap-3">
{[
{ sym: "TVC:DXY", label: "🇺🇸 USD · DXY" },
{ sym: "FX:EURUSD", label: "🇪🇺 EUR/USD" },
{ sym: "FX:GBPUSD", label: "🇬🇧 GBP/USD" },
{ sym: "FX:USDJPY", label: "🇯🇵 USD/JPY" },
{ sym: "FX:USDCHF", label: "🇨🇭 USD/CHF" },
{ sym: "FX:USDCAD", label: "🇨🇦 USD/CAD" },
{ sym: "FX:AUDUSD", label: "🇦🇺 AUD/USD" },
{ sym: "FX:NZDUSD", label: "🇳🇿 NZD/USD" },
].map(({ sym, label }) => (
<TvMiniChart key={sym} symbol={sym} label={label} height={160} showInfo={false} />
))}
</div>
<p className="text-slate-700 text-[9px] text-center pt-2">
{state.weekLabel} · {state.author} · Sources TradingView
</p>
</div>
)}
{/* ── PAGE 4 : CALENDRIER + NOTES ─────────────────────────────────── */}
{calDays.length > 0 && (
<div className="report-page bg-[#080c14] min-h-[297mm] p-10 space-y-5">
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-sky-500/30" />
<h2 className="text-sky-400 text-xs font-bold uppercase tracking-[0.3em]">Calendrier Économique · Semaine à Venir</h2>
<div className="h-px flex-1 bg-sky-500/30" />
</div>
{calDays.map(day => (
<div key={day} className="space-y-1">
<div className="flex items-center gap-2 py-1">
<div className="w-1 h-4 rounded-full bg-sky-500" />
<span className="text-sky-300 text-[11px] font-bold uppercase tracking-wider">
{new Date(day + "T12:00:00").toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" })}
</span>
</div>
<div className="ml-3 rounded-lg overflow-hidden border border-white/[0.05]">
<table className="w-full">
<tbody>
{calByDay[day].map(ev => {
const time = new Date(ev.date).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
const dot = ev.impact === "high" ? "bg-red-500" : ev.impact === "medium" ? "bg-amber-400" : "bg-slate-600";
const meta = CURRENCY_META[ev.currency as keyof typeof CURRENCY_META];
return (
<tr key={ev.id} className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.02]">
<td className="py-1.5 px-3 text-[11px] text-slate-500 tabular-nums w-14">{time}</td>
<td className="py-1.5 px-2 w-12">
<span className="text-xs font-bold text-slate-300">{meta?.flag} {ev.currency}</span>
</td>
<td className="py-1.5 px-2 w-5 text-center">
<span className={`inline-block w-1.5 h-1.5 rounded-full ${dot}`} />
</td>
<td className="py-1.5 px-2 text-[11px] text-slate-300">{ev.title}</td>
<td className="py-1.5 px-3 text-[10px] text-slate-500 text-right tabular-nums">{ev.previous ?? "—"}</td>
<td className="py-1.5 px-3 text-[10px] text-sky-400 text-right font-medium tabular-nums">{ev.forecast ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
{/* Notes éditables */}
<div className="mt-4 p-4 rounded-xl bg-[#0f1623] border border-white/[0.05] space-y-2">
<p className="text-sky-400 text-[10px] font-bold uppercase tracking-wider">Points d&apos;attention pour la semaine à venir</p>
<Field value={state.notes} onChange={v => upd({ notes: v })} multiline
className="text-slate-300 text-xs leading-relaxed"
placeholder="Thèmes clés, banques centrales à surveiller, niveaux importants, risques géopolitiques…" />
</div>
<p className="text-slate-700 text-[9px] text-center pt-4">{state.weekLabel} · {state.author}</p>
</div>
)}
</div>
{/* ── CSS print ─────────────────────────────────────────────────────── */}
<style>{`
@media print {
body > * { visibility: hidden !important; }
.report-root, .report-root * { visibility: visible !important; }
.report-root { position: fixed; inset: 0; overflow: visible; }
.no-print { display: none !important; }
.report-page { page-break-after: always; min-height: 100vh; }
@page { size: A4; margin: 0; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
input, textarea { border: none !important; padding: 0 !important; }
}
.print-only { display: none; }
@media print { .print-only { display: inline !important; } }
`}</style>
</div>
);
}
+219 -82
View File
@@ -1,22 +1,27 @@
"use client";
import { useState } from "react";
import { CURRENCY_META } from "@/lib/constants";
import type { Currency } from "@/lib/types";
interface MyfxSymbol {
name: string;
name: string;
longPercentage: number;
shortPercentage: number;
shortPercentage:number;
longVolume: number;
shortVolume: number;
longPositions: number;
shortPositions: number;
totalPositions: number;
avgLongPrice?: number;
avgShortPrice?: 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" },
@@ -24,146 +29,278 @@ const PAIRS: { base: Currency; quote: Currency; std: string }[] = [
{ 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"] },
{ 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;
function fmtVol(v: number): string {
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(0);
}
function fmtPos(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
// ── Barre Long/Short % ────────────────────────────────────────────────────────
function PctBar({ longPct }: { longPct: number }) {
const extreme = 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"}`}>
<div className="flex items-center gap-1.5">
<span className={`text-[11px] tabular-nums font-semibold w-8 text-right ${extreme ? "text-amber-400" : "text-emerald-400"}`}>
{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 className="relative flex h-2.5 w-24 rounded-full overflow-hidden bg-slate-700">
<div
className={`h-full transition-all rounded-full ${extreme ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${longPct}%` }}
/>
<div className="absolute inset-0 flex">
<div style={{ width: `${longPct}%` }} />
<div className="flex-1 bg-red-500/70 rounded-r-full" />
</div>
</div>
<span className={`text-[10px] tabular-nums font-medium w-8 ${isContrarian ? "text-amber-600 font-bold" : "text-red-500"}`}>
<span className={`text-[11px] tabular-nums font-semibold w-8 ${extreme ? "text-amber-400" : "text-red-400"}`}>
{100 - longPct}%
</span>
{isContrarian && (
<span className="text-[9px] text-amber-500 font-semibold"></span>
)}
{extreme && <span className="text-amber-400 text-[11px] font-bold" title="Signal contrarien"></span>}
</div>
);
}
// ── Barre Volume ──────────────────────────────────────────────────────────────
function VolBar({ longVol, shortVol }: { longVol: number; shortVol: number }) {
const total = longVol + shortVol || 1;
const longPct = Math.round((longVol / total) * 100);
return (
<div className="flex items-center gap-1.5">
<span className="text-[10px] tabular-nums text-emerald-400 w-10 text-right">{fmtVol(longVol)}</span>
<div className="relative flex h-1.5 w-20 rounded-full overflow-hidden bg-slate-700">
<div className="h-full bg-emerald-500/70" style={{ width: `${longPct}%` }} />
<div className="flex-1 bg-red-500/60" />
</div>
<span className="text-[10px] tabular-nums text-red-400 w-10">{fmtVol(shortVol)}</span>
</div>
);
}
// ── Row ───────────────────────────────────────────────────────────────────────
function PairRow({ pairName, sym, base, quote, showVol }: {
pairName: string;
sym: MyfxSymbol | undefined;
base: Currency;
quote: Currency;
showVol: boolean;
}) {
const baseMeta = CURRENCY_META[base];
const quoteMeta = CURRENCY_META[quote];
const extreme = sym && (sym.longPercentage >= 70 || sym.longPercentage <= 30);
return (
<tr className={`border-b border-slate-800/50 hover:bg-slate-800/30 transition-colors ${extreme ? "bg-amber-500/5" : ""}`}>
{/* Paire */}
<td className="py-2.5 px-3 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<span className="text-base leading-none">{baseMeta?.flag}</span>
<span className="text-base leading-none">{quoteMeta?.flag}</span>
<span className={`text-xs font-bold ${extreme ? "text-amber-300" : "text-slate-200"}`}>{pairName}</span>
</div>
</td>
{/* % Long/Short */}
<td className="py-2.5 px-3">
{sym
? <PctBar longPct={sym.longPercentage} />
: <span className="text-[10px] text-slate-700 italic">N/D</span>}
</td>
{/* Volume lots */}
{showVol && (
<td className="py-2.5 px-3">
{sym
? <VolBar longVol={sym.longVolume} shortVol={sym.shortVolume} />
: <span className="text-[10px] text-slate-700"></span>}
</td>
)}
{/* Positions (traders) */}
<td className="py-2.5 px-3 whitespace-nowrap">
{sym ? (
<div className="flex items-center gap-1 text-[10px] tabular-nums">
<span className="text-emerald-400">{fmtPos(sym.longPositions)}</span>
<span className="text-slate-600">/</span>
<span className="text-red-400">{fmtPos(sym.shortPositions)}</span>
</div>
) : <span className="text-slate-700"></span>}
</td>
{/* Prix moy. */}
<td className="py-2.5 px-3 text-right hidden lg:table-cell">
{sym?.avgLongPrice ? (
<div className="text-[9px] tabular-nums space-y-0.5">
<div className="text-emerald-400/70">{sym.avgLongPrice.toFixed(4)}</div>
<div className="text-red-400/70">{sym.avgShortPrice?.toFixed(4) ?? "—"}</div>
</div>
) : <span className="text-slate-700 text-[10px]"></span>}
</td>
</tr>
);
}
// ── Main ──────────────────────────────────────────────────────────────────────
export default function SentimentPairsTab({ symbols }: Props) {
const [showVol, setShowVol] = useState(true);
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 };
// Paires avec signal contrarien pour le résumé
const contrarians = PAIRS
.map(p => ({ ...p, sym: symMap[p.std] }))
.filter(p => p.sym && (p.sym.longPercentage >= 70 || p.sym.longPercentage <= 30))
.sort((a, b) => {
const scoreA = Math.abs((a.sym!.longPercentage) - 50);
const scoreB = Math.abs((b.sym!.longPercentage) - 50);
return scoreB - scoreA;
});
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 (&gt;70% ou &lt;30%)
</p>
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Sentiment Retail Myfxbook Community Outlook
</h2>
<p className="text-[11px] text-slate-600 mt-0.5">
{symbols ? `${Object.keys(symMap).length} paires` : "chargement…"}
{" "}· Long % = traders retail haussiers sur la devise de base
{" "}· signal contrarien (&gt;70% ou &lt;30%)
</p>
</div>
<label className="flex items-center gap-2 text-[11px] text-slate-500 cursor-pointer select-none">
<input
type="checkbox"
checked={showVol}
onChange={e => setShowVol(e.target.checked)}
className="w-3 h-3 accent-amber-500"
/>
Afficher volumes (lots)
</label>
</div>
<div className="overflow-x-auto">
{GROUPS.map((group) => (
{/* Résumé signaux contrarien */}
{contrarians.length > 0 && (
<div className="bg-amber-500/8 border border-amber-500/20 rounded-xl p-3">
<p className="text-[10px] font-semibold text-amber-400 uppercase tracking-wider mb-2">
{contrarians.length} signal{contrarians.length > 1 ? "s" : ""} contrarien{contrarians.length > 1 ? "s" : ""}
</p>
<div className="flex flex-wrap gap-2">
{contrarians.slice(0, 8).map(p => {
const dir = p.sym!.longPercentage >= 70 ? "short" : "long";
return (
<div key={p.std} className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-[11px] font-medium ${
dir === "short"
? "bg-red-500/10 border-red-500/20 text-red-300"
: "bg-emerald-500/10 border-emerald-500/20 text-emerald-300"
}`}>
<span>{CURRENCY_META[p.base]?.flag}{CURRENCY_META[p.quote]?.flag}</span>
<span className="font-bold">{p.std}</span>
<span className="text-[10px] opacity-70">
{p.sym!.longPercentage}%L signal {dir === "short" ? "↓ SELL" : "↑ BUY"}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Tables par groupe */}
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
{GROUPS.map((group, gi) => (
<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 className={`px-4 py-2 border-b border-slate-800 ${gi > 0 ? "border-t border-t-slate-700" : ""} bg-slate-900/60`}>
<span className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">
{group.label}
</span>
</div>
<table className="w-full text-sm min-w-[600px]">
<table className="w-full min-w-[500px]">
<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 className="text-[9px] text-slate-600 uppercase tracking-wider border-b border-slate-800/60">
<th className="py-1.5 px-3 text-left w-32">Paire</th>
<th className="py-1.5 px-3 text-left">% Long / Short (retail)</th>
{showVol && <th className="py-1.5 px-3 text-left">Volume lots (L / S)</th>}
<th className="py-1.5 px-3 text-left">Traders (L / S)</th>
<th className="py-1.5 px-3 text-right hidden lg:table-cell">Prix moy. entrée</th>
</tr>
</thead>
<tbody>
{group.pairs.map((pairName) => {
{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>
<PairRow
key={pairName}
pairName={pairName}
sym={symMap[pairName]}
base={def?.base ?? "USD"}
quote={def?.quote ?? "EUR"}
showVol={showVol}
/>
);
})}
</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
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-800 text-[10px] text-slate-600 flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" /> Long
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-red-500 inline-block" /> Short
</span>
<span className="flex items-center gap-1.5">
<span className="text-amber-400"></span> Contrarien (&gt;70% ou &lt;30%)
</span>
<span className="ml-auto hidden sm:inline">Source : Myfxbook Community Outlook · ~50k traders retail trackés</span>
</div>
</div>
</div>
);
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useEffect, useRef, useId } from "react";
// Déclaration globale TradingView (chargé via script)
declare global {
interface Window {
TradingView?: {
MiniSymbolOverview: new (config: Record<string, unknown>) => void;
widget: new (config: Record<string, unknown>) => void;
};
}
}
interface TvMiniChartProps {
symbol: string; // ex: "FX:EURUSD", "TVC:DXY", "SP:SPX"
label?: string; // titre affiché au-dessus
interval?: "W" | "D" | "M";
height?: number;
showInfo?: boolean; // afficher nom + prix sous le graphique
}
// Script TradingView chargé une seule fois
let scriptLoaded = false;
let scriptLoading = false;
const onLoadCallbacks: (() => void)[] = [];
function loadTvScript(cb: () => void) {
if (scriptLoaded) { cb(); return; }
onLoadCallbacks.push(cb);
if (scriptLoading) return;
scriptLoading = true;
const s = document.createElement("script");
s.src = "https://s3.tradingview.com/tv.js";
s.async = true;
s.onload = () => {
scriptLoaded = true;
onLoadCallbacks.forEach(f => f());
onLoadCallbacks.length = 0;
};
document.head.appendChild(s);
}
export function TvMiniChart({ symbol, label, height = 180, showInfo = true }: TvMiniChartProps) {
const uid = useId().replace(/:/g, "_");
const id = `tv_mini_${uid}`;
const ref = useRef<HTMLDivElement>(null);
const init = useRef(false);
useEffect(() => {
if (init.current) return;
init.current = true;
loadTvScript(() => {
if (!window.TradingView || !ref.current) return;
try {
new window.TradingView.MiniSymbolOverview({
symbol,
container_id: id,
width: "100%",
height,
locale: "fr",
dateRange: "1M",
colorTheme: "dark",
trendLineColor: "#38bdf8",
underLineColor: "rgba(56,189,248,0.08)",
underLineBottomColor: "rgba(56,189,248,0)",
isTransparent: true,
autosize: false,
largeChartUrl: "",
noTimeScale: false,
chartOnly: !showInfo,
});
} catch { /* TradingView indisponible */ }
});
}, []); // eslint-disable-line
return (
<div className="flex flex-col gap-1">
{label && <p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">{label}</p>}
<div
ref={ref}
id={id}
className="rounded-lg overflow-hidden bg-[#0f1623]"
style={{ height }}
/>
</div>
);
}
// ── Vue avancée plein format (pour la page graphiques) ──────────────────────
interface TvAdvancedChartProps {
symbol: string;
label?: string;
interval?: string;
height?: number;
}
export function TvAdvancedChart({ symbol, label, interval = "W", height = 250 }: TvAdvancedChartProps) {
const uid = useId().replace(/:/g, "_");
const id = `tv_adv_${uid}`;
const ref = useRef<HTMLDivElement>(null);
const init = useRef(false);
useEffect(() => {
if (init.current) return;
init.current = true;
loadTvScript(() => {
if (!window.TradingView || !ref.current) return;
try {
new window.TradingView.widget({
autosize: false,
width: "100%",
height,
symbol,
interval,
timezone: "Europe/Paris",
theme: "dark",
style: "1",
locale: "fr",
toolbar_bg: "#0f1623",
enable_publishing: false,
hide_top_toolbar: true,
hide_legend: false,
save_image: false,
container_id: id,
backgroundColor: "rgba(8,12,20,0)",
gridColor: "rgba(30,45,61,0.5)",
hide_volume: false,
studies: [],
});
} catch { /* TradingView indisponible */ }
});
}, []); // eslint-disable-line
return (
<div className="flex flex-col gap-1">
{label && <p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">{label}</p>}
<div
ref={ref}
id={id}
className="rounded-lg overflow-hidden bg-[#0f1623] border border-white/[0.05]"
style={{ height }}
/>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
// lib/financialjuice.ts
// Fetch news depuis FinancialJuice / Forex Crunch (https://www.financialjuice.com)
// Stratégie :
// 1. Essai RSS public (company feed + global feed)
// 2. Authentification via session cookie si RSS échoue
// 3. Headers "full browser" pour passer Cloudflare basic protection
//
// Credentials stockés dans .env.local — jamais dans le code source.
import type { NewsItem } from "./newsfeed";
import { applyRulesPublic } from "./newsfeed";
const FJ_BASE = "https://www.financialjuice.com";
// Headers qui imitent un vrai navigateur Chrome — meilleure chance de passer Cloudflare
const BROWSER_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
};
// ── Parseur RSS ───────────────────────────────────────────────────────────────
function parseRssBlock(xml: string, source: string): NewsItem[] {
const items: NewsItem[] = [];
const blocks = xml.match(/<(?:item|entry)>([\s\S]*?)<\/(?:item|entry)>/gi) ?? [];
for (const block of blocks.slice(0, 50)) {
const titleM = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i);
const linkM = block.match(/<link[^>]*href=["']([^"']+)["']/i)
?? block.match(/<link>([\s\S]*?)<\/link>/i)
?? block.match(/<guid[^>]*>(https?:\/\/[^\s<]+)<\/guid>/i);
const dateM = block.match(/<(?:pubDate|published|updated)>([\s\S]*?)<\/(?:pubDate|published|updated)>/i);
const descM = block.match(/<(?:description|summary)>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/(?:description|summary)>/i);
if (!titleM || !linkM) continue;
const title = titleM[1].replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&#\d+;/g, "").trim();
const url = linkM[1].trim();
const dateStr = dateM?.[1]?.trim() ?? "";
const summary = descM?.[1].replace(/<[^>]+>/g, "").trim().slice(0, 300);
if (!title || !url.startsWith("http")) continue;
// Filtre 8 jours
const pubDate = new Date(dateStr);
if (!isNaN(pubDate.getTime()) && Date.now() - pubDate.getTime() > 8 * 86400_000) continue;
const combined = `${title} ${summary ?? ""}`;
const { impacts, categories } = applyRulesPublic(combined);
items.push({
id: `fj-${Buffer.from(url).toString("base64").slice(0, 12)}`,
title,
url,
source,
publishedAt: dateStr ? new Date(dateStr).toISOString() : new Date().toISOString(),
summary,
impacts,
categories,
});
}
return items;
}
// ── Essai RSS public ──────────────────────────────────────────────────────────
const PUBLIC_RSS_URLS = [
`${FJ_BASE}/company/Forex%20Crunch/feed`,
`${FJ_BASE}/feed`,
`${FJ_BASE}/rss`,
`${FJ_BASE}/news/feed`,
];
async function tryPublicRss(): Promise<NewsItem[]> {
for (const url of PUBLIC_RSS_URLS) {
try {
const res = await fetch(url, {
next: { revalidate: 300 }, // 5 min
headers: { ...BROWSER_HEADERS, "Accept": "application/rss+xml, application/xml, text/xml, */*" },
});
if (!res.ok) continue;
const xml = await res.text();
if (!xml.includes("<item>") && !xml.includes("<entry>")) continue;
const items = parseRssBlock(xml, "FinancialJuice");
if (items.length > 0) return items;
} catch { /* essai suivant */ }
}
return [];
}
// ── Authentification + scraping session ──────────────────────────────────────
// Utilisé si RSS public échoue (Cloudflare peut bloquer même les RSS)
let _sessionCookie = "";
let _sessionExpiry = 0;
async function authenticate(): Promise<string> {
if (_sessionCookie && Date.now() < _sessionExpiry) return _sessionCookie;
const email = process.env.FINANCIALJUICE_EMAIL ?? "";
const password = process.env.FINANCIALJUICE_PASSWORD ?? "";
if (!email || !password) return "";
try {
// Étape 1 : récupérer le token CSRF
const loginPage = await fetch(`${FJ_BASE}/login`, {
headers: BROWSER_HEADERS,
redirect: "follow",
});
if (!loginPage.ok) return "";
const html = await loginPage.text();
const cookieHdr = loginPage.headers.get("set-cookie") ?? "";
const csrfMatch = html.match(/(?:name=["']_token["']|name=["']csrf_token["'])[^>]*value=["']([^"']+)["']/i)
?? html.match(/meta[^>]*name=["']csrf-token["'][^>]*content=["']([^"']+)["']/i);
const csrfToken = csrfMatch?.[1] ?? "";
// Étape 2 : POST credentials
const body = new URLSearchParams({
email,
password,
...(csrfToken ? { _token: csrfToken } : {}),
remember: "on",
});
const loginRes = await fetch(`${FJ_BASE}/login`, {
method: "POST",
headers: {
...BROWSER_HEADERS,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": `${FJ_BASE}/login`,
"Cookie": cookieHdr.split(",").map(c => c.split(";")[0]).join("; "),
},
body: body.toString(),
redirect: "manual",
});
const newCookies = loginRes.headers.get("set-cookie") ?? "";
if (!newCookies) return "";
// Extraire les cookies de session
const session = newCookies.split(",")
.map(c => c.split(";")[0].trim())
.filter(c => c.includes("=") && !c.startsWith("expires"))
.join("; ");
_sessionCookie = session;
_sessionExpiry = Date.now() + 3600_000; // valide 1h
return session;
} catch { return ""; }
}
async function fetchWithSession(): Promise<NewsItem[]> {
const cookie = await authenticate();
if (!cookie) return [];
try {
const res = await fetch(`${FJ_BASE}/company/Forex%20Crunch`, {
next: { revalidate: 300 },
headers: { ...BROWSER_HEADERS, "Cookie": cookie, "Referer": FJ_BASE },
});
if (!res.ok) return [];
const html = await res.text();
// Extraire les articles depuis le HTML (structure probable FinancialJuice)
const items: NewsItem[] = [];
const articleRe = /<(?:article|div)[^>]*class=["'][^"']*(?:news|post|headline|feed)[^"']*["'][^>]*>([\s\S]*?)<\/(?:article|div)>/gi;
let m: RegExpExecArray | null;
while ((m = articleRe.exec(html)) !== null && items.length < 30) {
const block = m[1];
const linkM = block.match(/href=["'](https?:\/\/[^"']+)["']/i);
const titleM = block.match(/<h[1-4][^>]*>([\s\S]*?)<\/h[1-4]>/i)
?? block.match(/<a[^>]*>([\s\S]{5,200}?)<\/a>/i);
const dateM = block.match(/datetime=["']([^"']+)["']/i)
?? block.match(/data-time=["']([^"']+)["']/i);
if (!linkM || !titleM) continue;
const title = titleM[1].replace(/<[^>]+>/g, "").trim();
const url = linkM[1];
const dateStr = dateM?.[1] ?? "";
if (title.length < 10) continue;
const pubDate = dateStr ? new Date(dateStr) : new Date();
if (!isNaN(pubDate.getTime()) && Date.now() - pubDate.getTime() > 8 * 86400_000) continue;
const { impacts, categories } = applyRulesPublic(title);
items.push({
id: `fj-${Buffer.from(url).toString("base64").slice(0, 12)}`,
title,
url,
source: "FinancialJuice",
publishedAt: pubDate.toISOString(),
impacts,
categories,
});
}
return items;
} catch { return []; }
}
// ── Export principal ──────────────────────────────────────────────────────────
export async function fetchFinancialJuiceNews(): Promise<NewsItem[]> {
// Essai 1 : RSS public (plus léger, moins de risque Cloudflare)
const rssItems = await tryPublicRss();
if (rssItems.length > 0) return rssItems;
// Essai 2 : Session authentifiée
const sessionItems = await fetchWithSession();
return sessionItems;
}
+188 -82
View File
@@ -485,6 +485,11 @@ const PERSON_RULES: ImpactRule[] = buildPersonRules();
const ALL_RULES: ImpactRule[] = [...IMPACT_RULES, ...PERSON_RULES];
function applyRules(text: string): { impacts: NewsImpact[]; categories: string[] } {
return applyRulesPublic(text);
}
// Export pour les modules externes (financialjuice.ts, etc.)
export function applyRulesPublic(text: string): { impacts: NewsImpact[]; categories: string[] } {
const impacts: NewsImpact[] = [];
const categories = new Set<string>();
const seenCcy = new Set<Currency>();
@@ -526,79 +531,35 @@ const TE_HEADERS = {
"Accept-Language": "en-US,en;q=0.9",
};
// ── Source 1 : InvestingLive /forex/ ─────────────────────────────────────────
async function fetchInvestingLiveNews(): Promise<NewsItem[]> {
try {
const res = await fetch("https://investinglive.com/forex/", {
next: { revalidate: 1800 },
headers: TE_HEADERS,
});
if (!res.ok) return [];
const html = await res.text();
// Articles listés sous forme <article> ou <div class="...post...">
// On cherche les liens + titres + dates dans le HTML
const items: NewsItem[] = [];
// Pattern : <h2 ...><a href="URL">TITLE</a></h2> + datetime="DATE"
const articlePattern = /<article[^>]*>([\s\S]*?)<\/article>/gi;
let m: RegExpExecArray | null;
while ((m = articlePattern.exec(html)) !== null && items.length < 15) {
const block = m[1];
const linkM = block.match(/href=["'](https?:\/\/investinglive\.com\/[^"']+)["']/i);
const titleM = block.match(/<h[1-4][^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h[1-4]>/i)
?? block.match(/<a[^>]*class=["'][^"']*title[^"']*["'][^>]*>([\s\S]*?)<\/a>/i);
const dateM = block.match(/datetime=["']([^"']+)["']/i);
if (!linkM || !titleM) continue;
const url = linkM[1];
const title = titleM[1].replace(/<[^>]+>/g, "").trim();
const dateStr = dateM ? dateM[1] : "";
const { impacts, categories } = applyRules(title);
items.push({
id: `il-${Buffer.from(url).toString("base64").slice(0, 12)}`,
title,
url,
source: "InvestingLive",
publishedAt: parseDate(dateStr),
impacts,
categories,
});
}
return items;
} catch { return []; }
}
// ── Source 2 : Reuters RSS (marchés) ─────────────────────────────────────────
const REUTERS_FEEDS = [
"https://feeds.reuters.com/reuters/businessNews",
"https://feeds.reuters.com/reuters/topNews",
];
// ── Parseur RSS générique ─────────────────────────────────────────────────────
function parseRssItems(xml: string, source: string): NewsItem[] {
const items: NewsItem[] = [];
const itemBlocks = xml.match(/<item>([\s\S]*?)<\/item>/g) ?? [];
// Support RSS <item> et Atom <entry>
const blockPattern = /<(?:item|entry)>([\s\S]*?)<\/(?:item|entry)>/gi;
let m: RegExpExecArray | null;
for (const block of itemBlocks.slice(0, 20)) {
const titleM = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i);
const linkM = block.match(/<link>([\s\S]*?)<\/link>/i)
?? block.match(/<guid[^>]*>([\s\S]*?)<\/guid>/i);
const dateM = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/i);
const descM = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
while ((m = blockPattern.exec(xml)) !== null && items.length < 25) {
const block = m[1];
// Titre : CDATA ou texte brut
const titleM = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i);
// Lien : <link href="…"/> (Atom) ou <link>…</link> (RSS) ou <guid>
const linkM = block.match(/<link[^>]*href=["']([^"']+)["']/i)
?? block.match(/<link[^>]*>([\s\S]*?)<\/link>/i)
?? block.match(/<guid[^>]*isPermaLink=["']true["'][^>]*>([\s\S]*?)<\/guid>/i)
?? block.match(/<guid[^>]*>(https?:\/\/[^\s<]+)<\/guid>/i);
const dateM = block.match(/<(?:pubDate|published|updated|dc:date)>([\s\S]*?)<\/(?:pubDate|published|updated|dc:date)>/i);
const descM = block.match(/<(?:description|summary|content)>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/(?:description|summary|content)>/i);
if (!titleM || !linkM) continue;
const title = titleM[1].replace(/<[^>]+>/g, "").trim();
const url = linkM[1].trim();
const dateStr = dateM?.[1] ?? "";
const summary = descM?.[1].replace(/<[^>]+>/g, "").trim().slice(0, 200);
const title = titleM[1].replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').trim();
const url = linkM[1].trim();
const dateStr = dateM?.[1]?.trim() ?? "";
const summary = descM?.[1].replace(/<[^>]+>/g, "").trim().slice(0, 300);
if (!title || !url.startsWith("http")) continue;
const combined = `${title} ${summary ?? ""}`;
const { impacts, categories } = applyRules(combined);
@@ -618,17 +579,83 @@ function parseRssItems(xml: string, source: string): NewsItem[] {
return items;
}
async function fetchReutersNews(): Promise<NewsItem[]> {
async function fetchRssFeed(url: string, source: string): Promise<NewsItem[]> {
try {
const res = await fetch(url, {
next: { revalidate: 1800 },
headers: {
...TE_HEADERS,
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
},
});
if (!res.ok) return [];
const xml = await res.text();
if (!xml.includes("<item>") && !xml.includes("<entry>")) return [];
return parseRssItems(xml, source);
} catch { return []; }
}
// ── Source 1 : InvestingLive via WordPress REST API ───────────────────────────
// Beaucoup plus fiable que le scraping HTML — retourne du JSON structuré
async function fetchInvestingLiveNews(): Promise<NewsItem[]> {
try {
// WordPress REST API : liste des posts récents, filtrée sur catégorie forex si dispo
const res = await fetch(
"https://investinglive.com/wp-json/wp/v2/posts?per_page=20&orderby=date&order=desc&_fields=id,title,link,date,excerpt,categories",
{
next: { revalidate: 1800 },
headers: { ...TE_HEADERS, "Accept": "application/json" },
}
);
if (!res.ok) return [];
const posts = await res.json() as Array<{
id: number;
title: { rendered: string };
link: string;
date: string;
excerpt: { rendered: string };
}>;
if (!Array.isArray(posts)) return [];
return posts.slice(0, 20).map(post => {
const title = post.title?.rendered?.replace(/<[^>]+>/g, "").replace(/&#8217;/g, "'").replace(/&#8220;/g, '"').replace(/&#8221;/g, '"').trim() ?? "";
const summary = post.excerpt?.rendered?.replace(/<[^>]+>/g, "").trim().slice(0, 250);
const combined = `${title} ${summary ?? ""}`;
const { impacts, categories } = applyRules(combined);
return {
id: `il-${post.id}`,
title,
url: post.link,
source: "InvestingLive",
publishedAt: parseDate(post.date),
summary,
impacts,
categories,
} as NewsItem;
}).filter(i => i.title.length > 5);
} catch { return []; }
}
// ── Source 2 : Flux RSS forex/marchés ────────────────────────────────────────
// FXStreet : très forex-focused, RSS public
// ForexLive : commentaires macro en temps réel
// MarketWatch : marchés généraux
const RSS_FEEDS: { url: string; source: string }[] = [
{ url: "https://www.fxstreet.com/rss/news", source: "FXStreet" },
{ url: "https://www.forexlive.com/feed/news", source: "ForexLive" },
{ url: "https://feeds.content.dowjones.io/public/rss/mw_realtimeheadlines", source: "MarketWatch" },
{ url: "https://finance.yahoo.com/rss/topfinstories", source: "Yahoo Finance" },
{ url: "https://feeds.reuters.com/reuters/businessNews", source: "Reuters" },
{ url: "https://www.investing.com/rss/news.rss", source: "Investing.com" },
];
async function fetchRssFeeds(): Promise<NewsItem[]> {
const results = await Promise.allSettled(
REUTERS_FEEDS.map(async (feedUrl) => {
const res = await fetch(feedUrl, {
next: { revalidate: 1800 },
headers: { ...TE_HEADERS, "Accept": "application/rss+xml, application/xml, text/xml, */*" },
});
if (!res.ok) return [];
const xml = await res.text();
return parseRssItems(xml, "Reuters");
})
RSS_FEEDS.map(({ url, source }) => fetchRssFeed(url, source))
);
const all: NewsItem[] = [];
@@ -716,26 +743,105 @@ async function fetchBloombergMeta(): Promise<NewsItem[]> {
return items;
}
// ── Source 5 : ZeroHedge (FeedBurner RSS) ────────────────────────────────────
// ZeroHedge couvre des dizaines de sujets (crypto, politique, marchés, macro).
// On filtre : seuls les articles où applyRules trouve au moins une devise ou
// catégorie macro sont conservés. Ça élimine le bruit (IPO, crypto pures, etc.)
async function fetchZeroHedgeNews(): Promise<NewsItem[]> {
const FEED_URL = "https://feeds.feedburner.com/zerohedge/feed";
try {
const res = await fetch(FEED_URL, {
next: { revalidate: 600 }, // 10 min — ZH publie très fréquemment
headers: {
...TE_HEADERS,
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!res.ok) return [];
const xml = await res.text();
if (!xml.includes("<item>")) return [];
const items: NewsItem[] = [];
const blockPattern = /<item>([\s\S]*?)<\/item>/gi;
let m: RegExpExecArray | null;
while ((m = blockPattern.exec(xml)) !== null && items.length < 40) {
const block = m[1];
const titleM = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i);
const linkM = block.match(/<link>([\s\S]*?)<\/link>/i)
?? block.match(/<guid[^>]*>(https?:\/\/[^\s<]+)<\/guid>/i);
const dateM = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/i);
const descM = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
if (!titleM || !linkM) continue;
const title = titleM[1].replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim();
const url = linkM[1].trim();
if (!title || !url.startsWith("http")) continue;
// Décoder les entités HTML puis supprimer les balises (ZH encode son HTML dans CDATA)
const rawDesc = (descM?.[1] ?? "")
.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"');
const plainText = rawDesc.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
const summary = plainText.slice(0, 300);
const analysisText = plainText.slice(0, 800);
// Filtre de pertinence forex : garder seulement si une règle matche
const { impacts, categories } = applyRules(`${title} ${analysisText}`);
if (impacts.length === 0 && categories.length === 0) continue;
const dateStr = dateM?.[1]?.trim() ?? "";
items.push({
id: `zh-${Buffer.from(url).toString("base64").slice(0, 12)}`,
title,
url,
source: "ZeroHedge",
publishedAt: parseDate(dateStr),
summary,
impacts,
categories,
});
}
return items;
} catch { return []; }
}
// ── Main export ───────────────────────────────────────────────────────────────
export async function fetchAllNews(): Promise<NewsItem[]> {
const [ilNews, reutersNews, bbNews] = await Promise.allSettled([
// Import dynamique pour éviter les dépendances circulaires
const { fetchFinancialJuiceNews } = await import("./financialjuice");
const [ilNews, rssNews, bbNews, fjNews, zhNews] = await Promise.allSettled([
fetchInvestingLiveNews(),
fetchReutersNews(),
fetchRssFeeds(),
fetchBloombergMeta(),
fetchFinancialJuiceNews(),
fetchZeroHedgeNews(),
]);
const all: NewsItem[] = [
...(ilNews.status === "fulfilled" ? ilNews.value : []),
...(reutersNews.status === "fulfilled" ? reutersNews.value : []),
...(bbNews.status === "fulfilled" ? bbNews.value : []),
...(fjNews.status === "fulfilled" ? fjNews.value : []), // FJ en priorité
...(zhNews.status === "fulfilled" ? zhNews.value : []), // ZH en 2ème (macro heavy)
...(ilNews.status === "fulfilled" ? ilNews.value : []),
...(rssNews.status === "fulfilled" ? rssNews.value : []),
...(bbNews.status === "fulfilled" ? bbNews.value : []),
];
// Dédupliquer sur l'URL, trier par date décroissante
const EIGHT_DAYS_MS = 8 * 24 * 3600_000;
const cutoff = Date.now() - EIGHT_DAYS_MS;
// Dédupliquer, filtrer > 8 jours, trier par date décroissante
const seenUrls = new Set<string>();
const deduped = all.filter(item => {
if (seenUrls.has(item.url)) return false;
seenUrls.add(item.url);
// Exclure les articles trop anciens (> 8 jours)
const t = new Date(item.publishedAt).getTime();
if (!isNaN(t) && t < cutoff) return false;
return true;
});