From 3264dd438157b301774c9d813a51ab5ba3c0a253 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 11 Aug 2026 16:44:56 +0800 Subject: [PATCH] Fix OO expiry settle index: persist spot and never invent strike from OTM fill. Co-authored-by: Cursor --- backend/app/api/trades.py | 53 ++++++++++++++++++++------- backend/app/live/binance_executor.py | 9 ++++- backend/app/live/executor.py | 9 ++++- backend/app/sim/matcher.py | 9 ++++- backend/tests/test_oo_settle_index.py | 48 ++++++++++++++++++++++++ docs/更新说明.md | 13 +++++++ 6 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 backend/tests/test_oo_settle_index.py diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index 08d37ef..3b5e3c8 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -25,6 +25,7 @@ def _is_oo_group(g: dict) -> bool: def _infer_settle_index(g: dict, fills: list) -> float | None: + """优先库内 settle_index_px;否则用「实值腿」成交反推。虚值 fill≈0 时禁止推成行权价。""" settle_index = g.get("settle_index_px") if settle_index is not None: try: @@ -33,25 +34,44 @@ def _infer_settle_index(g: dict, fills: list) -> float | None: return v except (TypeError, ValueError): pass - strike = g.get("strike") - side = str(g.get("option_side") or "").lower() - if strike is None: - return None + + candidates: list[float] = [] for raw in fills: f = dict(raw) if not isinstance(raw, dict) else raw - if str(f.get("leg")) != "option" or str(f.get("action")) != "close": + if str(f.get("action") or "") != "close": + continue + leg = str(f.get("leg") or "") + if leg not in ("option", "option2"): continue if abs(float(f.get("slip") or 0)) > 1e-12: continue - px = float(f.get("fill_px") or 0) - k = float(strike) + try: + px = float(f.get("fill_px") or 0) + except (TypeError, ValueError): + continue + # 虚值到期 fill=0:k+0 / k-0 会得到行权价,不是真实结算指数 + if px <= 1e-9: + continue + if leg == "option": + strike = g.get("strike") + side = str(g.get("option_side") or "").lower() + else: + strike = g.get("strike2") + side = str(g.get("option2_side") or "put").lower() + if strike is None: + continue + try: + k = float(strike) + except (TypeError, ValueError): + continue if side in ("call", "c"): - return k + px - if side in ("put", "p"): - return k - px - break - return None - + candidates.append(k + px) + elif side in ("put", "p"): + candidates.append(k - px) + if not candidates: + return None + # 多腿一致时取平均;实值腿通常只有一条 + return round(sum(candidates) / len(candidates), 4) def _intrinsic(side: str, settle_index: float, strike: float) -> float: s = str(side or "").lower() @@ -112,7 +132,7 @@ def _expiry_settle_info(g: dict, fills: list) -> dict | None: def _close_index_px(g: dict, fills: list) -> float | None: - """平仓时标的指数:优先 settle_index_px,否则用永续平仓价近似。""" + """平仓时标的指数:优先库内 settle;到期才用实值腿反推;否则永续平仓价。""" raw = g.get("settle_index_px") if raw is not None: try: @@ -121,6 +141,11 @@ def _close_index_px(g: dict, fills: list) -> float | None: return v except (TypeError, ValueError): pass + # 仅到期:期权平仓价=内在价值,可反推指数;中途卖出的权利金不能当指数 + if str(g.get("close_reason") or "") == "expiry": + inferred = _infer_settle_index(g, fills) + if inferred is not None and inferred > 0: + return inferred for row in fills: f = dict(row) if not isinstance(row, dict) else row if str(f.get("leg") or "") == "perp" and str(f.get("action") or "") == "close": diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 664a939..083c933 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -1082,16 +1082,23 @@ class BinanceLiveExecutor(Matcher): ) summary = summarize_fills_pnl(list(fill_rows)) net = float(summary.get("net_pnl") or 0.0) + settle_px = None + if reason == "expiry": + try: + settle_px = self._close_spot_px(get_session().snapshot()) + except Exception: + settle_px = None with self.db._lock: self.db._conn.execute( """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, - note=? WHERE group_id=?""", + note=?, settle_index_px=COALESCE(?, settle_index_px) WHERE group_id=?""", ( "closed", int(time.time() * 1000), reason, net, f"oo full close {reason} exchange_flat_or_settle", + float(settle_px) if settle_px is not None else None, group_id, ), ) diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 716e9bd..047c896 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -1129,16 +1129,23 @@ class OkxLiveExecutor(Matcher): ) summary = summarize_fills_pnl(list(fill_rows)) net = float(summary.get("net_pnl") or 0.0) + settle_px = None + if reason == "expiry": + try: + settle_px = self._close_spot_px(get_session().snapshot()) + except Exception: + settle_px = None with self.db._lock: self.db._conn.execute( """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, - note=? WHERE group_id=?""", + note=?, settle_index_px=COALESCE(?, settle_index_px) WHERE group_id=?""", ( "closed", int(time.time() * 1000), reason, net, f"oo full close {reason} exchange_flat_or_settle", + float(settle_px) if settle_px is not None else None, group_id, ), ) diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 31782ca..940d799 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -1000,9 +1000,15 @@ class Matcher: now += 1 with self.db._lock: + settle_px = None + if reason == "expiry": + try: + settle_px = self._close_spot_px(get_session().snapshot()) + except Exception: + settle_px = None self.db._conn.execute( """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, - fees=COALESCE(fees,0)+?, note=? + fees=COALESCE(fees,0)+?, note=?, settle_index_px=COALESCE(?, settle_index_px) WHERE group_id=?""", ( "closed", @@ -1011,6 +1017,7 @@ class Matcher: float(total_pnl), float(total_fees), f"oo full close {reason}", + float(settle_px) if settle_px is not None else None, group_id, ), ) diff --git a/backend/tests/test_oo_settle_index.py b/backend/tests/test_oo_settle_index.py new file mode 100644 index 0000000..25dbb91 --- /dev/null +++ b/backend/tests/test_oo_settle_index.py @@ -0,0 +1,48 @@ +"""期期到期结算指数:虚值 Call fill=0 不得反推成行权价。""" + +from __future__ import annotations + +from app.api.trades import _infer_settle_index + + +def test_otm_call_zero_fill_does_not_become_strike() -> None: + g = { + "hedge_mode": "option_option", + "option_side": "call", + "option2_side": "put", + "strike": 1920.0, + "strike2": 1890.0, + "settle_index_px": None, + } + fills = [ + {"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}, + {"leg": "option2", "action": "close", "fill_px": 0.0, "slip": 0}, + ] + assert _infer_settle_index(g, fills) is None + + +def test_itm_put_fill_infers_settle_near_1875() -> None: + g = { + "hedge_mode": "option_option", + "option_side": "call", + "option2_side": "put", + "strike": 1920.0, + "strike2": 1920.0, + "settle_index_px": None, + } + fills = [ + {"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}, + {"leg": "option2", "action": "close", "fill_px": 45.0, "slip": 0}, + ] + # put intrinsic 45 → settle = 1920 - 45 = 1875 + assert _infer_settle_index(g, fills) == 1875.0 + + +def test_stored_settle_wins() -> None: + g = { + "option_side": "call", + "strike": 1920.0, + "settle_index_px": 1875.2, + } + fills = [{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}] + assert _infer_settle_index(g, fills) == 1875.2 diff --git a/docs/更新说明.md b/docs/更新说明.md index be9052c..f61c1b1 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,19 @@ --- +## 2026-08-11 — 期期到期结算指数修正 + +### 变更 + +1. 期期 `close_oo_full`(SIM/OKX/BN)到期写入真实 `settle_index_px`(快照指数)。 +2. 交易详情反推结算价:虚值 Call fill=0 不再误显示为行权价;优先实值 Put 腿反推。 + +### 审计 + +今天下午约 1875 到期,详情曾把结算指数显示成行权价(如 1920):因未落库 settle,且用 Call 内在价值 0 反推成 `strike+0`。 + +--- + ## 2026-08-11 — 期期交易记录展示与盈亏汇总 ### 变更