From 77d2effb6d566c5b6f84d7c5047cb268ed858d01 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 11 Aug 2026 16:58:02 +0800 Subject: [PATCH] Backfill OO expiry settle display via public ETHUSDT and intrinsic overlay. Co-authored-by: Cursor --- backend/app/api/public_index.py | 65 ++++++++++ backend/app/api/trades.py | 100 +++++++++++++-- backend/tests/test_oo_settle_index.py | 36 +++++- docs/更新说明.md | 14 ++ scripts/repair_oo_settle_index.py | 177 ++++++++++++++++++++++++++ 5 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 backend/app/api/public_index.py create mode 100644 scripts/repair_oo_settle_index.py diff --git a/backend/app/api/public_index.py b/backend/app/api/public_index.py new file mode 100644 index 0000000..1965961 --- /dev/null +++ b/backend/app/api/public_index.py @@ -0,0 +1,65 @@ +"""公开行情辅助:补历史到期结算指数展示(不发明成交现金)。""" + +from __future__ import annotations + +import json +import logging +import urllib.error +import urllib.request +from typing import Any + +logger = logging.getLogger(__name__) + +_CACHE: dict[int, float] = {} +_CACHE_MAX = 256 + + +def looks_binance_option(inst_id: str | None) -> bool: + return "USD_UM" in str(inst_id or "") + + +def eth_usdt_close_at_ms(ts_ms: int | None) -> float | None: + """币安 ETHUSDT 1m K 线收盘价(近似期权结算指数)。失败返回 None。""" + if ts_ms is None: + return None + try: + ms = int(ts_ms) + except (TypeError, ValueError): + return None + if ms <= 0: + return None + minute = (ms // 60_000) * 60_000 + cached = _CACHE.get(minute) + if cached is not None: + return cached + url = ( + "https://api.binance.com/api/v3/klines" + f"?symbol=ETHUSDT&interval=1m&startTime={minute}&limit=1" + ) + try: + with urllib.request.urlopen(url, timeout=4) as resp: + raw = resp.read().decode("utf-8", "replace") + rows = json.loads(raw) + if not rows: + return None + close_px = float(rows[0][4]) + if close_px <= 0: + return None + if len(_CACHE) >= _CACHE_MAX: + _CACHE.clear() + _CACHE[minute] = close_px + return close_px + except (urllib.error.URLError, TimeoutError, ValueError, TypeError, IndexError) as e: + logger.debug("eth_usdt_close_at_ms failed ms=%s: %s", minute, e) + return None + + +def maybe_public_settle_index(g: dict[str, Any]) -> float | None: + """库内无结算价时,币安期权到期组用公开 ETHUSDT 收盘近似。""" + if str(g.get("close_reason") or "") != "expiry": + return None + inst = g.get("option_inst_id") or g.get("option2_inst_id") + if not looks_binance_option(str(inst) if inst else None): + return None + ts = g.get("close_at_ms") or g.get("hold_close_at_ms") + return eth_usdt_close_at_ms(ts if ts is not None else None) diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index 3b5e3c8..0627857 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -25,7 +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_px;否则用「实值腿」成交反推;再否则公开指数近似。""" settle_index = g.get("settle_index_px") if settle_index is not None: try: @@ -68,10 +68,62 @@ def _infer_settle_index(g: dict, fills: list) -> float | 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) + if candidates: + return round(sum(candidates) / len(candidates), 4) + + try: + from .public_index import maybe_public_settle_index + + pub = maybe_public_settle_index(g) + if pub is not None and pub > 0: + return round(float(pub), 4) + except Exception: + pass + return None + + +def _overlay_expiry_zero_fills( + g: dict, fills: list, settle_index: float | None +) -> list: + """到期 close 价为 0 且已有结算指数时,用内在价值覆盖展示(响应层,不写库)。""" + if settle_index is None or settle_index <= 0: + return fills + if str(g.get("close_reason") or "") != "expiry": + return fills + out: list = [] + changed = False + for raw in fills: + f = dict(raw) if not isinstance(raw, dict) else dict(raw) + if str(f.get("action") or "") == "close" and str(f.get("leg") or "") in ( + "option", + "option2", + ): + try: + 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)) + qty = float(f.get("qty_eth") or 0) + f["fill_px"] = intrinsic + f["base_px"] = intrinsic + f["notional"] = intrinsic * qty + f["slip"] = 0.0 + f["_overlay_intrinsic"] = True + changed = True + except (TypeError, ValueError): + pass + out.append(f) + return out if changed else fills def _intrinsic(side: str, settle_index: float, strike: float) -> float: s = str(side or "").lower() @@ -211,15 +263,26 @@ def _option_leverage_for_leg( def _enrich_group(g: dict, fills: list) -> dict: is_oo = _is_oo_group(g) g["is_oo"] = is_oo - summary = summarize_fills_pnl(fills) - # LIVE:优先 groups.realized_pnl(已按交易所回写,含资金费) - if str(g.get("exec_mode") or "").upper() == "LIVE" and g.get("realized_pnl") is not None: + settle = _infer_settle_index(g, fills) + view_fills = _overlay_expiry_zero_fills(g, fills, settle) + overlaid = any( + isinstance(f, dict) and f.get("_overlay_intrinsic") for f in view_fills + ) + summary = summarize_fills_pnl(view_fills) + # LIVE 且未做内在价值覆盖:优先 groups.realized_pnl(含资金费) + if ( + not overlaid + and str(g.get("exec_mode") or "").upper() == "LIVE" + and g.get("realized_pnl") is not None + ): summary = dict(summary) summary["net_pnl"] = float(g["realized_pnl"]) if g.get("funding_usdt") is not None: summary["funding_usdt"] = float(g["funding_usdt"]) summary["pnl_source"] = "live_exchange" - # 期期 SIM:若成交汇总缺腿但组上已有 realized_pnl,用组值兜底 + elif overlaid: + summary = dict(summary) + summary["pnl_source"] = "expiry_intrinsic_overlay" elif ( is_oo and g.get("realized_pnl") is not None @@ -240,17 +303,20 @@ def _enrich_group(g: dict, fills: list) -> dict: prem2 = float(g.get("initial_premium2") or 0) if is_oo else 0.0 g["total_initial_premium"] = prem1 + prem2 if is_oo else prem1 g.update(hold_timing(g, fills)) - info = _expiry_settle_info(g, fills) + if settle is not None and g.get("settle_index_px") is None: + g["settle_index_px"] = float(settle) + info = _expiry_settle_info(g, view_fills) if info: g["expiry_settle"] = info if g.get("settle_index_px") is None and info.get("settle_index_px") is not None: g["settle_index_px"] = info["settle_index_px"] - mp = _move_points(g, fills) + mp = _move_points(g, view_fills) g["move_points"] = mp - g["close_index_px"] = _close_index_px(g, fills) + g["close_index_px"] = _close_index_px(g, view_fills) g["option_leverage"] = _option_leverage_for_leg(g, fills, leg="option") if is_oo: g["option2_leverage"] = _option_leverage_for_leg(g, fills, leg="option2") + g["_view_fills"] = view_fills return g @@ -265,7 +331,9 @@ async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (g["group_id"],), ) - groups.append(_enrich_group(g, fills)) + gr = _enrich_group(g, fills) + gr.pop("_view_fills", None) + groups.append(gr) return {"groups": groups} @@ -281,9 +349,13 @@ async def group_detail( "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,) ) gr = _enrich_group(_row(g), fills) + view_fills = gr.pop("_view_fills", None) or fills return { "group": gr, - "fills": [_row(x) for x in fills], + "fills": [ + {k: v for k, v in (dict(x) if not isinstance(x, dict) else x).items() if k != "_overlay_intrinsic"} + for x in view_fills + ], "pnl_summary": gr.get("pnl_summary"), } diff --git a/backend/tests/test_oo_settle_index.py b/backend/tests/test_oo_settle_index.py index 25dbb91..9755e80 100644 --- a/backend/tests/test_oo_settle_index.py +++ b/backend/tests/test_oo_settle_index.py @@ -2,22 +2,28 @@ from __future__ import annotations -from app.api.trades import _infer_settle_index +from app.api.trades import _infer_settle_index, _overlay_expiry_zero_fills -def test_otm_call_zero_fill_does_not_become_strike() -> None: +def test_otm_call_zero_fill_does_not_become_strike(monkeypatch) -> None: g = { "hedge_mode": "option_option", "option_side": "call", "option2_side": "put", + "option_inst_id": "ETH-USD-260811-1920-C", # OKX 样式:不走公开回退 "strike": 1920.0, "strike2": 1890.0, "settle_index_px": None, + "close_reason": "expiry", + "close_at_ms": 1, } fills = [ {"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}, {"leg": "option2", "action": "close", "fill_px": 0.0, "slip": 0}, ] + monkeypatch.setattr( + "app.api.public_index.maybe_public_settle_index", lambda _g: None + ) assert _infer_settle_index(g, fills) is None @@ -34,7 +40,6 @@ def test_itm_put_fill_infers_settle_near_1875() -> None: {"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 @@ -46,3 +51,28 @@ def test_stored_settle_wins() -> None: } fills = [{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}] assert _infer_settle_index(g, fills) == 1875.2 + + +def test_public_fallback_and_overlay(monkeypatch) -> None: + g = { + "hedge_mode": "option_option", + "option_side": "call", + "option2_side": "put", + "option_inst_id": "ETH-USD_UM-260811-1940-C", + "strike": 1940.0, + "strike2": 1920.0, + "settle_index_px": None, + "close_reason": "expiry", + "close_at_ms": 1786435200000, + } + fills = [ + {"leg": "option", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0}, + {"leg": "option2", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0}, + ] + monkeypatch.setattr( + "app.api.public_index.maybe_public_settle_index", lambda _g: 1877.8 + ) + assert _infer_settle_index(g, fills) == 1877.8 + 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 diff --git a/docs/更新说明.md b/docs/更新说明.md index f61c1b1..c36719c 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,20 @@ --- +## 2026-08-11 — 期期到期结算指数:公开行情回退 + 零成交覆盖 + +### 变更 + +1. 币安期期到期若库内无 `settle_index_px`、成交也为 0:用到期时刻 ETHUSDT 1m 收盘近似结算指数(约 1877)。 +2. 详情用内在价值覆盖展示零价到期成交,并重算 Call/Put 盈亏。 +3. `scripts/repair_oo_settle_index.py` 可写回库(需 `DEPLOY_PASS`)。 + +### 审计 + +G-20260810-01 更新后仍显示「结算指数 —」:旧记录未落库 settle,且两腿 close=0,仅禁止误推后变成空。 + +--- + ## 2026-08-11 — 期期到期结算指数修正 ### 变更 diff --git a/scripts/repair_oo_settle_index.py b/scripts/repair_oo_settle_index.py new file mode 100644 index 0000000..9527fca --- /dev/null +++ b/scripts/repair_oo_settle_index.py @@ -0,0 +1,177 @@ +"""回填期期到期组的 settle_index(及可选内在价值成交)。 + +用法: + $env:DEPLOY_PASS='...' + $env:REPAIR_GROUP_ID='G-20260810-01' + # 可选: $env:SETTLE_INDEX='1877.8' 不设则用币安 ETHUSDT 1m 收盘 + python scripts/repair_oo_settle_index.py +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.request + +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-20260810-01") +SETTLE = os.environ.get("SETTLE_INDEX", "").strip() +REPAIR_FILLS = os.environ.get("REPAIR_FILLS", "1").strip() not in ("0", "false", "no") + + +def _eth_close(ms: int) -> float | None: + minute = (int(ms) // 60_000) * 60_000 + url = ( + "https://api.binance.com/api/v3/klines" + f"?symbol=ETHUSDT&interval=1m&startTime={minute}&limit=1" + ) + with urllib.request.urlopen(url, timeout=10) as resp: + rows = json.loads(resp.read().decode()) + if not rows: + return None + return float(rows[0][4]) + + +REMOTE = r''' +import sqlite3 +DB = %(db)r +GROUP = %(group)r +SETTLE = %(settle)s +REPAIR_FILLS = %(repair_fills)s + +con = sqlite3.connect(DB) +con.row_factory = sqlite3.Row +g = con.execute("SELECT * FROM groups WHERE group_id=?", (GROUP,)).fetchone() +if not g: + print("missing", GROUP) + raise SystemExit(1) +if str(g["close_reason"] or "") != "expiry": + print("not expiry", GROUP) + raise SystemExit(1) +settle = float(SETTLE) +strike = float(g["strike"] or 0) +strike2 = float(g["strike2"] or 0) if g["strike2"] is not None else None +side = str(g["option_side"] or "call").lower() +side2 = str(g["option2_side"] or "put").lower() + +def intrinsic(side, spot, k): + if k is None: + return None + if side in ("call", "c"): + return max(spot - k, 0.0) + if side in ("put", "p"): + return max(k - spot, 0.0) + return 0.0 + +c_iv = intrinsic(side, settle, strike) +p_iv = intrinsic(side2, settle, strike2) if strike2 is not None else None +con.execute("BEGIN") +con.execute( + "UPDATE groups SET settle_index_px=?, note=COALESCE(note,'') || ? WHERE group_id=?", + (settle, f" | settle_backfill={settle}", GROUP), +) +updated = [] +if REPAIR_FILLS: + for leg, iv in (("option", c_iv), ("option2", p_iv)): + if iv is None: + continue + row = con.execute( + "SELECT id, qty_eth, fill_px FROM fills WHERE group_id=? AND leg=? AND action='close'", + (GROUP, leg), + ).fetchone() + if not row: + continue + if abs(float(row["fill_px"] or 0)) > 1e-9: + 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)) +# 重算 realized +fills = list(con.execute("SELECT * FROM fills WHERE group_id=?", (GROUP,))) +opt = {} +for f in fills: + key = (f["leg"], f["action"]) + opt[key] = f +net = 0.0 +fees = 0.0 +for leg in ("option", "option2"): + o = opt.get((leg, "open")) + c = opt.get((leg, "close")) + if not o or not c: + continue + qty = float(o["qty_eth"] or c["qty_eth"] or 0) + net += (float(c["fill_px"]) - float(o["fill_px"])) * qty + fees += float(o["fee"] or 0) + float(c["fee"] or 0) +net_after = net - fees +con.execute( + "UPDATE groups SET realized_pnl=? WHERE group_id=?", + (net_after, GROUP), +) +con.commit() +print({ + "group": GROUP, + "settle": settle, + "call_iv": c_iv, + "put_iv": p_iv, + "fills_updated": updated, + "realized_pnl": net_after, +}) +''' + + +def main() -> int: + if not PASSWORD: + print("Set DEPLOY_PASS", file=sys.stderr) + return 2 + import paramiko + + settle = SETTLE + if not settle: + # peek close_at from remote first + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(HOST, username=USER, password=PASSWORD, timeout=30) + _, stdout, _ = client.exec_command( + f"sqlite3 {DB} \"SELECT close_at_ms FROM groups WHERE group_id='{GROUP}'\"" + ) + raw = stdout.read().decode().strip() + client.close() + if not raw: + print("cannot read close_at_ms", file=sys.stderr) + return 1 + px = _eth_close(int(raw)) + if px is None: + print("cannot fetch ETHUSDT close", file=sys.stderr) + return 1 + settle = f"{px:.4f}" + print("using public ETHUSDT close", settle) + + body = REMOTE % { + "db": DB, + "group": GROUP, + "settle": settle, + "repair_fills": "True" if REPAIR_FILLS else "False", + } + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(HOST, username=USER, password=PASSWORD, timeout=30) + _, stdout, stderr = client.exec_command("python3 - <<'PY'\n" + body + "\nPY") + print(stdout.read().decode("utf-8", "replace")) + err = stderr.read().decode("utf-8", "replace") + if err: + print(err, file=sys.stderr) + code = stdout.channel.recv_exit_status() + client.close() + return code + + +if __name__ == "__main__": + raise SystemExit(main())