Implement dual target-close paths and residual option expiry.

Document and enforce: A full dual-leg close, B perp-only when deep OTM with residual archive that does not block next open, and expiry settlement when target is missed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 18:32:05 +08:00
parent 96e8e5cb70
commit 4994aaab16
14 changed files with 595 additions and 118 deletions
+1 -62
View File
@@ -5,6 +5,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from ..models.db import get_db
from ..sim.pnl import summarize_fills_pnl
from .auth import require_user
router = APIRouter(prefix="/api/trades", tags=["trades"])
@@ -14,68 +15,6 @@ def _row(r: Any) -> dict:
return dict(r)
def summarize_fills_pnl(fills: list[Any]) -> dict[str, float | None]:
"""
从成交明细重算腿盈亏与净盈亏。
价差盈亏按 fill_px(成交价);手续费另扣。
净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费(开+平)。
"""
rows = [dict(x) for x in fills]
opt_open = next(
(f for f in rows if f.get("leg") == "option" and f.get("action") == "open"),
None,
)
opt_close = next(
(f for f in rows if f.get("leg") == "option" and f.get("action") == "close"),
None,
)
perp_open = next(
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "open"),
None,
)
perp_close = next(
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "close"),
None,
)
option_pnl: float | None = None
if opt_open and opt_close:
qty = float(opt_open.get("qty_eth") or opt_close.get("qty_eth") or 0)
option_pnl = (float(opt_close["fill_px"]) - float(opt_open["fill_px"])) * qty
perp_pnl: float | None = None
if perp_open and perp_close:
qty = float(perp_open.get("qty_eth") or perp_close.get("qty_eth") or 0)
side = str(perp_open.get("side") or "")
o = float(perp_open["fill_px"])
c = float(perp_close["fill_px"])
if side == "long":
perp_pnl = (c - o) * qty
else:
perp_pnl = (o - c) * qty
fees_total = sum(float(f.get("fee") or 0) for f in rows)
gross = None
net = None
if option_pnl is not None and perp_pnl is not None:
gross = option_pnl + perp_pnl
net = gross - fees_total
elif option_pnl is not None:
gross = option_pnl
net = option_pnl - fees_total
elif perp_pnl is not None:
gross = perp_pnl
net = perp_pnl - fees_total
return {
"option_pnl": option_pnl,
"perp_pnl": perp_pnl,
"fees_total": fees_total,
"gross_pnl": gross,
"net_pnl": net,
}
@router.get("/groups")
async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
db = get_db()
+22
View File
@@ -97,6 +97,28 @@ CREATE TABLE IF NOT EXISTS strategy_state (
last_error TEXT,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS residual_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL UNIQUE,
option_inst_id TEXT NOT NULL,
option_side TEXT NOT NULL,
option_qty_eth REAL NOT NULL,
option_qty_contracts REAL,
option_entry_px REAL NOT NULL,
strike REAL,
expiry_ymd TEXT,
expiry_ms INTEGER,
entry_index_px REAL,
initial_premium REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at_ms INTEGER NOT NULL,
settled_at_ms INTEGER,
settle_px REAL,
settle_pnl REAL,
note TEXT,
FOREIGN KEY(group_id) REFERENCES groups(group_id)
);
"""
+312
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 (
is_deep_otm,
option_expiry_settle,
option_fill,
option_intrinsic,
@@ -576,6 +577,317 @@ class Matcher:
},
)
def option_is_deep_otm(self) -> bool:
"""活跃组期权是否远虚(内在价值≈0)。"""
pos = self.current_position()
if pos.get("status") != "open" or not pos.get("group_id"):
return False
group_id = str(pos["group_id"])
option_inst_id = str(pos.get("option_inst_id") or "")
option_side = str(pos.get("option_side") or "")
strike = self._group_strike(group_id, option_inst_id)
spot = self._close_spot_px(get_session().snapshot())
if strike is None or spot is None:
return False
return is_deep_otm(
option_side=option_side, strike=float(strike), spot=float(spot)
)
def close_perp_abandon_option(self, *, reason: str = "target_perp_only") -> CloseResult:
"""
目标平仓 B:只平永续,期权归档为到期残留(不再盯盘、不挡新开)。
"""
s = get_settings()
pos = self.current_position()
if pos.get("status") != "open" or not pos.get("group_id"):
return CloseResult(ok=False, detail="无持仓可平")
group_id = str(pos["group_id"])
sess = get_session()
snap = sess.snapshot()
if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
return CloseResult(ok=False, detail="永续盘口不可用")
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
strike = self._group_strike(group_id, option_inst_id)
spot = self._close_spot_px(snap)
if strike is None or spot is None:
return CloseResult(ok=False, detail="无法判断远虚:缺行权价或标的价")
if not is_deep_otm(
option_side=option_side, strike=float(strike), spot=float(spot)
):
return CloseResult(ok=False, detail="期权非远虚,应走双腿全平")
fee_rate = self._fee_rate()
perp_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"])
perp_entry = float(pos["perp_entry_px"])
pf = perp_fill(
side=perp_side,
action="close",
bid=float(snap.perp.bid),
ask=float(snap.perp.ask),
qty_eth=perp_qty,
fee_rate=fee_rate,
)
if perp_side == "long":
perp_pnl = (pf.fill_px - perp_entry) * perp_qty
else:
perp_pnl = (perp_entry - pf.fill_px) * perp_qty
self.ledger.apply_cash(
perp_pnl - pf.fee,
kind="close_perp",
group_id=group_id,
note=f"close perp {reason} abandon option",
)
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
expiry_ms = None
if expiry_ymd:
try:
from ..exchange.expiry import expiry_ms_from_ymd
expiry_ms = int(expiry_ms_from_ymd(expiry_ymd))
except Exception:
expiry_ms = None
now = int(time.time() * 1000)
open_fees = float((g["fees"] if g else 0) or 0)
fees = open_fees + pf.fee
slip = float((g["slip_cost"] if g else 0) or 0) + pf.slip
# 暂记永续段实现盈亏;期权到期结算后再按全部成交重算
interim_net = perp_pnl - open_fees - pf.fee
with self.db._lock:
self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"perp",
"close",
"flat",
s.perp_inst_id,
perp_qty,
None,
pf.base_px,
pf.fill_px,
pf.fee,
pf.slip,
pf.notional,
now,
),
)
self.db._conn.execute(
"""INSERT INTO residual_options(
group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts,
option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px,
initial_premium, status, created_at_ms, note
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
option_inst_id,
option_side,
float(pos["option_qty_eth"]),
float(pos["option_qty_contracts"] or 0),
float(pos["option_entry_px"]),
float(strike),
expiry_ymd,
expiry_ms,
float(pos["entry_index_px"] or 0),
float(pos["initial_premium"] or 0),
"pending",
now,
f"abandoned after {reason}; deep_otm spot={spot:.4f} K={strike}",
),
)
self.db._conn.execute(
"""UPDATE groups SET status=?, close_reason=?, realized_pnl=?,
fees=?, slip_cost=?, note=? WHERE group_id=?""",
(
"option_residual",
reason,
interim_net,
fees,
slip,
f"perp_closed; option residual until expiry",
group_id,
),
)
self.db._conn.execute(
"""UPDATE positions SET
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
WHERE id=1"""
)
self.db._conn.commit()
return CloseResult(
ok=True,
detail="perp_closed_option_residual",
data={
"group_id": group_id,
"reason": reason,
"mode": "target_perp_only",
"perp_pnl": perp_pnl,
"interim_net": interim_net,
"option_abandoned": True,
"strike": float(strike),
"spot": float(spot),
},
)
def list_residual_options(self, *, pending_only: bool = True) -> list[dict[str, Any]]:
if pending_only:
rows = self.db.fetchall(
"SELECT * FROM residual_options WHERE status='pending' ORDER BY created_at_ms ASC"
)
else:
rows = self.db.fetchall(
"SELECT * FROM residual_options ORDER BY created_at_ms DESC LIMIT 100"
)
return [dict(r) for r in rows]
def settle_due_residuals(self, *, now_ms: int | None = None) -> list[dict[str, Any]]:
"""到期结算所有 pending 残留期权(不扫描进活跃组平仓)。"""
now = int(now_ms if now_ms is not None else time.time() * 1000)
pending = self.db.fetchall(
"SELECT * FROM residual_options WHERE status='pending' ORDER BY id ASC"
)
out: list[dict[str, Any]] = []
for row in pending:
ems = row["expiry_ms"]
if ems is None:
ymd = row["expiry_ymd"]
if ymd:
try:
from ..exchange.expiry import expiry_ms_from_ymd
ems = int(expiry_ms_from_ymd(str(ymd)))
except Exception:
continue
else:
continue
if now < int(ems):
continue
r = self._settle_one_residual(dict(row), now_ms=now)
if r:
out.append(r)
return out
def settle_all_residuals_now(self) -> list[dict[str, Any]]:
"""紧急:立即按内在价值结算全部残留(不等到期)。"""
pending = self.db.fetchall(
"SELECT * FROM residual_options WHERE status='pending' ORDER BY id ASC"
)
now = int(time.time() * 1000)
out: list[dict[str, Any]] = []
for row in pending:
r = self._settle_one_residual(dict(row), now_ms=now, force=True)
if r:
out.append(r)
return out
def _settle_one_residual(
self, row: dict[str, Any], *, now_ms: int, force: bool = False
) -> dict[str, Any] | None:
group_id = str(row["group_id"])
sess = get_session()
snap = sess.snapshot()
spot = self._close_spot_px(snap)
strike = row["strike"]
if strike is None or spot is None:
logger = __import__("logging").getLogger(__name__)
logger.warning("residual settle skip %s: no strike/spot", group_id)
return None
fee_rate = self._fee_rate()
intrinsic = option_intrinsic(
option_side=str(row["option_side"]),
strike=float(strike),
spot=float(spot),
)
of = option_expiry_settle(
intrinsic=float(intrinsic),
qty_eth=float(row["option_qty_eth"]),
fee_rate=fee_rate,
)
opt_entry = float(row["option_entry_px"])
opt_qty = float(row["option_qty_eth"])
opt_pnl = (of.fill_px - opt_entry) * opt_qty
opt_cash = of.notional - of.fee
self.ledger.apply_cash(
opt_cash,
kind="close_option",
group_id=group_id,
note=f"residual option expiry settle{' force' if force else ''}",
)
with self.db._lock:
self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"option",
"close",
"flat",
str(row["option_inst_id"]),
opt_qty,
float(row["option_qty_contracts"] or 0),
of.base_px,
of.fill_px,
of.fee,
of.slip,
of.notional,
now_ms,
),
)
fills = self.db._conn.execute(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
).fetchall()
from ..sim.pnl import summarize_fills_pnl
summary = summarize_fills_pnl(list(fills))
net = summary.get("net_pnl")
if net is None:
net = opt_pnl - of.fee
g = self.db._conn.execute(
"SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,)
).fetchone()
fees = float(g["fees"] or 0) + of.fee
slip = float(g["slip_cost"] or 0) + of.slip
self.db._conn.execute(
"""UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=?
WHERE group_id=?""",
(
"settled",
now_ms,
of.fill_px,
opt_pnl,
"settled at intrinsic",
group_id,
),
)
self.db._conn.execute(
"""UPDATE groups SET status=?, close_at_ms=?, realized_pnl=?, fees=?, slip_cost=?
WHERE group_id=?""",
("closed", now_ms, float(net), fees, slip, group_id),
)
self.db._conn.commit()
return {
"group_id": group_id,
"option_pnl": opt_pnl,
"settle_px": of.fill_px,
"net_pnl": net,
"forced": force,
}
def unrealized(self) -> dict[str, Any]:
pos = self.current_position()
if pos.get("status") != "open":
+67
View File
@@ -0,0 +1,67 @@
"""从成交明细汇总腿盈亏与净盈亏。"""
from __future__ import annotations
from typing import Any
def summarize_fills_pnl(fills: list[Any]) -> dict[str, float | None]:
"""
价差盈亏按 fill_px;手续费另扣。
净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费(开+平)。
允许只有永续已平、期权尚未结算的半组。
"""
rows = [dict(x) for x in fills]
opt_open = next(
(f for f in rows if f.get("leg") == "option" and f.get("action") == "open"),
None,
)
opt_close = next(
(f for f in rows if f.get("leg") == "option" and f.get("action") == "close"),
None,
)
perp_open = next(
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "open"),
None,
)
perp_close = next(
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "close"),
None,
)
option_pnl: float | None = None
if opt_open and opt_close:
qty = float(opt_open.get("qty_eth") or opt_close.get("qty_eth") or 0)
option_pnl = (float(opt_close["fill_px"]) - float(opt_open["fill_px"])) * qty
perp_pnl: float | None = None
if perp_open and perp_close:
qty = float(perp_open.get("qty_eth") or perp_close.get("qty_eth") or 0)
side = str(perp_open.get("side") or "")
o = float(perp_open["fill_px"])
c = float(perp_close["fill_px"])
if side == "long":
perp_pnl = (c - o) * qty
else:
perp_pnl = (o - c) * qty
fees_total = sum(float(f.get("fee") or 0) for f in rows)
gross = None
net = None
if option_pnl is not None and perp_pnl is not None:
gross = option_pnl + perp_pnl
net = gross - fees_total
elif option_pnl is not None:
gross = option_pnl
net = option_pnl - fees_total
elif perp_pnl is not None:
gross = perp_pnl
net = perp_pnl - fees_total
return {
"option_pnl": option_pnl,
"perp_pnl": perp_pnl,
"fees_total": fees_total,
"gross_pnl": gross,
"net_pnl": net,
}
+16
View File
@@ -29,6 +29,22 @@ def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float:
return 0.0
def is_deep_otm(
*,
option_side: str,
strike: float,
spot: float,
max_intrinsic: float = 0.01,
) -> bool:
"""
远虚:内在价值≈0(多头期权已无行权价值)。
100×杠杆 ATM 在标的波动约1%后常落入此状态。
"""
return option_intrinsic(
option_side=option_side, strike=strike, spot=spot
) <= float(max_intrinsic)
def option_expiry_settle(
*,
intrinsic: float,
+57 -9
View File
@@ -88,6 +88,7 @@ class StrategyEngine:
"can_open": allow_open,
"last_error": last_error,
"position": upl,
"residuals": self.matcher.list_residual_options(pending_only=True),
"ledger": self.ledger.snapshot(),
}
@@ -118,16 +119,24 @@ class StrategyEngine:
async def emergency_close(self) -> dict[str, Any]:
async with self._lock:
# 紧急全平:绕过期权流动性/偏差校验
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
if r.ok:
self._after_close()
close_data: dict[str, Any] | None = None
detail = "flat"
ok = True
pos = self.matcher.current_position()
if pos.get("status") == "open":
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
ok = r.ok
detail = r.detail
close_data = r.data
if r.ok:
self._after_close()
residuals = self.matcher.settle_all_residuals_now()
return {
"close": {
"ok": r.ok,
"detail": r.detail,
"liquidity_wait": r.liquidity_wait,
"data": r.data,
"ok": ok,
"detail": detail,
"data": close_data,
"residuals_settled": residuals,
},
"state": self.state(),
}
@@ -175,9 +184,27 @@ class StrategyEngine:
reason: str,
bypass_liquidity: bool,
pending_close: bool,
abandon_if_deep_otm: bool = False,
) -> None:
if not pending_close:
self._set_state(phase="closing", last_error=None)
# 目标平仓 B:远虚 → 只平永续,期权归档
if abandon_if_deep_otm and reason != "expiry" and self.matcher.option_is_deep_otm():
r = await asyncio.to_thread(
self.matcher.close_perp_abandon_option,
reason="target_perp_only",
)
if r.ok:
self._after_close()
self._set_state(
last_error=None,
phase="resting",
)
else:
self._set_state(phase="closing", last_error=r.detail)
return
r = await asyncio.to_thread(
self.matcher.close_group,
reason=reason,
@@ -186,12 +213,25 @@ class StrategyEngine:
if r.ok:
self._after_close()
elif r.liquidity_wait and not bypass_liquidity:
# 等待期间若已变成远虚,下一 tick 走归档
if self.matcher.option_is_deep_otm():
r2 = await asyncio.to_thread(
self.matcher.close_perp_abandon_option,
reason="target_perp_only",
)
if r2.ok:
self._after_close()
return
self._set_state(phase="liquidity_wait", last_error=r.detail)
else:
self._set_state(phase="closing", last_error=r.detail)
async def _settle_residuals(self) -> None:
await asyncio.to_thread(self.matcher.settle_due_residuals)
async def _maybe_expiry_close(self) -> bool:
"""若持仓已到期则强制全平。返回是否触发到期平仓。"""
await self._settle_residuals()
pos = self.matcher.current_position()
if pos.get("status") != "open":
return False
@@ -206,6 +246,7 @@ class StrategyEngine:
reason="expiry",
bypass_liquidity=True,
pending_close=pending,
abandon_if_deep_otm=False,
)
return True
@@ -242,6 +283,9 @@ class StrategyEngine:
await asyncio.sleep(1)
async def _tick_async(self) -> None:
# 残留期权到期结算(与活跃组隔离,不挡开仓)
await self._settle_residuals()
s = get_settings()
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
@@ -260,7 +304,7 @@ class StrategyEngine:
)
pos = self.matcher.current_position()
# 有未平仓:只盯平仓,绝不开下一组
# 有活跃持仓:只盯当前组平仓;残留期权不在此扫描
if pos.get("status") == "open":
upl = self.matcher.unrealized()
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
@@ -276,13 +320,17 @@ class StrategyEngine:
if expired.should_close:
reason = "expiry"
bypass = True
abandon = False
else:
reason = decision.reason or "liquidity_retry"
bypass = False
# 目标达标(或流动性等待重试)时:远虚走只平永续
abandon = bool(decision.should_close or pending_close)
await self._close_open_position(
reason=reason,
bypass_liquidity=bypass,
pending_close=pending_close,
abandon_if_deep_otm=abandon,
)
else:
self._set_state(phase="open", last_error=None)
+12
View File
@@ -144,6 +144,18 @@ def test_expiry_close() -> None:
assert d3.should_close is True
def test_deep_otm_and_expiry_settle() -> None:
from app.sim.pricing import is_deep_otm, option_expiry_settle, option_intrinsic
assert is_deep_otm(option_side="call", strike=1860, spot=1840) is True
assert is_deep_otm(option_side="call", strike=1860, spot=1882) is False
assert is_deep_otm(option_side="put", strike=1860, spot=1882) is True
assert option_intrinsic(option_side="call", strike=1860, spot=1840) == 0.0
settled = option_expiry_settle(intrinsic=0.0, qty_eth=2.0, fee_rate=0.0005)
assert settled.fill_px == 0.0
assert settled.notional == 0.0
def test_option_intrinsic_and_close_bid_floor() -> None:
from app.sim.pricing import (
option_expiry_settle,