/** * 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁). */ (function () { const page = document.getElementById("page-dashboard"); if (!page) return; let dashEventSource = null; let dashReconnectTimer = null; let localDashVersion = 0; let inited = false; let loading = false; const elStatus = document.getElementById("dash-status"); const elBanner = document.getElementById("dash-alert-banner"); const elBannerText = document.getElementById("dash-alert-banner-text"); const elKpi = document.getElementById("dash-kpi-row"); const elAccounts = document.getElementById("dash-accounts"); const elTrades = document.getElementById("dash-trades-body"); const elUpdated = document.getElementById("dash-updated-at"); const elDay = document.getElementById("dash-trading-day"); const btnRefresh = document.getElementById("dash-btn-refresh"); function fmt(n, d) { if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; return Number(n).toFixed(d == null ? 2 : d); } function pnlClass(v) { const n = Number(v); if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return ""; return n > 0 ? "pos" : "neg"; } function pnlSigned(v, digits) { const n = Number(v); if (!Number.isFinite(n)) return "—"; const abs = fmt(Math.abs(n), digits); if (Math.abs(n) < 1e-9) return `${abs}U`; return `${n > 0 ? "+" : "-"}${abs}U`; } function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function setStatus(msg, isErr) { if (!elStatus) return; elStatus.textContent = msg || ""; elStatus.className = "dash-status" + (isErr ? " err" : ""); } function renderKpi(totals) { if (!elKpi || !totals) return; const closed = Number(totals.total_pnl_u); const floating = Number(totals.float_pnl_u); const funding = totals.total_funding_usdt; const trading = totals.total_trading_usdt; const optionsFloat = Number(totals.options_float_pnl_u); const optionsPos = totals.options_open_position_count; elKpi.innerHTML = [ kpiCard("交易日", esc(totals.trading_day || "—"), ""), kpiCard("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)), kpiCard( "平仓笔数", `${totals.closed_count || 0}`, "", `胜 ${totals.win_count || 0} / 负 ${totals.loss_count || 0}` ), kpiCard("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)), kpiCard( "资金合计", funding != null && trading != null ? `${fmt(Number(funding) + Number(trading), 2)}U` : "—", "", `永续+期权 USDT 等价 · 资金 ${fmt(funding, 2)} + 交易 ${fmt(trading, 2)}` ), kpiCard( "实盘持仓", `${totals.open_position_count || 0} 仓`, "", Number.isFinite(optionsPos) && optionsPos > 0 ? `含期权 ${optionsPos} 仓` : "" ), Number.isFinite(optionsFloat) && Math.abs(optionsFloat) > 1e-9 ? kpiCard("期权浮盈", pnlSigned(optionsFloat, 2), pnlClass(optionsFloat)) : "", ] .filter(Boolean) .join(""); } function kpiCard(label, value, valCls, sub) { return `
${esc(label)}
${value}
${sub ? `
${esc(sub)}
` : ""}
`; } function renderMonitorCountChips(counts) { const mc = counts || {}; const chips = []; const keys = Number(mc.keys) || 0; const orders = Number(mc.orders) || 0; const trends = Number(mc.trends) || 0; const rolls = Number(mc.rolls) || 0; if (keys > 0) chips.push(`关键位 ${keys}`); if (orders > 0) { chips.push(`下单监控 ${orders}`); } if (trends > 0) chips.push(`趋势回调 ${trends}`); if (rolls > 0) chips.push(`顺势加仓 ${rolls}`); return chips; } function dashOptionsExpiryCd(expMs) { const ms = expMs != null && expMs !== "" ? String(expMs) : ""; if (!ms) return "—"; return ``; } function renderDashboardOptionsTable(positions) { const pos = Array.isArray(positions) ? positions : []; if (!pos.length) return ""; const rows = pos .map((p) => { const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : p.opt_type || "—"; return ` ${esc(shortDashInst(p.inst_id))} ${esc(optType)} ${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)} ${p.idx_px != null ? fmt(p.idx_px, 0) : "—"} ${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"} ${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"} ${p.upl != null ? pnlSigned(p.upl, 2) : "—"} `; }) .join(""); return `
期权持仓
${rows}
合约类型到期倒计时指数到期平衡平掉回本浮盈
`; } function shortDashInst(instId) { const s = String(instId || ""); if (s.length <= 18) return s; return s.slice(0, 8) + "…" + s.slice(-6); } function renderAccountDetail(ac) { const counts = (ac && ac.monitor_counts) || {}; const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : []; const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : []; const issues = Array.isArray(ac && ac.issues) ? ac.issues : []; const exId = ac && ac.id != null ? String(ac.id) : ""; const chips = renderMonitorCountChips(counts); const expandBtn = exId ? `` : ""; const monitorRow = chips.length || expandBtn ? `
${chips.join("")}${expandBtn}
` : ""; let posHtml = ""; const perpLines = ac && ac.options_layout ? positions.filter((ln) => (ln && ln.kind) !== "options") : positions; if (perpLines.length) { posHtml = perpLines .map((ln) => { const text = esc((ln && ln.text) || ""); if (ln.pnl != null && Number.isFinite(Number(ln.pnl))) { const pnl = Number(ln.pnl); return ( `
` + `${text} 浮${pnlSigned(pnl, 2)}` + `
` ); } return `
${text}
`; }) .join(""); } else if (!chips.length && !issues.length && !(ac && ac.options_layout && optionsPositions.length)) { posHtml = `
无持仓
`; } const issueHtml = issues .map((text) => `
${esc(text)}
`) .join(""); const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : ""; return `
${monitorRow}
${posHtml}
${optionsHtml}${issueHtml}
`; } function bindDashboardExpand() { if (!elAccounts) return; elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => { btn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); const id = btn.getAttribute("data-dash-ex-id"); if (id && window.hubOpenMonitorExpand) window.hubOpenMonitorExpand(id); }); }); } function dashMetric(label, value, valCls) { return `
${esc(label)}${value}
`; } function dashSectionLabel(text) { return `
${esc(text)}
`; } function renderOkxOptionsMetrics(ac) { const pnl = Number(ac.pnl_u); const perpFloat = Number( ac.perpetual_float_pnl_u != null ? ac.perpetual_float_pnl_u : ac.float_pnl_u ); const optFloat = Number(ac.options_float_pnl_u); const pf = ac.perpetual_funding_usdt != null ? ac.perpetual_funding_usdt : ac.funding_usdt; const pt = ac.perpetual_trading_usdt != null ? ac.perpetual_trading_usdt : ac.trading_usdt; return `
${dashSectionLabel("永续账户")} ${dashMetric("资金账户", `${fmt(pf, 2)}U`)} ${dashMetric("交易账户", `${fmt(pt, 2)}U`)} ${dashMetric("今日盈亏", pnlSigned(pnl, 2), pnlClass(pnl))} ${dashMetric("平仓笔数", String(Number(ac.closed_count) || 0))} ${dashMetric("浮盈亏", pnlSigned(perpFloat, 2), pnlClass(perpFloat))} ${dashSectionLabel("期权账户")} ${dashMetric("资金账户", `${fmt(ac.options_funding_usdt, 2)}U`)} ${dashMetric("交易账户", `${fmt(ac.options_trading_usdt, 2)}U`)} ${dashMetric("期权浮盈亏", pnlSigned(optFloat, 2), pnlClass(optFloat))}
总资金 ${fmt(ac.capital_total_usdt, 2)}U
`; } function renderStandardMetrics(ac) { const pnl = Number(ac.pnl_u); const floatPnl = Number(ac.float_pnl_u); return `
${dashMetric("资金账户", `${fmt(ac.funding_usdt, 2)}U`)} ${dashMetric("交易账户", `${fmt(ac.trading_usdt, 2)}U`)} ${dashMetric("资金合计", `${fmt(ac.capital_total_usdt, 2)}U`)} ${dashMetric("今日盈亏", pnlSigned(pnl, 2), pnlClass(pnl))} ${dashMetric("平仓笔数", String(Number(ac.closed_count) || 0))} ${dashMetric("浮盈亏", pnlSigned(floatPnl, 2), pnlClass(floatPnl))}
`; } function renderAccounts(accounts, threshold) { const rows = Array.isArray(accounts) ? accounts : []; if (!rows.length) { elAccounts.innerHTML = '
暂无账户数据
'; return; } elAccounts.innerHTML = rows .map((ac) => { const alert = !!ac.loss_alert; const unmon = !ac.monitored; const lossPct = Number(ac.daily_loss_pct); const barW = alert && Number.isFinite(lossPct) ? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100) : 0; const badge = alert ? `单日亏损 ≥${threshold}%` : `${esc(ac.status || "—")}`; const lossBar = alert && barW > 0 ? `
` : ""; const metricsHtml = ac.options_layout ? renderOkxOptionsMetrics(ac) : renderStandardMetrics(ac); const cardCls = ac.options_layout ? " dash-ac-card-options" : ""; return `
${esc(ac.name || "—")}
${badge}
${metricsHtml} ${lossBar} ${renderAccountDetail(ac)}
`; }) .join(""); bindDashboardExpand(); if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { OptionsExpiryCountdown.ensureTimer(); } } function renderTrades(trades, accounts) { if (!elTrades) return; const rows = Array.isArray(trades) ? trades : []; if (!rows.length) { elTrades.innerHTML = '
今日暂无平仓
'; return; } const alertNames = new Set( (accounts || []).filter((a) => a.loss_alert).map((a) => String(a.name || "")) ); const body = rows .map((t) => { const pnl = Number(t.pnl_amount); const rowAlert = alertNames.has(String(t.account_name || "")); return ` ${esc(t.trading_day || "—")} ${esc(t.account_name || "—")} ${esc(t.symbol || "—")} ${esc(t.direction || "—")} ${esc(t.result || "—")} ${pnlSigned(pnl, 2)} ${esc(t.closed_at || "—")} `; }) .join(""); elTrades.innerHTML = `
${body}
交易日账户合约方向结果盈亏时间
`; } function renderPayload(data) { const totals = data.totals || {}; const threshold = Number(data.loss_alert_pct_threshold) || 5; const alertCount = Number(data.loss_alert_count) || 0; if (elDay) elDay.textContent = totals.trading_day || data.trading_day || "—"; if (elUpdated) elUpdated.textContent = data.updated_at || "—"; renderKpi(totals); renderAccounts(data.accounts, threshold); renderTrades(data.closed_trades, data.accounts); if (elBanner && elBannerText) { if (alertCount > 0) { const names = (data.accounts || []) .filter((a) => a.loss_alert) .map((a) => a.name) .join(","); elBanner.classList.add("is-on"); elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%:${names}`; } else { elBanner.classList.remove("is-on"); elBannerText.textContent = ""; } } } async function fetchDashboardSnapshot(opts) { const options = opts || {}; if (loading && !options.force) return; loading = true; if (!options.silent) setStatus("同步中…"); try { const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" }); if (r.status === 401) { location.href = "/login?next=" + encodeURIComponent(location.pathname); return; } const data = await r.json(); if (!data.ok) throw new Error(data.detail || data.msg || data.error || "加载失败"); const ver = Number(data.dashboard_version) || 0; if (ver) localDashVersion = ver; renderPayload(data); const sec = Number(data.poll_interval_sec) || 60; setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`); } catch (e) { setStatus(String(e.message || e), true); } finally { loading = false; } } function closeDashboardStream() { if (dashEventSource) { dashEventSource.close(); dashEventSource = null; } if (dashReconnectTimer) { clearTimeout(dashReconnectTimer); dashReconnectTimer = null; } } function connectDashboardStream() { closeDashboardStream(); dashEventSource = new EventSource("/api/dashboard/stream"); dashEventSource.addEventListener("dashboard", (ev) => { try { const st = JSON.parse(ev.data || "{}"); const ver = Number(st.dashboard_version) || 0; if (ver && ver !== localDashVersion) { void fetchDashboardSnapshot({ silent: true }); } else if (st.aggregating) { setStatus("后台聚合中…"); } } catch (_) { /* ignore */ } }); dashEventSource.onerror = () => { closeDashboardStream(); setStatus("SSE 断开,8s 后重连…", true); dashReconnectTimer = setTimeout(() => { if (inited) { connectDashboardStream(); void fetchDashboardSnapshot({ silent: true }); } }, 8000); }; } async function requestDashboardRefresh() { try { await fetch("/api/dashboard/refresh", { method: "POST", credentials: "same-origin" }); } catch (_) { /* ignore */ } } function startLive() { void fetchDashboardSnapshot(); connectDashboardStream(); } function stopLive() { closeDashboardStream(); setStatus(""); } if (btnRefresh) { btnRefresh.addEventListener("click", () => { void requestDashboardRefresh(); void fetchDashboardSnapshot({ force: true }); }); } window.hubDashboardPage = { init() { inited = true; startLive(); }, destroy() { inited = false; stopLive(); }, }; })();