From 48d3b9c473e3bc37567cdd8c1b08ce08501baf67 Mon Sep 17 00:00:00 2001 From: caty21 Date: Mon, 29 Jun 2026 23:32:35 +0200 Subject: [PATCH] feat(COT/Idees): redesign COT chart + NC group + editeur riche IdeesTab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COT : - Ajoute groupe NC (Non-Commercial Legacy) via Socrata API CFTC - Graphique bidirectionnel (barres avec gradient, zéro centré, track complet) - Barre L/S split sous chaque groupe, valeur nette + %L empilés - Corrige verdict bug : amDominates vérifié avant hfIsShort - Supprime 'k contrats', ajoute légende ΔL/ΔS/ΔNet IdeesTab : - Réécriture NotePane : contentEditable Notion-like avec images inline - Toolbar riche : gras, italique, souligné, listes, alignement - Redimensionnement image inline (25/40/60/80/100%) - Archives : affichage complet screenshot + texte, restauration vers slot actif Sentiment DXM : - Affichage brut paire par paire (Myfxbook) pour vérification directe - SentimentPair type + pairs[] dans SentimentEntry Co-Authored-By: Claude Sonnet 4.6 --- app/api/cot/route.ts | 125 +++++++--- app/page.tsx | 40 ++-- components/CurrencyCard.tsx | 441 ++++++++++++++++++++-------------- components/IdeesTab.tsx | 459 +++++++++++++++++++++++++++--------- lib/types.ts | 53 +++-- 5 files changed, 766 insertions(+), 352 deletions(-) diff --git a/app/api/cot/route.ts b/app/api/cot/route.ts index 494a73f..df52a03 100644 --- a/app/api/cot/route.ts +++ b/app/api/cot/route.ts @@ -7,18 +7,7 @@ export const dynamic = "force-dynamic"; // ── CFTC Traders in Financial Futures (TFF) — fichier annuel ZIP ────────────── // URL : https://www.cftc.gov/files/dea/history/fut_fin_txt_YYYY.zip -// Contient toutes les semaines de l'année en ordre décroissant. -// On extrait les 2 dernières semaines par devise pour calculer les deltas. -// -// Colonnes (0-based, séparées par virgule) : -// 0 Market_and_Exchange_Names -// 1 As_of_Date_In_Form_YYMMDD -// 2 Report_Date_as_YYYY-MM-DD -// 3 CFTC_Contract_Market_Code -// 11 Asset_Mgr_Positions_Long_All -// 12 Asset_Mgr_Positions_Short_All -// 14 Lev_Money_Positions_Long_All ← hedge funds -// 15 Lev_Money_Positions_Short_All +// Colonnes : 3=code, 2=date, 11=AM long, 12=AM short, 14=HF long, 15=HF short const IDX_CODE = 3; const IDX_DATE = 2; @@ -27,6 +16,14 @@ const IDX_AM_SHORT = 12; const IDX_LEV_LONG = 14; const IDX_LEV_SHORT = 15; +// ── CFTC Legacy COT (Non-Commercial) — via Socrata API ──────────────────────── +// Dataset : 6dca-aqww (Legacy Futures Only) +// Fields : noncomm_positions_long_all, noncomm_positions_short_all + changes + +const SODA_BASE = "https://publicreporting.cftc.gov/resource"; +const CODES_LIST = Object.values(COT_CODES).map(c => `'${c}'`).join(","); +const SODA_WHERE = `cftc_contract_market_code in(${CODES_LIST}) AND futonly_or_combined='FutOnly'`; + function cftcZipUrl(): string { return `https://www.cftc.gov/files/dea/history/fut_fin_txt_${new Date().getFullYear()}.zip`; } @@ -48,16 +45,71 @@ function cacheTtl(): number { export type { CotEntry } from "@/lib/types"; +// ── NC (Non-Commercial Legacy) via Socrata ───────────────────────────────────── + +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; +} + +interface NcData { + longs: number; shorts: number; + longsDelta: number | null; shortsDelta: number | null; +} + +async function fetchNcData(): Promise> { + const url = `${SODA_BASE}/6dca-aqww.json?$where=${encodeURIComponent(SODA_WHERE)}&$limit=20&$order=report_date_as_yyyy_mm_dd DESC`; + const rows: LegacyRow[] = await fetch(url, { cache: "no-store" }).then(r => r.json()).catch(() => []); + + // On garde max 2 semaines par devise (ordre DESC = plus récente en premier) + const seen: Record = {}; + for (const row of rows) { + const code = row.cftc_contract_market_code; + if (!seen[code]) seen[code] = []; + if (seen[code].length < 2) seen[code].push(row); + } + + const codeMap = Object.fromEntries( + (Object.entries(COT_CODES) as [Currency, string][]).map(([ccy, code]) => [code, ccy]) + ); + + const result: Record = {}; + for (const [code, weeks] of Object.entries(seen)) { + const ccy = codeMap[code]; + if (!ccy || weeks.length === 0) continue; + const cur = weeks[0]; + const prev = weeks[1] ?? null; + const longs = parseInt(cur.noncomm_positions_long_all ?? "0", 10) || 0; + const shorts = parseInt(cur.noncomm_positions_short_all ?? "0", 10) || 0; + const prevL = prev ? parseInt(prev.noncomm_positions_long_all ?? "0", 10) || 0 : null; + const prevS = prev ? parseInt(prev.noncomm_positions_short_all ?? "0", 10) || 0 : null; + result[ccy] = { + longs, shorts, + longsDelta: prevL !== null ? longs - prevL : null, + shortsDelta: prevS !== null ? shorts - prevS : null, + }; + } + return result; +} + export async function GET() { if (_cache && Date.now() - _cache.ts < cacheTtl()) { return NextResponse.json(_cache.data); } try { - const res = await fetch(cftcZipUrl(), { - cache: "no-store", - headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" }, - }); + const [res, ncRaw] = await Promise.all([ + fetch(cftcZipUrl(), { + cache: "no-store", + headers: { "User-Agent": "Mozilla/5.0 (compatible; ForexDashboard/1.0)" }, + }), + fetchNcData(), + ]); + if (!res.ok) { return NextResponse.json({ error: `CFTC fetch failed: ${res.status}` }, { status: 502 }); } @@ -66,7 +118,7 @@ export async function GET() { const text = extractFirstFileFromZip(zipBuf); if (!text) return NextResponse.json({ error: "ZIP parse failed" }, { status: 502 }); - const result = parseCOT(text); + const result = parseCOT(text, ncRaw); _cache = { data: result, ts: Date.now() }; return NextResponse.json(result); } catch (err) { @@ -113,7 +165,7 @@ type RawWeek = { weekDate: string; }; -function parseCOT(csv: string): Record { +function parseCOT(csv: string, nc: Record): Record { const lines = csv.split("\n"); const targetCodes = new Set(Object.values(COT_CODES)); const raw: Record = {}; @@ -134,7 +186,6 @@ function parseCOT(csv: string): Record { const weekDate = cols[IDX_DATE]?.trim() ?? ""; if (!raw[code]) raw[code] = []; - // Keep only the 2 most-recent weeks (file is in descending date order) if (raw[code].length < 2) raw[code].push({ hfLongs, hfShorts, amLongs, amShorts, weekDate }); } @@ -153,26 +204,40 @@ function parseCOT(csv: string): Record { const amTotal = cur.amLongs + cur.amShorts; const amNet = cur.amLongs - cur.amShorts; + const ncEntry = nc[currency] ?? null; + const ncLongs = ncEntry?.longs ?? 0; + const ncShorts = ncEntry?.shorts ?? 0; + const ncTotal = ncLongs + ncShorts; + const ncNet = ncLongs - ncShorts; + result[currency] = { - net: hfNet, - hfLongs: cur.hfLongs, - hfShorts: cur.hfShorts, - longPct: hfTotal > 0 ? Math.round((cur.hfLongs / hfTotal) * 100) : 50, - shortPct: hfTotal > 0 ? Math.round((cur.hfShorts / hfTotal) * 100) : 50, - totalLev: hfTotal, + net: hfNet, + hfLongs: cur.hfLongs, + hfShorts: cur.hfShorts, + longPct: hfTotal > 0 ? Math.round((cur.hfLongs / hfTotal) * 100) : 50, + shortPct: hfTotal > 0 ? Math.round((cur.hfShorts / hfTotal) * 100) : 50, + totalLev: hfTotal, amNet, - amLongs: cur.amLongs, - amShorts: cur.amShorts, - amLongPct: amTotal > 0 ? Math.round((cur.amLongs / amTotal) * 100) : 50, + amLongs: cur.amLongs, + amShorts: cur.amShorts, + amLongPct: amTotal > 0 ? Math.round((cur.amLongs / amTotal) * 100) : 50, amTotal, + ncNet, + ncLongs, + ncShorts, + ncLongPct: ncTotal > 0 ? Math.round((ncLongs / ncTotal) * 100) : 50, + ncTotal, netDelta: prev !== null ? hfNet - (prev.hfLongs - prev.hfShorts) : null, longsDelta: prev !== null ? cur.hfLongs - prev.hfLongs : null, shortsDelta: prev !== null ? cur.hfShorts - prev.hfShorts : null, amNetDelta: prev !== null ? amNet - (prev.amLongs - prev.amShorts) : null, amLongsDelta: prev !== null ? cur.amLongs - prev.amLongs : null, amShortsDelta: prev !== null ? cur.amShorts - prev.amShorts : null, - weekDate: cur.weekDate, - prevWeekDate: prev?.weekDate ?? null, + ncNetDelta: ncEntry?.longsDelta != null && ncEntry?.shortsDelta != null ? ncEntry.longsDelta - ncEntry.shortsDelta : null, + ncLongsDelta: ncEntry?.longsDelta ?? null, + ncShortsDelta: ncEntry?.shortsDelta ?? null, + weekDate: cur.weekDate, + prevWeekDate: prev?.weekDate ?? null, }; } diff --git a/app/page.tsx b/app/page.tsx index 47fa569..51083a8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from "react"; import { RefreshCw, Database, Activity, Maximize2, Minimize2, X, BarChart2 } from "lucide-react"; import { CURRENCIES, CURRENCY_META } from "@/lib/constants"; -import type { Currency, DriverData, SentimentEntry, CotEntry, MacroSection } from "@/lib/types"; +import type { Currency, DriverData, SentimentEntry, SentimentPair, CotEntry, MacroSection } from "@/lib/types"; import type { RateProbData } from "@/lib/rateprobability"; import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache"; import CurrencyCard from "@/components/CurrencyCard"; @@ -103,36 +103,42 @@ export default function Dashboard() { const OUR_CCYS = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]; const longWeighted: Record = {}; const totalPos: Record = {}; - const pairCount: Record = {}; + // paire par paire pour affichage direct + const rawPairs: Record = {}; for (const sym of symbols) { const def = PAIR_DEF[sym.name]; - if (!def || sym.totalPositions <= 0) continue; + if (!def) continue; const { base, quote } = def; + const hasPos = sym.totalPositions > 0 || sym.longPercentage > 0 || sym.shortPercentage > 0; + if (!hasPos) continue; + const total = sym.totalPositions || 100; // fallback si totalPositions absent - // Base currency : long la paire = long la base + // Base currency if (OUR_CCYS.includes(base)) { - longWeighted[base] = (longWeighted[base] ?? 0) + sym.longPercentage * sym.totalPositions; - totalPos[base] = (totalPos[base] ?? 0) + sym.totalPositions; - pairCount[base] = (pairCount[base] ?? 0) + 1; + longWeighted[base] = (longWeighted[base] ?? 0) + sym.longPercentage * total; + totalPos[base] = (totalPos[base] ?? 0) + total; + if (!rawPairs[base]) rawPairs[base] = []; + rawPairs[base].push({ name: sym.name, longPct: sym.longPercentage, shortPct: sym.shortPercentage, longIsBaseLong: true }); } - // Quote currency : long la paire = short la cotation → long cotation = shortPercentage + // Quote currency : long paire = short cotation if (OUR_CCYS.includes(quote)) { - longWeighted[quote] = (longWeighted[quote] ?? 0) + sym.shortPercentage * sym.totalPositions; - totalPos[quote] = (totalPos[quote] ?? 0) + sym.totalPositions; - pairCount[quote] = (pairCount[quote] ?? 0) + 1; + longWeighted[quote] = (longWeighted[quote] ?? 0) + sym.shortPercentage * total; + totalPos[quote] = (totalPos[quote] ?? 0) + total; + if (!rawPairs[quote]) rawPairs[quote] = []; + rawPairs[quote].push({ name: sym.name, longPct: sym.longPercentage, shortPct: sym.shortPercentage, longIsBaseLong: false }); } } const result: Record = {}; for (const ccy of OUR_CCYS) { const total = totalPos[ccy] ?? 0; - if (total === 0) continue; - const n = pairCount[ccy] ?? 1; - const longPct = Math.round(longWeighted[ccy] / total); - // Label : "DXY (7 paires)" pour USD, "EUR (6 paires)" pour EUR, etc. - const label = ccy === "USD" ? `DXY (${n} paires)` : `${ccy} (${n} paire${n > 1 ? "s" : ""})`; - result[ccy] = { pair: label, longPct, shortPct: 100 - longPct }; + const pairs = rawPairs[ccy] ?? []; + if (total === 0 && pairs.length === 0) continue; + const longPct = total > 0 ? Math.round(longWeighted[ccy] / total) : 50; + const n = pairs.length; + const label = ccy === "USD" ? `DXY (${n} paires)` : `${ccy} (${n} paire${n > 1 ? "s" : ""})`; + result[ccy] = { pair: label, longPct, shortPct: 100 - longPct, pairs }; } return result; diff --git a/components/CurrencyCard.tsx b/components/CurrencyCard.tsx index e318d38..bc6585a 100644 --- a/components/CurrencyCard.tsx +++ b/components/CurrencyCard.tsx @@ -1793,35 +1793,119 @@ export default function CurrencyCard({ {/* COT */} {signauxSlide === "cot" && cot && (() => { - const hfIsShort = cot.net < 0; - const amDominates = Math.abs(cot.amNet) > Math.abs(cot.net); + // ── helpers ─────────────────────────────────────────────── + const netFmt = (v: number) => `${v > 0 ? "+" : ""}${(v / 1000).toFixed(1)}k`; + + function evolText(isLong: boolean, dL: number | null, dS: number | null): { text: string; cls: string } { + if (dL == null && dS == null) return { text: "pas de données semaine précédente", cls: "text-slate-600" }; + const ld = dL ?? 0, sd = dS ?? 0; + if (isLong) { + if (ld > 0 && sd < 0) return { text: "↑↑ renforcement haussier — accumule longs + couvre shorts", cls: "text-emerald-400" }; + if (ld > 0 && sd > 0) return { text: "↑ accumule des longs (+ aussi des shorts)", cls: "text-emerald-400/70" }; + if (ld > 0) return { text: "↑ prend des positions inverses — rachète des longs", cls: "text-emerald-400/80" }; + if (sd > 0 && ld < 0) return { text: "↓↓ retournement baissier en cours — réduit longs + ajoute shorts", cls: "text-red-400" }; + if (sd > 0) return { text: "↓ ajoute des shorts — signal de distribution", cls: "text-red-400/80" }; + if (ld < 0) return { text: "↓ réduit les longs", cls: "text-red-400/70" }; + } else { + if (sd > 0 && ld < 0) return { text: "↓↓ renforcement baissier — accumule shorts + réduit longs", cls: "text-red-400" }; + if (sd > 0 && ld > 0) return { text: "↓ renforce les shorts (+ rachète des longs)", cls: "text-red-400/70" }; + if (sd > 0) return { text: "↓ renforce les shorts", cls: "text-red-400/80" }; + if (ld > 0 && sd < 0) return { text: "↑↑ retournement haussier en cours — rachète longs + couvre shorts", cls: "text-emerald-400" }; + if (ld > 0) return { text: "↑ prend des positions inverses — rachète des longs", cls: "text-emerald-400/80" }; + if (sd < 0) return { text: "↑ couvre des shorts", cls: "text-emerald-400/70" }; + } + return { text: "stable — pas de mouvement significatif", cls: "text-slate-500" }; + } + + // ── groupes ─────────────────────────────────────────────── + const groups = [ + { + id: "hf", label: "HF", desc: "Hedge Funds · CTAs (Leveraged Money CFTC)", + accentT: "text-amber-400", accentB: "border-amber-500/25", accentBg: "bg-amber-950/30", + net: cot.net, longs: cot.hfLongs, shorts: cot.hfShorts, + dL: cot.longsDelta, dS: cot.shortsDelta, + }, + { + id: "am", label: "AM", desc: "Asset Managers · institutionnels (TFF CFTC)", + accentT: "text-indigo-400", accentB: "border-indigo-500/25", accentBg: "bg-indigo-950/50", + net: cot.amNet, longs: cot.amLongs, shorts: cot.amShorts, + dL: cot.amLongsDelta, dS: cot.amShortsDelta, + }, + { + id: "nc", label: "NC", desc: "Non-Commercial · grands spéculateurs (rapport Legacy CFTC)", + accentT: "text-purple-400", accentB: "border-purple-500/25", accentBg: "bg-purple-950/30", + net: cot.ncNet, longs: cot.ncLongs, shorts: cot.ncShorts, + dL: cot.ncLongsDelta, dS: cot.ncShortsDelta, + }, + ]; + + // ── verdict ─────────────────────────────────────────────── + const hfIsShort = cot.net < 0; + const amDominates = Math.abs(cot.amNet) > Math.abs(cot.net); const hfLongsGrowing = cot.longsDelta !== null && cot.longsDelta > 0; const hfShortsReducing= cot.shortsDelta !== null && cot.shortsDelta < 0; const hfShortsGrowing = cot.shortsDelta !== null && cot.shortsDelta > 0; const hfLongsReducing = cot.longsDelta !== null && cot.longsDelta < 0; - - let hfTrend = ""; - if (hfIsShort) { - if (hfLongsGrowing && hfShortsReducing) hfTrend = "retournement en cours (L↑ S↓)"; - else if (hfLongsGrowing) hfTrend = "accumulation de longs (L↑)"; - else if (hfShortsReducing) hfTrend = "couverture de shorts (S↓)"; - else hfTrend = "exposition baissière stable"; - } else { - if (hfShortsGrowing && hfLongsReducing) hfTrend = "retournement baissier (S↑ L↓)"; - else if (hfShortsGrowing) hfTrend = "ajout de shorts (S↑)"; - else if (hfLongsReducing) hfTrend = "réduction de longs (L↓)"; - else hfTrend = "exposition haussière stable"; - } - - // Poids relatif AM vs HF (barre de dominance) const totalAbs = Math.abs(cot.amNet) + Math.abs(cot.net); const amPct = totalAbs > 0 ? Math.round(Math.abs(cot.amNet) / totalAbs * 100) : 50; const hfPct = 100 - amPct; - const dFmt = (v: number) => `${v > 0 ? "+" : ""}${(v / 1000).toFixed(1)}k`; + + const amBull = cot.amNet > 0; + const amPilote = amDominates; + let verdict = "", sub = "", vCls = ""; + if (!hfIsShort && amBull) { + verdict = "▲▲ Convergence haussière — AM + HF long alignés"; + sub = "AM et HF achètent tous les deux → signal fort"; + vCls = "text-emerald-400 border-emerald-500/25 bg-emerald-500/8"; + } else if (hfIsShort && !amBull) { + verdict = "▼▼ Convergence baissière — AM + HF short alignés"; + sub = "AM et HF vendent tous les deux → signal fort"; + vCls = "text-red-400 border-red-500/25 bg-red-500/8"; + } else if (amPilote && amBull && hfIsShort) { + verdict = (hfLongsGrowing || hfShortsReducing) + ? `▲ AM long pilote (${amPct}%) — HF couvre ses shorts` + : `▲ AM long pilote (${amPct}%) — HF short minoritaire`; + sub = (hfLongsGrowing || hfShortsReducing) + ? "AM dominant et les HF commencent à se retourner → momentum haussier" + : "AM achète massivement · HF vend mais reste minoritaire par volume"; + vCls = "text-emerald-400/80 border-emerald-500/20 bg-emerald-500/5"; + } else if (amPilote && !amBull && !hfIsShort) { + verdict = (hfShortsGrowing || hfLongsReducing) + ? `▼ AM short pilote (${amPct}%) — HF commence à vendre` + : `▼ AM short pilote (${amPct}%) — HF long minoritaire`; + sub = (hfShortsGrowing || hfLongsReducing) + ? "AM dominant et HF rejoint → signal baissier" + : "AM vend massivement · HF long mais minoritaire par volume"; + vCls = "text-red-400/80 border-red-500/20 bg-red-500/5"; + } else if (!amPilote && hfIsShort && hfLongsGrowing && hfShortsReducing) { + verdict = "↻ HF short pilote — retournement haussier en cours"; + sub = "HF couvre ses shorts ET rachète des longs → signal de retournement"; + vCls = "text-emerald-400 border-emerald-500/25 bg-emerald-500/8"; + } else if (!amPilote && hfIsShort && hfLongsGrowing) { + verdict = "▲ HF short — mais prend des positions inverses (longs)"; + sub = "Signal d'accumulation → surveiller si les shorts baissent aussi"; + vCls = "text-emerald-400/80 border-emerald-500/20 bg-emerald-500/5"; + } else if (!amPilote && hfIsShort && hfShortsReducing) { + verdict = "▲ HF short — couvre ses positions"; + sub = "Pression baissière qui s'allège → retournement possible"; + vCls = "text-emerald-400/70 border-emerald-500/15 bg-emerald-500/5"; + } else if (!amPilote && !hfIsShort && hfShortsGrowing && hfLongsReducing) { + verdict = "↻ HF long pilote — retournement baissier en cours"; + sub = "HF réduit ses longs ET ajoute des shorts → signal de retournement"; + vCls = "text-red-400 border-red-500/25 bg-red-500/8"; + } else if (!amPilote && !hfIsShort && hfShortsGrowing) { + verdict = "▼ HF long — prend des positions inverses (shorts)"; + sub = "Signal de distribution → surveiller si les longs baissent aussi"; + vCls = "text-red-400/80 border-red-500/20 bg-red-500/5"; + } else { + verdict = "→ Flux mixtes"; + sub = `AM ${amBull ? "long" : "short"} (${amPct}%) · HF ${hfIsShort ? "short" : "long"} (${hfPct}%) · pas de signal directionnel clair`; + vCls = "text-slate-400 border-slate-700/30 bg-slate-800/20"; + } return (
- {/* ① Header */} + {/* Header */}
@@ -1830,156 +1914,123 @@ export default function CurrencyCard({ onClick={() => setShowCotInfo(v => !v)} className="ml-0.5 text-slate-500 hover:text-slate-300 transition-colors" title="Comprendre ces données" - > - - + >
{cot.prevWeekDate ? `vs ${cot.prevWeekDate}` : ""}
- {/* ⓘ Tooltip explicatif */} + {/* Tooltip explicatif */} {showCotInfo && (
-

AM (Asset Managers) — fonds pension, souverains, assurances. Prennent des positions pour couvrir des expositions réelles (hedging). Leur flux pilote la devise à moyen terme.

-

HF (Hedge Funds / CTAs) — spéculation directionnelle à court terme. Réactifs aux catalyseurs macro. Retournements rapides possibles.

-

Lire : Δ Net HF = variation du net (longs−shorts) vs sem. précédente → chiffre clé de la pression spéculative. L / S = variation de chaque jambe séparément. Ex : L↑ + S↑ = les deux côtés s'accumulent ; Δ Net négatif = pression baissière nette qui s'intensifie.

+

HF — Hedge Funds / CTAs. Spéculation directionnelle, court terme. Réactifs aux catalyseurs macro.

+

AM — Asset Managers (fonds pension, souverains). Flux de couverture institutionnel. Pilote la devise à moyen terme.

+

NC — Non-Commercial (rapport Legacy CFTC). Tous les grands spéculateurs non commerciaux, catégorie historique.

+

+ Volume : k = milliers de contrats futures standardisés CFTC. +
Ex : 1 contrat EUR/USD = 125 000 € · GBP = 62 500 £ · JPY = 12 500 000 ¥ · USD Index = 1 000 × indice. +

)} - {/* ② Deux cartes côte à côte : NET en gros + DELTA bien visible */} -
- - {/* Carte AM */} -
-
- AM · hedge {amDominates ? "▶ pilote" : ""} -
- {/* NET — très grand */} -
0 ? "text-emerald-400" : "text-red-400"}`}> - {cot.amNet > 0 ? "LONG" : "SHORT"} -
-
0 ? "text-emerald-400" : "text-red-400"}`}> - {dFmt(cot.amNet)} -
- {/* DELTA AM — L/S séparés comme HF */} -
-
Δ cette semaine
-
- {cot.amLongsDelta != null && Number.isFinite(cot.amLongsDelta) ? ( -
0 ? "text-emerald-400" : "text-red-400"}`}> - L {cot.amLongsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.amLongsDelta / 1000).toFixed(1)}k -
- ) : cot.amNetDelta != null && Number.isFinite(cot.amNetDelta) ? ( -
0 ? "text-emerald-400" : "text-red-400"}`}> - net {cot.amNetDelta > 0 ? "↑" : "↓"}{Math.abs(cot.amNetDelta / 1000).toFixed(1)}k -
- ) :
} - {cot.amShortsDelta != null && Number.isFinite(cot.amShortsDelta) && ( -
0 ? "text-red-400" : "text-emerald-400"}`}> - S {cot.amShortsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.amShortsDelta / 1000).toFixed(1)}k -
- )} -
-
-
- - {/* Carte HF */} -
-
- HF · spécu {!amDominates ? "▶ pilote" : ""} -
- {/* NET — très grand */} -
0 ? "text-emerald-400" : "text-red-400"}`}> - {cot.net > 0 ? "LONG" : "SHORT"} -
-
0 ? "text-emerald-400" : "text-red-400"}`}> - {dFmt(cot.net)} -
- {/* DELTA — Longs / Shorts séparés + delta net */} -
-
Δ cette semaine
- {/* Delta net HF — le chiffre le plus utile */} - {cot.netDelta != null && Number.isFinite(cot.netDelta) && ( -
0 ? "text-emerald-400" : "text-red-400"}`}> - Δ Net {cot.netDelta > 0 ? "+" : ""}{(cot.netDelta / 1000).toFixed(1)}k -
- )} -
- {cot.longsDelta !== null && ( -
0 ? "text-emerald-400" : "text-red-400"}`}> - L {cot.longsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.longsDelta / 1000).toFixed(1)}k -
- )} - {cot.shortsDelta !== null && ( -
0 ? "text-red-400" : "text-emerald-400"}`}> - S {cot.shortsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.shortsDelta / 1000).toFixed(1)}k -
- )} -
-
-
-
- - {/* ③ Barre de dominance */} -
-
-
-
-
-
- AM {amPct}% - HF {hfPct}% -
-
- - {/* ④ Verdict — la phrase qui résume le mouvement */} + {/* Chart COT — barres bidirectionnelles */} {(() => { - const amBull = cot.amNet > 0; - let verdict = ""; - let sub = ""; - let cls = ""; - - if (hfIsShort && hfLongsGrowing && hfShortsReducing) { - verdict = "↻ Majorité vend — mais de + en + achètent"; - sub = "HF couvre ses shorts ET accumule des longs → retournement potentiel"; - cls = "text-emerald-400 border-emerald-500/25 bg-emerald-500/8"; - } else if (hfIsShort && hfLongsGrowing) { - verdict = "▲ Majorité vend — longs HF en hausse"; - sub = "Accumulation : surveiller si les shorts commencent à baisser"; - cls = "text-emerald-400/80 border-emerald-500/20 bg-emerald-500/5"; - } else if (hfIsShort && hfShortsReducing) { - verdict = "▲ Shorts HF se réduisent — pression baissière s'allège"; - sub = "Signal de couverture — retournement possible si longs suivent"; - cls = "text-emerald-400/70 border-emerald-500/15 bg-emerald-500/5"; - } else if (!hfIsShort && amBull) { - verdict = "▲▲ Convergence haussière — AM + HF alignés"; - sub = "AM long (hedging) et HF long (spécu) → signal fort"; - cls = "text-emerald-400 border-emerald-500/25 bg-emerald-500/8"; - } else if (hfIsShort && !amBull) { - verdict = "▼▼ Convergence baissière — AM + HF alignés"; - sub = "AM short (hedging) et HF short (spécu) → signal fort"; - cls = "text-red-400 border-red-500/25 bg-red-500/8"; - } else if (!hfIsShort && hfShortsGrowing && hfLongsReducing) { - verdict = "▼ Majorité achète — mais distribution en cours"; - sub = "HF réduit ses longs ET renforce ses shorts → retournement potentiel"; - cls = "text-red-400 border-red-500/25 bg-red-500/8"; - } else if (!hfIsShort && hfShortsGrowing) { - verdict = "▼ Majorité achète — shorts HF en hausse"; - sub = "Signal de distribution — surveiller la réduction des longs"; - cls = "text-red-400/80 border-red-500/20 bg-red-500/5"; - } else { - verdict = "→ Flux mixtes AM / HF"; - sub = `AM ${amBull ? "long" : "short"} · HF ${hfIsShort ? "short" : "long"} · pas de signal directionnel clair`; - cls = "text-slate-400 border-slate-700/30 bg-slate-800/20"; - } + const maxAbs = Math.max(...groups.map(g => Math.abs(g.net)), 1); + const dk = (v: number | null, invert = false): React.ReactNode => + v == null + ? + : 0) !== invert ? "text-emerald-400/90" : "text-rose-400/90"}>{v >= 0 ? "+" : ""}{(v / 1000).toFixed(1)}k; return ( -
-
{verdict}
-
{sub}
+
+ {/* Axe */} +
+
+
+
+ Short +
+
+
+ Long +
+
+
+
+ + {groups.map(g => { + const isLong = g.net >= 0; + const total = g.longs + g.shorts; + const longPct = total > 0 ? Math.round(g.longs / total * 100) : 50; + const barPct = Math.abs(g.net) / maxAbs * 100; + const posClr = isLong ? "text-emerald-400" : "text-rose-400"; + const evol = evolText(isLong, g.dL, g.dS); + const gradLong = "linear-gradient(to right, rgba(52,211,153,0.80) 0%, rgba(52,211,153,0.15) 100%)"; + const gradShort = "linear-gradient(to left, rgba(248,113,113,0.80) 0%, rgba(248,113,113,0.15) 100%)"; + const grad = isLong ? gradLong : gradShort; + + return ( +
+ {/* Barre + valeur */} +
+ {g.label} + + {/* Barre bidirectionnelle */} +
+ {/* Track short */} +
+ {!isLong && ( +
+ )} +
+ {/* Séparateur zéro */} +
+ {/* Track long */} +
+ {isLong && ( +
+ )} +
+
+ + {/* Valeur */} +
+
+ {netFmt(g.net)} +
+
{longPct}% long
+
+
+ + {/* Ligne L/S split sous la barre */} +
+
+
+
+
+
+
+
+ + {/* Deltas */} + {(g.dL != null || g.dS != null) && ( +
+ ΔL {dk(g.dL)} · ΔS {dk(g.dS, true)} + · + {evol.text} +
+ )} +
+ ); + })}
); })()} + + {/* Verdict */} +
+
{verdict}
+
{sub}
+
); })()} @@ -1987,31 +2038,75 @@ export default function CurrencyCard({ {/* Sentiment */} {signauxSlide === "sent" && sentiment && (() => { const sentDir = sentiment.longPct < 30 ? "bullish" : sentiment.longPct > 70 ? "bearish" : "neutral"; - const sentCls = sentDir === "bullish" ? "text-emerald-400" : sentDir === "bearish" ? "text-red-400" : "text-slate-400"; - const sentBg = sentDir === "bullish" ? "border-emerald-500/20 bg-emerald-500/5" : sentDir === "bearish" ? "border-red-500/20 bg-red-500/5" : "border-slate-700/40 bg-slate-800/20"; + const sentCls = sentDir === "bullish" ? "text-emerald-400" : sentDir === "bearish" ? "text-rose-400" : "text-slate-400"; + // Pour chaque paire : "long paire" = long devise BASE + // Si longIsBaseLong=false (ccy est cotation), le "long devise" = short paire + const pairsToShow = (sentiment.pairs ?? []).slice(0, 6); + return ( -
-
- - Sentiment Retail (DXM) -
-
-
-
Long
-
{sentiment.longPct.toFixed(0)}%
+
+ {/* Header */} +
+
+ + Sentiment Retail · Myfxbook
-
-
-
-
+ {sentiment.pair} +
+ + {/* Résumé agrégé */} +
+
+
Long {currency} (agrégé)
+ + {sentiment.longPct.toFixed(0)}% + +
+
+
+
+
+
+
+ {sentiment.longPct.toFixed(0)}% long + {sentiment.shortPct.toFixed(0)}% short
-
LongShort
-
-
Short
-
{sentiment.shortPct.toFixed(0)}%
+
+ {sentDir === "bullish" ? "Signal ▲" : sentDir === "bearish" ? "Signal ▼" : "Neutre"}
+ + {/* Données brutes par paire (source directe Myfxbook) */} + {pairsToShow.length > 0 && ( +
+
+ Paire + % Long paire + % Short paire +
+ {pairsToShow.map(p => ( +
+ {p.name} + {/* Barre */} +
+
+
+
+ {p.longPct.toFixed(0)}% + {p.shortPct.toFixed(0)}% +
+ ))} +
+ )} + +

+ Signal contrarian : majorité retail long → bearish · majorité retail short → bullish +

); })()} diff --git a/components/IdeesTab.tsx b/components/IdeesTab.tsx index 2d66daa..6f83a5b 100644 --- a/components/IdeesTab.tsx +++ b/components/IdeesTab.tsx @@ -30,14 +30,11 @@ const QUICK_SYMBOLS = [ // ── Types ───────────────────────────────────────────────────────────────────── -interface NoteImage { id: string; dataUrl: string; } - interface SlotState { symbol: string; interval: string; title: string; - notes: string; - images: NoteImage[]; + notes: string; // HTML (images inline en base64) } interface Archive { @@ -60,13 +57,103 @@ function saveLS(key: string, val: unknown) { } const DEFAULT_SLOT = (symbol = "FX:EURUSD"): SlotState => ({ - symbol, interval: "240", title: "", notes: "", images: [], + symbol, interval: "240", title: "", notes: "", }); -// ── NotePane ────────────────────────────────────────────────────────────────── +// ── Toolbar de formatage ────────────────────────────────────────────────────── -function NotePane({ slot, onChange }: { slot: SlotState; onChange: (s: SlotState) => void }) { - const [expanded, setExpanded] = useState(false); +const TOOLBAR_GROUPS = [ + [ + { cmd: "bold", label: "B", cls: "font-bold", title: "Gras (Ctrl+B)" }, + { cmd: "italic", label: "I", cls: "italic", title: "Italique (Ctrl+I)" }, + { cmd: "underline", label: "U", cls: "underline", title: "Souligné (Ctrl+U)" }, + ], + [ + { cmd: "insertUnorderedList", label: "•", cls: "", title: "Liste à puces" }, + { cmd: "insertOrderedList", label: "1.", cls: "", title: "Liste numérotée" }, + ], + [ + { cmd: "justifyLeft", label: "⬱", cls: "", title: "Aligner à gauche" }, + { cmd: "justifyCenter", label: "≡", cls: "", title: "Centrer" }, + { cmd: "justifyRight", label: "⬰", cls: "", title: "Aligner à droite" }, + ], +]; + +function FormatToolbar({ editorRef, onSave, selImg, onResizeImg, onDeleteImg }: { + editorRef: React.RefObject; + onSave: () => void; + selImg: HTMLImageElement | null; + onResizeImg: (pct: number) => void; + onDeleteImg: () => void; +}) { + const exec = (cmd: string) => { + editorRef.current?.focus(); + document.execCommand(cmd, false); + onSave(); + }; + + return ( +
+ {TOOLBAR_GROUPS.map((group, gi) => ( +
+ {gi > 0 &&
} + {group.map(b => ( + + ))} +
+ ))} + + {/* Toolbar image si sélectionnée */} + {selImg && ( + <> +
+ Image : + {[25, 40, 60, 80, 100].map(p => ( + + ))} + + + )} +
+ ); +} + +// ── RichEditor ──────────────────────────────────────────────────────────────── + +function RichEditor({ + html, onChange, className, style, showToolbar = true, +}: { + html: string; + onChange: (h: string) => void; + className?: string; + style?: React.CSSProperties; + showToolbar?: boolean; +}) { + const ref = useRef(null); + const skipRef = useRef(false); + const [selImg, setSelImg] = useState(null); + + useEffect(() => { + if (!ref.current || ref.current.innerHTML === html) return; + skipRef.current = true; + ref.current.innerHTML = html; + }, [html]); + + const save = useCallback(() => { + if (skipRef.current) { skipRef.current = false; return; } + onChange(ref.current?.innerHTML ?? ""); + }, [onChange]); const handlePaste = useCallback((e: React.ClipboardEvent) => { const imgItem = Array.from(e.clipboardData.items).find(i => i.type.startsWith("image/")); @@ -77,81 +164,122 @@ function NotePane({ slot, onChange }: { slot: SlotState; onChange: (s: SlotState const reader = new FileReader(); reader.onload = ev => { const dataUrl = ev.target?.result as string; - onChange({ ...slot, images: [...slot.images, { id: Date.now().toString(), dataUrl }] }); + const img = document.createElement("img"); + img.src = dataUrl; + img.style.width = "60%"; + img.style.maxWidth = "100%"; + img.style.borderRadius = "6px"; + img.style.display = "block"; + img.style.margin = "6px 0"; + img.style.cursor = "pointer"; + img.draggable = false; + const br = document.createElement("br"); + const sel = window.getSelection(); + if (sel?.rangeCount) { + const range = sel.getRangeAt(0); + range.collapse(false); + range.insertNode(br); + range.insertNode(img); + range.setStartAfter(br); + sel.removeAllRanges(); + sel.addRange(range); + } else { + ref.current?.appendChild(img); + ref.current?.appendChild(br); + } + onChange(ref.current?.innerHTML ?? ""); }; reader.readAsDataURL(file); - }, [slot, onChange]); + }, [onChange]); + + const handleResizeImg = (pct: number) => { + if (!selImg) return; + selImg.style.width = `${pct}%`; + onChange(ref.current?.innerHTML ?? ""); + }; + + const handleDeleteImg = () => { + if (!selImg) return; + selImg.remove(); + setSelImg(null); + onChange(ref.current?.innerHTML ?? ""); + }; + + return ( +
+ {showToolbar && ( + + )} +
{ + const t = e.target as HTMLElement; + setSelImg(t.tagName === "IMG" ? t as HTMLImageElement : null); + }} + className={className} + style={{ lineHeight: 1.7, ...style }} + /> +
+ ); +} + +// ── NotePane ────────────────────────────────────────────────────────────────── + +function NotePane({ slot, onChange }: { slot: SlotState; onChange: (s: SlotState) => void }) { + const [expanded, setExpanded] = useState(false); + + const editorCls = "flex-1 bg-slate-800/30 border border-slate-700/30 rounded-lg p-3 text-[11px] text-slate-300 outline-none focus:border-slate-600 transition-all overflow-y-auto min-h-0"; return ( <> - {/* Overlay expanded */} {expanded && ( -
setExpanded(false)}> -
e.stopPropagation()}> -
- {slot.title || slot.symbol} - +
setExpanded(false)}> +
e.stopPropagation()}> +
+ onChange({ ...slot, title: e.target.value })} + /> +
-