mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-13 20:48:05 +00:00
feat(COT/Idees): redesign COT chart + NC group + editeur riche IdeesTab
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
333ce5947a
commit
48d3b9c473
+95
-30
@@ -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<Record<string, NcData>> {
|
||||
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<string, LegacyRow[]> = {};
|
||||
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<string, NcData> = {};
|
||||
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<string, CotEntry> {
|
||||
function parseCOT(csv: string, nc: Record<string, NcData>): Record<string, CotEntry> {
|
||||
const lines = csv.split("\n");
|
||||
const targetCodes = new Set(Object.values(COT_CODES));
|
||||
const raw: Record<string, RawWeek[]> = {};
|
||||
@@ -134,7 +186,6 @@ function parseCOT(csv: string): Record<string, CotEntry> {
|
||||
|
||||
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<string, CotEntry> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+23
-17
@@ -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<string, number> = {};
|
||||
const totalPos: Record<string, number> = {};
|
||||
const pairCount: Record<string, number> = {};
|
||||
// paire par paire pour affichage direct
|
||||
const rawPairs: Record<string, SentimentPair[]> = {};
|
||||
|
||||
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<string, SentimentEntry> = {};
|
||||
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;
|
||||
|
||||
+268
-173
@@ -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 (
|
||||
<div className="rounded-xl border border-slate-700/40 bg-slate-800/20 p-3 space-y-2">
|
||||
{/* ① Header */}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[9px] text-slate-300 uppercase tracking-widest">
|
||||
<BarChart2 size={9} />
|
||||
@@ -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"
|
||||
>
|
||||
<Info size={10} />
|
||||
</button>
|
||||
><Info size={10} /></button>
|
||||
</div>
|
||||
<span className="text-[9px] text-slate-400">{cot.prevWeekDate ? `vs ${cot.prevWeekDate}` : ""}</span>
|
||||
</div>
|
||||
|
||||
{/* ⓘ Tooltip explicatif */}
|
||||
{/* Tooltip explicatif */}
|
||||
{showCotInfo && (
|
||||
<div className="bg-slate-900 border border-slate-700/60 rounded-lg px-2.5 py-2 text-[9px] text-slate-400 space-y-1 leading-relaxed">
|
||||
<p><span className="text-indigo-400 font-bold">AM (Asset Managers)</span> — fonds pension, souverains, assurances. Prennent des positions pour <em>couvrir</em> des expositions réelles (hedging). Leur flux pilote la devise à moyen terme.</p>
|
||||
<p><span className="text-amber-400 font-bold">HF (Hedge Funds / CTAs)</span> — spéculation directionnelle à court terme. Réactifs aux catalyseurs macro. Retournements rapides possibles.</p>
|
||||
<p className="pt-0.5 border-t border-slate-700/40"><span className="text-slate-300 font-semibold">Lire :</span> <span className="text-amber-300">Δ Net HF</span> = variation du net (longs−shorts) vs sem. précédente → chiffre clé de la pression spéculative. <span className="text-slate-300">L / S</span> = 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.</p>
|
||||
<p><span className="text-amber-400 font-bold">HF</span> — Hedge Funds / CTAs. Spéculation directionnelle, court terme. Réactifs aux catalyseurs macro.</p>
|
||||
<p><span className="text-indigo-400 font-bold">AM</span> — Asset Managers (fonds pension, souverains). Flux de couverture institutionnel. Pilote la devise à moyen terme.</p>
|
||||
<p><span className="text-purple-400 font-bold">NC</span> — Non-Commercial (rapport Legacy CFTC). Tous les grands spéculateurs non commerciaux, catégorie historique.</p>
|
||||
<p className="pt-0.5 border-t border-slate-700/40">
|
||||
<span className="font-semibold text-slate-300">Volume :</span> k = milliers de contrats futures standardisés CFTC.
|
||||
<br />Ex : 1 contrat EUR/USD = 125 000 € · GBP = 62 500 £ · JPY = 12 500 000 ¥ · USD Index = 1 000 × indice.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ② Deux cartes côte à côte : NET en gros + DELTA bien visible */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
{/* Carte AM */}
|
||||
<div className="bg-indigo-950/50 border border-indigo-500/25 rounded-xl p-2.5">
|
||||
<div className="text-[8px] text-indigo-400 font-bold uppercase tracking-wider mb-2">
|
||||
AM · hedge {amDominates ? "▶ pilote" : ""}
|
||||
</div>
|
||||
{/* NET — très grand */}
|
||||
<div className={`text-[11px] font-black leading-none ${cot.amNet > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{cot.amNet > 0 ? "LONG" : "SHORT"}
|
||||
</div>
|
||||
<div className={`text-[19px] font-black tabular-nums leading-tight mb-2 ${cot.amNet > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{dFmt(cot.amNet)}
|
||||
</div>
|
||||
{/* DELTA AM — L/S séparés comme HF */}
|
||||
<div className="border-t border-indigo-500/15 pt-1.5">
|
||||
<div className="text-[7px] text-white/60 uppercase mb-0.5">Δ cette semaine</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
{cot.amLongsDelta != null && Number.isFinite(cot.amLongsDelta) ? (
|
||||
<div className={`text-[12px] font-black tabular-nums leading-none ${cot.amLongsDelta > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
L {cot.amLongsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.amLongsDelta / 1000).toFixed(1)}k
|
||||
</div>
|
||||
) : cot.amNetDelta != null && Number.isFinite(cot.amNetDelta) ? (
|
||||
<div className={`text-[12px] font-black tabular-nums leading-none ${cot.amNetDelta > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
net {cot.amNetDelta > 0 ? "↑" : "↓"}{Math.abs(cot.amNetDelta / 1000).toFixed(1)}k
|
||||
</div>
|
||||
) : <div className="text-slate-700 text-[11px]">—</div>}
|
||||
{cot.amShortsDelta != null && Number.isFinite(cot.amShortsDelta) && (
|
||||
<div className={`text-[12px] font-black tabular-nums leading-none ${cot.amShortsDelta > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
S {cot.amShortsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.amShortsDelta / 1000).toFixed(1)}k
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carte HF */}
|
||||
<div className="bg-amber-950/30 border border-amber-500/25 rounded-xl p-2.5">
|
||||
<div className="text-[8px] text-amber-400 font-bold uppercase tracking-wider mb-2">
|
||||
HF · spécu {!amDominates ? "▶ pilote" : ""}
|
||||
</div>
|
||||
{/* NET — très grand */}
|
||||
<div className={`text-[11px] font-black leading-none ${cot.net > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{cot.net > 0 ? "LONG" : "SHORT"}
|
||||
</div>
|
||||
<div className={`text-[19px] font-black tabular-nums leading-tight mb-2 ${cot.net > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{dFmt(cot.net)}
|
||||
</div>
|
||||
{/* DELTA — Longs / Shorts séparés + delta net */}
|
||||
<div className="border-t border-amber-500/15 pt-1.5">
|
||||
<div className="text-[7px] text-white/60 uppercase mb-0.5">Δ cette semaine</div>
|
||||
{/* Delta net HF — le chiffre le plus utile */}
|
||||
{cot.netDelta != null && Number.isFinite(cot.netDelta) && (
|
||||
<div className={`text-[11px] font-black tabular-nums leading-none mb-1 ${cot.netDelta > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
Δ Net {cot.netDelta > 0 ? "+" : ""}{(cot.netDelta / 1000).toFixed(1)}k
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline gap-2">
|
||||
{cot.longsDelta !== null && (
|
||||
<div className={`text-[11px] font-black tabular-nums leading-none ${cot.longsDelta > 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
L {cot.longsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.longsDelta / 1000).toFixed(1)}k
|
||||
</div>
|
||||
)}
|
||||
{cot.shortsDelta !== null && (
|
||||
<div className={`text-[11px] font-black tabular-nums leading-none ${cot.shortsDelta > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
S {cot.shortsDelta > 0 ? "↑" : "↓"}{Math.abs(cot.shortsDelta / 1000).toFixed(1)}k
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ③ Barre de dominance */}
|
||||
<div>
|
||||
<div className="flex rounded-full overflow-hidden h-1.5">
|
||||
<div className="bg-indigo-500 transition-all" style={{ width: `${amPct}%` }} />
|
||||
<div className="bg-amber-500/70 transition-all" style={{ width: `${hfPct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-[8px] mt-0.5">
|
||||
<span className="text-indigo-400/70">AM {amPct}%</span>
|
||||
<span className="text-amber-400/70">HF {hfPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ④ 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
|
||||
? <span className="text-slate-700">—</span>
|
||||
: <span className={(v > 0) !== invert ? "text-emerald-400/90" : "text-rose-400/90"}>{v >= 0 ? "+" : ""}{(v / 1000).toFixed(1)}k</span>;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-2.5 py-2 ${cls}`}>
|
||||
<div className="text-[10px] font-bold leading-snug">{verdict}</div>
|
||||
<div className="text-[9px] opacity-70 mt-0.5 leading-snug">{sub}</div>
|
||||
<div className="space-y-4">
|
||||
{/* Axe */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 flex items-center text-[7px] uppercase tracking-widest">
|
||||
<div className="flex-1 flex items-center justify-end gap-1 text-rose-400/35 pr-2">
|
||||
<span>Short</span><span>←</span>
|
||||
</div>
|
||||
<div className="w-[2px] h-3 bg-slate-600/60 rounded-full shrink-0" />
|
||||
<div className="flex-1 flex items-center gap-1 text-emerald-400/35 pl-2">
|
||||
<span>→</span><span>Long</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-20 shrink-0" />
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div key={g.id}>
|
||||
{/* Barre + valeur */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-semibold w-8 shrink-0 ${g.accentT}`}>{g.label}</span>
|
||||
|
||||
{/* Barre bidirectionnelle */}
|
||||
<div className="flex-1 flex h-7 rounded-md overflow-hidden ring-1 ring-slate-700/30">
|
||||
{/* Track short */}
|
||||
<div className="w-1/2 h-full bg-slate-800/60 flex items-center justify-end overflow-hidden rounded-l-md">
|
||||
{!isLong && (
|
||||
<div className="h-full" style={{ width: `${barPct}%`, background: grad }} />
|
||||
)}
|
||||
</div>
|
||||
{/* Séparateur zéro */}
|
||||
<div className="w-[2px] h-full bg-slate-500/70 shrink-0" />
|
||||
{/* Track long */}
|
||||
<div className="w-1/2 h-full bg-slate-800/60 flex items-center overflow-hidden rounded-r-md">
|
||||
{isLong && (
|
||||
<div className="h-full" style={{ width: `${barPct}%`, background: grad }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Valeur */}
|
||||
<div className="w-20 shrink-0 pl-1">
|
||||
<div className={`text-[11px] font-bold tabular-nums font-mono leading-none ${posClr}`}>
|
||||
{netFmt(g.net)}
|
||||
</div>
|
||||
<div className="text-[7px] text-slate-500 tabular-nums mt-0.5 font-mono">{longPct}% long</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ligne L/S split sous la barre */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 flex rounded-sm overflow-hidden h-[3px]">
|
||||
<div className="bg-emerald-500/50" style={{ width: `${longPct}%` }} />
|
||||
<div className="bg-rose-500/35" style={{ width: `${100 - longPct}%` }} />
|
||||
</div>
|
||||
<div className="w-20 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Deltas */}
|
||||
{(g.dL != null || g.dS != null) && (
|
||||
<div className="flex items-center gap-2 pl-10 mt-1.5 text-[8px] font-mono">
|
||||
<span className="text-slate-500 shrink-0">ΔL {dk(g.dL)} · ΔS {dk(g.dS, true)}</span>
|
||||
<span className="text-slate-700 shrink-0">·</span>
|
||||
<span className={`${evol.cls} truncate`}>{evol.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Verdict */}
|
||||
<div className={`rounded-lg border px-2.5 py-1.5 ${vCls}`}>
|
||||
<div className="text-[9px] font-semibold leading-snug">{verdict}</div>
|
||||
<div className="text-[8px] opacity-60 mt-0.5 leading-snug">{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-slate-700/40 bg-slate-800/20 p-3 h-full space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-[9px] text-slate-300 uppercase tracking-widest">
|
||||
<Activity size={9} />
|
||||
Sentiment Retail (DXM)
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center shrink-0">
|
||||
<div className="text-[9px] text-slate-400 mb-0.5">Long</div>
|
||||
<div className="text-[26px] font-black tabular-nums text-emerald-400 leading-none">{sentiment.longPct.toFixed(0)}%</div>
|
||||
<div className="rounded-xl border border-slate-700/40 bg-slate-800/20 p-3 space-y-2.5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[9px] text-slate-300 uppercase tracking-widest">
|
||||
<Activity size={9} />
|
||||
Sentiment Retail · Myfxbook
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex rounded-full overflow-hidden h-2.5">
|
||||
<div className="bg-emerald-500/70 transition-all" style={{ width: `${sentiment.longPct}%` }} />
|
||||
<div className="bg-red-500/60 transition-all" style={{ width: `${sentiment.shortPct}%` }} />
|
||||
<span className="text-[8px] text-slate-600">{sentiment.pair}</span>
|
||||
</div>
|
||||
|
||||
{/* Résumé agrégé */}
|
||||
<div className={`rounded-lg border px-2.5 py-1.5 flex items-center gap-3 ${
|
||||
sentDir === "bullish" ? "border-emerald-500/20 bg-emerald-500/5"
|
||||
: sentDir === "bearish" ? "border-rose-500/20 bg-rose-500/5"
|
||||
: "border-slate-700/30 bg-slate-800/20"
|
||||
}`}>
|
||||
<div>
|
||||
<div className="text-[7px] text-slate-500 uppercase mb-0.5">Long {currency} (agrégé)</div>
|
||||
<span className={`text-[18px] font-bold tabular-nums font-mono leading-none ${sentCls}`}>
|
||||
{sentiment.longPct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<div className="flex rounded-full overflow-hidden h-1.5">
|
||||
<div className="bg-emerald-500/60" style={{ width: `${sentiment.longPct}%` }} />
|
||||
<div className="bg-rose-500/45" style={{ width: `${sentiment.shortPct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-[7px] text-slate-600">
|
||||
<span>{sentiment.longPct.toFixed(0)}% long</span>
|
||||
<span>{sentiment.shortPct.toFixed(0)}% short</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[8px] text-slate-400"><span>Long</span><span>Short</span></div>
|
||||
</div>
|
||||
<div className="text-center shrink-0">
|
||||
<div className="text-[9px] text-slate-400 mb-0.5">Short</div>
|
||||
<div className="text-[26px] font-black tabular-nums text-red-400 leading-none">{sentiment.shortPct.toFixed(0)}%</div>
|
||||
<div className={`text-[8px] font-semibold ${sentCls}`}>
|
||||
{sentDir === "bullish" ? "Signal ▲" : sentDir === "bearish" ? "Signal ▼" : "Neutre"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Données brutes par paire (source directe Myfxbook) */}
|
||||
{pairsToShow.length > 0 && (
|
||||
<div className="border border-slate-700/25 rounded-lg overflow-hidden divide-y divide-slate-700/20">
|
||||
<div className="px-2.5 py-1 bg-slate-800/40 flex items-center gap-1">
|
||||
<span className="text-[7px] text-slate-500 uppercase tracking-wider">Paire</span>
|
||||
<span className="ml-auto text-[7px] text-slate-500 uppercase tracking-wider">% Long paire</span>
|
||||
<span className="w-16 ml-2 text-[7px] text-slate-500 uppercase tracking-wider text-right">% Short paire</span>
|
||||
</div>
|
||||
{pairsToShow.map(p => (
|
||||
<div key={p.name} className="px-2.5 py-1.5 flex items-center gap-2 bg-slate-800/15 hover:bg-slate-800/30 transition-colors">
|
||||
<span className="text-[9px] font-mono text-slate-300 w-14 shrink-0">{p.name}</span>
|
||||
{/* Barre */}
|
||||
<div className="flex flex-1 rounded-full overflow-hidden h-1">
|
||||
<div className="bg-emerald-500/50" style={{ width: `${p.longPct}%` }} />
|
||||
<div className="bg-rose-500/40" style={{ width: `${p.shortPct}%` }} />
|
||||
</div>
|
||||
<span className="text-[9px] tabular-nums font-mono text-emerald-400/80 w-8 text-right shrink-0">{p.longPct.toFixed(0)}%</span>
|
||||
<span className="text-[9px] tabular-nums font-mono text-rose-400/70 w-8 text-right shrink-0">{p.shortPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[7px] text-slate-700">
|
||||
Signal contrarian : majorité retail long → bearish · majorité retail short → bullish
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
+345
-114
@@ -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<HTMLDivElement>;
|
||||
onSave: () => void;
|
||||
selImg: HTMLImageElement | null;
|
||||
onResizeImg: (pct: number) => void;
|
||||
onDeleteImg: () => void;
|
||||
}) {
|
||||
const exec = (cmd: string) => {
|
||||
editorRef.current?.focus();
|
||||
document.execCommand(cmd, false);
|
||||
onSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-slate-800/70 border border-slate-700/40 rounded-lg px-2 py-1 shrink-0 flex-wrap">
|
||||
{TOOLBAR_GROUPS.map((group, gi) => (
|
||||
<div key={gi} className="flex items-center gap-0.5">
|
||||
{gi > 0 && <div className="w-px h-3.5 bg-slate-700 mx-1" />}
|
||||
{group.map(b => (
|
||||
<button
|
||||
key={b.cmd}
|
||||
title={b.title}
|
||||
onMouseDown={e => { e.preventDefault(); exec(b.cmd); }}
|
||||
className={`w-6 h-6 rounded text-[10px] ${b.cls} text-slate-400 hover:text-white hover:bg-slate-600/60 transition-colors`}
|
||||
>{b.label}</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Toolbar image si sélectionnée */}
|
||||
{selImg && (
|
||||
<>
|
||||
<div className="w-px h-3.5 bg-slate-700 mx-1" />
|
||||
<span className="text-[8px] text-slate-500">Image :</span>
|
||||
{[25, 40, 60, 80, 100].map(p => (
|
||||
<button key={p}
|
||||
onMouseDown={e => { e.preventDefault(); onResizeImg(p); }}
|
||||
className="text-[8px] px-1.5 py-0.5 rounded bg-slate-700/60 text-slate-300 hover:bg-sky-500/20 hover:text-sky-300 transition-colors"
|
||||
>{p}%</button>
|
||||
))}
|
||||
<button
|
||||
onMouseDown={e => { e.preventDefault(); onDeleteImg(); }}
|
||||
className="text-[8px] text-red-400/60 hover:text-red-400 ml-1 transition-colors"
|
||||
>✕</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<HTMLDivElement>(null);
|
||||
const skipRef = useRef(false);
|
||||
const [selImg, setSelImg] = useState<HTMLImageElement | null>(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 (
|
||||
<div className="flex flex-col gap-1.5 h-full min-h-0">
|
||||
{showToolbar && (
|
||||
<FormatToolbar
|
||||
editorRef={ref}
|
||||
onSave={save}
|
||||
selImg={selImg}
|
||||
onResizeImg={handleResizeImg}
|
||||
onDeleteImg={handleDeleteImg}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={save}
|
||||
onPaste={handlePaste}
|
||||
onClick={e => {
|
||||
const t = e.target as HTMLElement;
|
||||
setSelImg(t.tagName === "IMG" ? t as HTMLImageElement : null);
|
||||
}}
|
||||
className={className}
|
||||
style={{ lineHeight: 1.7, ...style }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 && (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6" onClick={() => setExpanded(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5 w-full max-w-3xl max-h-[90vh] overflow-auto shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[11px] font-semibold text-slate-300">{slot.title || slot.symbol}</span>
|
||||
<button onClick={() => setExpanded(false)} className="text-slate-500 hover:text-white text-sm">✕</button>
|
||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-6" onClick={() => setExpanded(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5 w-full max-w-4xl h-[88vh] flex flex-col shadow-2xl gap-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<input
|
||||
className="bg-transparent text-[13px] font-semibold text-slate-200 placeholder-slate-600 outline-none flex-1"
|
||||
placeholder="Titre / hypothèse…"
|
||||
value={slot.title}
|
||||
onChange={e => onChange({ ...slot, title: e.target.value })}
|
||||
/>
|
||||
<button onClick={() => setExpanded(false)} className="text-slate-500 hover:text-white ml-4 text-lg">✕</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full bg-slate-800/60 border border-slate-700/40 rounded-lg p-3 text-[12px] text-slate-200 outline-none resize-none focus:border-slate-500 h-[50vh]"
|
||||
value={slot.notes}
|
||||
onChange={e => onChange({ ...slot, notes: e.target.value })}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Notes…"
|
||||
<RichEditor
|
||||
html={slot.notes}
|
||||
onChange={notes => onChange({ ...slot, notes })}
|
||||
className="flex-1 bg-slate-800/40 border border-slate-700/30 rounded-xl p-4 text-[12px] text-slate-200 outline-none overflow-y-auto"
|
||||
/>
|
||||
{slot.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{slot.images.map(img => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img src={img.dataUrl} alt="" className="h-28 w-auto rounded-lg border border-slate-700/40 object-cover" />
|
||||
<button
|
||||
onClick={() => onChange({ ...slot, images: slot.images.filter(i => i.id !== img.id) })}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 rounded-full text-white text-[9px] hidden group-hover:flex items-center justify-center"
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline note */}
|
||||
<div className="flex flex-col h-full gap-2">
|
||||
<div className="flex flex-col h-full gap-2 min-h-0">
|
||||
<input
|
||||
className="bg-transparent border-b border-slate-700/50 text-[10px] text-slate-300 placeholder-slate-600 outline-none pb-1 shrink-0"
|
||||
placeholder="Titre / hypothèse…"
|
||||
value={slot.title}
|
||||
onChange={e => onChange({ ...slot, title: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className="flex-1 bg-slate-800/30 border border-slate-700/30 rounded-lg p-2.5 text-[10px] text-slate-300 placeholder-slate-600 outline-none resize-none focus:border-slate-600 transition-all min-h-[100px]"
|
||||
placeholder={"Notes, niveaux clés…\nCtrl+V pour coller une image"}
|
||||
value={slot.notes}
|
||||
onChange={e => onChange({ ...slot, notes: e.target.value })}
|
||||
onPaste={handlePaste}
|
||||
<RichEditor
|
||||
html={slot.notes}
|
||||
onChange={notes => onChange({ ...slot, notes })}
|
||||
className={editorCls}
|
||||
style={{ minHeight: 120 }}
|
||||
/>
|
||||
{/* Thumbnails */}
|
||||
{slot.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 shrink-0">
|
||||
{slot.images.map(img => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img src={img.dataUrl} alt="" className="h-12 w-auto rounded border border-slate-700/40 object-cover" />
|
||||
<button
|
||||
onClick={() => onChange({ ...slot, images: slot.images.filter(i => i.id !== img.id) })}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-white text-[8px] hidden group-hover:flex items-center justify-center"
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Expand button */}
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-[8px] text-slate-600 hover:text-sky-400 transition-colors self-end shrink-0"
|
||||
>
|
||||
↗ Agrandir
|
||||
</button>
|
||||
>↗ Agrandir</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -265,11 +393,11 @@ function ResearchSlot({
|
||||
key={`${slot.symbol}_${slot.interval}`}
|
||||
symbol={slot.symbol}
|
||||
interval={slot.interval}
|
||||
height={380}
|
||||
height={760}
|
||||
/>
|
||||
</div>
|
||||
{/* Notes */}
|
||||
<div className="p-3 flex flex-col" style={{ minHeight: 380 }}>
|
||||
<div className="p-3 flex flex-col" style={{ minHeight: 760 }}>
|
||||
<NotePane slot={slot} onChange={onChange} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,61 +405,153 @@ function ResearchSlot({
|
||||
);
|
||||
}
|
||||
|
||||
// ── ArchiveCard ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ArchiveCard({ a, onDelete, onRestore }: {
|
||||
a: Archive;
|
||||
onDelete: () => void;
|
||||
onRestore: (slot: 0 | 1) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const date = new Date(a.savedAt).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
const intervalLabel: Record<string, string> = {
|
||||
"1": "1m", "5": "5m", "15": "15m", "30": "30m",
|
||||
"60": "1H", "120": "2H", "240": "4H", "D": "1J", "W": "1S",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modal plein écran */}
|
||||
{expanded && (
|
||||
<div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-6" onClick={() => setExpanded(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-2xl w-full max-w-5xl max-h-[92vh] flex flex-col overflow-hidden shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-slate-700/40 shrink-0">
|
||||
<span className="text-[13px] font-bold text-slate-100 font-mono">{a.slot.symbol}</span>
|
||||
<span className="text-[9px] bg-slate-700/60 text-slate-400 rounded px-2 py-0.5">{intervalLabel[a.slot.interval] ?? a.slot.interval}</span>
|
||||
{a.slot.title && <span className="text-[12px] text-slate-300 flex-1">{a.slot.title}</span>}
|
||||
<span className="text-[9px] text-slate-600 ml-auto shrink-0">{date}</span>
|
||||
<button onClick={() => setExpanded(false)} className="text-slate-500 hover:text-white ml-3 text-lg shrink-0">✕</button>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-8 py-5 text-[13px] text-slate-200 prose-invert archive-content"
|
||||
dangerouslySetInnerHTML={{ __html: a.slot.notes }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Carte compacte */}
|
||||
<div className="border border-slate-700/30 rounded-xl overflow-hidden bg-slate-900/30 hover:bg-slate-800/30 transition-colors">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-700/20">
|
||||
<span className="text-[10px] font-bold text-sky-400/80 font-mono">{a.slot.symbol}</span>
|
||||
<span className="text-[8px] bg-slate-700/50 text-slate-500 rounded px-1.5 py-0.5">{intervalLabel[a.slot.interval] ?? a.slot.interval}</span>
|
||||
{a.slot.title && (
|
||||
<span className="text-[10px] text-slate-300 truncate flex-1 font-medium">{a.slot.title}</span>
|
||||
)}
|
||||
<span className="text-[8px] text-slate-600 ml-auto shrink-0">{date}</span>
|
||||
{/* Restaurer */}
|
||||
<div className="relative ml-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setRestoring(v => !v)}
|
||||
className="text-[8px] text-emerald-500/60 hover:text-emerald-400 transition-colors"
|
||||
title="Restaurer dans un slot actif"
|
||||
>↩ Restaurer</button>
|
||||
{restoring && (
|
||||
<div className="absolute right-0 top-5 z-20 bg-slate-800 border border-slate-700/60 rounded-lg shadow-xl p-2 flex flex-col gap-1 min-w-[120px]">
|
||||
<p className="text-[8px] text-slate-500 mb-1">Charger dans :</p>
|
||||
<button
|
||||
onClick={() => { onRestore(0); setRestoring(false); }}
|
||||
className="text-[9px] text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-300 transition-colors"
|
||||
>Recherche A</button>
|
||||
<button
|
||||
onClick={() => { onRestore(1); setRestoring(false); }}
|
||||
className="text-[9px] text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-300 transition-colors"
|
||||
>Recherche B</button>
|
||||
<button
|
||||
onClick={() => setRestoring(false)}
|
||||
className="text-[8px] text-slate-600 hover:text-slate-400 mt-1 text-left px-2 transition-colors"
|
||||
>Annuler</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-[8px] text-slate-600 hover:text-sky-400 transition-colors ml-1 shrink-0"
|
||||
>↗ Voir</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-[8px] text-red-500/30 hover:text-red-400 transition-colors ml-1 shrink-0"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Corps : texte + images */}
|
||||
{a.slot.notes && (
|
||||
<div className="px-4 py-3">
|
||||
{/* Texte brut (sans tags HTML) */}
|
||||
{(() => {
|
||||
const doc = new DOMParser().parseFromString(a.slot.notes, "text/html");
|
||||
const text = doc.body.textContent?.trim() ?? "";
|
||||
const imgs = Array.from(doc.images);
|
||||
return (
|
||||
<>
|
||||
{text && (
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed whitespace-pre-line mb-2">{text}</p>
|
||||
)}
|
||||
{imgs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imgs.map((img, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img.src}
|
||||
alt=""
|
||||
className="max-h-48 w-auto rounded-lg border border-slate-700/40 object-cover cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setExpanded(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Archives panel ────────────────────────────────────────────────────────────
|
||||
|
||||
function ArchivesPanel({ archives, onDelete }: { archives: Archive[]; onDelete: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
function ArchivesPanel({ archives, onDelete, onRestore }: {
|
||||
archives: Archive[];
|
||||
onDelete: (id: string) => void;
|
||||
onRestore: (id: string, slot: 0 | 1) => void;
|
||||
}) {
|
||||
if (!archives.length) return null;
|
||||
|
||||
return (
|
||||
<div className="border border-slate-700/30 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-slate-800/40 text-[10px] text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
Archives — {archives.length} recherche{archives.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="text-slate-600">{open ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="divide-y divide-slate-700/20">
|
||||
{archives.map(a => (
|
||||
<div key={a.id} className="px-4 py-3 flex items-start justify-between gap-4 hover:bg-slate-800/20 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[10px] font-semibold text-slate-300 font-mono">{a.slot.symbol}</span>
|
||||
<span className="text-[8px] text-slate-600 border border-slate-700/50 rounded px-1">{a.slot.interval}</span>
|
||||
{a.slot.title && <span className="text-[9px] text-slate-400 truncate">{a.slot.title}</span>}
|
||||
</div>
|
||||
<p className="text-[8px] text-slate-600">
|
||||
{new Date(a.savedAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
{a.slot.notes && (
|
||||
<p className="text-[9px] text-slate-500 mt-1 line-clamp-2 max-w-xl">{a.slot.notes}</p>
|
||||
)}
|
||||
{a.slot.images.length > 0 && (
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
{a.slot.images.slice(0, 4).map(img => (
|
||||
<img key={img.id} src={img.dataUrl} alt="" className="h-8 w-auto rounded border border-slate-700/40 object-cover" />
|
||||
))}
|
||||
{a.slot.images.length > 4 && (
|
||||
<span className="text-[8px] text-slate-600 self-end">+{a.slot.images.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(a.id)}
|
||||
className="text-[8px] text-red-500/40 hover:text-red-400 shrink-0 transition-colors mt-0.5"
|
||||
>Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-500">
|
||||
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
<span className="text-[11px] font-semibold text-slate-400">Archives</span>
|
||||
<span className="text-[9px] bg-slate-700/50 text-slate-500 rounded-full px-2 py-0.5">{archives.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{archives.map(a => (
|
||||
<ArchiveCard
|
||||
key={a.id}
|
||||
a={a}
|
||||
onDelete={() => onDelete(a.id)}
|
||||
onRestore={slot => onRestore(a.id, slot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -379,6 +599,17 @@ export default function IdeesTab() {
|
||||
saveLS(LS_ARCHIVES, next);
|
||||
}, [archives]);
|
||||
|
||||
const restoreArchive = useCallback((id: string, slotIdx: 0 | 1) => {
|
||||
const entry = archives.find(a => a.id === id);
|
||||
if (!entry) return;
|
||||
setSlots(prev => {
|
||||
const next = [...prev] as [SlotState, SlotState];
|
||||
next[slotIdx] = { ...entry.slot };
|
||||
saveLS(LS_SLOTS, next);
|
||||
return next;
|
||||
});
|
||||
}, [archives]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
@@ -391,7 +622,7 @@ export default function IdeesTab() {
|
||||
<ResearchSlot slot={slots[0]} label="Recherche A" onChange={s => updateSlot(0, s)} onArchive={() => archiveSlot(0)} />
|
||||
<ResearchSlot slot={slots[1]} label="Recherche B" onChange={s => updateSlot(1, s)} onArchive={() => archiveSlot(1)} />
|
||||
|
||||
<ArchivesPanel archives={archives} onDelete={deleteArchive} />
|
||||
<ArchivesPanel archives={archives} onDelete={deleteArchive} onRestore={restoreArchive} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+35
-18
@@ -143,36 +143,53 @@ export interface FXRates {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SentimentPair {
|
||||
name: string; // ex: "EURUSD"
|
||||
longPct: number; // % retail long SUR LA PAIRE (pas la devise)
|
||||
shortPct: number;
|
||||
longIsBaseLong: boolean; // si true, long paire = long devise affichée
|
||||
}
|
||||
|
||||
export interface SentimentEntry {
|
||||
longPct: number;
|
||||
longPct: number; // agrégé pondéré (gardé pour les signaux existants)
|
||||
shortPct: number;
|
||||
pair: string;
|
||||
pairs: SentimentPair[]; // données brutes paire par paire
|
||||
}
|
||||
|
||||
export type MacroSection = "all" | "inflation" | "pmi" | "employment" | "gdp" | "policy";
|
||||
|
||||
export interface CotEntry {
|
||||
// HF — Leveraged Money (spéculation directionnelle, hedge funds / CTAs)
|
||||
net: number; // longs - shorts
|
||||
hfLongs: number; // contrats long bruts
|
||||
hfShorts: number; // contrats short bruts
|
||||
longPct: number; // % longs / total HF
|
||||
shortPct: number; // % shorts / total HF
|
||||
totalLev: number; // total contrats HF
|
||||
// AM — Asset Manager (hedging institutionnel, fonds pension / souverains)
|
||||
amNet: number;
|
||||
amLongs: number;
|
||||
amShorts: number;
|
||||
amLongPct: number; // % longs / total AM
|
||||
amTotal: number;
|
||||
// HF — Leveraged Money (hedge funds / CTAs — spéculation directionnelle)
|
||||
net: number; // longs - shorts
|
||||
hfLongs: number; // contrats long bruts
|
||||
hfShorts: number; // contrats short bruts
|
||||
longPct: number; // % longs / total HF
|
||||
shortPct: number; // % shorts / total HF
|
||||
totalLev: number; // total contrats HF
|
||||
// AM — Asset Manager (fonds pension / souverains — hedging institutionnel)
|
||||
amNet: number;
|
||||
amLongs: number;
|
||||
amShorts: number;
|
||||
amLongPct: number; // % longs / total AM
|
||||
amTotal: number;
|
||||
// NC — Non-Commercial Legacy (grands spéculateurs — rapport COT classique CFTC)
|
||||
ncNet: number;
|
||||
ncLongs: number;
|
||||
ncShorts: number;
|
||||
ncLongPct: number; // % longs / total NC
|
||||
ncTotal: number;
|
||||
// Δ semaine précédente (null si pas de données J-7)
|
||||
netDelta: number | null; // Δ net HF
|
||||
longsDelta: number | null; // Δ longs HF (+= ajout de longs)
|
||||
shortsDelta: number | null; // Δ shorts HF (+= ajout de shorts)
|
||||
longsDelta: number | null; // Δ longs HF
|
||||
shortsDelta: number | null; // Δ shorts HF
|
||||
amNetDelta: number | null; // Δ net AM
|
||||
amLongsDelta: number | null; // Δ longs AM
|
||||
amShortsDelta: number | null; // Δ shorts AM
|
||||
ncNetDelta: number | null; // Δ net NC
|
||||
ncLongsDelta: number | null; // Δ longs NC
|
||||
ncShortsDelta: number | null; // Δ shorts NC
|
||||
// Métadonnées
|
||||
weekDate: string;
|
||||
prevWeekDate: string | null;
|
||||
weekDate: string;
|
||||
prevWeekDate: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user