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
+36 -20
View File
@@ -13,6 +13,7 @@ from ..strategy.session import get_session
from .ledger import Ledger from .ledger import Ledger
from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth
from .pricing import ( from .pricing import (
option_expiry_settle,
option_fill, option_fill,
option_intrinsic, option_intrinsic,
perp_fill, perp_fill,
@@ -329,9 +330,9 @@ class Matcher:
def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
""" """
全平一组。默认校验期权买一深度 + 买一/标记偏差(默认≤30%)。 全平一组。默认校验期权买一深度 + 买一/标记偏差(默认≤30%)。
bypass_liquidity=True:紧急/到期可绕过深度闸门;平仓价取 max(买一, 标记, 内在价值), reason=expiry:对齐实盘,期权按标的结算价的内在价值入账(不吃盘口)。
避免到期垃圾盘口把实值期权按近零价卖掉 bypass_liquidity=True(紧急):绕过深度闸门,价取 max(买一, 标记, 内在价值)
成交顺序:先平期权(薄)→ 再瞬时平永续(对冲先留着);永续盘口失败则回滚期权入账。 成交顺序:先平期权 → 再瞬时平永续;永续盘口失败则回滚期权入账。
""" """
s = get_settings() s = get_settings()
pos = self.current_position() pos = self.current_position()
@@ -349,12 +350,6 @@ class Matcher:
oq = get_exchange().quote(option_inst_id) or ( oq = get_exchange().quote(option_inst_id) or (
snap.call if option_side == "call" else snap.put 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) ct_mult = self._ct_mult(option_inst_id)
need_eth = float(pos["option_qty_eth"] or s.option_qty_eth) 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_mark_max_pct", s.close_bid_mark_max_pct
) )
close_bid = oq.bid
strike = self._group_strike(group_id, option_inst_id) strike = self._group_strike(group_id, option_inst_id)
spot = self._close_spot_px(snap) spot = self._close_spot_px(snap)
intrinsic: float | None = None intrinsic: float | None = None
@@ -371,6 +365,30 @@ class Matcher:
option_side=option_side, strike=strike, spot=spot option_side=option_side, strike=strike, spot=spot
) )
fee_rate = self._fee_rate()
is_expiry = reason == "expiry"
if is_expiry:
# 实盘到期:直接按内在价值结算,不依赖盘口
if intrinsic is None:
return CloseResult(
ok=False,
detail="到期结算失败:缺少行权价或标的结算价",
)
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 not bypass_liquidity:
if close_bid is None: if close_bid is None:
return self._liquidity_wait(group_id, "期权买一不可用") return self._liquidity_wait(group_id, "期权买一不可用")
@@ -395,7 +413,6 @@ class Matcher:
return self._liquidity_wait(group_id, "期权平仓价不可用") return self._liquidity_wait(group_id, "期权平仓价不可用")
close_bid = resolved close_bid = resolved
else: else:
# 到期/紧急:买一/标记可能枯死,用 max(买一, 标记, 内在价值)
resolved = resolve_option_close_bid( resolved = resolve_option_close_bid(
bid=close_bid, bid=close_bid,
mark=oq.mark_px, mark=oq.mark_px,
@@ -408,22 +425,21 @@ class Matcher:
detail="紧急全平失败:无买一/标记/内在价值", detail="紧急全平失败:无买一/标记/内在价值",
) )
close_bid = resolved 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_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"]) perp_qty = float(pos["perp_qty_eth"])
opt_qty = float(pos["option_qty_eth"]) opt_qty = float(pos["option_qty_eth"])
perp_entry = float(pos["perp_entry_px"]) perp_entry = float(pos["perp_entry_px"])
opt_entry = float(pos["option_entry_px"]) opt_entry = float(pos["option_entry_px"])
# 1) 先平期权(买一流动性差);永续对冲暂留 # 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,
)
opt_pnl = (of.fill_px - opt_entry) * opt_qty opt_pnl = (of.fill_px - opt_entry) * opt_qty
opt_cash = of.notional - of.fee opt_cash = of.notional - of.fee
self.ledger.apply_cash( 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 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( def resolve_option_close_bid(
*, *,
bid: float | None, bid: float | None,
@@ -37,8 +52,9 @@ def resolve_option_close_bid(
bypass_liquidity: bool, bypass_liquidity: bool,
) -> float | None: ) -> float | None:
""" """
平仓用买一价;多头卖出不得低于内在价值(SIM 防到期垃圾盘口)。 非到期平仓用买一价;多头卖出不得低于内在价值(SIM)。
bypass 时:买一缺失可用标记/内在价值兜底 紧急 bypassmax(买一, 标记, 内在价值)
到期请用 option_expiry_settle,不要走本函数。
""" """
candidates: list[float] = [] candidates: list[float] = []
if bid is not None and bid >= 0: if bid is not None and bid >= 0:
@@ -49,8 +65,6 @@ def resolve_option_close_bid(
candidates.append(float(intrinsic)) candidates.append(float(intrinsic))
if not candidates: if not candidates:
return None return None
# 常规:有买一时,仍用 max(买一, 内在价值) 抬到合理底价
# bypassmax(买一, 标记, 内在价值)
if bypass_liquidity: if bypass_liquidity:
return max(candidates) return max(candidates)
if bid is None: 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: 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="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=1882) == 0.0
assert option_intrinsic(option_side="put", strike=1860, spot=1840) == 20.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 ( assert (
resolve_option_close_bid( resolve_option_close_bid(
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=True 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`); - 买一相对标记偏差默认 ≤ **30%**`close_bid_mark_max_pct`);
- 不满足 → `liquidity_wait`,继续等待,不改开仓。 - 不满足 → `liquidity_wait`,继续等待,不改开仓。
**到期 / 紧急全平**绕过上述闸门;期权平仓价取 **max(买一, 标记, 内在价值)**,避免到期盘口枯死把实值期权按近零价结算 **到期自动全平**对齐实盘,期权按标的结算价计算 **内在价值** 入账(不吃盘口、无价差滑点);永续仍市价平掉。策略暂停时仍执行
**紧急全平**:绕过流动性闸门;期权价取 max(买一, 标记, 内在价值)。
### 4.4 其它平仓入口 ### 4.4 其它平仓入口
@@ -257,4 +259,4 @@
|------|------| |------|------|
| 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 | | 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 |
| 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) spot = perp_fill / (1.0 + FEE)
intrinsic = max(spot - strike, 0.0) if str(g["option_side"])=="call" else max(strike - spot, 0.0) 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"]) 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) print("already ok", old_base, intrinsic)
raise SystemExit(0) raise SystemExit(0)
qty = float(o_close["qty_eth"]) qty = float(o_close["qty_eth"])
new_fill = new_base * (1.0 - FEE)
new_notional = new_fill * qty new_notional = new_fill * qty
new_fee = new_notional * FEE 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"]) old_cash = float(o_close["notional"]) - float(o_close["fee"])
new_cash = new_notional - new_fee new_cash = new_notional - new_fee
cash_delta = new_cash - old_cash cash_delta = new_cash - old_cash