From 7fe7c2e91871b5462636f670189d54580bdbe6a0 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 16 Jun 2026 16:54:38 +0800 Subject: [PATCH] feat(binance): recover live position when local monitor was lost Detect exchange positions without active order_monitors, show a recover banner on the trade page, and reactivate stopped monitors with optional TP/SL restore via API. Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 227 ++++++++++++++++++++ crypto_monitor_binance/templates/index.html | 42 ++++ 2 files changed, 269 insertions(+) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 4a0a4cd..ccaa1b1 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -3700,6 +3700,200 @@ def get_live_position_contracts(exchange_symbol, direction): return total +def _infer_position_direction_from_row(position_dict): + if not position_dict: + return "long" + info = position_dict.get("info") or {} + ps = str( + info.get("positionSide") + or position_dict.get("side") + or info.get("posSide") + or "" + ).strip().lower() + if ps in ("long", "short"): + return ps + for key in ("positionAmt", "pos", "size"): + v = info.get(key) + if v is None or v == "": + continue + try: + amt = float(v) + if amt > 0: + return "long" + if amt < 0: + return "short" + except (TypeError, ValueError): + continue + side = str(position_dict.get("side") or "").strip().lower() + if side in ("long", "short"): + return side + return "long" + + +def _monitor_symbol_from_ccxt_symbol(ccxt_symbol): + s = str(ccxt_symbol or "").strip() + if ":" in s: + return s.split(":")[0].upper() + return s.upper() + + +def _fetch_nonempty_live_position_rows(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + try: + rows = exchange.fetch_positions() or [] + except Exception: + return [] + out = [] + for p in rows: + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + ex_sym = p.get("symbol") + if not ex_sym: + continue + direction = _infer_position_direction_from_row(p) + out.append( + { + "exchange_symbol": normalize_exchange_symbol(str(ex_sym)), + "monitor_symbol": _monitor_symbol_from_ccxt_symbol(ex_sym), + "direction": direction, + "contracts": contracts, + "position_row": p, + } + ) + return out + + +def _find_inactive_monitor_for_live(conn, exchange_symbol, monitor_symbol, direction): + direction = (direction or "long").strip().lower() + norm_ex = normalize_exchange_symbol(exchange_symbol or monitor_symbol) + rows = conn.execute( + """ + SELECT * FROM order_monitors + WHERE status IN ('stopped', 'error') AND direction=? + ORDER BY id DESC + LIMIT 20 + """, + (direction,), + ).fetchall() + for r in rows: + row_ex = normalize_exchange_symbol(r["exchange_symbol"] or r["symbol"]) + if row_ex == norm_ex: + return r + row_sym = str(r["symbol"] or "").strip().upper() + if row_sym and row_sym == str(monitor_symbol or "").strip().upper(): + return r + return None + + +def list_orphan_live_positions(conn): + """交易所有仓、但无对应 active 监控的持仓(可尝试恢复本地监控)。""" + live_rows = _fetch_nonempty_live_position_rows() + if not live_rows: + return [] + active_keys = set() + for r in conn.execute( + "SELECT symbol, exchange_symbol, direction FROM order_monitors WHERE status='active'" + ): + ex = normalize_exchange_symbol(r["exchange_symbol"] or r["symbol"]) + active_keys.add((ex, (r["direction"] or "long").strip().lower())) + + from hub_position_metrics import parse_position_entry_price + + orphans = [] + for lp in live_rows: + key = (lp["exchange_symbol"], lp["direction"]) + if key in active_keys: + continue + mon = _find_inactive_monitor_for_live( + conn, lp["exchange_symbol"], lp["monitor_symbol"], lp["direction"] + ) + entry = parse_position_entry_price(lp["position_row"]) + item = { + "exchange_symbol": lp["exchange_symbol"], + "symbol": lp["monitor_symbol"], + "direction": lp["direction"], + "contracts": lp["contracts"], + "entry_price": entry, + "recoverable_monitor_id": int(mon["id"]) if mon else None, + "plan_stop_loss": float(mon["stop_loss"]) if mon and mon["stop_loss"] else None, + "plan_take_profit": float(mon["take_profit"]) if mon and mon["take_profit"] else None, + "monitor_status": mon["status"] if mon else None, + } + orphans.append(item) + return orphans + + +def recover_live_position_monitor(conn, monitor_id=None, place_tpsl=True): + orphans = list_orphan_live_positions(conn) + if not orphans: + return False, "未检测到「交易所有仓但未在监控」的持仓", None + + row = None + if monitor_id is not None: + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (int(monitor_id),)).fetchone() + if not row: + return False, "监控记录不存在", None + if row["status"] == "active": + return True, "该监控已在实时持仓中", int(row["id"]) + ex_sym = normalize_exchange_symbol(row["exchange_symbol"] or row["symbol"]) + direction = (row["direction"] or "long").strip().lower() + matched = any(o["exchange_symbol"] == ex_sym and o["direction"] == direction for o in orphans) + if not matched: + live = get_live_position_contracts(ex_sym, direction) + if live is None: + return False, "暂时无法读取交易所持仓,请稍后重试", None + if live <= 0: + return False, "交易所该方向已无持仓,无法恢复", None + else: + for o in orphans: + rid = o.get("recoverable_monitor_id") + if not rid: + continue + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (int(rid),)).fetchone() + if row: + break + if not row: + o = orphans[0] + dir_zh = "多" if o["direction"] == "long" else "空" + return ( + False, + f"检测到 {o['symbol']} {dir_zh}仓,但无匹配的已停监控记录(可能已被删除),需在数据库手动处理", + None, + ) + + if get_active_position_count(conn) >= MAX_ACTIVE_POSITIONS: + return False, f"已达最大持仓数({MAX_ACTIVE_POSITIONS})", None + + ex_sym = resolve_monitor_exchange_symbol(row) + live = get_live_position_contracts(ex_sym, row["direction"]) + if live is None: + return False, "暂时无法读取交易所持仓,请稍后重试", None + if live <= 0: + return False, "交易所该方向已无持仓,无法恢复监控", None + + oid = int(row["id"]) + conn.execute( + "UPDATE order_monitors SET status='active', exchange_close_order_id=NULL WHERE id=?", + (oid,), + ) + conn.commit() + + tpsl_msg = "" + if place_tpsl and row["stop_loss"] and row["take_profit"]: + ok_live, _live_reason = ensure_exchange_live_ready() + if ok_live: + try: + replace_active_monitor_tpsl_on_exchange(row, row["stop_loss"], row["take_profit"]) + tpsl_msg = ",并已重新挂止盈止损" + except Exception as e: + tpsl_msg = f"。监控已恢复,但挂止盈止损失败:{friendly_exchange_error(e)}" + + return True, f"已恢复实时监控{tpsl_msg}", oid + + def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" if not rows: @@ -7066,6 +7260,8 @@ def api_price_snapshot(): pass order_prices.append(payload) + orphan_live_positions = list_orphan_live_positions(conn) if exchange_private_api_configured() else [] + try: conn.commit() except Exception: @@ -7085,6 +7281,7 @@ def api_price_snapshot(): "order_prices": order_prices, "position_marks": position_marks, "positions_raw_count": len(all_swap_positions), + "orphan_live_positions": orphan_live_positions, }) @@ -7177,6 +7374,36 @@ def api_order_place_tpsl(order_id): ) +@app.route("/api/orphan_live_positions") +@login_required +def api_orphan_live_positions(): + conn = get_db() + orphans = list_orphan_live_positions(conn) + conn.close() + return jsonify({"ok": True, "orphan_live_positions": orphans}) + + +@app.route("/api/recover_live_position", methods=["POST"]) +@login_required +def api_recover_live_position(): + data = request.get_json(silent=True) or {} + monitor_id = data.get("monitor_id") + if monitor_id is not None: + try: + monitor_id = int(monitor_id) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "monitor_id 无效"}), 400 + place_tpsl = data.get("place_tpsl", True) + if isinstance(place_tpsl, str): + place_tpsl = place_tpsl.lower() not in ("0", "false", "no") + conn = get_db() + ok, msg, oid = recover_live_position_monitor(conn, monitor_id=monitor_id, place_tpsl=bool(place_tpsl)) + conn.close() + if not ok: + return jsonify({"ok": False, "msg": msg}), 400 + return jsonify({"ok": True, "msg": msg, "monitor_id": oid}) + + @app.route("/api/symbol_liquidity_rank") @login_required def api_symbol_liquidity_rank(): diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index eed438e..7bc602b 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -402,6 +402,7 @@

实时持仓

+
{% for o in order %}
0 || !orphans || !orphans.length){ + el.style.display = "none"; + el.innerHTML = ""; + return; + } + const o = orphans[0]; + const dir = o.direction === "short" ? "空" : "多"; + const mid = o.recoverable_monitor_id; + let html = `检测到交易所仍有 ${o.symbol} ${dir}仓,但本地监控已中断(误同步时可能无交易记录)。`; + if(mid){ + const tpslHint = (o.plan_stop_loss && o.plan_take_profit) ? "并挂止盈止损" : ""; + html += ` `; + } else { + html += " 未找到可恢复的监控记录,需在服务器数据库处理。"; + } + el.innerHTML = html; + el.style.display = "block"; +} + +async function recoverLivePosition(monitorId){ + const withTpsl = confirm("确认恢复本地实时监控?若原计划有止盈止损,将尝试重新挂到交易所。"); + if(!withTpsl) return; + try{ + const res = await fetch("/api/recover_live_position", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({monitor_id: monitorId, place_tpsl: true}) + }); + const data = await res.json(); + alert(data.msg || (data.ok ? "已恢复" : "失败")); + if(data.ok) location.reload(); + }catch(e){ + alert("恢复失败:" + e); + } +} + function refreshPriceSnapshot(){ fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); @@ -1941,6 +1982,7 @@ function refreshPriceSnapshot(){ paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); }); + renderOrphanRecoverBanner(data.orphan_live_positions); }).catch(()=>{}); }