From 9effca9f0537a8c8364b2b91ada193938ca468ad Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 16 Aug 2026 15:39:39 +0800 Subject: [PATCH] Add sim option expiry settlement so expired positions clear from current holdings. Co-authored-by: Cursor --- lib/sim/broker_lib.py | 177 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 167 insertions(+), 10 deletions(-) diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index db3d2de..27caa53 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -552,19 +552,183 @@ class SimBroker: ) return result + def _index_px_for_option( + self, + exchange: Any, + inst_id: str, + *, + idx_cache: dict[str, float | None] | None = None, + ) -> float | None: + """到期结算/持仓展示用指数价:优先合约行情,否则 family 指数.""" + from lib.exchange.okx_options_lib import ( + fetch_index_price, + inst_family_from_inst_id, + quote_option_contract, + ) + + cache = idx_cache if idx_cache is not None else {} + if exchange is None or not inst_id: + return None + try: + q = quote_option_contract(exchange, inst_id) + if q.get("ok") and q.get("index_px") is not None: + return float(q["index_px"]) + except Exception: + pass + family = inst_family_from_inst_id(inst_id) or "" + uly = family.replace("_UM", "") if family else "" + if not uly: + return None + if uly not in cache: + try: + cache[uly] = fetch_index_price(exchange, uly) + except Exception: + cache[uly] = None + return cache.get(uly) + + def settle_expired_option_positions( + self, + exchange: Any = None, + *, + now_ms: int | None = None, + ) -> list[dict[str, Any]]: + """模拟盘到期结算:按指数内在价值兑付后删除本地仓(无实盘交割). + + 虚值兑付 0;实值 credit 交易账户 USDC.同时回写 options_trades 为 closed. + """ + import time + + from lib.exchange.okx_options_lib import ( + expiry_ms_from_inst_id, + option_fields_from_inst_id, + ) + from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl + + now = int(now_ms if now_ms is not None else time.time() * 1000) + settled: list[dict[str, Any]] = [] + idx_cache: dict[str, float | None] = {} + + for p in self.list_option_positions(): + inst_id = str(p.get("inst_id") or "").strip() + if not inst_id: + continue + exp_ms = expiry_ms_from_inst_id(inst_id) + if exp_ms is None or now < int(exp_ms): + continue + opt_type, strike = option_fields_from_inst_id(inst_id) + if strike is None: + continue + spot = self._index_px_for_option(exchange, inst_id, idx_cache=idx_cache) + if spot is None: + # 无指数则本轮跳过,避免实值误按 0 结算 + continue + + sheets = float(p.get("sheets") or 0) + ct_mult = float(p.get("ct_mult") or 0.01) + prem = float(p.get("premium_paid_usdc") or 0) + pnl = float( + option_expiry_pnl( + opt_type=str(opt_type or "P"), + strike=float(strike), + spot=float(spot), + sheets=sheets, + ct_mult=ct_mult, + premium_paid=prem, + ) + ) + settle_recv = round(max(0.0, prem + pnl), 4) + o = (opt_type or "").strip().upper() + if o in ("C", "CALL"): + intrinsic_u = max(0.0, float(spot) - float(strike)) + elif o in ("P", "PUT"): + intrinsic_u = max(0.0, float(strike) - float(spot)) + else: + intrinsic_u = 0.0 + + conn = self.get_db() + try: + conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,)) + try: + closed_at = _now() + conn.execute( + """ + UPDATE options_trades + SET status = 'closed', + close_quote = ?, + premium_received = ?, + realized_pnl = ?, + closed_at = COALESCE(closed_at, ?), + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' + THEN '到期结算' + ELSE signal_note + END + WHERE inst_id = ? AND status = 'open' + """, + ( + round(intrinsic_u, 6), + settle_recv, + round(pnl, 4), + closed_at, + inst_id, + ), + ) + except Exception: + pass + conn.commit() + finally: + conn.close() + + if settle_recv > 1e-12: + self.wallets.credit_trading( + "USDC", + settle_recv, + kind="option_expiry", + note=f"expiry settle {inst_id} @{spot:g} recv={settle_recv}", + ) + ord_id = f"sim-opt-exp-{uuid.uuid4().hex[:16]}" + self._store_option_order( + ord_id=ord_id, + inst_id=inst_id, + side="settle", + sheets=sheets, + avg_px=intrinsic_u, + ) + try: + from lib.options.options_positions_lib import forget_close_gate_for_inst + + forget_close_gate_for_inst(inst_id) + except Exception: + pass + settled.append( + { + "inst_id": inst_id, + "spot": spot, + "intrinsic": intrinsic_u, + "premium_received": settle_recv, + "realized_pnl": round(pnl, 4), + "ord_id": ord_id, + } + ) + return settled + def option_positions_okx_rows(self, exchange: Any = None) -> list[dict[str, Any]]: """对齐 OKX positions 行字段, 供 format_position_row 使用. 模拟盘补充公开行情的 idxPx / markPx, 否则指数价与平掉回本均为空. + 拉取前先结算已到期仓,避免虚值到期后一直挂在当前持仓. """ from lib.exchange.okx_options_lib import ( expiry_ms_from_inst_id, - fetch_index_price, - inst_family_from_inst_id, option_fields_from_inst_id, quote_option_contract, ) + try: + self.settle_expired_option_positions(exchange) + except Exception: + pass + rows: list[dict[str, Any]] = [] idx_cache: dict[str, float | None] = {} for p in self.list_option_positions(): @@ -600,14 +764,7 @@ class SimBroker: except Exception: pass if idx is None: - family = inst_family_from_inst_id(inst_id) or "" - uly = family.replace("_UM", "") if family else "" - if uly and uly not in idx_cache: - try: - idx_cache[uly] = fetch_index_price(exchange, uly) - except Exception: - idx_cache[uly] = None - idx = idx_cache.get(uly) + idx = self._index_px_for_option(exchange, inst_id, idx_cache=idx_cache) eth = abs(sheets) * ct_mult upl = (mark - entry) * eth