(function () { const COLORS = [ "#22d3ee", "#34d399", "#fbbf24", "#fb7185", "#a78bfa", "#60a5fa", "#f472b6", "#2dd4bf", ]; let windowKey = "7d"; let curveData = null; let summaryData = null; function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmtNum(v, digits) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return "—"; return n.toFixed(digits == null ? 2 : digits); } function fmtPnl(v) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return "—"; return (n > 0 ? "+" : "") + n.toFixed(2) + " U"; } function pnlClass(v) { const n = Number(v); if (!Number.isFinite(n) || n === 0) return "pnl-flat"; return n > 0 ? "pnl-up" : "pnl-down"; } function fmtHold(sec) { if (sec == null || sec === "") return "—"; const n = Number(sec); if (!Number.isFinite(n) || n < 0) return "—"; if (n < 60) return Math.round(n) + "s"; if (n < 3600) return (n / 60).toFixed(1) + "m"; return (n / 3600).toFixed(1) + "h"; } function setStatus(msg, isErr) { const el = document.getElementById("stats-status"); if (!el) return; el.textContent = msg || ""; el.classList.toggle("err", !!isErr); } function renderKpis(totals) { const grid = document.getElementById("stats-kpi-grid"); if (!grid) return; const t = totals || {}; const items = [ { label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) }, { label: "合计余额", value: fmtNum(t.cash_usdt) + " U" }, { label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) }, { label: "胜率", value: fmtNum(t.win_rate, 1) + "%" }, { label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") }, { label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) }, ]; grid.innerHTML = items .map(function (it) { return ( '
' + '
' + escapeHtml(it.label) + "
" + '
' + escapeHtml(it.value) + "
" ); }) .join(""); } function renderTable(machines) { const body = document.getElementById("stats-table-body"); if (!body) return; const list = machines || []; if (!list.length) { body.innerHTML = '暂无启用实例'; return; } body.innerHTML = list .map(function (m) { const status = m.online ? '在线' : '离线'; const err = m.error ? '
' + escapeHtml(m.error) + "
" : ""; return ( "" + "" + escapeHtml(m.name || m.id) + "" + err + "" + "" + status + "" + "" + escapeHtml(fmtNum(m.cash_usdt)) + "" + "" + escapeHtml(fmtNum(m.premium_usdt)) + "" + '' + escapeHtml(fmtPnl(m.unrealized_usdt)) + "" + "" + escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") + "" + "" + escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") + "" + '' + escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") + "" + "" + escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") + "" + '工作台' + "" ); }) .join(""); } function drawCurve(data) { const canvas = document.getElementById("stats-curve-canvas"); const empty = document.getElementById("stats-curve-empty"); const legend = document.getElementById("stats-curve-legend"); if (!canvas) return; const ctx = canvas.getContext("2d"); const dpr = window.devicePixelRatio || 1; const cssW = canvas.clientWidth || 960; const cssH = 320; canvas.width = Math.floor(cssW * dpr); canvas.height = Math.floor(cssH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); const showMachines = !!(document.getElementById("stats-show-machines") || {}).checked; const lines = []; if (data && data.total && data.total.length) { lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 }); } if (showMachines && data && data.series) { data.series.forEach(function (s, i) { if (!s.points || !s.points.length) return; lines.push({ name: s.name || String(s.id), color: COLORS[i % COLORS.length], points: s.points, width: 1.4, }); }); } if (legend) { legend.innerHTML = lines .map(function (ln) { return ( '' + escapeHtml(ln.name) + "" ); }) .join(""); } const allPts = lines.reduce(function (acc, ln) { return acc.concat(ln.points || []); }, []); if (!allPts.length) { if (empty) empty.hidden = false; return; } if (empty) empty.hidden = true; let minT = Math.min.apply( null, allPts.map(function (p) { return p.t; }) ); let maxT = Math.max.apply( null, allPts.map(function (p) { return p.t; }) ); let minV = Math.min.apply( null, allPts.map(function (p) { return p.v; }) ); let maxV = Math.max.apply( null, allPts.map(function (p) { return p.v; }) ); if (maxT <= minT) maxT = minT + 1; if (maxV <= minV) { maxV = minV + 1; minV = minV - 1; } const pad = { l: 52, r: 16, t: 16, b: 28 }; const w = cssW - pad.l - pad.r; const h = cssH - pad.t - pad.b; function xOf(t) { return pad.l + ((t - minT) / (maxT - minT)) * w; } function yOf(v) { return pad.t + (1 - (v - minV) / (maxV - minV)) * h; } ctx.strokeStyle = "rgba(56,189,248,0.15)"; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const y = pad.t + (h * i) / 4; ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + w, y); ctx.stroke(); const val = maxV - ((maxV - minV) * i) / 4; ctx.fillStyle = "#8aa0b8"; ctx.font = "11px IBM Plex Sans, sans-serif"; ctx.fillText(val.toFixed(0), 8, y + 4); } lines.forEach(function (ln) { const pts = ln.points || []; if (pts.length < 1) return; ctx.strokeStyle = ln.color; ctx.lineWidth = ln.width || 1.5; ctx.beginPath(); pts.forEach(function (p, idx) { const x = xOf(p.t); const y = yOf(p.v); if (idx === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }); } async function loadAll() { setStatus("加载中…"); try { const [sumRes, curveRes] = await Promise.all([ fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey)), fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey)), ]); const sum = await sumRes.json(); const curve = await curveRes.json(); if (!sum.ok) throw new Error(sum.msg || "汇总失败"); if (!curve.ok) throw new Error(curve.msg || "曲线失败"); summaryData = sum; curveData = curve; renderKpis(sum.totals); renderTable(sum.machines); drawCurve(curve); setStatus( "窗口 " + windowKey + " · 在线 " + (sum.totals.online || 0) + "/" + (sum.totals.machines || 0) + " · 曲线采样点以巡检累积" ); } catch (e) { setStatus(String(e.message || e), true); } } function bind() { const tabs = document.getElementById("stats-window-tabs"); if (tabs) { tabs.addEventListener("click", function (ev) { const btn = ev.target.closest("[data-window]"); if (!btn) return; windowKey = btn.getAttribute("data-window") || "7d"; tabs.querySelectorAll(".stats-win-btn").forEach(function (b) { b.classList.toggle("is-active", b === btn); }); loadAll(); }); } const toggle = document.getElementById("stats-show-machines"); if (toggle) { toggle.addEventListener("change", function () { if (curveData) drawCurve(curveData); }); } window.addEventListener("resize", function () { if (curveData) drawCurve(curveData); }); } bind(); loadAll(); })();