Wire manual close to actually flatten option then perp then sell coin.

UI 平仓 now calls /api/hedge-plan/<id>/close instead of end-only (不平仓).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 16:49:24 +08:00
parent b7b1a7e7e0
commit 989accdc50
4 changed files with 304 additions and 8 deletions
+271 -1
View File
@@ -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,
}