Unify hub dashboard positions into one card with exchange links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-20 12:27:19 +08:00
parent d2028ed0ee
commit ee3c1bcdca
4 changed files with 130 additions and 96 deletions
+10
View File
@@ -1100,6 +1100,11 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]:
contracts = p.get("size")
upnl = _position_float_pnl(p)
source = resolve_position_monitor_source(p, hub_mon)
entry = _safe_float(p.get("entry_price"))
mark = _safe_float(p.get("mark_price"))
notional = _safe_float(p.get("notional_usdt"))
if notional is None:
notional = _safe_float(p.get("notional"))
position_lines.append(
{
"kind": "position",
@@ -1107,6 +1112,11 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]:
"symbol": sym,
"side": side,
"contracts": contracts,
"entry_price": entry,
"entry_price_fmt": p.get("entry_price_fmt"),
"mark_price": mark,
"mark_price_fmt": p.get("mark_price_fmt"),
"notional_usdt": notional,
"text": f"{sym} {side}",
"pnl": round(upnl, 4),
}
+33 -1
View File
@@ -247,11 +247,43 @@ body.hub-page-dashboard .page#page-dashboard {
.dash-ac-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
grid-template-columns: 1fr;
gap: 12px;
padding: 14px;
}
.dash-pos-unified-card {
width: 100%;
}
.dash-ex-link {
appearance: none;
border: 0;
background: transparent;
color: var(--dash-accent);
font: inherit;
font-weight: 600;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.dash-ex-link:hover {
color: color-mix(in srgb, var(--dash-accent) 80%, #fff);
}
.dash-empty-inline {
padding: 8px 0 4px;
font-size: 0.78rem;
}
.dash-pos-alert-note {
margin-top: 4px;
font-size: 0.75rem;
color: var(--dash-warn);
}
.dash-ac-card {
position: relative;
padding: 14px 16px;
+85 -93
View File
@@ -88,22 +88,6 @@
</div>`;
}
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(`<span class="dash-monitor-chip dash-monitor-key">关键位 ${keys}</span>`);
if (orders > 0) {
chips.push(`<span class="dash-monitor-chip dash-monitor-order">下单监控 ${orders}</span>`);
}
if (trends > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-trend">趋势回调 ${trends}</span>`);
if (rolls > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-roll">顺势加仓 ${rolls}</span>`);
return chips;
}
function dashOptionsExpiryCd(expMs) {
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
if (!ms) return "—";
@@ -130,34 +114,75 @@
return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0);
}
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 exchangeLinkCell(ac) {
const name = esc((ac && ac.name) || "");
const exId = ac && ac.id != null ? String(ac.id) : "";
if (!exId) return name;
return (
`<button type="button" class="dash-ex-link" data-dash-ex-id="${esc(exId)}" ` +
`title="打开监控区放大查看">${name}</button>`
);
}
function renderDashboardPerpTable(lines) {
const rows = Array.isArray(lines) ? lines : [];
if (!rows.length) 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 floatPctText(pnl, notional) {
const n = Number(pnl);
const notion = Number(notional);
if (!Number.isFinite(n) || !Number.isFinite(notion) || Math.abs(notion) < 1e-8) return "—";
const pct = (n / Math.abs(notion)) * 100;
const abs = Math.abs(pct).toFixed(2);
if (Math.abs(pct) < 1e-9) return "0.00%";
return `${pct > 0 ? "+" : "-"}${abs}%`;
}
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 renderUnifiedPerpTable(rows) {
if (!rows.length) {
return `<div class="dash-pos-block">
<div class="dash-ac-section-label">永续持仓</div>
<div class="dash-empty dash-empty-inline">当前无永续持仓</div>
</div>`;
}
const body = rows
.map((ln) => {
const source = String((ln && ln.source) || "—");
.map(({ ac, ln }) => {
const symbol = esc((ln && (ln.symbol || ln.text)) || "—");
const side = esc((ln && ln.side) || "—");
const contracts =
ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—";
const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN;
const pct = floatPctText(pnl, ln && ln.notional_usdt);
return `<tr>
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td>
<td>${exchangeLinkCell(ac)}</td>
<td>${symbol}</td>
<td>${side}</td>
<td>${priceCell(ln && ln.entry_price_fmt, ln && ln.entry_price)}</td>
<td>${priceCell(ln && ln.mark_price_fmt, ln && ln.mark_price)}</td>
<td>${contracts}</td>
<td class="${pnlClass(pnl)}">${Number.isFinite(pnl) ? pnlSigned(pnl, 2) : "—"}</td>
<td class="${pnlClass(pnl)}">${pct}</td>
</tr>`;
})
.join("");
@@ -166,7 +191,7 @@
<div class="dash-table-wrap">
<table class="dash-table dash-pos-table">
<thead><tr>
<th>来源</th><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th>
<th>交易所</th><th>合约</th><th>方向</th><th>开仓价</th><th>标记价</th><th>张数</th><th>盈利金额</th><th>浮盈</th>
</tr></thead>
<tbody>${body}</tbody>
</table>
@@ -188,23 +213,26 @@
return null;
}
function renderDashboardOptionsTable(positions) {
const pos = Array.isArray(positions) ? positions : [];
if (!pos.length) return "";
const rows = pos
.map((p) => {
function renderUnifiedOptionsTable(rows) {
if (!rows.length) {
return `<div class="dash-pos-block dash-options-block">
<div class="dash-ac-section-label">期权持仓</div>
<div class="dash-empty dash-empty-inline">当前无期权持仓</div>
</div>`;
}
const body = rows
.map(({ ac, p }) => {
const optType =
(p.opt_type || "").toUpperCase() === "C"
? "Call"
: (p.opt_type || "").toUpperCase() === "P"
? "Put"
: p.opt_type || "—";
const source = String(p.source_label || p.source || "纯期权");
const target = String(p.target_monitor_text || "—");
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
const net = optionsNetPnl(p);
return `<tr>
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td>
<td>${exchangeLinkCell(ac)}</td>
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
<td>${esc(optType)}</td>
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
@@ -219,33 +247,17 @@
<div class="dash-table-wrap dash-options-table-wrap">
<table class="dash-table dash-options-table">
<thead><tr>
<th>来源</th><th>合约</th><th>类型</th><th>到期倒计时</th><th>指数</th><th>目标监控</th><th>净盈亏</th>
<th>交易所</th><th>合约</th><th>类型</th><th>到期倒计时</th><th>指数</th><th>目标监控</th><th>净盈亏</th>
</tr></thead>
<tbody>${rows}</tbody>
<tbody>${body}</tbody>
</table>
</div>
</div>`;
}
function renderAccountPositions(ac) {
const perpLines = accountPerpLines(ac);
const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : [];
const issues = Array.isArray(ac && ac.issues) ? ac.issues : [];
const chips = renderMonitorCountChips((ac && ac.monitor_counts) || {});
const monitorRow = chips.length
? `<div class="dash-ac-monitor-row">${chips.join("")}</div>`
: "";
const perpHtml = renderDashboardPerpTable(perpLines);
const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : "";
const issueHtml = issues
.map((text) => `<div class="dash-ac-remark-line dash-ac-remark-issue">${esc(text)}</div>`)
.join("");
return `${monitorRow}${perpHtml}${optionsHtml}${issueHtml}`;
}
function bindDashboardExpand() {
if (!elAccounts) return;
elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => {
elAccounts.querySelectorAll("[data-dash-ex-id]").forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
@@ -256,44 +268,24 @@
}
function renderAccounts(accounts, threshold) {
const rows = (Array.isArray(accounts) ? accounts : []).filter(accountHasOpenPositions);
if (!rows.length) {
const unified = collectUnifiedPositions(accounts);
if (!unified.perp.length && !unified.options.length) {
elAccounts.innerHTML = '<div class="dash-empty">当前无持仓账户</div>';
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
? `<span class="dash-ac-badge alert">单日亏损 ≥${threshold}%</span>`
: `<span class="dash-ac-badge ok">${esc(ac.status || "—")}</span>`;
const exId = ac && ac.id != null ? String(ac.id) : "";
const expandBtn = exId
? `<button type="button" class="dash-ac-expand-btn" data-dash-ex-id="${esc(exId)}" title="放大查看监控详情" aria-label="放大查看监控详情">` +
`<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M15 3h6v6h-2V6.41l-7.29 7.3-1.42-1.42 7.3-7.29H15V3zM3 9h2v10h10v2H3V9z"/></svg>` +
`</button>`
: "";
const lossBar =
alert && barW > 0
? `<div class="dash-loss-bar" title="占资金合计 ${fmt(lossPct, 2)}%"><i style="width:${barW}%"></i></div>`
: "";
const cardCls = ac.options_layout ? " dash-ac-card-options" : "";
return `<article class="dash-ac-card dash-ac-card-pos-only${cardCls}${alert ? " is-alert" : ""}${unmon ? " is-unmon" : ""}">
<div class="dash-ac-top">
<div class="dash-ac-name">${esc(ac.name || "—")}</div>
<div class="dash-ac-top-actions">${badge}${expandBtn}</div>
</div>
${lossBar}
<div class="dash-ac-pos-body">${renderAccountPositions(ac)}</div>
</article>`;
})
.join("");
const alertAccounts = (Array.isArray(accounts) ? accounts : []).filter((a) => a && a.loss_alert);
const alertNote = alertAccounts.length
? `<div class="dash-pos-alert-note">风险: ${esc(
alertAccounts.map((a) => a.name || "").filter(Boolean).join("、")
)} 单日亏损 ≥${threshold}%</div>`
: "";
elAccounts.innerHTML = `<article class="dash-ac-card dash-pos-unified-card">
<div class="dash-ac-pos-body">
${renderUnifiedPerpTable(unified.perp)}
${renderUnifiedOptionsTable(unified.options)}
${alertNote}
</div>
</article>`;
bindDashboardExpand();
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
OptionsExpiryCountdown.ensureTimer();
+2 -2
View File
@@ -20,7 +20,7 @@
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script>
<link rel="stylesheet" href="/assets/dashboard.css?v=20260717-dash-kpi-spread" />
<link rel="stylesheet" href="/assets/dashboard.css?v=20260720-dash-pos-unified" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -1385,7 +1385,7 @@
<script src="/assets/archive.js?v=20260717-archive-cal-chart"></script>
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
<script src="/assets/dashboard.js?v=20260717-dash-kpi-merge"></script>
<script src="/assets/dashboard.js?v=20260720-dash-pos-unified"></script>
<script src="/assets/strategy.js?v=8"></script>
<script src="/assets/help.js?v=1"></script>
<script src="/assets/logs.js?v=1"></script>