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
+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);