From 989accdc50fdbe085231920ef843cdad18d31c67 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 14 Oct 2026 16:49:24 +0800 Subject: [PATCH] Wire manual close to actually flatten option then perp then sell coin. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI 平仓 now calls /api/hedge-plan//close instead of end-only (不平仓). Co-authored-by: Cursor --- lib/common/static/hedge_plan.js | 13 +- lib/hedge_plan/hedge_plan_orders_lib.py | 272 +++++++++++++++++- lib/hedge_plan/hedge_plan_register.py | 25 +- .../templates/hedge_plan_panel.html | 2 +- 4 files changed, 304 insertions(+), 8 deletions(-) diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 2f79580..2738e9f 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -2920,26 +2920,29 @@ async function endActivePlan(planId) { if ( !window.confirm( - "确认平仓并结束计划 #" + + "确认平仓 #" + planId + - "?\n未成交/待补腿将取消。\n说明:当前为结束计划标记,交易所持仓请自行确认已平。" + "?\n将按顺序:平期权(须买一流动性)→平永续→卖币回 USDT。\n买一不足时会中止并保留计划。" ) ) { return; } try { - const d = await apiJson("/api/hedge-plan/" + planId + "/end", { + const d = await apiJson("/api/hedge-plan/" + planId + "/close", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); - alert(d.msg || "计划已结束 #" + (d.plan_id || planId)); + alert(d.msg || "已平仓 #" + (d.plan_id || planId)); + void loadLivePositions(); void loadActivePlans(); void loadHistory(); void loadGates(); void refreshPoStrategyStatus(); } catch (e) { alert(e.message || String(e)); + void loadLivePositions(); + void loadActivePlans(); } } @@ -3017,7 +3020,7 @@ '">成交细节 ' + ''; + '">平仓'; tbody.appendChild(tr); }); bindActivePlanRowActions(tbody); diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index f679255..7ef36d7 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -1393,7 +1393,7 @@ def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]: - """人工结束进行中计划:不自动平仓;未成交腿标 cancelled.""" + """仅结束计划记录(不平仓).盯盘/无持仓时用;有持仓请走 execute_manual_close_plan.""" from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end from lib.exchange.okx_options_lib import cancel_option_order @@ -1446,3 +1446,273 @@ def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dic "msg": "计划已结束(未自动平仓;有持仓请自行平掉)", "notes": notes, } + + +def execute_manual_close_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]: + """人工平仓:先期权(须买一流动性)→永续→卖币回 USDT,再结束计划.""" + from lib.exchange.okx_options_lib import cancel_option_order + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + option_bid_liquidity_ok, + perp_direction_for_view, + ) + from lib.hedge_plan.hedge_plan_settle_lib import resolve_option_leg_realized_pnl + from lib.options.options_spot_bridge_lib import spot_market_sell_coin_to_usdt + + plan = get_plan(conn, int(plan_id)) + if not plan: + return {"ok": False, "msg": "计划不存在"} + st = str(plan.get("status") or "") + if st not in ("opening", "active", "partial", "watching"): + return {"ok": False, "msg": f"当前状态 {st or '—'} 不可平仓"} + + # 盯盘无持仓:直接结束 + if st == "watching": + return execute_manual_end_plan(cfg, conn, plan_id) + + notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id)) + legs = get_plan_legs(conn, int(plan_id)) + ex = cfg.get("exchange_options") + + # 取消待补委托 + for leg in legs: + if str(leg.get("status") or "").lower() != "pending": + continue + inst = str(leg.get("inst_id") or "").strip() + oid = str(leg.get("exchange_ord_id") or "").strip() + if ex is not None and inst and oid: + try: + cancel_option_order(ex, inst_id=inst, ord_id=oid) + except Exception: + pass + update_leg( + conn, + int(leg["id"]), + status="cancelled", + close_reason="manual_close", + closed_at=_now(), + avg_open=None, + premium=0, + ) + notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled") + + legs = get_plan_legs(conn, int(plan_id)) + opt = next( + ( + x + for x in legs + if str(x.get("leg_role") or "") in ("option_hedge", "option_a", "option_b") + and str(x.get("status") or "").lower() == "open" + ), + None, + ) + perp = next( + ( + x + for x in legs + if str(x.get("leg_role") or "") == "perp" and str(x.get("status") or "").lower() == "open" + ), + None, + ) + + if not opt and not perp: + update_plan( + conn, + int(plan_id), + status="closed", + close_reason="manual_close", + closed_at=_now(), + note=((plan.get("note") or "") + " · 人工平仓(无持仓腿)").strip(" ·")[:500], + ) + return {"ok": True, "plan_id": int(plan_id), "msg": "无持仓腿,计划已结束", "notes": notes} + + view = str(plan.get("direction") or "long").lower() + uly = str(plan.get("underlying") or "ETH").upper() + opt_pnl = 0.0 + perp_pnl = 0.0 + close_detail: dict[str, Any] = {} + + # 1) 先平期权 + if opt: + inst_id = str(opt.get("inst_id") or "") + sheets = float(opt.get("size") or 1) + quote_fn = cfg.get("quote_option_contract") + bid = None + bid_sz = None + if callable(quote_fn) and ex is not None and inst_id: + try: + q = quote_fn(ex, inst_id) or {} + if q.get("ok"): + bid = q.get("bid") + bid_sz = q.get("bid_sz") + except Exception: + pass + liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) + if not liq_ok: + return { + "ok": False, + "msg": liq_msg or "买一流动性不足,无法平期权", + "plan_id": int(plan_id), + "need_liquidity": True, + } + close_r = _sell_option(cfg, inst_id=inst_id, sheets=sheets, dry_run=False) + close_detail["option"] = close_r + if not close_r.get("already_flat") and (not close_r.get("ok") or not close_r.get("fully_closed", True)): + return { + "ok": False, + "msg": close_r.get("msg") or "期权平仓失败", + "plan_id": int(plan_id), + "close": close_r, + } + # 盈亏:优先交易所历史,否则用买一估算 + ct = float(opt.get("ct_mult") or 0.01) + avg = float(opt.get("avg_open") or 0) + premium = float(opt.get("premium") or (avg * sheets * ct)) + bid_f = float(close_r.get("bid") or bid or 0) + fallback = bid_f * sheets * ct - premium + try: + pnl, _src = resolve_option_leg_realized_pnl(ex=ex, leg=opt, fallback=fallback) + opt_pnl = float(pnl if pnl is not None else fallback) + except Exception: + opt_pnl = float(fallback) + update_leg( + conn, + int(opt["id"]), + status="closed", + close_reason="manual_close", + closed_at=_now(), + realized_pnl=round(opt_pnl, 4), + ) + notes.append(f"期权已平 {inst_id}") + + # 2) 再平永续 + if perp: + symbol = str(perp.get("symbol") or plan.get("exchange_symbol") or "") + perp_dir = str(perp.get("side") or plan.get("perp_direction") or perp_direction_for_view(view)).lower() + contracts = float(perp.get("size") or plan.get("perp_size") or 0) + # 尽量用实盘张数 + get_live = cfg.get("get_live_position_contracts") + if callable(get_live) and symbol: + try: + live_sz = get_live(symbol, perp_dir) + if live_sz is not None and float(live_sz) > 0: + contracts = float(live_sz) + except Exception: + pass + if contracts <= 0: + update_leg( + conn, + int(perp["id"]), + status="closed", + close_reason="manual_close", + closed_at=_now(), + realized_pnl=0, + ) + notes.append("永续无仓/已平") + else: + perp_close = _close_perp( + cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False + ) + close_detail["perp"] = perp_close + if not perp_close.get("ok"): + # 期权可能已平:计划挂 partial 提示 + update_plan( + conn, + int(plan_id), + close_reason="manual_close_perp_pending", + note=((plan.get("note") or "") + " · 人工平仓:期权已平,永续失败待补").strip(" ·")[:500], + ) + return { + "ok": False, + "msg": perp_close.get("msg") or "永续平仓失败(期权可能已平)", + "plan_id": int(plan_id), + "partial_closed": True, + "close": close_detail, + "notes": notes, + } + # 粗估永续盈亏 + try: + entry = float(perp.get("avg_open") or plan.get("entry_mark") or 0) + get_m = cfg.get("get_live_position_exchange_metrics") + mark = entry + if callable(get_m): + m = get_m(symbol, perp_dir) or {} + mark = float(m.get("mark_price") or m.get("mark") or entry) + cs = 0.01 + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + cs = float(get_cs(symbol) or 0.01) + coins = contracts * cs + if perp_dir == "short": + perp_pnl = (entry - mark) * coins + else: + perp_pnl = (mark - entry) * coins + except Exception: + perp_pnl = 0.0 + update_leg( + conn, + int(perp["id"]), + status="closed", + close_reason="manual_close", + closed_at=_now(), + realized_pnl=round(perp_pnl, 4), + ) + notes.append(f"永续已平 {symbol}") + + # 3) 卖币回 USDT + sell_r: dict[str, Any] = {"ok": True, "skipped": True} + try: + sell_ex = cfg.get("exchange_options") or cfg.get("exchange") + if sell_ex is not None: + sell_r = spot_market_sell_coin_to_usdt(sell_ex, underlying=uly) + except Exception as e: + sell_r = {"ok": False, "msg": str(e)} + close_detail["sell_coin"] = sell_r + if sell_r.get("ok"): + notes.append("已卖币回 USDT" if not sell_r.get("skipped") else "卖币跳过") + else: + notes.append("卖币失败: " + str(sell_r.get("msg") or "")) + + total = float(opt_pnl) + float(perp_pnl) + # 期权币本位盈亏换算到 U(粗):用指数 + try: + fetch_idx = cfg.get("fetch_index_price") + idx = float(fetch_idx(uly) or 0) if callable(fetch_idx) else 0.0 + # opt_pnl 可能已是币;若为币且绝对值很小则 × 指数 + # resolve_option_leg_realized_pnl 对币本位通常返回币;统一展示用 preview 风格 + if abs(opt_pnl) < 10 and idx > 0 and abs(opt_pnl * idx) > abs(opt_pnl): + # 更可能是币单位 + opt_pnl_u = opt_pnl * idx + else: + opt_pnl_u = opt_pnl + except Exception: + opt_pnl_u = opt_pnl + + update_plan( + conn, + int(plan_id), + status="closed", + close_reason="manual_close", + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl_u if isinstance(opt_pnl_u, float) else opt_pnl, 4), + realized_pnl_total=round(float(opt_pnl_u) + float(perp_pnl), 4) + if isinstance(opt_pnl_u, float) + else round(total, 4), + closed_at=_now(), + note=((plan.get("note") or "") + " · 人工平仓").strip(" ·")[:500], + ) + plan2 = get_plan(conn, int(plan_id)) + if plan2: + try: + notify_plan_end(cfg, conn, plan2) + except Exception: + pass + return { + "ok": True, + "plan_id": int(plan_id), + "msg": "已平仓并结束计划", + "notes": notes, + "close": close_detail, + "realized_pnl_total": plan2.get("realized_pnl_total") if plan2 else total, + } diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 6b8ca29..b4fc5a5 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -919,7 +919,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: @app.route("/api/hedge-plan//end", methods=["POST"]) @lr def api_hedge_end_plan(plan_id: int): - """人工结束进行中计划:不自动平仓;未成交腿改为 cancelled.""" + """仅结束计划记录(不平仓).有持仓请用 /close.""" from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_end_plan @@ -934,6 +934,29 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: conn.close() return jsonify(out) + @app.route("/api/hedge-plan//close", methods=["POST"]) + @lr + def api_hedge_close_plan(plan_id: int): + """人工平仓:期权→永续→卖币,再结束计划.""" + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_close_plan + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + out = execute_manual_close_plan(cfg, conn, plan_id) + if not out.get("ok"): + # 半平成功时也要落库(期权已平/永续失败标记) + try: + conn.commit() + except Exception: + pass + return jsonify(out), 400 + conn.commit() + finally: + conn.close() + return jsonify(out) + @app.route("/api/hedge-plan//complete-leg", methods=["POST"]) @lr def api_hedge_complete_leg(plan_id: int): diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index cf459c7..ffb7037 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -431,4 +431,4 @@ - +