/** * 中控数据看板:后端 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 showAccountPnlPref() { if (typeof window.hubShowAccountPnlPref === "function") { return !!window.hubShowAccountPnlPref(); } return true; } function renderKpi(totals) { if (!elKpi || !totals) return; const showPnl = showAccountPnlPref(); 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 funds = funding != null && trading != null ? Number(funding) + Number(trading) : NaN; const totalPos = Number(totals.open_position_count) || 0; const optPos = Number(totals.options_open_position_count) || 0; const perpPos = totals.perpetual_open_position_count != null ? Number(totals.perpetual_open_position_count) || 0 : Math.max(0, totalPos - optPos); const items = [kpiItem("交易日", esc(totals.trading_day || "—"))]; if (showPnl) { items.push(kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—")); } items.push( kpiItem("总持仓数量", `${totalPos}`), kpiItem("期权持仓", `${optPos}`), kpiItem("永续持仓", `${perpPos}`), kpiItem("平仓数量", `${totals.closed_count || 0}`), kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)) ); if (showPnl) { items.push(kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating))); } elKpi.innerHTML = `
${items.join("")}
`; } function kpiItem(label, value, valCls) { return `
${esc(label)}
${value}
`; } function dashOptionsExpiryCd(expMs) { const ms = expMs != null && expMs !== "" ? String(expMs) : ""; if (!ms) return "—"; return ``; } function shortDashInst(instId) { const s = String(instId || ""); if (s.length <= 18) return s; return s.slice(0, 8) + "…" + s.slice(-6); } function accountPerpLines(ac) { const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : []; if (ac && ac.options_layout) { return positions.filter((ln) => (ln && ln.kind) !== "options"); } return positions; } function accountHasOpenPositions(ac) { const perp = accountPerpLines(ac); const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : []; return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0); } function exchangeLinkCell(ac) { const name = esc((ac && ac.name) || "—"); const exId = ac && ac.id != null ? String(ac.id) : ""; if (!exId) return name; return ( `` ); } function priceCell(fmtVal, raw) { if (fmtVal != null && String(fmtVal).trim() !== "") return esc(String(fmtVal)); if (raw != null && Number.isFinite(Number(raw))) return esc(fmt(raw, 4).replace(/\.?0+$/, "")); return "—"; } function floatPnlCell(ln) { const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN; if (!Number.isFinite(pnl)) return "—"; return `${fmt(pnl, 2)}`; } function slTpCell(ln, kind) { if (kind === "tp") { const note = String((ln && ln.tp_note) || "").trim(); if (note) return `${esc(note)}`; } const raw = kind === "sl" ? ln && ln.stop_loss : ln && ln.take_profit; if (raw == null || raw === "") return "—"; if (Number.isFinite(Number(raw))) { return esc(fmt(raw, 4).replace(/\.?0+$/, "")); } return esc(String(raw)); } function collectUnifiedPositions(accounts) { const perp = []; const options = []; (Array.isArray(accounts) ? accounts : []).forEach((ac) => { if (!accountHasOpenPositions(ac)) return; accountPerpLines(ac).forEach((ln) => { if (!ln) return; perp.push({ ac: ac, ln: ln }); }); if (ac && ac.options_layout) { (Array.isArray(ac.options_positions) ? ac.options_positions : []).forEach((p) => { if (!p) return; options.push({ ac: ac, p: p }); }); } }); return { perp: perp, options: options }; } function sourceBadgeClass(source) { const s = String(source || ""); if (s.indexOf("对冲") >= 0) return "is-hedge"; if (s.indexOf("纯期权") >= 0 || s === "期权") return "is-opt"; if (s.indexOf("顺势") >= 0) return "is-roll"; if (s.indexOf("趋势") >= 0) return "is-trend"; if (s.indexOf("关键位") >= 0) return "is-key"; if (s.indexOf("下单") >= 0) return "is-order"; return "is-none"; } function sourceTypeCell(source) { const s = String(source || "—"); return `${esc(s)}`; } function directionCell(side) { const s = String(side || "").toLowerCase(); if (s === "long" || s === "buy") { return `做多`; } if (s === "short" || s === "sell") { return `做空`; } return esc(side || "—"); } function renderUnifiedPerpTable(rows) { if (!rows.length) return ""; const showPnl = showAccountPnlPref(); const body = rows .map(({ ac, ln }) => { const source = String((ln && ln.source) || "—"); const symbol = esc((ln && (ln.symbol || ln.text)) || "—"); const contracts = ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—"; return ` ${exchangeLinkCell(ac)} ${sourceTypeCell(source)} ${symbol} ${directionCell(ln && ln.side)} ${priceCell(ln && ln.entry_price_fmt, ln && ln.entry_price)} ${priceCell(ln && ln.mark_price_fmt, ln && ln.mark_price)} ${contracts} ${slTpCell(ln, "sl")} ${slTpCell(ln, "tp")} ${showPnl ? `${floatPnlCell(ln)}` : ""} `; }) .join(""); return `
永续持仓
${ showPnl ? "" : "" } ${body}
交易所类型合约方向开仓价标记价张数止损止盈浮盈
`; } function optionsNetPnl(p) { if (!p || typeof p !== "object") return null; if (p.net_pnl != null && Number.isFinite(Number(p.net_pnl))) return Number(p.net_pnl); const preview = p.close_preview || {}; if (preview.estimated_pnl != null && Number.isFinite(Number(preview.estimated_pnl))) { return Number(preview.estimated_pnl); } if (preview.total_received != null && p.premium_paid != null) { const n = Number(preview.total_received) - Number(p.premium_paid); return Number.isFinite(n) ? n : null; } return null; } function optionsHedgePlanId(p) { if (!p || typeof p !== "object") return ""; const hedge = p.hedge_plan_target; if (hedge && typeof hedge === "object" && hedge.plan_id != null && hedge.plan_id !== "") { return String(hedge.plan_id); } if (p.source_plan_id != null && p.source_plan_id !== "") return String(p.source_plan_id); const target = String(p.target_monitor_text || ""); const m = target.match(/对冲\s*#\s*(\d+)/); return m ? m[1] : ""; } function optionsGroupKey(ac, p) { const ex = ac && ac.id != null ? String(ac.id) : String((ac && ac.name) || ""); const planId = optionsHedgePlanId(p); const source = String((p && (p.source_label || p.source)) || ""); if (planId && source.indexOf("对冲") >= 0) return "hedge:" + ex + ":" + planId; if (planId && /对冲#/.test(String((p && p.target_monitor_text) || ""))) { return "hedge:" + ex + ":" + planId; } return "solo:" + ex + ":" + String((p && p.inst_id) || Math.random()); } function optionsTypeLabel(p) { const source = String((p && (p.source_label || p.source)) || "纯期权").trim() || "纯期权"; const planId = optionsHedgePlanId(p); if (planId && (source.indexOf("对冲") >= 0 || /对冲#/.test(String((p && p.target_monitor_text) || "")))) { const base = source.indexOf("对冲") >= 0 ? source.replace(/\s*#\s*\d+\s*$/, "") : "对冲"; return base + "#" + planId; } return source; } function optionsRoiPct(p) { if (!p || typeof p !== "object") return null; const preview = p.close_preview || {}; if (preview.estimated_pnl_ratio_pct != null && Number.isFinite(Number(preview.estimated_pnl_ratio_pct))) { return Number(preview.estimated_pnl_ratio_pct); } const net = optionsNetPnl(p); const paid = Number(p.premium_paid); if (net != null && Number.isFinite(paid) && paid > 0) { return (net / paid) * 100; } return null; } function renderOptionsLegRow(ac, p, showPnl) { const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : p.opt_type || "—"; const source = optionsTypeLabel(p); const target = String(p.target_monitor_text || "—"); const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor"; const net = optionsNetPnl(p); const roi = optionsRoiPct(p); let html = ` ${exchangeLinkCell(ac)} ${sourceTypeCell(source)} ${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) : "—"} ${esc(target)}`; if (showPnl) { html += `${net != null ? pnlSigned(net, 2) : "—"} ${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}`; } html += ""; return html; } function renderUnifiedOptionsTable(rows) { if (!rows.length) return ""; const showPnl = showAccountPnlPref(); // 同所同计划相邻,Call 在前 Put 在后;不额外画分组框 const sorted = rows.slice().sort((a, b) => { const ka = optionsGroupKey(a.ac, a.p); const kb = optionsGroupKey(b.ac, b.p); if (ka !== kb) return ka.localeCompare(kb); const ta = String((a.p && a.p.opt_type) || "").toUpperCase(); const tb = String((b.p && b.p.opt_type) || "").toUpperCase(); if (ta === tb) return 0; if (ta === "C") return -1; if (tb === "C") return 1; return ta.localeCompare(tb); }); const body = sorted.map(({ ac, p }) => renderOptionsLegRow(ac, p, showPnl)).join(""); return `
期权持仓
${ showPnl ? "" : "" } ${body}
交易所类型合约Call/Put到期倒计时指数目标监控净盈亏收益率
`; } function bindDashboardExpand() { if (!elAccounts) return; elAccounts.querySelectorAll("[data-dash-ex-id]").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 renderAccounts(accounts, threshold) { const unified = collectUnifiedPositions(accounts); const perpHtml = renderUnifiedPerpTable(unified.perp); const optionsHtml = renderUnifiedOptionsTable(unified.options); if (!perpHtml && !optionsHtml) { elAccounts.innerHTML = '
当前无持仓账户
'; return; } const alertAccounts = (Array.isArray(accounts) ? accounts : []).filter((a) => a && a.loss_alert); const alertNote = alertAccounts.length ? `
风险: ${esc( alertAccounts.map((a) => a.name || "").filter(Boolean).join("、") )} 单日亏损 ≥${threshold}%
` : ""; elAccounts.innerHTML = `
${perpHtml} ${optionsHtml} ${alertNote}
`; 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) || 5; 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(); }, refresh() { if (!inited) return; void fetchDashboardSnapshot({ silent: true, force: true }); }, }; })();