feat(hub): show floating PnL on market page and drag stop-loss to place TP/SL

Pass unrealized PnL from monitor jump context, refresh from board snapshot, and let users drag the SL price line to call the same place-tpsl API as the monitor entrust dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 19:31:45 +08:00
parent 93e148a3e7
commit e39fac2c16
5 changed files with 313 additions and 7 deletions
+21
View File
@@ -2940,6 +2940,19 @@ body.login-page {
padding: 2px 8px;
}
.market-pos-pnl {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.market-pos-pnl.pnl-up {
color: #3ddc84;
}
.market-pos-pnl.pnl-down {
color: #ff7070;
}
.market-pos-panel .ohlcv-item {
font-weight: 600;
color: var(--text);
@@ -3283,6 +3296,14 @@ html[data-theme="light"] .market-pos-order-price {
color: #9a6b10;
}
html[data-theme="light"] .market-pos-pnl.pnl-up {
color: #0a7a3d;
}
html[data-theme="light"] .market-pos-pnl.pnl-down {
color: #c62828;
}
html[data-theme="light"] .market-pos-clear {
font-weight: 600;
color: var(--text);
+9 -2
View File
@@ -1304,7 +1304,7 @@
};
}
function buildPositionMarketContext(pos, monitorOrder, trendPlan) {
function buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId) {
const tpsl = resolvePositionTpsl(pos, monitorOrder, trendPlan);
const cond = condOrdersFromPosition(pos);
const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : [];
@@ -1330,7 +1330,10 @@
amount: num(o.amount),
});
});
const upnl = resolveTrendFloatingPnl(pos, trendPlan);
return {
exchange_id: exchangeId || null,
symbol: (pos.symbol || "").trim(),
side: (pos.side || "long").toLowerCase(),
entry: num(tpsl.entry),
stop_loss: num(tpsl.sl),
@@ -1338,6 +1341,8 @@
tp_monitored: !!tpsl.tp_monitored,
is_trend: !!tpsl.is_trend,
contracts: num(pos.contracts),
unrealized_pnl: upnl != null ? Number(upnl) : null,
notional_usdt: num(pos.notional_usdt),
orders: orders,
};
}
@@ -1364,7 +1369,9 @@
function marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;");
const ctxEnc = esc(encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan))).replace(
const ctxEnc = esc(
encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId))
).replace(
/"/g,
"&quot;"
);
+279 -3
View File
@@ -82,6 +82,7 @@
const elPosSl = document.getElementById("mkt-pos-sl");
const elPosTp = document.getElementById("mkt-pos-tp");
const elPosSize = document.getElementById("mkt-pos-size");
const elPosPnl = document.getElementById("mkt-pos-pnl");
const elPosOrders = document.getElementById("market-pos-orders");
const elPosClear = document.getElementById("market-pos-clear");
const elChartWrap = document.getElementById("market-chart-wrap");
@@ -123,6 +124,9 @@
let rangeMarkers = [];
let positionLines = [];
let posContext = null;
let posPnlTimer = null;
const SL_DRAG_HIT_PX = 12;
let slDrag = null;
let currentPriceLine = null;
let lastCandles = [];
let candleByTime = {};
@@ -184,6 +188,10 @@
const el = { entry: elPosEntry, sl: elPosSl, tp: elPosTp, size: elPosSize }[k];
if (el) el.textContent = "—";
});
if (elPosPnl) {
elPosPnl.textContent = "—";
elPosPnl.className = "market-pos-pnl";
}
if (elPosOrders) elPosOrders.innerHTML = "";
syncChartWrapLayout();
}
@@ -882,6 +890,253 @@
setChartFullscreen(!chartFullscreen);
}
function showHubToast(msg, isErr) {
const t = document.getElementById("toast");
if (!t) return;
t.textContent = msg;
t.classList.toggle("err", !!isErr);
t.classList.add("show");
clearTimeout(showHubToast._hideTimer);
showHubToast._hideTimer = setTimeout(function () {
t.classList.remove("show");
}, 3500);
}
function formatPosPnlText(ctx) {
const upnl = ctx && ctx.unrealized_pnl;
if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" };
const n = Number(upnl);
let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
const notional = ctx.notional_usdt;
if (notional != null && Number(notional) > 1e-8) {
const pct = (n / Math.abs(Number(notional))) * 100;
text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)";
}
return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" };
}
function paintPosPnl(ctx) {
if (!elPosPnl) return;
const p = formatPosPnlText(ctx);
elPosPnl.textContent = p.text;
elPosPnl.className = "market-pos-pnl " + p.cls;
}
function stopPosPnlPoll() {
if (posPnlTimer) {
clearInterval(posPnlTimer);
posPnlTimer = null;
}
}
function startPosPnlPoll() {
stopPosPnlPoll();
if (!posContext || !posContext.exchange_id) return;
refreshPosPnlFromBoard();
posPnlTimer = setInterval(refreshPosPnlFromBoard, 5000);
}
async function refreshPosPnlFromBoard() {
if (!posContext || !posContext.exchange_id) return;
try {
const r = await fetch("/api/monitor/board/snapshot", { credentials: "same-origin" });
if (!r.ok) return;
const data = await r.json();
const rows = data.rows || [];
const sym = normalizeMarketSymbol(posContext.symbol || "");
const side = (posContext.side || "long").toLowerCase();
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const ex = row.exchange || {};
if (ex.id !== posContext.exchange_id) continue;
const positions = (row.agent && row.agent.positions) || [];
for (let j = 0; j < positions.length; j++) {
const p = positions[j];
if ((p.side || "").toLowerCase() !== side) continue;
if (normalizeMarketSymbol(p.symbol || "") !== sym) continue;
if (p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl))) {
posContext.unrealized_pnl = Number(p.unrealized_pnl);
if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) {
posContext.notional_usdt = Number(p.notional_usdt);
}
paintPosPnl(posContext);
try {
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
} catch (_) {}
}
return;
}
}
} catch (_) {}
}
function resolveTpForPlace(ctx) {
if (!ctx) return null;
const tp = ctx.take_profit;
if (tp != null && Number(tp) > 0) return Number(tp);
const orders = ctx.orders || [];
for (let i = 0; i < orders.length; i++) {
const o = orders[i];
const lbl = String(o.label || "");
if (/止盈/.test(lbl) && o.price != null && Number(o.price) > 0) return Number(o.price);
}
return null;
}
async function placeTpslFromChart(newSl) {
if (!posContext || !posContext.exchange_id) {
showHubToast("缺少交易所信息,无法挂单", true);
return;
}
const sl = roundToTick(newSl);
if (sl == null || !Number.isFinite(sl) || sl <= 0) {
showHubToast("止损价无效", true);
return;
}
const tp = resolveTpForPlace(posContext);
if (tp == null || tp <= 0) {
showHubToast("未找到有效止盈价,请先在监控区用「委托」填写止盈", true);
return;
}
const sym = normalizeMarketSymbol(posContext.symbol || "");
const side = posContext.side || "long";
const contracts = posContext.contracts;
const oldSl = posContext.stop_loss;
if (
!confirm(
"确认 " +
sym +
" " +
side +
"\n先撤销全部条件单,再挂止损 " +
fmtPrice(sl) +
"、止盈 " +
fmtPrice(tp) +
(oldSl != null ? "\n(原止损 " + fmtPrice(oldSl) + "" : "")
)
) {
return;
}
try {
const r = await fetch(
"/api/orders/" + encodeURIComponent(posContext.exchange_id) + "/place-tpsl",
{
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol: sym,
side: side,
stop_loss: sl,
take_profit: tp,
contracts: contracts > 0 ? contracts : null,
}),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
showHubToast(
ok ? "止损已更新(已撤旧条件单并重新挂单)" : pl.error || JSON.stringify(j),
!ok
);
if (ok) {
posContext.stop_loss = sl;
try {
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
} catch (_) {}
if (elPosSl) elPosSl.textContent = fmtPrice(sl);
updatePositionLines();
fetch("/api/monitor/board/refresh", { method: "POST", credentials: "same-origin" });
}
} catch (e) {
showHubToast(String(e.message || e), true);
}
}
function slLineCoordinate() {
if (!candleSeries || !posContext) return null;
const px =
slDrag && slDrag.active && slDrag.previewSl != null
? slDrag.previewSl
: posContext.stop_loss;
if (px == null || !Number.isFinite(Number(px))) return null;
return candleSeries.priceToCoordinate(roundToTick(px));
}
function clientYToChartPrice(clientY) {
if (!candleSeries || !chartHost) return null;
const rect = chartHost.getBoundingClientRect();
const y = clientY - rect.top;
const p = candleSeries.coordinateToPrice(y);
if (p == null || !Number.isFinite(Number(p))) return null;
return roundToTick(p);
}
function isPointerNearSlLine(clientY) {
const coord = slLineCoordinate();
if (coord == null || !chartHost) return false;
const rect = chartHost.getBoundingClientRect();
return Math.abs(clientY - rect.top - coord) <= SL_DRAG_HIT_PX;
}
function onSlLineHover(e) {
if (!chartHost || (slDrag && slDrag.active)) return;
if (!posContext || posContext.stop_loss == null) {
chartHost.style.cursor = "";
return;
}
chartHost.style.cursor = isPointerNearSlLine(e.clientY) ? "ns-resize" : "";
}
function onSlDragStart(e) {
if (!posContext || posContext.stop_loss == null || !candleSeries) return;
if (e.button !== 0) return;
if (!isPointerNearSlLine(e.clientY)) return;
e.preventDefault();
slDrag = {
active: true,
moved: false,
startSl: Number(posContext.stop_loss),
previewSl: Number(posContext.stop_loss),
};
if (chartHost) chartHost.style.cursor = "ns-resize";
updatePositionLines();
}
function onSlDragMove(e) {
if (!slDrag || !slDrag.active) return;
const p = clientYToChartPrice(e.clientY);
if (p == null || p <= 0) return;
slDrag.previewSl = p;
if (Math.abs(p - slDrag.startSl) > 1e-12) slDrag.moved = true;
if (elPosSl) elPosSl.textContent = fmtPrice(p);
updatePositionLines();
}
function onSlDragEnd() {
if (!slDrag || !slDrag.active) {
slDrag = null;
if (chartHost) chartHost.style.cursor = "";
return;
}
const preview = slDrag.previewSl;
const moved = slDrag.moved;
slDrag = null;
if (chartHost) chartHost.style.cursor = "";
updatePositionLines();
if (!moved || preview == null) return;
placeTpslFromChart(preview);
}
function bindSlDrag() {
if (!chartHost) return;
chartHost.addEventListener("mousedown", onSlDragStart);
chartHost.addEventListener("mousemove", onSlLineHover);
document.addEventListener("mousemove", onSlDragMove);
document.addEventListener("mouseup", onSlDragEnd);
}
function renderPosPanel(ctx) {
if (!elPosPanel || !ctx) {
clearPosPanel();
@@ -908,6 +1163,7 @@
}
}
if (elPosSize) elPosSize.textContent = ctx.contracts != null ? String(ctx.contracts) : "—";
paintPosPnl(ctx);
if (elPosOrders) {
const orders = Array.isArray(ctx.orders) ? ctx.orders : [];
if (!orders.length) {
@@ -950,9 +1206,24 @@
function updatePositionLines() {
clearPositionLines();
if (!candleSeries || !posContext) return;
const slPrice =
slDrag && slDrag.active && slDrag.previewSl != null
? slDrag.previewSl
: posContext.stop_loss;
const slTitle =
slDrag && slDrag.active
? "止损 " + fmtPrice(slPrice)
: slPrice != null
? "止损 ⟷"
: "止损";
const specs = [
{ price: posContext.entry, color: "#5b9cf5", title: "入场" },
{ price: posContext.stop_loss, color: "#ff4d6d", title: "止损" },
{ price: posContext.entry, color: "#5b9cf5", title: "入场", lineWidth: 1 },
{
price: slPrice,
color: "#ff4d6d",
title: slTitle,
lineWidth: slPrice != null ? 2 : 1,
},
];
if (posContext.take_profit != null) {
specs.push({
@@ -969,7 +1240,7 @@
candleSeries.createPriceLine({
price: Number(px),
color: s.color,
lineWidth: 1,
lineWidth: s.lineWidth != null ? s.lineWidth : 1,
lineStyle: 2,
axisLabelVisible: true,
title: s.title,
@@ -980,17 +1251,21 @@
function clearPosContext() {
posContext = null;
slDrag = null;
stopPosPnlPoll();
try {
sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY);
} catch (e) {}
clearPosPanel();
clearPositionLines();
if (chartHost) chartHost.style.cursor = "";
}
function applyPosContext(ctx) {
posContext = ctx;
renderPosPanel(ctx);
updatePositionLines();
startPosPnlPoll();
}
function syncPosContextForView(exKey, sym) {
@@ -1779,6 +2054,7 @@
}
function bind() {
bindSlDrag();
if (elRefresh) {
elRefresh.addEventListener("click", function () {
loadChart(true);
+2 -1
View File
@@ -163,6 +163,7 @@
<span class="ohlcv-item"><span class="k">止损</span><span id="mkt-pos-sl"></span></span>
<span class="ohlcv-item"><span class="k">止盈</span><span id="mkt-pos-tp"></span></span>
<span class="ohlcv-item"><span class="k">张数</span><span id="mkt-pos-size"></span></span>
<span class="ohlcv-item"><span class="k">浮盈亏</span><span id="mkt-pos-pnl" class="market-pos-pnl"></span></span>
<button type="button" id="market-pos-clear" class="ghost market-pos-clear" title="清除 K 线上的持仓价格线">清除标记</button>
</div>
<div id="market-pos-orders" class="market-pos-orders"></div>
@@ -248,7 +249,7 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-hub-inst-theme"></script>
<script src="/assets/chart.js?v=20260604-market-pnl-sl-drag"></script>
<script src="/assets/app.js?v=20260604-hub-inst-theme"></script>
</body>
</html>
+2 -1
View File
@@ -67,7 +67,8 @@
- **主图**K 线 + 成交量(Lightweight Charts)。
- **价格轴**:「自动」切换是否跟随最新价缩放。
- **技术指标**(可选勾选):EMA 21/55、MACD、RSI(含 30/70 参考线);副图自上而下为 MACD、RSI。
- **持仓标记**(从监控跳转时):展示入场、止损、止盈、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。
- **持仓标记**(从监控跳转时):展示入场、止损、止盈、张数、**浮盈亏**(约 5 秒随监控快照刷新)、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。
- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈)。须已有有效止盈价(交易所条件单或计划止盈);仅改止损、不改止盈时止盈价沿用当前上下文。
- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明)。
---