From e39fac2c1617a5f8ccb2aebbb1362b55f40e12f3 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 4 Jun 2026 19:31:45 +0800 Subject: [PATCH] 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 --- manual_trading_hub/static/app.css | 21 ++ manual_trading_hub/static/app.js | 11 +- manual_trading_hub/static/chart.js | 282 ++++++++++++++++++++++++++- manual_trading_hub/static/index.html | 3 +- manual_trading_hub/行情区说明.md | 3 +- 5 files changed, 313 insertions(+), 7 deletions(-) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 095277b..88f95e7 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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); diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index ecd22e9..eac01ae 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -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, """ ); diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 2b92861..fcee944 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -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); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 1ff8c36..090750e 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -163,6 +163,7 @@ 止损 止盈 张数 + 浮盈亏
@@ -248,7 +249,7 @@
- + diff --git a/manual_trading_hub/行情区说明.md b/manual_trading_hub/行情区说明.md index ec27a05..3cd402a 100644 --- a/manual_trading_hub/行情区说明.md +++ b/manual_trading_hub/行情区说明.md @@ -67,7 +67,8 @@ - **主图**:K 线 + 成交量(Lightweight Charts)。 - **价格轴**:「自动」切换是否跟随最新价缩放。 - **技术指标**(可选勾选):EMA 21/55、MACD、RSI(含 30/70 参考线);副图自上而下为 MACD、RSI。 -- **持仓标记**(从监控跳转时):展示入场、止损、止盈、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。 +- **持仓标记**(从监控跳转时):展示入场、止损、止盈、张数、**浮盈亏**(约 5 秒随监控快照刷新)、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。 +- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈)。须已有有效止盈价(交易所条件单或计划止盈);仅改止损、不改止盈时止盈价沿用当前上下文。 - **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明)。 ---