Settle expiry options at intrinsic value like live exchange.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 16:08:44 +08:00
parent a58d97938c
commit e3671d9798
5 changed files with 109 additions and 65 deletions
+69 -53
View File
@@ -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(
+18 -4
View File
@@ -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)。
紧急 bypassmax(买一, 标记, 内在价值)
到期请用 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(买一, 内在价值) 抬到合理底价
# bypassmax(买一, 标记, 内在价值)
if bypass_liquidity:
return max(candidates)
if bid is None:
+13 -2
View File
@@ -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
+4 -2
View File
@@ -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(买一,标记,内在价值) |
+5 -4
View File
@@ -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