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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -919,7 +919,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
@app.route("/api/hedge-plan/<int:plan_id>/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/<int:plan_id>/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/<int:plan_id>/complete-leg", methods=["POST"])
|
||||
@lr
|
||||
def api_hedge_complete_leg(plan_id: int):
|
||||
|
||||
@@ -431,4 +431,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/hedge_plan.js?v=55"></script>
|
||||
<script src="/static/hedge_plan.js?v=56"></script>
|
||||
|
||||
Reference in New Issue
Block a user