/* 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 (
);
}
// 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 });