// Charts — pure SVG, no external libs.
const { useMemo: useMemoC, useEffect: useEffectC, useRef: useRefC, useState: useStateC } = React;
// ============================================================
// Sparkline (line + optional area fill)
// ============================================================
function Sparkline({ data, w = 120, h = 32, color = "#19E5A6", fill = true, strokeW = 1.5, ariaLabel }) {
const path = useMemoC(() => {
if (!data || data.length < 2) return { line: "", area: "" };
const min = Math.min(...data), max = Math.max(...data);
const span = max - min || 1;
const pts = data.map((v, i) => [
(i / (data.length - 1)) * w,
h - 2 - ((v - min) / span) * (h - 4),
]);
const line = pts.map(([x, y], i) => (i === 0 ? `M${x},${y}` : `L${x},${y}`)).join(" ");
const area = `${line} L${w},${h} L0,${h} Z`;
return { line, area };
}, [data, w, h]);
const id = useMemoC(() => "spg-" + Math.random().toString(36).slice(2, 8), []);
return (
);
}
// ============================================================
// Drawdown gauge (half-arc)
// ============================================================
function DrawdownGauge({ value, max, size = 180 }) {
const pct = Math.min(1, value / max);
const w = size, h = size * 0.62;
const cx = w / 2, cy = h - 8;
const r = w / 2 - 14;
const startA = Math.PI, endA = 0;
const arc = (a0, a1) => {
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
const large = Math.abs(a1 - a0) > Math.PI ? 1 : 0;
const sweep = a1 > a0 ? 1 : 0;
return `M${x0},${y0} A${r},${r} 0 ${large} ${sweep} ${x1},${y1}`;
};
const angAt = (p) => startA - (startA - endA) * p;
const color = pct < 0.5 ? "var(--profit)" : pct < 0.8 ? "var(--warn)" : "var(--loss)";
return (
);
}
// ============================================================
// Equity curve with benchmark overlay + hover
// ============================================================
function EquityCurve({ portfolio, benchmark, h = 280, accent = "#00D4AA", yLabel = (v) => "$" + Math.round(v / 1000) + "k" }) {
const wrapRef = useRefC(null);
const [w, setW] = useStateC(800);
const [hover, setHover] = useStateC(null);
useEffectC(() => {
if (!wrapRef.current) return;
const ro = new ResizeObserver(([e]) => setW(Math.max(400, e.contentRect.width)));
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const pad = { t: 18, r: 18, b: 26, l: 56 };
const innerW = w - pad.l - pad.r;
const innerH = h - pad.t - pad.b;
const all = [...portfolio, ...(benchmark || [])];
const min = Math.min(...all);
const max = Math.max(...all);
const span = max - min || 1;
const n = portfolio.length;
const toXY = (v, i) => [
pad.l + (i / (n - 1)) * innerW,
pad.t + innerH - ((v - min) / span) * innerH,
];
const linePath = (arr) =>
arr.map((v, i) => {
const [x, y] = toXY(v, i);
return (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
}).join(" ");
const areaPath = (arr) =>
linePath(arr) + ` L${pad.l + innerW},${pad.t + innerH} L${pad.l},${pad.t + innerH} Z`;
const yTicks = Array.from({ length: 5 }, (_, i) => min + (span * i) / 4);
const onMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const idx = Math.round(((x - pad.l) / innerW) * (n - 1));
if (idx >= 0 && idx < n) setHover(idx); else setHover(null);
};
return (
{hover != null && (() => {
const x = pad.l + (hover / (n - 1)) * innerW;
const left = Math.min(w - 180, Math.max(0, x + 12));
return (
{Math.round((1 - hover/(n-1)) * 6 * 30)}d ago
Equity
${portfolio[hover].toFixed(0)}
{benchmark && (
BTC HODL
${benchmark[hover].toFixed(0)}
)}
);
})()}
);
}
// ============================================================
// Portfolio sparkline (medium size, no axis)
// ============================================================
function PortfolioSpark({ data, accent = "#00D4AA", w = 320, h = 80 }) {
const min = Math.min(...data), max = Math.max(...data);
const span = max - min || 1;
const pts = data.map((v, i) => [
(i / (data.length - 1)) * w,
h - 4 - ((v - min) / span) * (h - 8),
]);
const line = pts.map(([x, y], i) => (i === 0 ? `M${x},${y}` : `L${x},${y}`)).join(" ");
const area = `${line} L${w},${h} L0,${h} Z`;
return (
);
}
// ============================================================
// Daily P&L bar chart (gain/loss bars + win rate dots)
// ============================================================
function DailyPnLBars({ data, h = 200 }) {
const wrapRef = useRefC(null);
const [w, setW] = useStateC(800);
useEffectC(() => {
if (!wrapRef.current) return;
const ro = new ResizeObserver(([e]) => setW(Math.max(300, e.contentRect.width)));
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const pad = { t: 14, r: 16, b: 24, l: 50 };
const iw = w - pad.l - pad.r;
const ih = h - pad.t - pad.b;
const max = Math.max(...data.map((d) => Math.abs(d.pnl)));
const zero = pad.t + ih / 2;
const halfH = ih / 2;
const barW = (iw / data.length) * 0.7;
const step = iw / data.length;
return (
);
}
// ============================================================
// Latency histogram (signal → execution)
// ============================================================
function LatencyHistogram({ samples, h = 140 }) {
const wrapRef = useRefC(null);
const [w, setW] = useStateC(400);
useEffectC(() => {
if (!wrapRef.current) return;
const ro = new ResizeObserver(([e]) => setW(Math.max(200, e.contentRect.width)));
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
// Bin samples into 8 buckets
const max = 1000;
const buckets = Array(8).fill(0);
samples.forEach((v) => {
const b = Math.min(7, Math.floor((v / max) * 8));
buckets[b]++;
});
const peak = Math.max(...buckets, 1);
const pad = { t: 8, r: 8, b: 24, l: 8 };
const iw = w - pad.l - pad.r;
const ih = h - pad.t - pad.b;
const barW = iw / buckets.length;
return (
);
}
// ============================================================
// Signal funnel — vertical bars showing dropoff
// ============================================================
function SignalFunnel({ stages }) {
const max = Math.max(...stages.map((s) => s.value));
return (
{stages.map((s, i) => {
const pct = (s.value / max) * 100;
return (
{s.label}
{s.value}{s.suffix || ""}
);
})}
);
}
// ============================================================
// Hourly heatmap — 24 cells
// ============================================================
function HourlyHeatmap({ data, h = 60 }) {
const max = Math.max(...data.map((d) => Math.abs(d.pnl)));
return (
{data.map((d, i) => {
const intensity = Math.abs(d.pnl) / max;
const color = d.pnl >= 0
? `rgba(25,229,166,${0.10 + intensity * 0.75})`
: `rgba(242,63,92,${0.10 + intensity * 0.75})`;
return (
0.5 ? "rgba(0,0,0,0.7)" : "var(--text-3)",
}} title={`${d.hour.toString().padStart(2,"0")}:00 UTC · ${d.pnl >= 0 ? "+" : "−"}$${Math.abs(d.pnl).toFixed(0)} · ${d.trades} trades`}>
{i % 3 === 0 ? d.hour.toString().padStart(2, "0") : ""}
);
})}
);
}
// ============================================================
// Monthly returns heatmap (backtest)
// ============================================================
function MonthlyHeatmap({ data }) {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const all = data.flatMap((r) => r.vals).filter((v) => v != null);
const max = Math.max(...all.map(Math.abs));
return (
|
{months.map((m) => {m} | )}
YTD |
{data.map((row) => {
const ytd = row.vals.filter((v) => v != null).reduce((a, b) => a + b, 0);
return (
| {row.year} |
{row.vals.map((v, i) => {
if (v == null) return | ;
const intensity = Math.abs(v) / max;
const bg = v >= 0
? `rgba(25,229,166,${0.12 + intensity * 0.7})`
: `rgba(242,63,92,${0.12 + intensity * 0.7})`;
return (
0.45 ? "rgba(0,0,0,0.78)" : "var(--text)",
}}>{v > 0 ? "+" : ""}{v.toFixed(1)} |
);
})}
= 0 ? "var(--profit)" : "var(--loss)",
background: "rgba(255,255,255,0.04)", borderRadius: 3, padding: "0 6px",
}}>{ytd > 0 ? "+" : ""}{ytd.toFixed(1)}% |
);
})}
);
}
// ============================================================
// P&L distribution histogram (trade outcomes)
// ============================================================
function PnLHistogram({ trades, h = 160 }) {
const wrapRef = useRefC(null);
const [w, setW] = useStateC(400);
useEffectC(() => {
if (!wrapRef.current) return;
const ro = new ResizeObserver(([e]) => setW(Math.max(200, e.contentRect.width)));
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const vals = trades.filter((t) => t.pnl != null).map((t) => t.pnl);
const min = Math.min(...vals, -120);
const max = Math.max(...vals, 120);
const n = 12;
const buckets = Array(n).fill(0);
vals.forEach((v) => {
const b = Math.max(0, Math.min(n - 1, Math.floor(((v - min) / (max - min)) * n)));
buckets[b]++;
});
const peak = Math.max(...buckets, 1);
const pad = { t: 8, r: 8, b: 24, l: 8 };
const iw = w - pad.l - pad.r;
const ih = h - pad.t - pad.b;
const barW = iw / n;
return (
);
}
// ============================================================
// Log volume line chart (last 24h, hourly buckets)
// ============================================================
function LogVolumeChart({ data, h = 80, accent = "#00D4AA" }) {
const wrapRef = useRefC(null);
const [w, setW] = useStateC(400);
useEffectC(() => {
if (!wrapRef.current) return;
const ro = new ResizeObserver(([e]) => setW(Math.max(200, e.contentRect.width)));
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const max = Math.max(...data, 1);
const pad = { t: 6, r: 6, b: 14, l: 6 };
const iw = w - pad.l - pad.r;
const ih = h - pad.t - pad.b;
const barW = iw / data.length;
return (
);
}
Object.assign(window, {
Sparkline, DrawdownGauge, EquityCurve, PortfolioSpark,
DailyPnLBars, LatencyHistogram, SignalFunnel, HourlyHeatmap, MonthlyHeatmap, PnLHistogram, LogVolumeChart,
});