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 <cursoragent@cursor.com>
This commit is contained in:
@@ -56,6 +56,29 @@ def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) -
|
|||||||
return "long"
|
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:
|
def parse_position_mark_price(p: dict[str, Any]) -> float | None:
|
||||||
"""四所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致)。"""
|
"""四所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致)。"""
|
||||||
if not isinstance(p, dict):
|
if not isinstance(p, dict):
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ _REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
if str(_REPO_ROOT) not in sys.path:
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_REPO_ROOT))
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
from hub_ohlcv_lib import format_price_by_tick, price_tick_from_market
|
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
|
import ccxt
|
||||||
from fastapi import FastAPI, Header, HTTPException, Request
|
from fastapi import FastAPI, Header, HTTPException, Request
|
||||||
@@ -602,10 +602,8 @@ def _status_inner(x_control_token: str | None) -> Any:
|
|||||||
continue
|
continue
|
||||||
sym = p.get("symbol") or ""
|
sym = p.get("symbol") or ""
|
||||||
side = _position_side(p, c)
|
side = _position_side(p, c)
|
||||||
upnl = p.get("unrealizedPnl")
|
upnl_f = parse_position_unrealized_pnl(p)
|
||||||
try:
|
if upnl_f is None:
|
||||||
upnl_f = float(upnl) if upnl is not None else 0.0
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
upnl_f = 0.0
|
upnl_f = 0.0
|
||||||
total_upnl += upnl_f
|
total_upnl += upnl_f
|
||||||
notional = p.get("notional")
|
notional = p.get("notional")
|
||||||
|
|||||||
@@ -725,6 +725,62 @@ def _gate_td_mode_cross() -> bool:
|
|||||||
return td in ("cross", "cross_margin")
|
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。
|
||||||
|
"""
|
||||||
|
last = _gate_last_price(ex, symbol)
|
||||||
|
if last is None or last <= 0:
|
||||||
|
return float(stop_loss), float(take_profit)
|
||||||
|
ex.load_markets()
|
||||||
|
unified = ex.market(symbol)["symbol"]
|
||||||
|
gap_pct = float(os.getenv("GATE_TPSL_LAST_PRICE_GAP_PCT", "0.05") or "0.05")
|
||||||
|
gap = max(0.0, gap_pct) / 100.0
|
||||||
|
if gap <= 0:
|
||||||
|
gap = 0.0005
|
||||||
|
sl = float(stop_loss)
|
||||||
|
tp = float(take_profit)
|
||||||
|
d = (direction or "long").strip().lower()
|
||||||
|
if d == "short":
|
||||||
|
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)))
|
||||||
|
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(
|
def _gate_place_tp_sl(
|
||||||
ex: Any,
|
ex: Any,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -794,6 +850,7 @@ def replace_position_tpsl(
|
|||||||
td = (os.getenv("OKX_TD_MODE") or "cross").strip()
|
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)
|
_okx_place_tp_sl(ex, symbol, direction, amt, sl, tp, pos_mode=pm, td_mode=td)
|
||||||
else:
|
else:
|
||||||
|
sl, tp = _gate_clamp_tpsl_prices(ex, symbol, direction, sl, tp)
|
||||||
_gate_place_tp_sl(ex, symbol, direction, amt, sl, tp)
|
_gate_place_tp_sl(ex, symbol, direction, amt, sl, tp)
|
||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
|
|||||||
@@ -1377,6 +1377,10 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const upnl = resolveTrendFloatingPnl(pos, trendPlan);
|
const upnl = resolveTrendFloatingPnl(pos, trendPlan);
|
||||||
|
const planMargin =
|
||||||
|
trendPlan && trendPlan.plan_margin_capital != null
|
||||||
|
? num(trendPlan.plan_margin_capital)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
exchange_id: exchangeId || null,
|
exchange_id: exchangeId || null,
|
||||||
symbol: (pos.symbol || "").trim(),
|
symbol: (pos.symbol || "").trim(),
|
||||||
@@ -1389,6 +1393,7 @@
|
|||||||
contracts: num(pos.contracts),
|
contracts: num(pos.contracts),
|
||||||
unrealized_pnl: upnl != null ? Number(upnl) : null,
|
unrealized_pnl: upnl != null ? Number(upnl) : null,
|
||||||
notional_usdt: num(pos.notional_usdt),
|
notional_usdt: num(pos.notional_usdt),
|
||||||
|
plan_margin: planMargin,
|
||||||
orders: orders,
|
orders: orders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -907,14 +907,78 @@
|
|||||||
if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" };
|
if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" };
|
||||||
const n = Number(upnl);
|
const n = Number(upnl);
|
||||||
let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||||
|
const margin = ctx.plan_margin;
|
||||||
const notional = ctx.notional_usdt;
|
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;
|
const pct = (n / Math.abs(Number(notional))) * 100;
|
||||||
text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)";
|
text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)";
|
||||||
}
|
}
|
||||||
return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" };
|
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) {
|
function paintPosPnl(ctx) {
|
||||||
if (!elPosPnl) return;
|
if (!elPosPnl) return;
|
||||||
const p = formatPosPnlText(ctx);
|
const p = formatPosPnlText(ctx);
|
||||||
@@ -949,23 +1013,46 @@
|
|||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
const ex = row.exchange || {};
|
const ex = row.exchange || {};
|
||||||
if (ex.id !== posContext.exchange_id) continue;
|
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) || [];
|
const positions = (row.agent && row.agent.positions) || [];
|
||||||
for (let j = 0; j < positions.length; j++) {
|
for (let j = 0; j < positions.length; j++) {
|
||||||
const p = positions[j];
|
const p = positions[j];
|
||||||
if ((p.side || "").toLowerCase() !== side) continue;
|
if ((p.side || "").toLowerCase() !== side) continue;
|
||||||
if (normalizeMarketSymbol(p.symbol || "") !== sym) continue;
|
if (normalizeMarketSymbol(p.symbol || "") !== sym) continue;
|
||||||
if (p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl))) {
|
let upnl =
|
||||||
posContext.unrealized_pnl = Number(p.unrealized_pnl);
|
p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl))
|
||||||
if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) {
|
? Number(p.unrealized_pnl)
|
||||||
posContext.notional_usdt = Number(p.notional_usdt);
|
: null;
|
||||||
}
|
if (upnl == null) upnl = findTrendFloatingPnl(row, sym, side);
|
||||||
paintPosPnl(posContext);
|
if (upnl == null) return;
|
||||||
try {
|
posContext.unrealized_pnl = upnl;
|
||||||
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
|
if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) {
|
||||||
} catch (_) {}
|
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;
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -2012,6 +2099,7 @@
|
|||||||
applyPriceAutoScale();
|
applyPriceAutoScale();
|
||||||
updateVisibleRangeMarkers();
|
updateVisibleRangeMarkers();
|
||||||
syncPosContextForView(exKey, sym);
|
syncPosContextForView(exKey, sym);
|
||||||
|
if (posContext) refreshPosPnlFromBoard();
|
||||||
showLatestOhlcv();
|
showLatestOhlcv();
|
||||||
try {
|
try {
|
||||||
updateIndicators();
|
updateIndicators();
|
||||||
|
|||||||
@@ -249,7 +249,7 @@
|
|||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
<script src="/assets/chart.js?v=20260604-market-pnl-sl-drag"></script>
|
<script src="/assets/chart.js?v=20260604-market-pnl-live"></script>
|
||||||
<script src="/assets/app.js?v=20260604-hub-cond-dedupe"></script>
|
<script src="/assets/app.js?v=20260604-market-pnl-plan"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
|||||||
|
|
||||||
from agent import _position_mark_price, _ticker_mark_price # noqa: E402
|
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):
|
class TestHubAgentMarkPrice(unittest.TestCase):
|
||||||
def test_binance_mark_price(self):
|
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)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user