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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:none;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8"></div>
|
||||
<div class="panel-scroll pos-list pos-list-live">
|
||||
{% for o in order %}
|
||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||
@@ -1866,6 +1867,46 @@ function paintPriceTrend(el, key, value){
|
||||
lastPriceMap[key] = value;
|
||||
}
|
||||
|
||||
function renderOrphanRecoverBanner(orphans){
|
||||
const el = document.getElementById("orphan-position-recover");
|
||||
if(!el) return;
|
||||
const liveCards = document.querySelectorAll(".pos-list-live .pos-card");
|
||||
if(liveCards.length > 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 = `检测到交易所仍有 <strong>${o.symbol}</strong> ${dir}仓,但本地监控已中断(误同步时可能无交易记录)。`;
|
||||
if(mid){
|
||||
const tpslHint = (o.plan_stop_loss && o.plan_take_profit) ? "并挂止盈止损" : "";
|
||||
html += ` <button type="button" class="pos-entrust-btn" onclick="recoverLivePosition(${mid})">恢复监控${tpslHint}</button>`;
|
||||
} 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(()=>{});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user