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 @@
-
+