diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index d4dd2e4..3359a26 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -13,6 +13,7 @@ from ..strategy.session import get_session from .ledger import Ledger from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth from .pricing import ( + option_expiry_settle, option_fill, option_intrinsic, perp_fill, @@ -329,9 +330,9 @@ class Matcher: def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: """ 全平一组。默认校验期权买一深度 + 买一/标记偏差(默认≤30%)。 - bypass_liquidity=True:紧急/到期可绕过深度闸门;平仓价取 max(买一, 标记, 内在价值), - 避免到期垃圾盘口把实值期权按近零价卖掉。 - 成交顺序:先平期权(薄)→ 再瞬时平永续(对冲先留着);永续盘口失败则回滚期权入账。 + reason=expiry:对齐实盘,期权按标的结算价的内在价值入账(不吃盘口)。 + bypass_liquidity=True(紧急):绕过深度闸门,价取 max(买一, 标记, 内在价值)。 + 成交顺序:先平期权 → 再瞬时平永续;永续盘口失败则回滚期权入账。 """ s = get_settings() pos = self.current_position() @@ -349,12 +350,6 @@ class Matcher: oq = get_exchange().quote(option_inst_id) or ( snap.call if option_side == "call" else snap.put ) - if not oq: - return CloseResult( - ok=False, - detail="期权盘口不可用", - liquidity_wait=not bypass_liquidity, - ) ct_mult = self._ct_mult(option_inst_id) need_eth = float(pos["option_qty_eth"] or s.option_qty_eth) @@ -362,7 +357,6 @@ class Matcher: "close_bid_mark_max_pct", s.close_bid_mark_max_pct ) - close_bid = oq.bid strike = self._group_strike(group_id, option_inst_id) spot = self._close_spot_px(snap) intrinsic: float | None = None @@ -371,59 +365,81 @@ class Matcher: option_side=option_side, strike=strike, spot=spot ) - if not bypass_liquidity: - if close_bid is None: - return self._liquidity_wait(group_id, "期权买一不可用") - if not bid_covers_eth( - bid_sz_contracts=oq.bid_sz, - ct_mult=ct_mult, - need_eth=need_eth, - ): - return self._liquidity_wait(group_id, "期权买一流动性不足") - ok_dev, why = bid_mark_ok( - bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev - ) - if not ok_dev: - return self._liquidity_wait(group_id, why) - resolved = resolve_option_close_bid( - bid=float(close_bid), - mark=oq.mark_px, - intrinsic=intrinsic, - bypass_liquidity=False, - ) - if resolved is None: - return self._liquidity_wait(group_id, "期权平仓价不可用") - close_bid = resolved - else: - # 到期/紧急:买一/标记可能枯死,用 max(买一, 标记, 内在价值) - resolved = resolve_option_close_bid( - bid=close_bid, - mark=oq.mark_px, - intrinsic=intrinsic, - bypass_liquidity=True, - ) - if resolved is None: + fee_rate = self._fee_rate() + is_expiry = reason == "expiry" + + if is_expiry: + # 实盘到期:直接按内在价值结算,不依赖盘口 + if intrinsic is None: return CloseResult( ok=False, - detail="紧急全平失败:无买一/标记/内在价值", + detail="到期结算失败:缺少行权价或标的结算价", ) - close_bid = resolved + of = option_expiry_settle( + intrinsic=float(intrinsic), + qty_eth=float(pos["option_qty_eth"]), + fee_rate=fee_rate, + ) + close_bid = float(intrinsic) + else: + if not oq: + return CloseResult( + ok=False, + detail="期权盘口不可用", + liquidity_wait=not bypass_liquidity, + ) + close_bid = oq.bid + if not bypass_liquidity: + if close_bid is None: + return self._liquidity_wait(group_id, "期权买一不可用") + if not bid_covers_eth( + bid_sz_contracts=oq.bid_sz, + ct_mult=ct_mult, + need_eth=need_eth, + ): + return self._liquidity_wait(group_id, "期权买一流动性不足") + ok_dev, why = bid_mark_ok( + bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev + ) + if not ok_dev: + return self._liquidity_wait(group_id, why) + resolved = resolve_option_close_bid( + bid=float(close_bid), + mark=oq.mark_px, + intrinsic=intrinsic, + bypass_liquidity=False, + ) + if resolved is None: + return self._liquidity_wait(group_id, "期权平仓价不可用") + close_bid = resolved + else: + resolved = resolve_option_close_bid( + bid=close_bid, + mark=oq.mark_px, + intrinsic=intrinsic, + bypass_liquidity=True, + ) + if resolved is None: + return CloseResult( + ok=False, + detail="紧急全平失败:无买一/标记/内在价值", + ) + close_bid = resolved + of = option_fill( + action="close", + bid=float(close_bid), + ask=float(oq.ask or close_bid), + qty_eth=float(pos["option_qty_eth"]), + fee_rate=fee_rate, + ) - fee_rate = self._fee_rate() perp_side = str(pos["perp_side"]) perp_qty = float(pos["perp_qty_eth"]) opt_qty = float(pos["option_qty_eth"]) perp_entry = float(pos["perp_entry_px"]) opt_entry = float(pos["option_entry_px"]) - # 1) 先平期权(买一流动性差);永续对冲暂留 - of = option_fill( - action="close", - bid=float(close_bid), - ask=float(oq.ask or close_bid), - qty_eth=opt_qty, - fee_rate=fee_rate, - ) + # 1) 先平期权;永续对冲暂留 opt_pnl = (of.fill_px - opt_entry) * opt_qty opt_cash = of.notional - of.fee self.ledger.apply_cash( diff --git a/backend/app/sim/pricing.py b/backend/app/sim/pricing.py index 807a63d..fe88469 100644 --- a/backend/app/sim/pricing.py +++ b/backend/app/sim/pricing.py @@ -29,6 +29,21 @@ def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float: return 0.0 +def option_expiry_settle( + *, + intrinsic: float, + qty_eth: float, + fee_rate: float, +) -> PriceResult: + """到期结算:按内在价值入账(对齐实盘),无买卖价差滑点,仅扣手续费。""" + base = max(float(intrinsic), 0.0) + fill = base + f = float(fee_rate) + notional = abs(fill * float(qty_eth)) + fee = notional * f + return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=0.0, notional=notional) + + def resolve_option_close_bid( *, bid: float | None, @@ -37,8 +52,9 @@ def resolve_option_close_bid( bypass_liquidity: bool, ) -> float | None: """ - 平仓用买一价;多头卖出不得低于内在价值(SIM 防到期垃圾盘口)。 - bypass 时:买一缺失可用标记/内在价值兜底。 + 非到期平仓用买一价;多头卖出不得低于内在价值(SIM)。 + 紧急 bypass:max(买一, 标记, 内在价值)。 + 到期请用 option_expiry_settle,不要走本函数。 """ candidates: list[float] = [] if bid is not None and bid >= 0: @@ -49,8 +65,6 @@ def resolve_option_close_bid( candidates.append(float(intrinsic)) if not candidates: return None - # 常规:有买一时,仍用 max(买一, 内在价值) 抬到合理底价 - # bypass:max(买一, 标记, 内在价值) if bypass_liquidity: return max(candidates) if bid is None: diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 2048bad..6e76650 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -145,13 +145,24 @@ def test_expiry_close() -> None: def test_option_intrinsic_and_close_bid_floor() -> None: - from app.sim.pricing import option_intrinsic, resolve_option_close_bid + from app.sim.pricing import ( + option_expiry_settle, + option_intrinsic, + resolve_option_close_bid, + ) assert option_intrinsic(option_side="call", strike=1860, spot=1882) == 22.0 assert option_intrinsic(option_side="put", strike=1860, spot=1882) == 0.0 assert option_intrinsic(option_side="put", strike=1860, spot=1840) == 20.0 - # 到期垃圾买一 0.2,内在价值 22 → 抬到 22 + # 到期:严格按内在价值,无滑点 + settled = option_expiry_settle(intrinsic=22.0, qty_eth=2.0, fee_rate=0.0005) + assert settled.fill_px == 22.0 + assert settled.slip == 0.0 + assert settled.notional == 44.0 + assert abs(settled.fee - 44.0 * 0.0005) < 1e-12 + + # 紧急垃圾买一 0.2,内在价值 22 → 抬到 22 assert ( resolve_option_close_bid( bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=True diff --git a/docs/策略说明.md b/docs/策略说明.md index 3d99182..b429423 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -134,7 +134,9 @@ - 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`); - 不满足 → `liquidity_wait`,继续等待,不改开仓。 -**到期 / 紧急全平**:绕过上述闸门;期权平仓价取 **max(买一, 标记, 内在价值)**,避免到期盘口枯死把实值期权按近零价结算。 +**到期自动全平**:对齐实盘,期权按标的结算价计算 **内在价值** 入账(不吃盘口、无价差滑点);永续仍市价平掉。策略暂停时仍执行。 + +**紧急全平**:绕过流动性闸门;期权价取 max(买一, 标记, 内在价值)。 ### 4.4 其它平仓入口 @@ -257,4 +259,4 @@ |------|------| | 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 | | 2026-07-25 | 平仓顺序改为先期权后永续(与开仓同理:薄腿优先) | -| 2026-07-26 | 到期/紧急平仓:期权价不低于内在价值,修复垃圾盘口错杀实值 | +| 2026-07-26 | 到期按内在价值结算(对齐实盘);紧急平仓仍用 max(买一,标记,内在价值) | diff --git a/scripts/repair_expiry_settle.py b/scripts/repair_expiry_settle.py index 344fe5b..0c993eb 100644 --- a/scripts/repair_expiry_settle.py +++ b/scripts/repair_expiry_settle.py @@ -50,16 +50,17 @@ perp_fill = float(p_close["fill_px"]) spot = perp_fill / (1.0 + FEE) intrinsic = max(spot - strike, 0.0) if str(g["option_side"])=="call" else max(strike - spot, 0.0) old_base = float(o_close["base_px"] or o_close["fill_px"]) -new_base = max(old_base, intrinsic) -if abs(new_base - old_base) < 1e-9: +# 到期对齐实盘:严格内在价值,无盘口滑点 +new_base = intrinsic +new_fill = intrinsic +if abs(new_base - old_base) < 1e-9 and abs(float(o_close["fill_px"]) - new_fill) < 1e-9: print("already ok", old_base, intrinsic) raise SystemExit(0) qty = float(o_close["qty_eth"]) -new_fill = new_base * (1.0 - FEE) new_notional = new_fill * qty new_fee = new_notional * FEE -new_slip = abs(new_fill - new_base) * qty +new_slip = 0.0 old_cash = float(o_close["notional"]) - float(o_close["fee"]) new_cash = new_notional - new_fee cash_delta = new_cash - old_cash