From 3520fc0214f7b9efbc5bd77f5b3cccab4cc1b8ae Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 11 Aug 2026 17:02:44 +0800 Subject: [PATCH] Fix OO expiry Put PnL: overlay wrong near-zero settlement fills with intrinsic. Co-authored-by: Cursor --- backend/app/api/trades.py | 45 ++++++++++++++-------- backend/tests/test_oo_settle_index.py | 54 +++++++++++++++++++++++++++ docs/更新说明.md | 12 ++++++ scripts/repair_oo_settle_index.py | 6 ++- 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index 0627857..cf7d09d 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -82,10 +82,14 @@ def _infer_settle_index(g: dict, fills: list) -> float | None: return None -def _overlay_expiry_zero_fills( +def _overlay_expiry_intrinsic_fills( g: dict, fills: list, settle_index: float | None ) -> list: - """到期 close 价为 0 且已有结算指数时,用内在价值覆盖展示(响应层,不写库)。""" + """到期且已有结算指数:close 成交按内在价值覆盖展示/盈亏(响应层)。 + + 交易所账单偶发落成近 0 价(如 0.2),与内在价值(如 42.2)严重不符时 + 若只覆盖 fill≈0,Put 仍会按错误价算成巨亏。 + """ if settle_index is None or settle_index <= 0: return fills if str(g.get("close_reason") or "") != "expiry": @@ -102,17 +106,21 @@ def _overlay_expiry_zero_fills( px = float(f.get("fill_px") or 0) except (TypeError, ValueError): px = 0.0 - if px <= 1e-9: - leg = str(f.get("leg") or "") - 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 not None: - try: - intrinsic = _intrinsic(side, float(settle_index), float(strike)) + leg = str(f.get("leg") or "") + 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 not None: + try: + intrinsic = float( + _intrinsic(side, float(settle_index), float(strike)) + ) + # 与内在价值偏差超过 0.5 USDT(或相对 5%)则覆盖 + tol = max(0.5, abs(intrinsic) * 0.05) + if abs(px - intrinsic) > tol: qty = float(f.get("qty_eth") or 0) f["fill_px"] = intrinsic f["base_px"] = intrinsic @@ -120,11 +128,16 @@ def _overlay_expiry_zero_fills( f["slip"] = 0.0 f["_overlay_intrinsic"] = True changed = True - except (TypeError, ValueError): - pass + except (TypeError, ValueError): + pass out.append(f) return out if changed else fills + +# 兼容旧测试名 +_overlay_expiry_zero_fills = _overlay_expiry_intrinsic_fills + + def _intrinsic(side: str, settle_index: float, strike: float) -> float: s = str(side or "").lower() if s in ("call", "c"): @@ -264,7 +277,7 @@ def _enrich_group(g: dict, fills: list) -> dict: is_oo = _is_oo_group(g) g["is_oo"] = is_oo settle = _infer_settle_index(g, fills) - view_fills = _overlay_expiry_zero_fills(g, fills, settle) + view_fills = _overlay_expiry_intrinsic_fills(g, fills, settle) overlaid = any( isinstance(f, dict) and f.get("_overlay_intrinsic") for f in view_fills ) diff --git a/backend/tests/test_oo_settle_index.py b/backend/tests/test_oo_settle_index.py index 9755e80..85c5e44 100644 --- a/backend/tests/test_oo_settle_index.py +++ b/backend/tests/test_oo_settle_index.py @@ -76,3 +76,57 @@ def test_public_fallback_and_overlay(monkeypatch) -> None: view = _overlay_expiry_zero_fills(g, fills, 1877.8) assert view[0]["fill_px"] == 0.0 # call OTM assert abs(view[1]["fill_px"] - (1920 - 1877.8)) < 1e-9 + + +def test_overlay_wrong_near_zero_put_fill() -> None: + """账单误写成 0.2 时,应按内在价值 42.2 覆盖,Put 显示盈利。""" + g = { + "hedge_mode": "option_option", + "option_side": "call", + "option2_side": "put", + "strike": 1940.0, + "strike2": 1920.0, + "close_reason": "expiry", + } + fills = [ + { + "leg": "option", + "action": "open", + "fill_px": 12.0, + "qty_eth": 7.5, + "fee": 0.045, + "slip": 0, + }, + { + "leg": "option2", + "action": "open", + "fill_px": 12.8, + "qty_eth": 7.0, + "fee": 0.045, + "slip": 0, + }, + { + "leg": "option", + "action": "close", + "fill_px": 0.0, + "qty_eth": 7.5, + "fee": 0, + "slip": 0, + }, + { + "leg": "option2", + "action": "close", + "fill_px": 0.2, + "qty_eth": 7.0, + "fee": 0.0007, + "slip": 0, + }, + ] + from app.sim.pnl import summarize_fills_pnl + + view = _overlay_expiry_zero_fills(g, fills, 1877.8) + assert abs(view[3]["fill_px"] - 42.2) < 1e-9 + s = summarize_fills_pnl(view) + # Put: (42.2 - 12.8) * 7 = 205.8 + assert abs(float(s["option2_pnl"] or 0) - 205.8) < 1e-6 + assert float(s["option2_pnl"] or 0) > 0 diff --git a/docs/更新说明.md b/docs/更新说明.md index c36719c..6285df6 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,18 @@ --- +## 2026-08-11 — 期期到期盈亏:错误近零成交按内在价值覆盖 + +### 变更 + +到期 close 成交与内在价值偏差过大(如 Put 账单 0.2 vs 内在 42.2)时,详情按内在价值重算腿盈亏;不再只覆盖 fill=0。 + +### 审计 + +G-20260810-01:结算指数 1877.8、Put 内在 42.2 正确,但成交仍用 0.2 → Put 显示 −88。 + +--- + ## 2026-08-11 — 期期到期结算指数:公开行情回退 + 零成交覆盖 ### 变更 diff --git a/scripts/repair_oo_settle_index.py b/scripts/repair_oo_settle_index.py index 9527fca..8602bed 100644 --- a/scripts/repair_oo_settle_index.py +++ b/scripts/repair_oo_settle_index.py @@ -86,14 +86,16 @@ if REPAIR_FILLS: ).fetchone() if not row: continue - if abs(float(row["fill_px"] or 0)) > 1e-9: + old_px = float(row["fill_px"] or 0) + tol = max(0.5, abs(iv) * 0.05) + if abs(old_px - iv) <= tol: continue qty = float(row["qty_eth"] or 0) con.execute( "UPDATE fills SET base_px=?, fill_px=?, notional=?, slip=0 WHERE id=?", (iv, iv, iv * qty, row["id"]), ) - updated.append((leg, iv, qty)) + updated.append((leg, old_px, iv, qty)) # 重算 realized fills = list(con.execute("SELECT * FROM fills WHERE group_id=?", (GROUP,))) opt = {}