From 1a2ea02b22846a2a224ea99e811a43e6f13f70ae Mon Sep 17 00:00:00 2001 From: Capucine Gest Date: Mon, 8 Jun 2026 14:33:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20v8=20=E2=80=94=20COT,=20FX?= =?UTF-8?q?=20Weekly,=20Report,=20TvChart,=20Sentiment,=20News?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/cot-history/route.ts | 174 ++++++++++ app/api/cot/route.ts | 30 +- app/api/fx-weekly/route.ts | 124 +++++++ app/api/narrative/route.ts | 24 +- app/api/news/route.ts | 4 +- app/page.tsx | 42 ++- components/CalendarTab.tsx | 270 +++++++-------- components/CotTab.tsx | 305 +++++++++++++++++ components/DriversBar.tsx | 141 ++++---- components/NewsTab.tsx | 62 +++- components/ReportTab.tsx | 552 +++++++++++++++++++++++++++++++ components/SentimentPairsTab.tsx | 301 ++++++++++++----- components/TvChart.tsx | 149 +++++++++ lib/financialjuice.ts | 225 +++++++++++++ lib/newsfeed.ts | 270 ++++++++++----- 15 files changed, 2284 insertions(+), 389 deletions(-) create mode 100644 app/api/cot-history/route.ts create mode 100644 app/api/fx-weekly/route.ts create mode 100644 components/CotTab.tsx create mode 100644 components/ReportTab.tsx create mode 100644 components/TvChart.tsx create mode 100644 lib/financialjuice.ts diff --git a/app/api/cot-history/route.ts b/app/api/cot-history/route.ts new file mode 100644 index 0000000..b53d6ee --- /dev/null +++ b/app/api/cot-history/route.ts @@ -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; // Leveraged Money — Hedge Funds + legacy: Record; // Non-Commercial — tous spéculateurs +} + +// ── Cache ───────────────────────────────────────────────────────────────────── + +let _cache: { data: CotHistory; ts: number } | null = null; + +function cacheTtl(): number { + const d = new Date(); + const daysUntilFriday = (5 - d.getUTCDay() + 7) % 7 || 7; + d.setUTCDate(d.getUTCDate() + daysUntilFriday); + d.setUTCHours(15, 30, 0, 0); + return Math.min(d.getTime() - Date.now(), 4 * 24 * 3600_000); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function codeMap(): Record { + return Object.fromEntries( + (Object.entries(COT_CODES) as [Currency, string][]).map(([ccy, code]) => [code, ccy]) + ); +} + +function initByCcy(): Record> { + return Object.fromEntries(Object.keys(COT_CODES).map(k => [k, new Map()])); +} + +function toWeeks(maps: Record>): Record { + const result = {} as Record; + for (const [ccy, m] of Object.entries(maps)) { + result[ccy as Currency] = Array.from(m.values()) + .sort((a, b) => b.weekDate.localeCompare(a.weekDate)) + .slice(0, 26); + } + return result; +} + +function int(v: string | undefined): number { + const n = parseInt(v ?? "0", 10); + return isNaN(n) ? 0 : n; +} + +// ── Parseurs ────────────────────────────────────────────────────────────────── + +interface TffRow { + cftc_contract_market_code: string; + report_date_as_yyyy_mm_dd: string; + lev_money_positions_long: string; + lev_money_positions_short: string; + change_in_lev_money_long: string; + change_in_lev_money_short: string; +} + +function parseTff(rows: TffRow[]): Record { + const codes = codeMap(); + const maps = initByCcy(); + + for (const row of rows) { + const ccy = codes[row.cftc_contract_market_code]; + if (!ccy) continue; + const longs = int(row.lev_money_positions_long); + const shorts = int(row.lev_money_positions_short); + const total = longs + shorts; + const dL = int(row.change_in_lev_money_long); + const dS = int(row.change_in_lev_money_short); + const weekDate = row.report_date_as_yyyy_mm_dd?.slice(0, 10); + if (!weekDate) continue; + maps[ccy].set(weekDate, { + weekDate, + net: longs - shorts, + longPct: total > 0 ? Math.round(longs / total * 100) : 50, + shortPct: total > 0 ? Math.round(shorts / total * 100) : 50, + totalLev: total, + deltaNet: dL - dS, + deltaLong: dL, + deltaShort: dS, + }); + } + return toWeeks(maps); +} + +interface LegacyRow { + cftc_contract_market_code: string; + report_date_as_yyyy_mm_dd: string; + noncomm_positions_long_all: string; + noncomm_positions_short_all:string; + change_in_noncomm_long_all: string; + change_in_noncomm_short_all:string; +} + +function parseLegacy(rows: LegacyRow[]): Record { + const codes = codeMap(); + const maps = initByCcy(); + + for (const row of rows) { + const ccy = codes[row.cftc_contract_market_code]; + if (!ccy) continue; + const longs = int(row.noncomm_positions_long_all); + const shorts = int(row.noncomm_positions_short_all); + const total = longs + shorts; + const dL = int(row.change_in_noncomm_long_all); + const dS = int(row.change_in_noncomm_short_all); + const weekDate = row.report_date_as_yyyy_mm_dd?.slice(0, 10); + if (!weekDate) continue; + maps[ccy].set(weekDate, { + weekDate, + net: longs - shorts, + longPct: total > 0 ? Math.round(longs / total * 100) : 50, + shortPct: total > 0 ? Math.round(shorts / total * 100) : 50, + totalLev: total, + deltaNet: dL - dS, + deltaLong: dL, + deltaShort: dS, + }); + } + return toWeeks(maps); +} + +// ── Route ───────────────────────────────────────────────────────────────────── + +async function sodaFetch(dataset: string): Promise { + const url = `${SODA_BASE}/${dataset}.json?$where=${encodeURIComponent(WHERE)}&$limit=${LIMIT}&$order=${encodeURIComponent(ORDER)}`; + const res = await fetch(url, { cache: "no-store", headers: { "Accept": "application/json" } }); + if (!res.ok) throw new Error(`SODA ${dataset} failed: ${res.status}`); + return res.json(); +} + +export async function GET() { + if (_cache && Date.now() - _cache.ts < cacheTtl()) { + return NextResponse.json(_cache.data); + } + + try { + const [tffRows, legacyRows] = await Promise.all([ + sodaFetch("gpe5-46if"), // TFF Futures Only + sodaFetch("6dca-aqww"), // Legacy Futures Only + ]); + + const data: CotHistory = { + tff: parseTff(tffRows as TffRow[]), + legacy: parseLegacy(legacyRows as LegacyRow[]), + }; + + _cache = { data, ts: Date.now() }; + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 502 }); + } +} diff --git a/app/api/cot/route.ts b/app/api/cot/route.ts index fd722b6..358765b 100644 --- a/app/api/cot/route.ts +++ b/app/api/cot/route.ts @@ -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; 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; 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)[0]?.weekDate ?? ""; + _cache = { data: result, ts: Date.now(), weekDate }; return NextResponse.json(result); } catch (err) { return NextResponse.json({ error: String(err) }, { status: 502 }); diff --git a/app/api/fx-weekly/route.ts b/app/api/fx-weekly/route.ts new file mode 100644 index 0000000..1d21460 --- /dev/null +++ b/app/api/fx-weekly/route.ts @@ -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 = { + 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 | 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 }; + 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); +} diff --git a/app/api/narrative/route.ts b/app/api/narrative/route.ts index 5f2e3bd..50a14f8 100644 --- a/app/api/narrative/route.ts +++ b/app/api/narrative/route.ts @@ -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 : diff --git a/app/api/news/route.ts b/app/api/news/route.ts index 7bab08b..f4b0062 100644 --- a/app/api/news/route.ts +++ b/app/api/news/route.ts @@ -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" } } ); } diff --git a/app/page.tsx b/app/page.tsx index a0e9a29..7956cea 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 | null>(null); const [calEvents, setCalEvents] = useState([]); 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([]); const [newsLoading, setNewsLoading] = useState(false); - const [rawSymbols, setRawSymbols] = useState | null>(null); + const [cotHistory, setCotHistory] = useState(null); + const [cotLoading, setCotLoading] = useState(false); + const [rawSymbols, setRawSymbols] = useState | null>(null); const [rateProbabilities, setRateProbabilities] = useState(null); const [lastRefresh, setLastRefresh] = useState(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 */}
- {(["dashboard", "calendar", "pairs", "yields", "news"] as const).map((tab) => ( + {(["dashboard", "calendar", "pairs", "yields", "news", "cot", "report"] as const).map((tab) => ( ))}
@@ -371,6 +395,14 @@ export default function Dashboard() { )} + {activeTab === "cot" && ( + + )} + + {activeTab === "report" && ( + + )} + {/* Legend */}
Haussier / Sous-évalué
diff --git a/components/CalendarTab.tsx b/components/CalendarTab.tsx index 47527e2..952676c 100644 --- a/components/CalendarTab.tsx +++ b/components/CalendarTab.tsx @@ -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 = { @@ -23,12 +23,6 @@ const CATEGORY_LABELS: Record = { trade_balance: "Balance comm.", }; -const IMPACT_COLOR: Record = { - 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 ; + const cls = impact === "high" ? "bg-red-500" + : impact === "medium" ? "bg-amber-400" + : "bg-slate-600"; + return ; } +// ── 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 ( - + + -
{day}
-
{time}
+
{day}
+
{time}
+ {meta?.flag} - {ev.currency} + {ev.currency} + -
{CATEGORY_LABELS[ev.category]}
+
{CATEGORY_LABELS[ev.category]}
+ - {ev.previous ?? "—"} + {ev.previous ?? "—"} + {ev.forecast - ? {ev.forecast} - : } + ? {ev.forecast} + : } + {ev.actual - ? {ev.actual} - : } + ? {ev.actual} + : } + @@ -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("ALL"); - const [expanded, setExpanded] = useState>(new Set()); - const [showLow, setShowLow] = useState(false); - const [weekTab, setWeekTab] = useState("all"); - const [fromDate, setFromDate] = useState(todayIso()); + const [filterCcy, setFilterCcy] = useState("ALL"); + const [expanded, setExpanded] = useState>(new Set()); + const [showLow, setShowLow] = useState(false); + const [weekTab, setWeekTab] = useState("all"); + const [fromDate, setFromDate] = useState(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 = {}; 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 ( -
+
{/* Header */} -
+
-

Calendrier économique

-

Sources : ForexFactory · FRED · Banques centrales

+

Calendrier économique

+

Sources : ForexFactory · FRED · Banques centrales

-
- {/* ── Onglets semaine ──────────────────────────────────────────────────── */} -
+ {/* Onglets semaine */} +
{([ - ["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 (
-
+
- {/* Filtre devise */}
- {CURRENCIES.map((ccy) => ( + {CURRENCIES.map(ccy => ( @@ -304,22 +306,22 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
- {/* ── Table ────────────────────────────────────────────────────────────── */} + {/* Table */} {loading ? (
- +
) : filtered.length === 0 ? (
-

Aucun événement pour cette sélection

+

Aucun événement pour cette sélection

{weekTab === "next" && !nextWeekAvail && ( -

+

ForexFactory ne publie la semaine prochaine que du lundi au vendredi.
Données disponibles dans quelques heures.

)} {fromDate > todayIso() && ( - )} @@ -328,7 +330,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
- + @@ -341,35 +343,39 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) { {(() => { 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 = { - 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( - - + ); } - // Séparateur de jour + + // Séparateur jour rows.push( - - + ); + for (const ev of dayEvents) { rows.push( +
Impact élevé Impact moyen - · Cliquer sur une ligne groupée pour voir les sous-indicateurs - · Prévision = consensus marché avant publication + · Cliquer sur une ligne groupée pour voir les sous-indicateurs + · Prévision = consensus marché avant publication
); diff --git a/components/CotTab.tsx b/components/CotTab.tsx new file mode 100644 index 0000000..9baae66 --- /dev/null +++ b/components/CotTab.tsx @@ -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
; + + 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 ( + + + + {min < 0 && max > 0 && ( + + )} + + ); +} + +// ── 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 ( +
+

{label}

+

= 0 ? "text-emerald-400" : "text-red-400"}`}>Net : {formatNet(net)}

+

{longPct}% L / {100 - longPct}% S

+ {deltaNet !== undefined && deltaNet !== null && ( +

0 ? "text-emerald-400" : deltaNet < 0 ? "text-red-400" : "text-slate-500"}`}> + Δ semaine : {formatNet(deltaNet)} +

+ )} +
+ ); +} + +// ── 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 ( + + ); +} + +// ── Composant principal ─────────────────────────────────────────────────────── +export default function CotTab({ history, loading }: Props) { + const [selected, setSelected] = useState(null); + const [mode, setMode] = useState<"tff" | "legacy">("tff"); + + if (loading) { + return
Chargement historique COT…
; + } + if (!history || (!Object.keys(history.tff ?? {}).length && !Object.keys(history.legacy ?? {}).length)) { + return
Données COT indisponibles
; + } + + 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 ( +
+ {/* En-tête + toggle */} +
+
+

COT · CFTC

+ {latestDate && Semaine du {latestDate}} +
+ + {/* Toggle TFF / Legacy */} +
+ {(["tff", "legacy"] as const).map(m => ( + + ))} +
+
+ + {/* Description du mode */} +

{MODE_LABELS[mode].desc}

+ + {/* Grille 8 cartes */} +
+ {CURRENCIES.map(ccy => ( + handleSelect(ccy)} + /> + ))} +
+ + {/* Panneau détail */} + {selected && chartData.length > 0 && ( +
+ {/* Résumé */} +
+ + {CURRENCY_META[selected]?.flag} {selected} + + {w0 && ( + = 0 ? "text-emerald-400" : "text-red-400"}`}> + {formatNet(w0.net)} net · {w0.longPct}%L / {w0.shortPct}%S + + )} + {d !== null && ( + 0 ? "text-emerald-400" : d < 0 ? "text-red-400" : "text-slate-500"}`}> + {d > 0 ? "▲" : "▼"} {formatNet(Math.abs(d))} Δ sem. + + )} + {w0?.deltaLong !== null && w0?.deltaLong !== undefined && ( + + +L {formatNet(w0.deltaLong)} / +S {formatNet(w0.deltaShort ?? 0)} + + )} + {selWeeks.length} semaines +
+ + {/* Chart */} + + + + + `${(v/1000).toFixed(0)}k`} width={36} /> + `${v}%`} width={30} /> + } /> + + + {chartData.map((e, i) => )} + + + + + + {/* Tableau 6 dernières semaines avec deltas */} +
Date / Heure Devise Événement
- {weekBanners[w] ?? w} +
+ 📅 {weekBanners[w] ?? w}
+
{fmtDayLabel(day)}
+ + + + + + + + + + + + {selWeeks.slice(0, 6).map((w, i) => ( + + + + + + + + + ))} + +
SemaineNetΔ NetΔ LongsΔ Shorts%L
{w.weekDate}= 0 ? "text-emerald-400" : "text-red-400"}`}> + {formatNet(w.net)} + 0 ? "text-emerald-400" + : w.deltaNet < 0 ? "text-red-400" + : "text-slate-500" + }`}> + {w.deltaNet !== null ? formatNet(w.deltaNet) : "—"} + 0 ? "text-emerald-400" : "text-red-400" + }`}> + {w.deltaLong !== null && w.deltaLong !== undefined ? formatNet(w.deltaLong) : "—"} + 0 ? "text-red-400" : "text-emerald-400" + }`}> + {w.deltaShort !== null && w.deltaShort !== undefined ? formatNet(w.deltaShort) : "—"} + {w.longPct}%
+
+ )} +
+ ); +} diff --git a/components/DriversBar.tsx b/components/DriversBar.tsx index d4fff71..b731552 100644 --- a/components/DriversBar.tsx +++ b/components/DriversBar.tsx @@ -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(null); const ref = useRef(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 ( <>
- {label} - - {fmt(value, dec, unit)} - - {dFmt && ( - - {dArrow}{dFmt} + {label} +
+ + {fmt(value, dec, unit)} - )} - {tooltip && ( - - i - - )} + {dFmt && ( + + {dArrow}{dFmt} + + )} +
{tip && typeof document !== "undefined" && createPortal( @@ -80,8 +83,12 @@ function D({ label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, toolti ); } -function VSep() { - return
; +function GroupLabel({ label }: { label: string }) { + return ( + + {label} + + ); } 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 ( -
+
- - DRIVERS GLOBAUX - + {/* Ligne titre + alerte */} +
+ + Drivers Globaux + + {riskOff && ( +
+ + Risk-Off +
+ )} +
- {riskOff && ( -
- - Risk-Off -
- )} + {/* Grille responsive — 2 lignes sur desktop, s'adapte sur mobile */} +
- + {/* ── Sentiment ─────────────────────────────────────────── */} + + 25 ? "red" : undefined} + tooltip="Clôture actuelle − clôture précédente (Yahoo Finance)." /> + + - {/* Sentiment / Risk-On */} - - - + {/* ── Crédit ────────────────────────────────────────────── */} +
+ + 500 ? "red" : (hySpread ?? 0) > 400 ? "amber" : undefined} + tooltip="High Yield spread vs Treasuries US. >500 bps = risk-off fort." /> + - + {/* ── FX / Taux ─────────────────────────────────────────── */} +
+ + + - {/* Crédit */} - - - - - - {/* Taux & FX */} - - - - - - {/* Commodités */} - - - - + {/* ── Commodités ────────────────────────────────────────── */} +
+ + + + + +
); } diff --git a/components/NewsTab.tsx b/components/NewsTab.tsx index aaabee1..6b9b17b 100644 --- a/components/NewsTab.tsx +++ b/components/NewsTab.tsx @@ -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("ALL"); - const [filterCat, setFilterCat] = useState("ALL"); - const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all"); + const [filterCcy, setFilterCcy] = useState("ALL"); + const [filterCat, setFilterCat] = useState("ALL"); + const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all"); + const [priorityOnly, setPriorityOnly] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); + const [lastRefreshAt, setLastRefreshAt] = useState(null); + const intervalRef = useRef | 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 && (
-
+
Biais actualités par devise - +
+ {/* Bouton Prioritaires */} + + {/* Auto-refresh */} + + +
{CCY_LIST.map(ccy => { diff --git a/components/ReportTab.tsx b/components/ReportTab.tsx new file mode 100644 index 0000000..6c01dc7 --- /dev/null +++ b/components/ReportTab.tsx @@ -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; + 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 ( +