/* FoxBot Pro — base components: Sparkline, PnLDisplay, ScoreBadge, PairLogo */ const { useState, useEffect, useRef, useMemo } = React; /* ─── Animated count-up ─────────────────────────────── */ function useCountUp(target, duration = 800, decimals = 2) { const [v, setV] = useState(target); const prev = useRef(target); useEffect(() => { const from = prev.current; const to = target; if (from === to) return; const start = performance.now(); let raf; const tick = (now) => { const t = Math.min(1, (now - start) / duration); const eased = 1 - Math.pow(1 - t, 3); setV(from + (to - from) * eased); if (t < 1) raf = requestAnimationFrame(tick); else prev.current = to; }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [target, duration]); return v; } /* ─── Number formatting ─────────────────────────────── */ function fmtNum(n, opts = {}) { const { decimals, sign = false } = opts; if (n === null || n === undefined || isNaN(n)) return "—"; const abs = Math.abs(n); let d = 2; if (abs < 0.01) d = 5; else if (abs < 1) d = 4; else if (abs < 1000) d = 2; else d = 2; if (decimals !== undefined) d = decimals; const s = n.toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }); return sign && n > 0 ? "+" + s : s; } function fmtEur(n, sign = false) { if (n === null || n === undefined) return "—"; const s = Math.abs(n).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return (n < 0 ? "−" : (sign && n > 0 ? "+" : "")) + s + " €"; } function fmtPct(n, sign = true, decimals = 2) { if (n === null || n === undefined) return "—"; const s = Math.abs(n).toFixed(decimals); return (n < 0 ? "−" : (sign ? "+" : "")) + s + "%"; } /* ─── Sparkline ─────────────────────────────────────── */ function Sparkline({ data, width = 80, height = 24, color = "var(--accent-400)", fill = true, animated = true }) { const path = useMemo(() => { if (!data || data.length < 2) return { line: "", area: "", points: [] }; const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const points = data.map((v, i) => [ (i / (data.length - 1)) * width, height - 2 - ((v - min) / range) * (height - 4) ]); const line = points.map(([x, y], i) => (i === 0 ? `M${x},${y}` : `L${x},${y}`)).join(" "); const area = `${line} L${width},${height} L0,${height} Z`; return { line, area, points }; }, [data, width, height]); const last = data[data.length - 1]; const first = data[0]; const up = last >= first; const c = color === "auto" ? (up ? "var(--bull)" : "var(--bear)") : color; const gradId = useMemo(() => `spark-${Math.random().toString(36).slice(2, 9)}`, []); return ( {fill && } {path.points.length > 0 && ( )} ); } // Inject keyframe once if (typeof document !== "undefined" && !document.getElementById("spark-kf")) { const s = document.createElement("style"); s.id = "spark-kf"; s.textContent = `@keyframes spark-draw { to { stroke-dashoffset: 0; } }`; document.head.appendChild(s); } /* ─── PnL display (animated, variant sizing) ────────── */ function PnLDisplay({ value, pct, variant = "md", showPct = true, prefix = "€" }) { const animated = useCountUp(value); const animPct = useCountUp(pct || 0); const up = value >= 0; const sizes = { sm: { num: 18, pct: 11 }, md: { num: 28, pct: 12 }, lg: { num: 36, pct: 13 }, hero: { num: 48, pct: 14 }, }; const s = sizes[variant]; const stackPct = variant === "md" || variant === "sm"; return (
{up ? "+" : "−"}{prefix}{Math.abs(animated).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {showPct && pct !== undefined && ( {fmtPct(animPct)} )}
); } /* ─── Score badge ───────────────────────────────────── */ function ScoreBadge({ score, size = "md" }) { let cls = "score-low"; if (score >= 9) cls = "score-premium"; else if (score >= 8) cls = "score-good"; else if (score >= 7) cls = "score-mid"; return ( {score.toFixed(1)} /10 ); } /* ─── Pair logo (color circle with symbol) ──────────── */ function PairLogo({ pair, size = 28 }) { const data = window.MOCK.PAIRS[pair] || { color: "#666", sym: pair }; const sym = (pair || "?").slice(0, Math.min(4, pair.length)); return (
{sym}
); } function shade(hex, amt) { const c = parseInt(hex.slice(1), 16); let r = Math.max(0, Math.min(255, ((c >> 16) & 0xff) + amt)); let g = Math.max(0, Math.min(255, ((c >> 8) & 0xff) + amt)); let b = Math.max(0, Math.min(255, (c & 0xff) + amt)); return "#" + ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0"); } /* ─── Direction badge ───────────────────────────────── */ function DirBadge({ dir }) { const isLong = dir === "LONG"; return ( {isLong ? : } {dir} ); } /* ─── Toast system ──────────────────────────────────── */ const TOAST_BUS = { listeners: new Set() }; function emitToast(toast) { TOAST_BUS.listeners.forEach(fn => fn(toast)); } function ToastHost() { const [items, setItems] = useState([]); useEffect(() => { const onToast = (t) => { const id = Math.random().toString(36).slice(2); setItems((prev) => [...prev, { ...t, id }]); setTimeout(() => setItems((prev) => prev.filter(x => x.id !== id)), t.duration || 3800); }; TOAST_BUS.listeners.add(onToast); return () => TOAST_BUS.listeners.delete(onToast); }, []); return (
{items.map(t => (
{t.type === "success" ? : t.type === "warn" ? : t.type === "error" ? : }
{t.msg} {t.sub && {t.sub}}
))}
); } /* ─── Risk level calculation (multi-factor, 0-13 → 4 buckets) ─── */ function calcRiskLevel(signal) { if (!signal) return { level: "MODERE", score: 6, factors: {} }; let s = 0; const factors = {}; // 1. ATR — volatilité instantanée const atr = Number(signal.atr) || 0; if (atr < 0.3) { s += 0; factors.atr = "calme"; } else if (atr < 1.5) { s += 1; factors.atr = "normal"; } else if (atr < 3.0) { s += 2; factors.atr = "volatil"; } else { s += 3; factors.atr = "extrême"; } // 2. SL distance — stop trop serré OU trop large = fragile const slPct = Math.abs(Number(signal.slPct) || 0); if (slPct === 0) { s += 2; factors.sl = "inconnu"; } else if (slPct < 0.4) { s += 2; factors.sl = "trop serré"; } else if (slPct < 1.5) { s += 1; factors.sl = "raisonnable"; } else if (slPct < 3.0) { s += 0; factors.sl = "confortable"; } else { s += 2; factors.sl = "trop large"; } // 3. Levier const lev = Number(signal.leverage) || 10; if (lev <= 3) s += 0; else if (lev <= 5) s += 1; else if (lev <= 10) s += 2; else s += 3; factors.lev = `x${lev}`; // 4. Volume (liquidité) const vol = Number(signal.volume) || 0; if (vol >= 100) { s += 0; factors.vol = "fort"; } else if (vol >= 50) { s += 1; factors.vol = "moyen"; } else { s += 2; factors.vol = "faible"; } // 5. Score inverse (faible score = setup fragile) const sc = Number(signal.score) || 0; if (sc >= 9) s += 0; else if (sc >= 8) s += 1; else if (sc >= 7) s += 2; else s += 3; factors.score = sc.toFixed(1); // 0-13 → 4 buckets let level; if (s <= 3) level = "FAIBLE"; else if (s <= 6) level = "MODERE"; else if (s <= 9) level = "ELEVE"; else level = "EXTREME"; return { level, score: s, factors }; } window.calcRiskLevel = calcRiskLevel; function RiskBadge({ signal, size = "md" }) { const { level } = calcRiskLevel(signal); const cls = "risk-badge risk-" + level.toLowerCase(); const lvlLabel = level === "MODERE" ? "MODÉRÉ" : level === "ELEVE" ? "ÉLEVÉ" : level; const icon = level === "EXTREME" ? "⚠️ " : ""; return ( {icon}{lvlLabel} ); } window.RiskBadge = RiskBadge; Object.assign(window, { Sparkline, PnLDisplay, ScoreBadge, PairLogo, DirBadge, ToastHost, emitToast, useCountUp, fmtNum, fmtEur, fmtPct, calcRiskLevel, RiskBadge });