mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-04 16:27:43 +00:00
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:
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 :
|
||||
|
||||
@@ -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
@@ -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
@@ -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'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'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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 close−open (Stooq)." />
|
||||
<D label="Argent $/oz" value={silver} dec={2} delta={silverDelta}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<D label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<D label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (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 close−open (Stooq)." />
|
||||
<Tile label="Argent $/oz" value={silver} dec={2} delta={silverDelta}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<Tile label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
<Tile label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1}
|
||||
tooltip="Delta intraday close−open (Stooq)." />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+46
-16
@@ -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 => {
|
||||
|
||||
@@ -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 2–3 : 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 (>70% ou <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 (>70% ou <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 (>70% ou <30%)
|
||||
</span>
|
||||
<span className="ml-auto hidden sm:inline">Source : Myfxbook Community Outlook · ~50k traders retail trackés</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(/&/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
@@ -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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/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(/’/g, "'").replace(/“/g, '"').replace(/”/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(/&/g, "&").replace(/</g, "<").replace(/>/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(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user