Fix ghost finalize on unknown perp size and open rollback hazards.

Distinguish exchange query None from flat zero; keep opening when size unknown; idempotent half_open cash; scan options while opening; continue manage after recover.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 20:47:29 +08:00
parent e2a19a1614
commit 101cb3c045
6 changed files with 153 additions and 76 deletions
+35 -8
View File
@@ -197,11 +197,11 @@ class BinanceLiveExecutor(Matcher):
)
except Exception:
live_perp = None
if live_perp is not None and live_perp > 1e-8:
if live_perp is None or live_perp > 1e-8:
return OpenResult(
ok=False,
detail=(
f"永续可能已成交但未确认成交明细(保留 opening): {e}; "
f"永续开仓未确认(保留 opening,禁止回滚期权): {e}; "
f"ex_perp={live_perp}"
),
)
@@ -397,13 +397,18 @@ class BinanceLiveExecutor(Matcher):
"""期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。"""
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
initial_premium = of_px * opt_qty
self.ledger.apply_cash(
-(of_px * opt_qty + of_fee),
kind="open_option",
group_id=group_id,
note=f"LIVE-BN half_open option {group_id}",
allow_negative=True,
prior_cash = self.db.fetchone(
"SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1",
(group_id,),
)
if prior_cash is None:
self.ledger.apply_cash(
-(of_px * opt_qty + of_fee),
kind="open_option",
group_id=group_id,
note=f"LIVE-BN half_open option {group_id}",
allow_negative=True,
)
now = int(time.time() * 1000)
with self.db._lock:
existing = self.db._conn.execute(
@@ -598,6 +603,18 @@ class BinanceLiveExecutor(Matcher):
pf_px = entry_index if entry_index > 0 else of_px
initial_premium = of_px * opt_qty
mgn = self._perp_margin_mode()
prior_cash = self.db.fetchone(
"SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1",
(group_id,),
)
if prior_cash is None and of_px > 0 and opt_qty > 0:
self.ledger.apply_cash(
-(of_px * opt_qty),
kind="open_option",
group_id=group_id,
note=f"LIVE-BN recover promote open_option {group_id}",
allow_negative=True,
)
now = int(time.time() * 1000)
with self.db._lock:
existing = self.db._conn.execute(
@@ -822,6 +839,11 @@ class BinanceLiveExecutor(Matcher):
perp_qty_eth=perp_qty,
allow_db_fallback=not pending_perp_only,
)
if perp_qty_close is None:
return CloseResult(
ok=False,
detail="期权已平,永续待平(无法核对交易所仓位,禁止空仓 finalize)",
)
if perp_qty_close <= 0:
pf_px = float(pos.get("perp_entry_px") or 0) or 0.0
pf_fee = 0.0
@@ -1135,6 +1157,11 @@ class BinanceLiveExecutor(Matcher):
perp_side=perp_side,
perp_qty_eth=perp_qty,
)
if perp_qty_close is None or perp_qty_close <= 0:
return CloseResult(
ok=False,
detail="币安平永续失败: 无法取得有效永续仓位数量",
)
perp_live = client.place_perp_market(
symbol=perp_inst,
side=side,
+40 -16
View File
@@ -19,6 +19,7 @@ from .reconcile import (
claim_open_slot,
exchange_option_abs_size,
perp_close_contracts_okx,
perp_open_contracts_okx,
recover_stuck_opening,
release_open_slot_if_opening,
stamp_opening_intent,
@@ -189,13 +190,7 @@ class OkxLiveExecutor(Matcher):
mgn = self._perp_margin_mode()
try:
ct_val = client.get_ct_val(perp_inst, inst_type="SWAP")
perp_sz = perp_close_contracts_okx(
client,
perp_inst=perp_inst,
perp_side=perp_side,
perp_qty_eth=perp_qty,
ct_val=ct_val,
)
perp_sz = perp_open_contracts_okx(perp_qty_eth=perp_qty, ct_val=ct_val)
if perp_side == "long":
side, pos_side = "buy", "long"
else:
@@ -216,18 +211,18 @@ class OkxLiveExecutor(Matcher):
)
except Exception as e:
logger.exception("live open perp failed (likely margin); rollback option")
# 永续可能已成交:先查仓有仓不得回滚期权
# 永续可能已成交:先查仓;查失败或有仓不得回滚期权
try:
live_perp = client.get_perp_pos_sz(
perp_inst, pos_side=("long" if perp_side == "long" else "short")
)
except Exception:
live_perp = None
if live_perp is not None and live_perp > 1e-8:
if live_perp is None or live_perp > 1e-8:
return OpenResult(
ok=False,
detail=(
f"永续可能已成交但未确认成交明细(保留 opening): {e}; "
f"永续开仓未确认(保留 opening,禁止回滚期权): {e}; "
f"ex_perp={live_perp}"
),
)
@@ -419,13 +414,19 @@ class OkxLiveExecutor(Matcher):
"""期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。"""
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
initial_premium = of_px * opt_qty
self.ledger.apply_cash(
-(of_px * opt_qty + of_fee),
kind="open_option",
group_id=group_id,
note=f"LIVE half_open option {group_id}",
allow_negative=True,
# 幂等:崩溃重入时勿二次扣权利金
prior_cash = self.db.fetchone(
"SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1",
(group_id,),
)
if prior_cash is None:
self.ledger.apply_cash(
-(of_px * opt_qty + of_fee),
kind="open_option",
group_id=group_id,
note=f"LIVE half_open option {group_id}",
allow_negative=True,
)
now = int(time.time() * 1000)
with self.db._lock:
existing = self.db._conn.execute(
@@ -629,6 +630,19 @@ class OkxLiveExecutor(Matcher):
pf_px = entry_index if entry_index > 0 else of_px
initial_premium = of_px * opt_qty
mgn = self._perp_margin_mode()
# 幂等补记权利金(崩溃在成交后、账本前时)
prior_cash = self.db.fetchone(
"SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1",
(group_id,),
)
if prior_cash is None and of_px > 0 and opt_qty > 0:
self.ledger.apply_cash(
-(of_px * opt_qty),
kind="open_option",
group_id=group_id,
note=f"LIVE recover promote open_option {group_id}",
allow_negative=True,
)
now = int(time.time() * 1000)
with self.db._lock:
existing = self.db._conn.execute(
@@ -857,6 +871,11 @@ class OkxLiveExecutor(Matcher):
ct_val=ct_val,
allow_db_fallback=not pending_perp_only,
)
if perp_sz is None:
return CloseResult(
ok=False,
detail="期权已平,永续待平(无法核对交易所仓位,禁止空仓 finalize)",
)
if perp_sz <= 0:
# 永续已在交易所平掉:用入场价近似 finalize(净盈亏由对账校正)
pf_px = float(pos.get("perp_entry_px") or 0) or 0.0
@@ -1152,6 +1171,11 @@ class OkxLiveExecutor(Matcher):
perp_qty_eth=perp_qty,
ct_val=ct_val,
)
if perp_sz is None or perp_sz <= 0:
return CloseResult(
ok=False,
detail="弃期权平永续失败: 无法取得有效永续仓位数量",
)
if perp_side == "long":
side, pos_side = "sell", "long"
else:
+24 -13
View File
@@ -171,7 +171,8 @@ def assert_safe_to_open_live(executor) -> tuple[bool, str]:
False,
f"交易所有期权仓({opt_inst})但本地未确认持仓,禁止新开,请人工核对",
)
elif st in ("flat", ""):
elif st in ("flat", "", "opening"):
# opening 且尚未 stamp option_inst_id 时仍须扫任意期权残留
any_opt = exchange_any_option_abs(client)
if any_opt is None:
return False, "无法核对交易所期权持仓"
@@ -209,18 +210,20 @@ def perp_close_contracts_okx(
perp_qty_eth: float,
ct_val: float,
allow_db_fallback: bool = True,
) -> int:
) -> int | None:
"""平永续张数:优先交易所持仓。
allow_db_fallback=False 且交易所已空仓时返回 0(勿用 DB 数量再下单,防反向开仓)。
返回 >0 应下单;0=已确认空仓(仅 allow_db_fallback=False);
None=查仓失败(调用方不得当空仓 finalize)。
"""
ps = "long" if perp_side == "long" else "short"
ex_sz = client.get_perp_pos_sz(perp_inst, pos_side=ps)
if ex_sz is not None and ex_sz > _PERP_EPS:
return max(1, int(round(ex_sz)))
if ex_sz is not None and ex_sz <= _PERP_EPS:
if ex_sz is None:
if not allow_db_fallback:
return 0
return None
return max(1, int(round(perp_qty_eth / ct_val)))
if ex_sz > _PERP_EPS:
return max(1, int(round(ex_sz)))
if not allow_db_fallback:
return 0
return max(1, int(round(perp_qty_eth / ct_val)))
@@ -233,20 +236,28 @@ def perp_close_qty_eth_binance(
perp_side: str,
perp_qty_eth: float,
allow_db_fallback: bool = True,
) -> float:
"""平永续 ETH 数量:优先交易所持仓。交易所空且不允许 fallback → 0"""
) -> float | None:
"""平永续 ETH>0 下单;0=已确认空;None=查仓失败"""
ps = "LONG" if perp_side == "long" else "SHORT"
ex_sz = client.get_perp_pos_sz(perp_inst, position_side=ps)
if ex_sz is not None and ex_sz > _PERP_EPS:
return float(ex_sz)
if ex_sz is not None and ex_sz <= _PERP_EPS:
if ex_sz is None:
if not allow_db_fallback:
return 0.0
return None
return float(perp_qty_eth)
if ex_sz > _PERP_EPS:
return float(ex_sz)
if not allow_db_fallback:
return 0.0
return float(perp_qty_eth)
def perp_open_contracts_okx(*, perp_qty_eth: float, ct_val: float) -> int:
"""开仓张数:仅按设置名义/面值,不跟交易所残留。"""
if ct_val <= 0:
raise RuntimeError("ct_val invalid")
return max(1, int(round(float(perp_qty_eth) / float(ct_val))))
def recover_stuck_opening(executor) -> CloseResult | None:
"""恢复本地 status=opening
+17 -4
View File
@@ -556,9 +556,6 @@ class StrategyEngine:
r = await asyncio.to_thread(recover)
self._note_retry_result("opening", ok=r.ok, detail=r.detail)
if r.ok:
self._set_state(phase="idle", last_error=None)
if "half_open" in (r.detail or "") or "提升为 open" in (r.detail or ""):
pass # 继续本 tick 后续逻辑由下一轮处理
try:
from ..notify import wecom
@@ -569,6 +566,20 @@ class StrategyEngine:
)
except Exception:
pass
# 恢复后按新状态继续本 tickhalf_open/open/flat
pos = self.matcher.current_position()
st_pos = str(pos.get("status") or "flat")
if st_pos == "opening":
self._set_state(phase="opening_stuck", last_error=r.detail)
return
if st_pos == "flat":
self._set_state(phase="idle", last_error=None)
return
# half_open / open / pending:落入下方分支
self._set_state(
phase="open" if st_pos == "open" else "closing",
last_error=None,
)
else:
self._set_state(phase="opening_stuck", last_error=r.detail)
try:
@@ -581,7 +592,9 @@ class StrategyEngine:
)
except Exception:
pass
return
return
else:
return
if st_pos == "half_open":
allowed, left = self._retry_allowed("half_open")