fix(gate-bot): show orphan exchange positions and relink monitor

When the exchange still has a position but order_monitors is not active, surface it on the trade page and allow restoring the latest stopped record.
This commit is contained in:
dekun
2026-06-04 16:17:34 +08:00
parent e327f1b1fb
commit 1618ef8668
2 changed files with 186 additions and 2 deletions
+138
View File
@@ -3601,6 +3601,88 @@ def get_live_position_exchange_metrics(exchange_symbol, direction):
return parse_ccxt_position_metrics(p)
def _fetch_all_swap_positions_live():
if not exchange_private_api_configured():
return []
ensure_markets_loaded()
try:
return exchange.fetch_positions(None, {"settle": "usdt"}) or []
except Exception:
try:
return exchange.fetch_positions() or []
except Exception:
return []
def _active_monitor_position_keys(active_orders):
covered = set()
for o in active_orders or []:
sym = (o.get("symbol") or "").strip()
ex = (o.get("exchange_symbol") or normalize_exchange_symbol(sym)).strip()
direction = (o.get("direction") or "long").lower()
for s in (ex, sym, _unified_symbol_for_match(ex), _unified_symbol_for_match(sym)):
if s:
covered.add((s, direction))
return covered
def collect_orphan_exchange_positions(active_orders):
"""交易所有持仓但未匹配 order_monitors.status=active(与中控 Agent 持仓对齐提示)。"""
from hub_position_metrics import (
parse_position_mark_price,
position_contracts,
position_side_from_ccxt,
)
rows = _fetch_all_swap_positions_live()
if not rows:
return []
covered = _active_monitor_position_keys(active_orders)
orphans = []
seen = set()
for p in rows:
contracts = position_contracts(p)
if abs(contracts) < 1e-12:
continue
ex_sym = (p.get("symbol") or "").strip()
if not ex_sym:
continue
direction = position_side_from_ccxt(p, contracts)
match_keys = (
(ex_sym, direction),
(_unified_symbol_for_match(ex_sym), direction),
)
if any(k in covered for k in match_keys):
continue
dedupe = (ex_sym, direction)
if dedupe in seen:
continue
seen.add(dedupe)
metrics = parse_ccxt_position_metrics(p) or {}
info = p.get("info") or {}
entry = _coerce_float(
p.get("entryPrice"),
p.get("entry_price"),
info.get("entry_price"),
info.get("avgEntryPrice"),
)
mark = parse_position_mark_price(p) or metrics.get("mark_price")
sym = normalize_symbol_input(ex_sym.split(":")[0] if ":" in ex_sym else ex_sym)
orphans.append(
{
"symbol": sym,
"exchange_symbol": ex_sym,
"direction": direction,
"contracts": round(abs(float(contracts)), 6),
"entry_price": entry,
"mark_price": mark,
"unrealized_pnl": metrics.get("unrealized_pnl"),
"initial_margin": metrics.get("initial_margin"),
}
)
return orphans
def _unified_symbol_for_match(symbol_str):
"""统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。"""
x = (symbol_str or "").strip().upper()
@@ -5543,6 +5625,12 @@ def render_main_page(page="trade"):
default_risk_percent=float(RISK_PERCENT),
count_active_trends=lambda c, ta=trend_active: int(ta or 0),
)
orphan_positions: list = []
if page == "trade":
try:
orphan_positions = collect_orphan_exchange_positions(order_list)
except Exception as exc:
print(f"[render_main_page] orphan positions: {exc}")
conn.close()
return render_template(
"index.html",
@@ -5551,6 +5639,7 @@ def render_main_page(page="trade"):
key_history=key_history,
stats_bundle=stats_bundle,
order=order_list,
orphan_positions=orphan_positions,
record=records,
total=total,
miss_count=miss_count,
@@ -5982,6 +6071,55 @@ def api_symbol_liquidity_rank():
)
@app.route("/api/order/relink_orphan", methods=["POST"])
@login_required
def api_order_relink_orphan():
"""交易所有仓但本地无 active 监控时,恢复最近一条已停止的同向监控记录。"""
data = request.get_json(silent=True) or {}
symbol = normalize_symbol_input(data.get("symbol"))
direction = (data.get("direction") or "long").strip().lower()
if not symbol:
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
if direction not in ("long", "short"):
direction = "long"
ok, reason = ensure_exchange_live_ready()
if not ok:
return jsonify({"ok": False, "msg": reason}), 400
exchange_symbol = normalize_exchange_symbol(symbol)
contracts = get_live_position_contracts(exchange_symbol, direction)
if contracts is None or float(contracts) <= 0:
return jsonify({"ok": False, "msg": "交易所当前无该方向持仓,无法恢复监控"}), 400
conn = get_db()
active = conn.execute(
"SELECT id FROM order_monitors WHERE status='active' AND symbol=? AND direction=? LIMIT 1",
(symbol, direction),
).fetchone()
if active:
conn.close()
return jsonify({"ok": True, "msg": "已有运行中的监控", "order_id": int(active["id"])})
row = conn.execute(
"""
SELECT * FROM order_monitors
WHERE symbol=? AND direction=? AND status IN ('stopped', 'error')
ORDER BY id DESC LIMIT 1
""",
(symbol, direction),
).fetchone()
if not row:
conn.close()
return jsonify(
{
"ok": False,
"msg": "未找到可恢复的历史监控记录,请在中控核对持仓或联系管理员",
}
), 404
conn.execute("UPDATE order_monitors SET status='active' WHERE id=?", (int(row["id"]),))
conn.commit()
oid = int(row["id"])
conn.close()
return jsonify({"ok": True, "msg": "已恢复本地监控", "order_id": oid})
@app.route("/api/order_defaults")
@login_required
def api_order_defaults():