"""LIVE 期权到期交割:从交易所账单/行权记录取结算现金,禁止本地 intrinsic 发明。""" from __future__ import annotations import logging import time from dataclasses import dataclass from typing import Any logger = logging.getLogger(__name__) # OKX bills subType:行权 / 对手行权 / 虚值到期 _OKX_EXERCISE_SUBTYPES = frozenset({"170", "171", "172"}) @dataclass(slots=True) class OptionSettlement: """qty_eth>0 时 fill_px = notional/qty_eth;cash 为入账净额(已扣费)。""" found: bool notional: float = 0.0 # 结算毛额(入账前) fee: float = 0.0 cash: float = 0.0 # notional - fee fill_px: float = 0.0 source: str = "" detail: str = "" def settlement_to_fill( st: OptionSettlement, *, qty_eth: float ) -> tuple[float, float, float]: """返回 (fill_px, fee, notional)。未找到则全 0。""" if not st.found: return 0.0, 0.0, 0.0 q = float(qty_eth) if q > 1e-12 and st.fill_px <= 0 and st.notional > 0: return float(st.notional) / q, float(st.fee), float(st.notional) return float(st.fill_px), float(st.fee), float(st.notional) def fetch_option_settlement( client: Any, *, exchange: str, option_inst_id: str, qty_eth: float, begin_ms: int | None, end_ms: int | None = None, ) -> OptionSettlement: """查交易所期权交割/行权入账。查不到 → found=False(调用方零价镜像、不发明)。""" inst = str(option_inst_id or "").strip() if not inst or client is None: return OptionSettlement(found=False, detail="no_inst_or_client") begin = int(begin_ms or 0) end = int(end_ms or int(time.time() * 1000)) if begin <= 0: # 无开仓时间:收窄到近 48h,避免扫全量 begin = end - 48 * 3600 * 1000 ex = (exchange or "").strip().lower() try: if ex in ("binance", "bn"): return _bn_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end) return _okx_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end) except Exception as e: logger.warning("fetch_option_settlement failed %s %s: %s", ex, inst, e) return OptionSettlement(found=False, detail=str(e)[:160]) def _okx_settlement( client: Any, inst_id: str, *, qty_eth: float, begin: int, end: int ) -> OptionSettlement: from .money import to_usdt from ..exchange.okx.parse import safe_float getter = getattr(client, "get_option_settlement_bills", None) if callable(getter): rows = getter(inst_id, begin_ms=begin, end_ms=end) else: rows = None if rows is None: return OptionSettlement(found=False, detail="okx_bills_unavailable") cash = 0.0 fee = 0.0 hit = False for row in rows: if not isinstance(row, dict): continue sub = str(row.get("subType") or "") typ = str(row.get("type") or "") if sub not in _OKX_EXERCISE_SUBTYPES and typ not in ("3",): continue ts = int(safe_float(row.get("ts")) or 0) if ts and (ts < begin - 120_000 or ts > end + 120_000): continue hit = True raw = safe_float(row.get("balChg")) if raw is None: raw = safe_float(row.get("pnl")) ccy = str(row.get("ccy") or "USDT") if raw is not None: cash += to_usdt(float(raw), ccy) fraw = safe_float(row.get("fee")) if fraw is not None: # OKX fee 常为负 fee += abs(to_usdt(float(fraw), ccy)) # 有些账单 pnl 已含费,fee 字段为 0 if not hit: return OptionSettlement(found=False, detail="okx_no_exercise_bill") # balChg/pnl 视为账户净变动;fee 另计时用净额+费还原毛额作 fill notional net_cash = float(cash) fee = float(fee) if net_cash >= 0: notional = float(net_cash) + fee else: notional = 0.0 q = float(qty_eth) fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0 return OptionSettlement( found=True, notional=float(notional), fee=float(fee), cash=float(net_cash), fill_px=float(fill_px), source="okx_bills", detail=f"subTypes exercise bills cash={net_cash:.6f}", ) def _bn_settlement( client: Any, symbol: str, *, qty_eth: float, begin: int, end: int ) -> OptionSettlement: from .money import to_usdt from ..exchange.okx.parse import safe_float getter = getattr(client, "get_option_exercise_records", None) if not callable(getter): return OptionSettlement(found=False, detail="bn_exercise_api_missing") rows = getter(symbol, begin_ms=begin, end_ms=end) if rows is None: return OptionSettlement(found=False, detail="bn_exercise_unavailable") if not rows: return OptionSettlement(found=False, detail="bn_no_exercise_record") amount = 0.0 fee = 0.0 hit = False for row in rows: if not isinstance(row, dict): continue if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol: continue hit = True ccy = str(row.get("currency") or row.get("quoteAsset") or "USDT") am = safe_float(row.get("amount")) if am is not None: amount += to_usdt(float(am), ccy) fr = safe_float(row.get("fee")) if fr is not None: fee += abs(to_usdt(float(fr), ccy)) if not hit: return OptionSettlement(found=False, detail="bn_no_matching_record") notional = max(0.0, float(amount)) net = float(amount) - float(fee) q = float(qty_eth) fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0 return OptionSettlement( found=True, notional=notional, fee=float(fee), cash=float(net), fill_px=float(fill_px), source="binance_exerciseRecord", detail=f"amount={amount:.6f} fee={fee:.6f}", )