Floor option close at intrinsic to fix expiry garbage quotes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 16:07:20 +08:00
parent b0dca59681
commit a58d97938c
5 changed files with 588 additions and 266 deletions
+96 -7
View File
@@ -12,7 +12,12 @@ from ..models.db import Database, get_db
from ..strategy.session import get_session 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 option_fill, perp_fill from .pricing import (
option_fill,
option_intrinsic,
perp_fill,
resolve_option_close_bid,
)
@dataclass(slots=True) @dataclass(slots=True)
@@ -71,6 +76,61 @@ class Matcher:
) )
return CloseResult(ok=False, detail=detail, liquidity_wait=True) return CloseResult(ok=False, detail=detail, liquidity_wait=True)
def _group_strike(self, group_id: str, option_inst_id: str) -> float | None:
g = self.db.fetchone(
"SELECT strike FROM groups WHERE group_id=?", (group_id,)
)
if g is not None and g["strike"] is not None:
try:
return float(g["strike"])
except (TypeError, ValueError):
pass
try:
from ..exchange.okx.parse import parse_option_inst_id
_, stk, _ = parse_option_inst_id(option_inst_id)
if stk is not None:
return float(stk)
except Exception:
pass
try:
from ..exchange.binance.parse import parse_option_symbol
_, stk, _ = parse_option_symbol(option_inst_id)
if stk is not None:
return float(stk)
except Exception:
pass
return None
def _close_spot_px(self, snap: Any) -> float | None:
if getattr(snap, "index_px", None) is not None:
try:
px = float(snap.index_px)
if px > 0:
return px
except (TypeError, ValueError):
pass
perp = getattr(snap, "perp", None)
if not perp:
return None
for attr in ("mark_px", "last"):
v = getattr(perp, attr, None)
if v is not None:
try:
px = float(v)
if px > 0:
return px
except (TypeError, ValueError):
pass
if perp.bid is not None and perp.ask is not None:
return (float(perp.bid) + float(perp.ask)) / 2.0
if perp.bid is not None:
return float(perp.bid)
if perp.ask is not None:
return float(perp.ask)
return None
def open_group( def open_group(
self, self,
*, *,
@@ -269,7 +329,8 @@ 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:紧急全平可绕过(仍需有可用买一价才能成交;无买一时用标记近似)。 bypass_liquidity=True:紧急/到期可绕过深度闸门;平仓价取 max(买一, 标记, 内在价值),
避免到期垃圾盘口把实值期权按近零价卖掉。
成交顺序:先平期权(薄)→ 再瞬时平永续(对冲先留着);永续盘口失败则回滚期权入账。 成交顺序:先平期权(薄)→ 再瞬时平永续(对冲先留着);永续盘口失败则回滚期权入账。
""" """
s = get_settings() s = get_settings()
@@ -302,6 +363,14 @@ class Matcher:
) )
close_bid = oq.bid close_bid = oq.bid
strike = self._group_strike(group_id, option_inst_id)
spot = self._close_spot_px(snap)
intrinsic: float | None = None
if strike is not None and spot is not None:
intrinsic = option_intrinsic(
option_side=option_side, strike=strike, spot=spot
)
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, "期权买一不可用")
@@ -316,12 +385,29 @@ class Matcher:
) )
if not ok_dev: if not ok_dev:
return self._liquidity_wait(group_id, why) 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: else:
# 紧急:优先买一,否则用标记价近似成交(SIM) # 到期/紧急:买一/标记可能枯死,用 max(买一, 标记, 内在价值)
if close_bid is None: resolved = resolve_option_close_bid(
close_bid = oq.mark_px bid=close_bid,
if close_bid is None: mark=oq.mark_px,
return CloseResult(ok=False, detail="紧急全平失败:无买一/标记价") intrinsic=intrinsic,
bypass_liquidity=True,
)
if resolved is None:
return CloseResult(
ok=False,
detail="紧急全平失败:无买一/标记/内在价值",
)
close_bid = resolved
fee_rate = self._fee_rate() fee_rate = self._fee_rate()
perp_side = str(pos["perp_side"]) perp_side = str(pos["perp_side"])
@@ -455,6 +541,9 @@ class Matcher:
"net": net, "net": net,
"close_sequence": ["option", "perp"], "close_sequence": ["option", "perp"],
"cash_delta": opt_cash + perp_pnl - pf.fee, "cash_delta": opt_cash + perp_pnl - pf.fee,
"option_close_bid": float(close_bid),
"option_intrinsic": intrinsic,
"settle_spot": spot,
}, },
) )
+43
View File
@@ -17,6 +17,49 @@ class PriceResult:
return asdict(self) return asdict(self)
def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float:
"""多头期权内在价值(USDT/ETH)。call=max(SK,0)put=max(KS,0)。"""
s = float(spot)
k = float(strike)
side = str(option_side).lower().strip()
if side in ("call", "c"):
return max(s - k, 0.0)
if side in ("put", "p"):
return max(k - s, 0.0)
return 0.0
def resolve_option_close_bid(
*,
bid: float | None,
mark: float | None,
intrinsic: float | None,
bypass_liquidity: bool,
) -> float | None:
"""
平仓用买一价;多头卖出不得低于内在价值(SIM 防到期垃圾盘口)。
bypass 时:买一缺失可用标记/内在价值兜底。
"""
candidates: list[float] = []
if bid is not None and bid >= 0:
candidates.append(float(bid))
if bypass_liquidity and mark is not None and mark >= 0:
candidates.append(float(mark))
if intrinsic is not None and intrinsic >= 0:
candidates.append(float(intrinsic))
if not candidates:
return None
# 常规:有买一时,仍用 max(买一, 内在价值) 抬到合理底价
# bypassmax(买一, 标记, 内在价值)
if bypass_liquidity:
return max(candidates)
if bid is None:
return None
if intrinsic is not None and intrinsic >= 0:
return max(float(bid), float(intrinsic))
return float(bid)
def perp_fill( def perp_fill(
*, *,
side: str, side: str,
+37
View File
@@ -142,3 +142,40 @@ def test_expiry_close() -> None:
assert d2.reason == "expiry" assert d2.reason == "expiry"
d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001) d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001)
assert d3.should_close is True assert d3.should_close is True
def test_option_intrinsic_and_close_bid_floor() -> None:
from app.sim.pricing import 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
assert (
resolve_option_close_bid(
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=True
)
== 22.0
)
# 常规也有内在价值地板
assert (
resolve_option_close_bid(
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=False
)
== 22.0
)
# 买一高于内在价值,保留买一
assert (
resolve_option_close_bid(
bid=25.0, mark=24.0, intrinsic=22.0, bypass_liquidity=True
)
== 25.0
)
# bypass 无买一,用标记与内在价值
assert (
resolve_option_close_bid(
bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True
)
== 22.0
)
+2 -1
View File
@@ -134,7 +134,7 @@
- 买一相对标记偏差默认 ≤ **30%**`close_bid_mark_max_pct`); - 买一相对标记偏差默认 ≤ **30%**`close_bid_mark_max_pct`);
- 不满足 → `liquidity_wait`,继续等待,不改开仓。 - 不满足 → `liquidity_wait`,继续等待,不改开仓。
**到期 / 紧急全平**:绕过上述闸门。 **到期 / 紧急全平**:绕过上述闸门;期权平仓价取 **max(买一, 标记, 内在价值)**,避免到期盘口枯死把实值期权按近零价结算
### 4.4 其它平仓入口 ### 4.4 其它平仓入口
@@ -257,3 +257,4 @@
|------|------| |------|------|
| 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 | | 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 |
| 2026-07-25 | 平仓顺序改为先期权后永续(与开仓同理:薄腿优先) | | 2026-07-25 | 平仓顺序改为先期权后永续(与开仓同理:薄腿优先) |
| 2026-07-26 | 到期/紧急平仓:期权价不低于内在价值,修复垃圾盘口错杀实值 |
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""一键修复服务器上因到期垃圾盘口导致的期权错结算(G-20260725-01)。
用法(本机):
$env:DEPLOY_PASS='...'
python scripts/repair_expiry_settle.py
"""
from __future__ import annotations
import os
import sys
import time
HOST = os.environ.get("DEPLOY_HOST", "47.236.184.99")
USER = os.environ.get("DEPLOY_USER", "root")
PASSWORD = os.environ.get("DEPLOY_PASS", "")
ROOT = os.environ.get("DEPLOY_ROOT", "/opt/eth_hedge_sim")
DB = f"{ROOT}/backend/data/hedge.db"
GROUP = os.environ.get("REPAIR_GROUP_ID", "G-20260725-01")
REMOTE_PY = r'''
import sqlite3, time
DB = "%(db)s"
GROUP = "%(group)s"
FEE = 0.0005
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
g = con.execute("SELECT * FROM groups WHERE group_id=?", (GROUP,)).fetchone()
if not g:
print("group missing", GROUP)
raise SystemExit(1)
if g["status"] != "closed" or g["close_reason"] != "expiry":
print("skip: not closed expiry", dict(g))
raise SystemExit(0)
fills = list(con.execute(
"SELECT * FROM fills WHERE group_id=? ORDER BY ts_ms", (GROUP,)
).fetchall())
o_open = next(f for f in fills if f["leg"]=="option" and f["action"]=="open")
o_close = next(f for f in fills if f["leg"]=="option" and f["action"]=="close")
p_open = next(f for f in fills if f["leg"]=="perp" and f["action"]=="open")
p_close = next(f for f in fills if f["leg"]=="perp" and f["action"]=="close")
strike = float(g["strike"] or 1860)
# 永续平仓为买回空头:fill≈ask*(1+f) → spot≈ask
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:
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
old_cash = float(o_close["notional"]) - float(o_close["fee"])
new_cash = new_notional - new_fee
cash_delta = new_cash - old_cash
opt_entry = float(o_open["fill_px"])
opt_pnl = (new_fill - opt_entry) * qty
perp_entry = float(p_open["fill_px"])
perp_side = str(p_open["side"])
if perp_side == "long":
perp_pnl = (float(p_close["fill_px"]) - perp_entry) * float(p_open["qty_eth"])
else:
perp_pnl = (perp_entry - float(p_close["fill_px"])) * float(p_open["qty_eth"])
net = perp_pnl + opt_pnl - float(p_close["fee"]) - new_fee
# fees on group: replace option close fee contribution
old_opt_fee = float(o_close["fee"])
old_opt_slip = float(o_close["slip"] or 0)
fees = float(g["fees"] or 0) - old_opt_fee + new_fee
slip = float(g["slip_cost"] or 0) - old_opt_slip + new_slip
now = int(time.time() * 1000)
meta = con.execute("SELECT equity, available FROM ledger_meta WHERE id=1").fetchone()
eq = float(meta["equity"]) + cash_delta
av = float(meta["available"]) + cash_delta
con.execute("BEGIN")
con.execute(
"UPDATE fills SET base_px=?, fill_px=?, fee=?, slip=?, notional=? WHERE group_id=? AND leg='option' AND action='close'",
(new_base, new_fill, new_fee, new_slip, new_notional, GROUP),
)
con.execute(
"UPDATE groups SET realized_pnl=?, fees=?, slip_cost=?, note=? WHERE group_id=?",
(net, fees, slip, f"repaired_intrinsic:{intrinsic:.4f}", GROUP),
)
con.execute(
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
(eq, av, now),
)
con.execute(
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
(GROUP, "repair_option_intrinsic", cash_delta, eq,
f"repair {GROUP}: option close {old_base:.4f}->{new_base:.4f} intrinsic={intrinsic:.4f}", now),
)
# also fix close_option entry amount if present
row = con.execute(
"SELECT id, amount FROM ledger_entries WHERE group_id=? AND kind='close_option' ORDER BY id DESC LIMIT 1",
(GROUP,),
).fetchone()
if row:
con.execute(
"UPDATE ledger_entries SET amount=?, note=? WHERE id=?",
(new_cash, f"close option expiry (repaired intrinsic {intrinsic:.4f})", row["id"]),
)
con.commit()
print({
"group": GROUP,
"spot": spot,
"intrinsic": intrinsic,
"old_base": old_base,
"new_base": new_base,
"cash_delta": cash_delta,
"new_equity": eq,
"new_realized_pnl": net,
})
''' % {"db": DB, "group": GROUP}
def main() -> int:
if not PASSWORD:
print("Set DEPLOY_PASS", file=sys.stderr)
return 2
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASSWORD, timeout=30)
cmd = "python3 - <<'PY'\n" + REMOTE_PY + "\nPY"
_, stdout, stderr = client.exec_command(cmd)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
print(out)
if err:
print(err, file=sys.stderr)
client.close()
return code
if __name__ == "__main__":
raise SystemExit(main())