feat(hub): exchange price precision, entry price, and trend DCA display

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-03 21:25:24 +08:00
parent fac28c402b
commit f95118065d
5 changed files with 368 additions and 49 deletions
+155 -43
View File
@@ -181,6 +181,77 @@
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
}
/** 交易所持仓开仓价(四所子代理 entry_price */
function positionEntryPrice(pos) {
if (!pos) return null;
const n = Number(pos.entry_price);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function symbolPriceKey(sym) {
return (sym || "").trim().toUpperCase();
}
function buildPriceTickMap(row) {
const map = Object.create(null);
const put = (sym, tick) => {
const k = symbolPriceKey(sym);
if (!k || tick == null || !Number.isFinite(Number(tick))) return;
if (map[k] == null) map[k] = Number(tick);
};
((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick));
const hm = (row && row.hub_monitor) || {};
(hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick));
(hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick));
return map;
}
function lookupPriceTick(symbol, tickMap) {
if (!tickMap || !symbol) return null;
const k = symbolPriceKey(symbol);
if (tickMap[k] != null) return tickMap[k];
const base = normSym(symbol);
if (base && tickMap[base] != null) return tickMap[base];
return null;
}
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const t = Number(tick);
if (t >= 1) return 0;
const s = t.toFixed(12).replace(/0+$/, "");
const frac = s.split(".")[1];
return frac ? Math.min(12, frac.length) : 0;
}
function defaultPriceDecimals(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 4;
const av = Math.abs(n);
if (av >= 10000) return 2;
if (av >= 100) return 3;
if (av >= 1) return 4;
if (av >= 0.01) return 6;
return 8;
}
/** 按交易所 tick(子代理/Flask 下发)格式化价格 */
function fmtSymbolPrice(value, symbol, tickMap, displayFallback) {
if (displayFallback != null && displayFallback !== "") return String(displayFallback);
if (value == null || value === "") return "—";
const n = Number(value);
if (!Number.isFinite(n)) return "—";
const tick = lookupPriceTick(symbol, tickMap);
const d = decimalsFromTick(tick);
return fmt(n, d != null ? d : defaultPriceDecimals(n));
}
function fmtEntryPrice(pos, tickMap) {
if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt);
return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap);
}
function pnlCls(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
@@ -845,7 +916,7 @@
});
}
function renderOrderRows(exchangeId, symbol, orders, kind) {
function renderOrderRows(exchangeId, symbol, orders, kind, tickMap) {
if (!orders || !orders.length) {
const hint =
kind === "conditional"
@@ -859,7 +930,11 @@
const oidAttr = esc(o.id || "").replace(/"/g, "&quot;");
const chAttr = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig =
o.trigger_price != null ? fmt(o.trigger_price, 4) : o.price != null ? fmt(o.price, 4) : "—";
o.trigger_price != null
? fmtSymbolPrice(o.trigger_price, symbol, tickMap)
: o.price != null
? fmtSymbolPrice(o.price, symbol, tickMap)
: "—";
return `<tr>
<td>${esc(o.label || o.type || "委托")}</td>
<td>${fmt(o.amount, 4)}</td>
@@ -875,7 +950,7 @@
return inferTpslFromCondOrders(side, cond, entry);
}
function renderOrdersCollapse(exchangeId, symbol, cond, reg) {
function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const orderTotal = cond.length + reg.length;
const collapseKey = ordersCollapseKey(exchangeId, symbol);
@@ -884,8 +959,8 @@
cond.length > 0
? `<button type="button" class="btn-cancel-cond-all btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销条件单</button>`
: "";
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional");
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit");
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap);
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap);
return `<details class="pos-orders-collapse"${openAttr} data-collapse-key="${esc(collapseKey)}">
<summary class="pos-orders-collapse-summary">
<span class="pos-orders-collapse-label">委托单 <em>${orderTotal}</em></span>
@@ -923,7 +998,7 @@
return { sl, tp };
}
function renderExTpslRows(exchangeId, symbol, cond) {
function renderExTpslRows(exchangeId, symbol, cond, tickMap) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const { sl, tp } = pickExTpslOrders(cond);
function row(label, o) {
@@ -932,7 +1007,8 @@
}
const oid = esc(o.id || "").replace(/"/g, "&quot;");
const ch = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig = o.trigger_price != null ? fmt(o.trigger_price, 4) : "—";
const trig =
o.trigger_price != null ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) : "—";
return `<div class="pos-ex-order-row">
<span class="pos-ex-order-main">${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}</span>
<button type="button" class="pos-ex-cancel-btn btn-cancel-order" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oid}" data-channel="${ch}">撤单</button>
@@ -941,7 +1017,42 @@
return row("止损", sl) + row("止盈", tp);
}
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan) {
function trendAddSummaryHtml(t, tickMap) {
const done = t.add_count != null ? t.add_count : t.legs_done;
const total = t.add_count_total != null ? t.add_count_total : t.dca_legs;
const sym = t.exchange_symbol || t.symbol || "";
let html = "";
if (done != null && Number(done) >= 0) {
html += total != null ? ` · 补仓 <strong>${esc(done)}/${esc(total)}</strong>` : ` · 补仓 <strong>${esc(done)}</strong> 次`;
const pxs = t.add_prices_display;
if (Array.isArray(pxs) && pxs.length) {
html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`;
} else if (Array.isArray(t.add_prices) && t.add_prices.length) {
html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`;
} else if (Number(done) === 0) {
html += " · 加仓价 —";
}
}
return html;
}
function renderTrendSection(trends, tickMap) {
if (!trends || !trends.length) return "";
return trends
.map((t) => {
const sym = t.exchange_symbol || t.symbol || "";
const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap);
const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap);
const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap);
return `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}</div>
<div class="hub-mini-line">均价 ${esc(avg)} · SL ${esc(sl)} · TP ${esc(tp)}${trendAddSummaryHtml(t, tickMap)} · 状态 ${esc(t.status || "active")}</div>
</div>`;
})
.join("");
}
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) {
const symbol = pos.symbol || "";
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;");
const side = (pos.side || "long").toLowerCase();
@@ -985,6 +1096,9 @@
meta.push(
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
);
if (trendPlan && trendPlan.id) {
meta.push(`趋势回调${trendAddSummaryHtml(trendPlan, tickMap)}`);
}
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
return `<div class="pos-card hub-pos-card">
@@ -1000,9 +1114,9 @@
</div>
<div class="pos-meta">${meta.map((m) => `<span class="pos-meta-item">${m}</span>`).join("")}</div>
<div class="pos-grid">
<div class="pos-cell"><span class="pos-label">成交价</span><span class="pos-value">${entry != null ? fmt(entry, 4) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${sl != null && sl !== "" ? fmt(sl, 4) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value">${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmt(tp, 4) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">开仓价</span><span class="pos-value">${fmtEntryPrice(pos, tickMap)}</span></div>
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value">${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmtSymbolPrice(tp, symbol, tickMap) : "—"}</span></div>
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${tpMonitored ? "—" : rr != null ? fmt(rr, 2) + ":1" : "-:1"}</span></div>
<div class="pos-cell"><span class="pos-label">张数</span><span class="pos-value">${fmt(pos.contracts, 4)}</span></div>
<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ${pnlCls(upnl)}">${pnlText}</span></div>
@@ -1014,9 +1128,9 @@
</div>
<div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div>
${renderExTpslRows(exchangeId, symbol, cond)}
${renderExTpslRows(exchangeId, symbol, cond, tickMap)}
</div>
${renderOrdersCollapse(exchangeId, symbol, cond, reg)}
${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
</div>`;
}
@@ -1055,43 +1169,32 @@
return `<div class="hub-key-list">${cards}</div>`;
}
function renderOrderMonitorSection(orders) {
function renderOrderMonitorSection(orders, tickMap) {
if (!orders || !orders.length) return "";
return orders
.map(
(o) => `<div class="hub-mini-card">
.map((o) => {
const sym = o.exchange_symbol || o.symbol || "";
return `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)}</div>
<div class="hub-mini-line">触发 ${fmt(o.trigger_price, 4)} · SL ${fmt(o.stop_loss, 4)} · TP ${fmt(o.take_profit, 4)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div>
</div>`
)
<div class="hub-mini-line">触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div>
</div>`;
})
.join("");
}
function renderTrendSection(trends) {
if (!trends || !trends.length) return "";
return trends
.map(
(t) => `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}</div>
<div class="hub-mini-line">SL ${fmt(t.stop_loss, 4)} · TP ${fmt(t.take_profit, 4)} · 状态 ${esc(t.status || "active")}</div>
</div>`
)
.join("");
}
function renderRollSection(rolls) {
function renderRollSection(rolls, tickMap) {
if (!rolls || !rolls.length) return "";
return rolls
.map(
(g) => `<div class="hub-mini-card">
<div class="hub-mini-title">组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}</div>
<div class="hub-mini-line">腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmt(g.current_stop_loss, 4)} · ${esc(g.status || "active")}</div>
<div class="hub-mini-line">腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmtSymbolPrice(g.current_stop_loss, g.symbol, tickMap)} · ${esc(g.status || "active")}</div>
</div>`
)
.join("");
}
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan) {
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap) {
const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;");
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
@@ -1107,10 +1210,11 @@
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
return `<div class="pos-block">
<div class="table-scroll">
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>开仓价</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
<tr>
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${symBeBadge}</td>
<td class="${sideDirCls(x.side)}">${renderDirectionHtml(x.side)}</td>
<td class="td-entry">${fmtEntryPrice(x, tickMap)}</td>
<td>${fmt(x.contracts, 4)}</td>
<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td>
<td class="td-actions">
@@ -1122,11 +1226,12 @@
</tr>
</tbody></table>
</div>
${renderOrdersCollapse(exchangeId, x.symbol, cond, reg)}
${renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap)}
</div>`;
}
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
const tickMap = buildPriceTickMap(row);
let inner = `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 2)}</div></div>
@@ -1140,7 +1245,8 @@
row.key || row.id,
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side)
findTrendPlan(trends, p.symbol, p.side),
tickMap
)
)
.join("");
@@ -1150,7 +1256,8 @@
if (orders.length) {
inner += `<div class="section-title">下单监控 · ${orders.length}</div>`;
orders.forEach((o) => {
inner += `<div class="list-line">${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)} · 触发 ${fmt(o.trigger_price, 4)}</div>`;
const sym = o.exchange_symbol || o.symbol || "";
inner += `<div class="list-line">${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)} · 触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)}</div>`;
});
}
if ((row.capabilities || []).includes("key")) {
@@ -1183,7 +1290,10 @@
if ((row.capabilities || []).includes("trend") && trends.length) {
inner += `<div class="section-title">趋势回调 · ${trends.length}</div>`;
trends.forEach((t) => {
inner += `<div class="list-line">#${t.id} ${esc(t.symbol)} ${renderDirectionHtml(t.direction)} · SL ${t.stop_loss} · TP ${t.take_profit}</div>`;
const sym = t.exchange_symbol || t.symbol || "";
const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap);
const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap);
inner += `<div class="list-line">#${t.id} ${esc(t.symbol)} ${renderDirectionHtml(t.direction)} · 均价 ${esc(t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap))} · SL ${esc(sl)} · TP ${esc(tp)}${trendAddSummaryHtml(t, tickMap)}</div>`;
});
}
if (rolls.length) {
@@ -1197,6 +1307,7 @@
}
function renderFullscreenExchange(row) {
const tickMap = buildPriceTickMap(row);
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const hm = row.hub_monitor || {};
@@ -1241,7 +1352,8 @@
row.key || row.id,
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side)
findTrendPlan(trends, p.symbol, p.side),
tickMap
);
});
} else {
@@ -1259,11 +1371,11 @@
);
}
}
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders), "暂无运行中的下单监控");
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控");
if ((row.capabilities || []).includes("trend")) {
html += renderHubSectionCard("趋势回调", renderTrendSection(trends), "暂无运行中的趋势回调计划");
html += renderHubSectionCard("趋势回调", renderTrendSection(trends, tickMap), "暂无运行中的趋势回调计划");
}
html += renderHubSectionCard("顺势加仓", renderRollSection(rolls), "暂无运行中的顺势加仓组");
html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组");
return html;
}