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:
@@ -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);
|
||||
|
||||
@@ -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, """);
|
||||
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
|
||||
const ctxEnc = esc(encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan))).replace(
|
||||
const ctxEnc = esc(
|
||||
encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId))
|
||||
).replace(
|
||||
/"/g,
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
- **主图**:K 线 + 成交量(Lightweight Charts)。
|
||||
- **价格轴**:「自动」切换是否跟随最新价缩放。
|
||||
- **技术指标**(可选勾选):EMA 21/55、MACD、RSI(含 30/70 参考线);副图自上而下为 MACD、RSI。
|
||||
- **持仓标记**(从监控跳转时):展示入场、止损、止盈、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。
|
||||
- **持仓标记**(从监控跳转时):展示入场、止损、止盈、张数、**浮盈亏**(约 5 秒随监控快照刷新)、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。
|
||||
- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈)。须已有有效止盈价(交易所条件单或计划止盈);仅改止损、不改止盈时止盈价沿用当前上下文。
|
||||
- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明)。
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user