From e6361a7fcca1ed9b3389f47bfc758e25849bf0db Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 4 Jun 2026 20:02:23 +0800 Subject: [PATCH] fix(hub): live market PnL and Gate drag SL place Parse Gate unrealised_pnl in agent; refresh hub market floating PnL from board and trends; clamp Gate TP/SL triggers before place. Co-authored-by: Cursor --- hub_position_metrics.py | 23 ++++++ manual_trading_hub/agent.py | 8 +- manual_trading_hub/exchange_orders.py | 57 ++++++++++++++ manual_trading_hub/static/app.js | 5 ++ manual_trading_hub/static/chart.js | 108 +++++++++++++++++++++++--- manual_trading_hub/static/index.html | 4 +- tests/test_hub_agent_mark_price.py | 9 +++ 7 files changed, 197 insertions(+), 17 deletions(-) diff --git a/hub_position_metrics.py b/hub_position_metrics.py index adccb61..906e19e 100644 --- a/hub_position_metrics.py +++ b/hub_position_metrics.py @@ -56,6 +56,29 @@ def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) - return "long" +def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None: + """四所 ccxt 持仓统一解析未实现盈亏(Gate 常在 info.unrealised_pnl)。""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + for key in ( + p.get("unrealizedPnl"), + p.get("unrealisedPnl"), + p.get("unrealized_pnl"), + p.get("unrealised_pnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + info.get("unrealisedPnl"), + info.get("unrealizedPnl"), + ): + px = _finite_or_none(key) + if px is not None: + return px + return None + + def parse_position_mark_price(p: dict[str, Any]) -> float | None: """四所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致)。""" if not isinstance(p, dict): diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index 16302d2..c0cccaa 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -31,7 +31,7 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) from hub_ohlcv_lib import format_price_by_tick, price_tick_from_market -from hub_position_metrics import parse_position_mark_price +from hub_position_metrics import parse_position_mark_price, parse_position_unrealized_pnl import ccxt from fastapi import FastAPI, Header, HTTPException, Request @@ -602,10 +602,8 @@ def _status_inner(x_control_token: str | None) -> Any: continue sym = p.get("symbol") or "" side = _position_side(p, c) - upnl = p.get("unrealizedPnl") - try: - upnl_f = float(upnl) if upnl is not None else 0.0 - except (TypeError, ValueError): + upnl_f = parse_position_unrealized_pnl(p) + if upnl_f is None: upnl_f = 0.0 total_upnl += upnl_f notional = p.get("notional") diff --git a/manual_trading_hub/exchange_orders.py b/manual_trading_hub/exchange_orders.py index 8178b5f..a194a1c 100644 --- a/manual_trading_hub/exchange_orders.py +++ b/manual_trading_hub/exchange_orders.py @@ -725,6 +725,62 @@ def _gate_td_mode_cross() -> bool: return td in ("cross", "cross_margin") +def _gate_last_price(ex: Any, symbol: str) -> float | None: + ex.load_markets() + unified = ex.market(symbol)["symbol"] + try: + t = ex.fetch_ticker(unified) + except Exception: + return None + if not isinstance(t, dict): + return None + info = t.get("info") if isinstance(t.get("info"), dict) else {} + for key in ("last", "mark", "close", "index_price"): + v = t.get(key) if key in t else info.get(key) + try: + f = float(v) + if f > 0: + return f + except (TypeError, ValueError): + continue + return None + + +def _gate_clamp_tpsl_prices( + ex: Any, + symbol: str, + direction: str, + stop_loss: float, + take_profit: float, +) -> tuple[float, float]: + """ + Gate price_orders:空仓止损/多仓止盈 trigger>last;空仓止盈/多仓止损 trigger= last: + tp = float(ex.price_to_precision(unified, last * (1 - gap))) + else: + if sl >= last: + sl = float(ex.price_to_precision(unified, last * (1 - gap))) + if tp <= last: + tp = float(ex.price_to_precision(unified, last * (1 + gap))) + return sl, tp + + def _gate_place_tp_sl( ex: Any, symbol: str, @@ -794,6 +850,7 @@ def replace_position_tpsl( td = (os.getenv("OKX_TD_MODE") or "cross").strip() _okx_place_tp_sl(ex, symbol, direction, amt, sl, tp, pos_mode=pm, td_mode=td) else: + sl, tp = _gate_clamp_tpsl_prices(ex, symbol, direction, sl, tp) _gate_place_tp_sl(ex, symbol, direction, amt, sl, tp) return { "symbol": symbol, diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index ac96de9..4670b48 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -1377,6 +1377,10 @@ }); }); const upnl = resolveTrendFloatingPnl(pos, trendPlan); + const planMargin = + trendPlan && trendPlan.plan_margin_capital != null + ? num(trendPlan.plan_margin_capital) + : null; return { exchange_id: exchangeId || null, symbol: (pos.symbol || "").trim(), @@ -1389,6 +1393,7 @@ contracts: num(pos.contracts), unrealized_pnl: upnl != null ? Number(upnl) : null, notional_usdt: num(pos.notional_usdt), + plan_margin: planMargin, orders: orders, }; } diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index fcee944..526ebe0 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -907,14 +907,78 @@ if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; const n = Number(upnl); let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + const margin = ctx.plan_margin; const notional = ctx.notional_usdt; - if (notional != null && Number(notional) > 1e-8) { + if (margin != null && Number(margin) > 1e-8) { + const pct = (n / Number(margin)) * 100; + text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; + } else 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 findTrendFloatingPnl(row, sym, side) { + const hm = row.hub_monitor; + if (!hm || !Array.isArray(hm.trends)) return null; + for (let i = 0; i < hm.trends.length; i++) { + const t = hm.trends[i]; + const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); + if (ts !== sym) continue; + if ((t.direction || "").toLowerCase() !== side) continue; + const fp = t.floating_pnl; + if (fp != null && Number.isFinite(Number(fp))) return Number(fp); + if (t.plan_margin_capital != null && Number(t.plan_margin_capital) > 0) { + /* 保留 plan_margin 供百分比 */ + } + } + return null; + } + + function findTrendPlanMargin(row, sym, side) { + const hm = row.hub_monitor; + if (!hm || !Array.isArray(hm.trends)) return null; + for (let i = 0; i < hm.trends.length; i++) { + const t = hm.trends[i]; + const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); + if (ts !== sym) continue; + if ((t.direction || "").toLowerCase() !== side) continue; + const m = t.plan_margin_capital; + if (m != null && Number.isFinite(Number(m)) && Number(m) > 0) return Number(m); + } + return null; + } + + function syncPosTpslFromAgentPosition(p) { + if (!posContext || !p) return; + const et = p.exchange_tpsl; + if (et && typeof et === "object") { + if (et.sl && et.sl.trigger_price != null) { + posContext.stop_loss = Number(et.sl.trigger_price); + } + if (et.tp && et.tp.trigger_price != null) { + posContext.take_profit = Number(et.tp.trigger_price); + posContext.tp_monitored = false; + } + } + const cond = Array.isArray(p.conditional_orders) ? p.conditional_orders : []; + for (let i = 0; i < cond.length; i++) { + const o = cond[i]; + const lbl = String(o.label || ""); + const px = + o.trigger_price != null && Number.isFinite(Number(o.trigger_price)) + ? Number(o.trigger_price) + : null; + if (px == null) continue; + if (/^止损/.test(lbl)) posContext.stop_loss = px; + else if (/^止盈/.test(lbl) && !/止盈止损/.test(lbl)) { + posContext.take_profit = px; + posContext.tp_monitored = false; + } + } + } + function paintPosPnl(ctx) { if (!elPosPnl) return; const p = formatPosPnlText(ctx); @@ -949,23 +1013,46 @@ const row = rows[i]; const ex = row.exchange || {}; if (ex.id !== posContext.exchange_id) continue; + const planMargin = findTrendPlanMargin(row, sym, side); + if (planMargin != null) posContext.plan_margin = planMargin; 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 (_) {} + let upnl = + p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl)) + ? Number(p.unrealized_pnl) + : null; + if (upnl == null) upnl = findTrendFloatingPnl(row, sym, side); + if (upnl == null) return; + posContext.unrealized_pnl = upnl; + if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) { + posContext.notional_usdt = Number(p.notional_usdt); } + syncPosTpslFromAgentPosition(p); + if (elPosSl && posContext.stop_loss != null) { + elPosSl.textContent = fmtPrice(posContext.stop_loss); + } + if (elPosTp && posContext.take_profit != null && !posContext.tp_monitored) { + elPosTp.textContent = fmtPrice(posContext.take_profit); + } + paintPosPnl(posContext); + updatePositionLines(); + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} return; } + const trendUpnl = findTrendFloatingPnl(row, sym, side); + if (trendUpnl != null) { + posContext.unrealized_pnl = trendUpnl; + paintPosPnl(posContext); + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} + } + return; } } catch (_) {} } @@ -2012,6 +2099,7 @@ applyPriceAutoScale(); updateVisibleRangeMarkers(); syncPosContextForView(exKey, sym); + if (posContext) refreshPosPnlFromBoard(); showLatestOhlcv(); try { updateIndicators(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 6191d46..62a550d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -249,7 +249,7 @@
- - + + diff --git a/tests/test_hub_agent_mark_price.py b/tests/test_hub_agent_mark_price.py index 20fc464..96ab1c1 100644 --- a/tests/test_hub_agent_mark_price.py +++ b/tests/test_hub_agent_mark_price.py @@ -10,6 +10,9 @@ sys.path.insert(0, str(ROOT / "manual_trading_hub")) from agent import _position_mark_price, _ticker_mark_price # noqa: E402 +sys.path.insert(0, str(ROOT)) +from hub_position_metrics import parse_position_unrealized_pnl # noqa: E402 + class TestHubAgentMarkPrice(unittest.TestCase): def test_binance_mark_price(self): @@ -39,6 +42,12 @@ class TestHubAgentMarkPrice(unittest.TestCase): self.assertAlmostEqual(_ticker_mark_price(_Ex(), "BTC/USDT:USDT"), 99.5) + def test_gate_unrealised_pnl_in_info(self): + pnl = parse_position_unrealized_pnl( + {"info": {"unrealised_pnl": "6.81"}, "unrealizedPnl": None} + ) + self.assertAlmostEqual(pnl, 6.81) + if __name__ == "__main__": unittest.main()