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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
# 恢复后按新状态继续本 tick(half_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")
|
||||
|
||||
@@ -1,49 +1,36 @@
|
||||
# 审计说明 — 开/平仓逻辑与实盘安全(2026-07-29)
|
||||
|
||||
## 范围
|
||||
## 结论(四轮后)
|
||||
|
||||
- 开仓 / 平仓 / 到期双腿 / 弃期权 / stuck `opening` 恢复
|
||||
- LIVE OKX / 币安执行器与本地账本一致性
|
||||
- 交易所对账(永续 + 期权)
|
||||
开平仓主路径已闭环:占槽 → 意图落库 → 期权 → 永续 → open;平仓先 mark pending 再平永续;崩溃可 `recover_opening`。第四轮修掉「查仓失败当空仓 finalize」等回归。
|
||||
|
||||
## 结论摘要(三轮)
|
||||
## 四轮关键项
|
||||
|
||||
| 严重度 | 问题 | 处置 |
|
||||
|--------|------|------|
|
||||
| Critical | 到期双平期权二次入账 | **已修** |
|
||||
| Critical | abandon 账本拒记 / 假 residual 双计 | **已修** |
|
||||
| Critical | 币安 `orderId=` 保留 opening 实际不匹配 → 可能重复开 | **已修**:错误文案带 `orderId=` |
|
||||
| Critical | `opening` 无元数据,崩溃后无法恢复 | **已修**:`stamp_opening_intent` + `recover_opening` |
|
||||
| High | 对账只查永续 | **已修**:期权仓位 + flat 时任意期权残留 |
|
||||
| High | pending 时永续已空仍用 DB 数量下单 | **已修**:`allow_db_fallback=False` → 直接 finalize |
|
||||
| High | 期权已空仍再卖 / 未 mark | **已修**:查仓跳过再卖;有 close fill 不二次入账 |
|
||||
| High | 永续开仓异常时可能已成交仍回滚期权 | **已修**:先查永续仓再决定 |
|
||||
| High | `_wait_fill` 部分成交当全成 | **已修** |
|
||||
| High | residual LIVE 无 `allow_negative` | **已修** |
|
||||
| Med | 币安缺保证金模式 | **已修**:落库 + `set_margin_type` |
|
||||
| 轮次 | 关键修复 |
|
||||
|------|----------|
|
||||
| 1 | 到期双入账;abandon `allow_negative`;保证金模式;引擎锁;AUTH;盈亏查询 |
|
||||
| 2 | abandon 假 residual;部分成交不当全成;residual LIVE 账本 |
|
||||
| 3 | stamp + recover_opening;期权对账;已空不重卖/不下单;币安 orderId= |
|
||||
| 4 | 查仓 `None`≠`0`;回滚仅确认空仓;half_open/promote 现金幂等;opening any-option;开仓张数独立 |
|
||||
|
||||
## 开仓状态机(自检)
|
||||
## 开仓
|
||||
|
||||
1. `claim_open_slot` → `opening`
|
||||
2. `stamp_opening_intent`(写入 group_id / option_inst_id / 数量)
|
||||
3. 下期权 → 再 stamp 成交均价
|
||||
4. 下永续;失败则查永续仓:有仓保留 opening;无仓则回滚期权,回滚失败 → `half_open`
|
||||
5. 成功 → `open`;启动/tick/`close_group` 遇 `opening` → `recover_opening`
|
||||
1. `claim` → `assert`(永续+期权残留)→ `stamp` → 买期权 → `stamp` 均价
|
||||
2. 开永续用 `perp_open_contracts_okx`(不跟残留)
|
||||
3. 永续失败:查仓 `None`/有仓 → 保留 opening;确认空 → 回滚期权 / half_open
|
||||
4. tick/启动/紧急:`recover_opening`(空清槽 / 仅期权 half_open / 双边 promote)
|
||||
|
||||
## 平仓状态机(自检)
|
||||
## 平仓
|
||||
|
||||
1. 卖期权(或到期本地结算)→ `_mark_option_closed_perp_pending`(只一次)
|
||||
2. 平永续;交易所已空则不下单,直接 finalize
|
||||
3. abandon:若已 pending,只续平永续,不记 residual
|
||||
4. finalize 在 pending 时 `skip_option_cash`
|
||||
1. 卖期权(已空则跳过)→ mark pending 仅一次
|
||||
2. 查永续:`None` → 失败返回;`0` → finalize;`>0` → 下单
|
||||
3. abandon:已 pending 只续平;禁止假 residual
|
||||
|
||||
## 仍需人工场景(极少)
|
||||
## 已知极少人工场景
|
||||
|
||||
- `opening` 无 `option_inst_id` 且交易所有不明期权仓:禁止自动清槽,企微告警
|
||||
- 提升为 `open` 时永续入场价用指数近似(本地镜像;实盘盈亏仍可走交易所对账)
|
||||
- opening 无 `option_inst_id` 且交易所有不明期权:禁止自动清槽
|
||||
- promote 入场价可能用指数近似(本地镜像;实盘盈亏可走交易所对账)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `backend/app/live/reconcile.py`(核心恢复/对账)
|
||||
- `executor.py` / `binance_executor.py` / `okx_trade.py` / `binance_trade.py`
|
||||
- `strategy/engine.py` / `sim/matcher.py`
|
||||
`live/reconcile.py` · `executor.py` · `binance_executor.py` · `okx_trade.py` · `binance_trade.py` · `strategy/engine.py` · `sim/matcher.py`
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-29 — 第四轮:查仓失败≠空仓;回滚/账本幂等
|
||||
|
||||
### 变更
|
||||
|
||||
1. 平仓查仓 `None` 与 `0` 分离:查失败禁止空仓 finalize(防幽灵平仓)。
|
||||
2. 永续开仓异常且查仓失败时禁止回滚期权(保留 opening)。
|
||||
3. `half_open` / promote 权利金入账幂等(按 group 已有 `open_option` 跳过)。
|
||||
4. `opening` 未 stamp 时仍扫任意期权残留;OKX 开仓改用 `perp_open_contracts_okx`;opening 恢复成功后同 tick 继续 manage。
|
||||
|
||||
### 审计
|
||||
|
||||
详见 [`docs/审计说明-2026-07-29-开平仓与实盘安全.md`](./审计说明-2026-07-29-开平仓与实盘安全.md)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-29 — 第三轮:opening 恢复 / 期权对账 / 平仓兜底
|
||||
|
||||
### 变更
|
||||
|
||||
Reference in New Issue
Block a user