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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user