diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index df3f43a..fc34040 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -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(): diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 1b4802a..ff676f0 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -246,6 +246,10 @@ .pos-value.price-flat{color:#e8ecf4} .pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689} .pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + .pos-card-orphan{border-color:#6a5528;background:#1a1810} + .pos-orphan-banner{font-size:.78rem;color:#eac147;background:#2a2418;border:1px solid #6a5528;border-radius:8px;padding:8px 10px;margin-bottom:10px;line-height:1.45} + .pos-relink-btn{padding:6px 12px;background:#3d4a2a;color:#d4e8a8;border:none;border-radius:8px;font-size:.82rem;cursor:pointer} + .pos-relink-btn:hover{filter:brightness(1.08)} @media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}} .stats-card{grid-column:1/-1;margin-top:14px} .stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer} @@ -434,6 +438,12 @@