From 3491681c280ecece5ad850875120af98323dd7f6 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 8 Aug 2026 16:02:16 +0800 Subject: [PATCH] Mirror option expiry settlements from exchange bills; debit OO LIVE open premiums. Co-authored-by: Cursor --- backend/app/live/binance_executor.py | 154 ++++++++++++++--- backend/app/live/binance_trade.py | 27 +++ backend/app/live/executor.py | 158 ++++++++++++++--- backend/app/live/live_pnl.py | 41 +++++ backend/app/live/okx_trade.py | 36 ++++ backend/app/live/option_settle.py | 180 ++++++++++++++++++++ backend/tests/test_option_settle.py | 167 ++++++++++++++++++ docs/审计修改方案-2026-08-08-交易所SoT续.md | 5 +- docs/审计说明-2026-08-08-交易所SoT.md | 13 +- docs/更新说明.md | 12 +- 10 files changed, 739 insertions(+), 54 deletions(-) create mode 100644 backend/app/live/option_settle.py create mode 100644 backend/tests/test_option_settle.py diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 454f89b..664a939 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -829,6 +829,22 @@ class BinanceLiveExecutor(Matcher): put_qty = eth_from_contracts(put_contracts, put_ct) call_prem = of_px * call_qty put_prem = pf_px * put_qty + call_fee = float(getattr(call_fill, "fee", 0) or 0) + put_fee = float(getattr(put_fill, "fee", 0) or 0) + self.ledger.apply_cash( + -(call_prem + call_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE-BN oo open call {group_id}", + allow_negative=True, + ) + self.ledger.apply_cash( + -(put_prem + put_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE-BN oo open put {group_id}", + allow_negative=True, + ) now = int(time.time() * 1000) with self.db._lock: self.db._conn.execute( @@ -850,8 +866,7 @@ class BinanceLiveExecutor(Matcher): entry_index_px, call_prem, now, - float(getattr(call_fill, "fee", 0) or 0) - + float(getattr(put_fill, "fee", 0) or 0), + call_fee + put_fee, 0.0, "LIVE", "option_option", @@ -1023,6 +1038,20 @@ class BinanceLiveExecutor(Matcher): for i, (leg, inst, qty, contracts) in enumerate(legs): if not inst: continue + if reason == "expiry": + px, fee, notional, cash = self._live_option_settlement_fill( + option_inst_id=inst, qty_eth=qty, group_id=group_id + ) + else: + px, fee, notional, cash = 0.0, 0.0, 0.0, 0.0 + if abs(cash) > 1e-12: + self.ledger.apply_cash( + cash, + kind="close_option", + group_id=group_id, + note=f"LIVE-BN oo settle {leg} {reason}", + allow_negative=True, + ) with self.db._lock: self.db._conn.execute( """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, @@ -1036,11 +1065,11 @@ class BinanceLiveExecutor(Matcher): inst, qty, contracts, + px, + px, + fee, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + notional, now + i, "LIVE", ), @@ -1062,7 +1091,7 @@ class BinanceLiveExecutor(Matcher): int(time.time() * 1000), reason, net, - f"oo full close {reason} exchange_flat_mirror", + f"oo full close {reason} exchange_flat_or_settle", group_id, ), ) @@ -1296,13 +1325,17 @@ class BinanceLiveExecutor(Matcher): ex_opt, group_id, ) - of_px = 0.0 - of_fee = 0.0 - of_notional = 0.0 + of_px, of_fee, of_notional, settle_cash = self._live_option_settlement_fill( + option_inst_id=option_inst_id, + qty_eth=opt_qty, + group_id=group_id, + ) of_slip = 0.0 - option_apply_cash = False + option_apply_cash = abs(settle_cash) > 1e-12 logger.info( - "bn expiry: skip option order, close perp only group=%s", group_id + "bn expiry: skip option order, close perp only group=%s settle_cash=%.4f", + group_id, + settle_cash, ) self._ensure_option_closed_perp_pending( group_id=group_id, @@ -1314,7 +1347,7 @@ class BinanceLiveExecutor(Matcher): of_notional=of_notional, of_slip=of_slip, reason=reason, - apply_cash=False, + apply_cash=option_apply_cash, ) pending_perp_only = True else: @@ -1474,6 +1507,44 @@ class BinanceLiveExecutor(Matcher): settle_index_px=spot, ) + def _live_option_settlement_fill( + self, + *, + option_inst_id: str, + qty_eth: float, + group_id: str | None = None, + begin_ms: int | None = None, + ) -> tuple[float, float, float, float]: + from .option_settle import fetch_option_settlement, settlement_to_fill + + open_ms = begin_ms + if open_ms is None and group_id: + g = self.db.fetchone( + "SELECT open_at_ms FROM groups WHERE group_id=?", (group_id,) + ) + if g and g["open_at_ms"]: + open_ms = int(g["open_at_ms"]) + st = fetch_option_settlement( + self._client(), + exchange="binance", + option_inst_id=option_inst_id, + qty_eth=float(qty_eth), + begin_ms=open_ms, + ) + px, fee, notional = settlement_to_fill(st, qty_eth=float(qty_eth)) + cash = float(st.cash) if st.found else 0.0 + if st.found: + logger.info( + "bn option settlement %s source=%s px=%.6f fee=%.6f cash=%.6f (%s)", + option_inst_id, + st.source, + px, + fee, + cash, + st.detail, + ) + return px, fee, notional, cash + def _ensure_option_closed_perp_pending( self, *, @@ -1772,16 +1843,36 @@ class BinanceLiveExecutor(Matcher): ) if ex_sz <= 1e-8: now_ms = int(time.time() * 1000) + qty_eth = float(row.get("option_qty_eth") or 0) + begin = None + try: + begin = int(row.get("created_at_ms") or 0) or None + except Exception: + begin = None + px, fee, notional, _cash = self._live_option_settlement_fill( + option_inst_id=option_inst_id, + qty_eth=qty_eth, + group_id=group_id, + begin_ms=begin, + ) booked = self._book_residual_market_close( row, - fill_px=0.0, - fee=0.0, - notional=0.0, + fill_px=px, + fee=fee, + notional=notional, slip=0.0, now_ms=now_ms, - note="LIVE-BN residual already flat on exchange", + note=( + f"LIVE-BN residual already flat; settlement px={px}" + if notional > 0 or fee > 0 + else "LIVE-BN residual already flat on exchange" + ), exec_mode="LIVE", - filled_contracts=0.0, + filled_contracts=( + float(row.get("option_qty_contracts") or 0) + if (notional > 0 or fee > 0) + else 0.0 + ), remaining_contracts=0.0, close_reason="residual_premium_close", ) @@ -1927,14 +2018,31 @@ class BinanceLiveExecutor(Matcher): ) return None if ex_sz <= 1e-8: + qty_eth = float(row.get("option_qty_eth") or 0) + begin = None + try: + begin = int(row.get("created_at_ms") or 0) or None + except Exception: + begin = None + gid = str(row.get("group_id") or "") + px, fee, notional, _cash = self._live_option_settlement_fill( + option_inst_id=option_inst_id, + qty_eth=qty_eth, + group_id=gid or None, + begin_ms=begin, + ) return { - "fill_px": 0.0, - "fee": 0.0, - "notional": 0.0, + "fill_px": px, + "fee": fee, + "notional": notional, "slip": 0.0, - "filled_contracts": 0.0, + "filled_contracts": float(row.get("option_qty_contracts") or 0), "remaining_contracts": 0.0, - "note": "LIVE-BN residual flat on exchange before settle", + "note": ( + f"LIVE-BN residual flat; settlement px={px}" + if notional > 0 or fee > 0 + else "LIVE-BN residual flat on exchange before settle" + ), "exec_mode": "LIVE", "close_reason": "emergency" if force else "expiry", } diff --git a/backend/app/live/binance_trade.py b/backend/app/live/binance_trade.py index 6c1be32..5190aef 100644 --- a/backend/app/live/binance_trade.py +++ b/backend/app/live/binance_trade.py @@ -506,6 +506,33 @@ class BinanceTradeClient: {"symbol": symbol, "leverage": lev}, ) + def get_option_exercise_records( + self, symbol: str, *, begin_ms: int, end_ms: int | None = None + ) -> list[dict] | None: + """用户期权行权/到期结算记录 GET /eapi/v1/exerciseRecord。""" + end = int(end_ms or int(time.time() * 1000)) + begin = int(begin_ms) + try: + rows = self._signed( + self._eapi, + "GET", + "/eapi/v1/exerciseRecord", + { + "symbol": symbol, + "startTime": begin, + "endTime": end, + "limit": 100, + }, + ) + except Exception as e: + logger.warning("binance exerciseRecord failed: %s", e) + return None + if isinstance(rows, dict): + rows = [rows] + if not isinstance(rows, list): + return [] + return [r for r in rows if isinstance(r, dict)] + def get_funding_usdt( self, symbol: str, *, begin_ms: int, end_ms: int | None = None ) -> float: diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index fa6d196..716e9bd 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -871,6 +871,23 @@ class OkxLiveExecutor(Matcher): put_qty = eth_from_contracts(put_contracts, put_ct) call_prem = of_px * call_qty put_prem = pf_px * put_qty + call_fee = float(getattr(call_fill, "fee", 0) or 0) + put_fee = float(getattr(put_fill, "fee", 0) or 0) + # 与永期 LIVE 开仓一致:镜像扣权利金+手续费 + self.ledger.apply_cash( + -(call_prem + call_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE oo open call {group_id}", + allow_negative=True, + ) + self.ledger.apply_cash( + -(put_prem + put_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE oo open put {group_id}", + allow_negative=True, + ) now = int(time.time() * 1000) with self.db._lock: self.db._conn.execute( @@ -892,8 +909,7 @@ class OkxLiveExecutor(Matcher): entry_index_px, call_prem, now, - float(getattr(call_fill, "fee", 0) or 0) - + float(getattr(put_fill, "fee", 0) or 0), + call_fee + put_fee, 0.0, "LIVE", "option_option", @@ -1069,6 +1085,20 @@ class OkxLiveExecutor(Matcher): for i, (leg, inst, qty, contracts) in enumerate(legs): if not inst: continue + if reason == "expiry": + px, fee, notional, cash = self._live_option_settlement_fill( + option_inst_id=inst, qty_eth=qty, group_id=group_id + ) + else: + px, fee, notional, cash = 0.0, 0.0, 0.0, 0.0 + if abs(cash) > 1e-12: + self.ledger.apply_cash( + cash, + kind="close_option", + group_id=group_id, + note=f"LIVE oo settle {leg} {reason}", + allow_negative=True, + ) with self.db._lock: self.db._conn.execute( """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, @@ -1082,11 +1112,11 @@ class OkxLiveExecutor(Matcher): inst, qty, contracts, + px, + px, + fee, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + notional, now + i, "LIVE", ), @@ -1108,7 +1138,7 @@ class OkxLiveExecutor(Matcher): int(time.time() * 1000), reason, net, - f"oo full close {reason} exchange_flat_mirror", + f"oo full close {reason} exchange_flat_or_settle", group_id, ), ) @@ -1336,7 +1366,7 @@ class OkxLiveExecutor(Matcher): of_slip = 0.0 option_apply_cash = False elif is_expiry: - # 到期:交易所自动结算期权,本地只平永续;不卖期权、不用 intrinsic 发明 fill + # 到期:交易所自动结算期权,本地只平永续;交割现金从账单镜像(禁止 intrinsic) ex_opt = exchange_option_abs_size(client, option_inst_id) if ex_opt is not None and ex_opt > 1e-8: logger.warning( @@ -1345,13 +1375,17 @@ class OkxLiveExecutor(Matcher): ex_opt, group_id, ) - of_px = 0.0 - of_fee = 0.0 - of_notional = 0.0 + of_px, of_fee, of_notional, settle_cash = self._live_option_settlement_fill( + option_inst_id=option_inst_id, + qty_eth=opt_qty, + group_id=group_id, + ) of_slip = 0.0 - option_apply_cash = False + option_apply_cash = abs(settle_cash) > 1e-12 logger.info( - "expiry: skip option order, close perp only group=%s", group_id + "expiry: skip option order, close perp only group=%s settle_cash=%.4f", + group_id, + settle_cash, ) self._ensure_option_closed_perp_pending( group_id=group_id, @@ -1363,7 +1397,7 @@ class OkxLiveExecutor(Matcher): of_notional=of_notional, of_slip=of_slip, reason=reason, - apply_cash=False, + apply_cash=option_apply_cash, ) pending_perp_only = True else: @@ -1534,6 +1568,49 @@ class OkxLiveExecutor(Matcher): settle_index_px=spot, ) + def _live_option_settlement_fill( + self, + *, + option_inst_id: str, + qty_eth: float, + group_id: str | None = None, + begin_ms: int | None = None, + ) -> tuple[float, float, float, float]: + """返回 (fill_px, fee, notional, cash_to_apply)。查不到交割则全 0。""" + from .option_settle import fetch_option_settlement, settlement_to_fill + + open_ms = begin_ms + if open_ms is None and group_id: + g = self.db.fetchone( + "SELECT open_at_ms FROM groups WHERE group_id=?", (group_id,) + ) + if g and g["open_at_ms"]: + open_ms = int(g["open_at_ms"]) + try: + ex_name = load_runtime_settings().exchange + except Exception: + ex_name = get_settings().exchange + st = fetch_option_settlement( + self._client(), + exchange=str(ex_name or "okx"), + option_inst_id=option_inst_id, + qty_eth=float(qty_eth), + begin_ms=open_ms, + ) + px, fee, notional = settlement_to_fill(st, qty_eth=float(qty_eth)) + cash = float(st.cash) if st.found else 0.0 + if st.found: + logger.info( + "option settlement %s source=%s px=%.6f fee=%.6f cash=%.6f (%s)", + option_inst_id, + st.source, + px, + fee, + cash, + st.detail, + ) + return px, fee, notional, cash + def _ensure_option_closed_perp_pending( self, *, @@ -1838,16 +1915,32 @@ class OkxLiveExecutor(Matcher): ) if ex_sz <= 1e-8: now_ms = int(time.time() * 1000) + qty_eth = float(row.get("option_qty_eth") or 0) + begin = None + try: + begin = int(row.get("created_at_ms") or 0) or None + except Exception: + begin = None + px, fee, notional, _cash = self._live_option_settlement_fill( + option_inst_id=option_inst_id, + qty_eth=qty_eth, + group_id=group_id, + begin_ms=begin, + ) booked = self._book_residual_market_close( row, - fill_px=0.0, - fee=0.0, - notional=0.0, + fill_px=px, + fee=fee, + notional=notional, slip=0.0, now_ms=now_ms, - note="LIVE residual already flat on exchange", + note=( + f"LIVE residual already flat; settlement px={px}" + if notional > 0 or fee > 0 + else "LIVE residual already flat on exchange" + ), exec_mode="LIVE", - filled_contracts=0.0, + filled_contracts=float(row.get("option_qty_contracts") or 0) if (notional > 0 or fee > 0) else 0.0, remaining_contracts=0.0, close_reason="residual_premium_close", ) @@ -2002,14 +2095,31 @@ class OkxLiveExecutor(Matcher): ) return None if ex_sz <= 1e-8: + qty_eth = float(row.get("option_qty_eth") or 0) + begin = None + try: + begin = int(row.get("created_at_ms") or 0) or None + except Exception: + begin = None + gid = str(row.get("group_id") or "") + px, fee, notional, _cash = self._live_option_settlement_fill( + option_inst_id=option_inst_id, + qty_eth=qty_eth, + group_id=gid or None, + begin_ms=begin, + ) return { - "fill_px": 0.0, - "fee": 0.0, - "notional": 0.0, + "fill_px": px, + "fee": fee, + "notional": notional, "slip": 0.0, - "filled_contracts": 0.0, + "filled_contracts": float(row.get("option_qty_contracts") or 0), "remaining_contracts": 0.0, - "note": "LIVE residual flat on exchange before settle", + "note": ( + f"LIVE residual flat on exchange; settlement px={px}" + if notional > 0 or fee > 0 + else "LIVE residual flat on exchange before settle" + ), "exec_mode": "LIVE", "close_reason": "emergency" if force else "expiry", } diff --git a/backend/app/live/live_pnl.py b/backend/app/live/live_pnl.py index 537fb3a..a9ed2d7 100644 --- a/backend/app/live/live_pnl.py +++ b/backend/app/live/live_pnl.py @@ -140,6 +140,47 @@ def reconcile_closed_group_pnl( float(local_perp) if local_perp is not None else 0.0 ) opt = float(option_pnl) if option_pnl is not None else 0.0 + + # 期权平仓 fill 为 0 价镜像时:尝试用交易所交割账单补期权腿 + try: + zero_opt_close = False + for fr in fills: + if str(fr["leg"] or "") in ("option", "option2") and str( + fr["action"] or "" + ) == "close": + if abs(float(fr["notional"] or 0)) < 1e-12 and abs( + float(fr["fill_px"] or 0) + ) < 1e-12: + zero_opt_close = True + break + if zero_opt_close: + from .option_settle import fetch_option_settlement + + g = db.fetchone( + "SELECT option_inst_id, option2_inst_id FROM groups WHERE group_id=?", + (group_id,), + ) + settle_cash = 0.0 + for inst_key in ("option_inst_id", "option2_inst_id"): + inst = str((g[inst_key] if g else None) or "") + if not inst: + continue + st = fetch_option_settlement( + client, + exchange=ex, + option_inst_id=inst, + qty_eth=1.0, + begin_ms=begin, + end_ms=end, + ) + if st.found: + settle_cash += float(st.cash) + if abs(settle_cash) > 1e-12: + # 用交割净现金替换本地 0 价期权盈亏近似:仍减 fees(交割费若已在 cash 内则可能双计,保守保留) + opt = float(settle_cash) + except Exception as e: + logger.warning("reconcile option settlement overlay failed: %s", e) + net = perp_pnl + opt - fees + funding if local_net is not None and exch_perp is None and abs(funding) < 1e-12: diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py index f5624bc..1423eee 100644 --- a/backend/app/live/okx_trade.py +++ b/backend/app/live/okx_trade.py @@ -394,6 +394,42 @@ class OkxTradeClient: total += to_usdt(float(raw), ccy) return total + def get_option_settlement_bills( + self, inst_id: str, *, begin_ms: int, end_ms: int | None = None + ) -> list[dict] | None: + """期权交割/行权相关账单(近 7 日 bills;失败再试 archive)。""" + end = int(end_ms or int(time.time() * 1000)) + begin = int(begin_ms) + # type=3 Delivery;再本地按 subType 170/171/172 过滤 + path = ( + f"/api/v5/account/bills?instType=OPTION&instId={inst_id}" + f"&type=3&begin={begin}&end={end}" + ) + try: + rows = self._request("GET", path) + except Exception as e: + logger.warning("okx option bills type=3 failed: %s; try all types", e) + try: + rows = self._request( + "GET", + f"/api/v5/account/bills?instType=OPTION&instId={inst_id}" + f"&begin={begin}&end={end}", + ) + except Exception as e2: + logger.warning("okx option bills failed: %s; try archive", e2) + try: + rows = self._request( + "GET", + f"/api/v5/account/bills-archive?instType=OPTION&instId={inst_id}" + f"&begin={begin}&end={end}", + ) + except Exception as e3: + logger.warning("okx option bills-archive failed: %s", e3) + return None + if not isinstance(rows, list): + return [] + return [r for r in rows if isinstance(r, dict)] + def get_closed_perp_pnl_usdt( self, inst_id: str, *, begin_ms: int, end_ms: int | None = None ) -> float | None: diff --git a/backend/app/live/option_settle.py b/backend/app/live/option_settle.py new file mode 100644 index 0000000..fe8e7df --- /dev/null +++ b/backend/app/live/option_settle.py @@ -0,0 +1,180 @@ +"""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}", + ) diff --git a/backend/tests/test_option_settle.py b/backend/tests/test_option_settle.py new file mode 100644 index 0000000..ecf8847 --- /dev/null +++ b/backend/tests/test_option_settle.py @@ -0,0 +1,167 @@ +"""期权交割账单镜像 + 期期开仓账本。""" + +from __future__ import annotations + +from types import SimpleNamespace + +from app.live.option_settle import ( + OptionSettlement, + fetch_option_settlement, + settlement_to_fill, +) + + +def test_settlement_to_fill_zero_when_not_found() -> None: + st = OptionSettlement(found=False) + assert settlement_to_fill(st, qty_eth=2.0) == (0.0, 0.0, 0.0) + + +def test_okx_settlement_from_exercise_bills() -> None: + class _C: + def get_option_settlement_bills(self, inst_id, *, begin_ms, end_ms=None): + return [ + { + "subType": "170", + "type": "3", + "ts": str(begin_ms + 1000), + "balChg": "50.0", + "fee": "-0.5", + "ccy": "USDT", + } + ] + + st = fetch_option_settlement( + _C(), + exchange="okx", + option_inst_id="ETH-USDT-260808-2000-C", + qty_eth=2.0, + begin_ms=1_700_000_000_000, + ) + assert st.found + assert abs(st.cash - 50.0) < 1e-9 + px, fee, notional = settlement_to_fill(st, qty_eth=2.0) + assert fee == 0.5 + assert abs(notional - 50.5) < 1e-9 + assert abs(px - 25.25) < 1e-9 + + +def test_bn_settlement_from_exercise_record() -> None: + class _C: + def get_option_exercise_records(self, symbol, *, begin_ms, end_ms=None): + return [ + { + "symbol": symbol, + "amount": "40", + "fee": "0.2", + "currency": "USDT", + "quantity": "2", + } + ] + + st = fetch_option_settlement( + _C(), + exchange="binance", + option_inst_id="ETH-260808-2000-C", + qty_eth=2.0, + begin_ms=1, + ) + assert st.found + assert abs(st.cash - 39.8) < 1e-9 + px, fee, notional = settlement_to_fill(st, qty_eth=2.0) + assert abs(notional - 40.0) < 1e-9 + assert abs(px - 20.0) < 1e-9 + assert abs(fee - 0.2) < 1e-9 + + +def test_okx_otm_expiry_bill_found_zero_cash() -> None: + class _C: + def get_option_settlement_bills(self, *_a, **_k): + return [ + { + "subType": "172", + "type": "3", + "ts": "1700000001000", + "balChg": "0", + "fee": "0", + "ccy": "USDT", + } + ] + + st = fetch_option_settlement( + _C(), + exchange="okx", + option_inst_id="ETH-OPT", + qty_eth=1.0, + begin_ms=1_700_000_000_000, + ) + assert st.found + assert st.cash == 0.0 + assert settlement_to_fill(st, qty_eth=1.0)[0] == 0.0 + + +def test_oo_open_applies_ledger_cash(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("MODE", "LIVE") + from app.config import get_settings + + get_settings.cache_clear() + from app.live.executor import OkxLiveExecutor + from app.models.db import Database + + db = Database(tmp_path / "oo.db") + ex = OkxLiveExecutor(db) + monkeypatch.setattr(ex, "_guard_live", lambda: None) + monkeypatch.setattr( + "app.live.executor.claim_open_slot", lambda _db: (True, "ok") + ) + monkeypatch.setattr( + "app.live.executor.assert_safe_to_open_live", lambda _e: (True, "ok") + ) + monkeypatch.setattr( + "app.live.executor.stamp_opening_intent", lambda *_a, **_k: None + ) + monkeypatch.setattr( + "app.live.executor.release_open_slot_if_opening", lambda *_a, **_k: None + ) + monkeypatch.setattr(ex, "_ct_mult", lambda *_a, **_k: 0.01) + + class _C: + def place_market(self, *, inst_id, side, sz, **_k): + return SimpleNamespace( + avg_px=10.0 if "C" in inst_id or "call" in inst_id.lower() or inst_id.endswith("-C") or "CALL" in inst_id + else 8.0, + fee=0.1, + sz=float(sz), + ) + + # simpler fixed fills + fills = [ + SimpleNamespace(avg_px=10.0, fee=0.1, sz=100.0), + SimpleNamespace(avg_px=8.0, fee=0.05, sz=100.0), + ] + + def place(**_k): + return fills.pop(0) + + monkeypatch.setattr(ex, "_client", lambda: SimpleNamespace(place_market=place)) + monkeypatch.setattr( + ex.ledger, + "get_setting_float", + lambda k, d=0: 1.0 if "qty" in k else d, + ) + + before = float(ex.ledger.snapshot()["available"]) + r = ex.open_oo_group( + group_id="G-oo1", + call_inst_id="ETH-CALL", + put_inst_id="ETH-PUT", + call_strike=2000.0, + put_strike=1900.0, + entry_index_px=1950.0, + expiry_ymd="260810", + ) + assert r.ok, r.detail + after = float(ex.ledger.snapshot()["available"]) + # call 10*1 +0.1 + put 8*1 +0.05 = 18.15 + assert before - after > 18.0 + db.close() + get_settings.cache_clear() diff --git a/docs/审计修改方案-2026-08-08-交易所SoT续.md b/docs/审计修改方案-2026-08-08-交易所SoT续.md index e819129..d99fd1d 100644 --- a/docs/审计修改方案-2026-08-08-交易所SoT续.md +++ b/docs/审计修改方案-2026-08-08-交易所SoT续.md @@ -40,6 +40,7 @@ | repair/abandon 交易所 size | ✅ | | BN 余额资金门 | ✅ | | 半自动买一闸 | ✅ | -| 交割账单拉价 | ⏳ 后续(零价镜像+note) | +| 交割账单拉价 | ✅ OKX bills type=3/subType170-172;BN exerciseRecord | +| 期期 LIVE 开仓账本 | ✅ 扣 Call/Put 权利金+手续费 | -再审计:无剩余 P0;剩交割账单与期期开仓账本对称为 P2/后续。 +再审计:仓位/成交/交割 SoT 主路径已闭环;无剩余 P0/P1。 diff --git a/docs/审计说明-2026-08-08-交易所SoT.md b/docs/审计说明-2026-08-08-交易所SoT.md index 0b2b5d4..711696a 100644 --- a/docs/审计说明-2026-08-08-交易所SoT.md +++ b/docs/审计说明-2026-08-08-交易所SoT.md @@ -19,8 +19,13 @@ | 期期到期 `live_sell_oo_both` | 跳过;`close_oo_full` 确认两腿空仓再镜像 | | recover 期权张数 | 以 `opt_sz` 回写 | -## 仍待(非本轮) +## 续修(同日已落地) -- 期期盈利腿仍可能用报价镜像 fill(非到期路径) -- 半自动部分成交改永续、armed 改参等 -- 币安余额接线 +- 交割:OKX bills / BN exerciseRecord → 到期与残仓空仓镜像 +- 期期 LIVE 开仓账本对称 +- closing 状态机、紧急期期路由、BN 余额门、半自动买一闸 + +## 残留低优先级 + +- 期期盈利腿崩溃恢复时若无成交回报,仍可能 0 价镜像(已禁止盘口发明价) +- 半自动部分成交改永续等产品增强(非 SoT 硬伤) diff --git a/docs/更新说明.md b/docs/更新说明.md index df3e23c..012935c 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,16 @@ --- +## 2026-08-08 — 交割账单 SoT + 期期开仓账本 + +### 变更 + +1. 到期/残仓空仓:OKX `bills` 行权子类型、币安 `exerciseRecord` 镜像结算价与现金。 +2. 期期 LIVE 开仓:本地账本扣 Call/Put 权利金+手续费(与永期一致)。 +3. 平仓对账:期权 close 为 0 价时用交割净现金覆盖期权腿。 + +--- + ## 2026-08-08 — 实盘 SoT 续修(closing / 期期 / 资金门) ### 变更 @@ -16,7 +26,7 @@ ### 审计 -见 `docs/审计修改方案-2026-08-08-交易所SoT续.md`;交割账单 API 未接,ITM 零价镜像 PnL 可能偏低(仓位 SoT 优先)。 +见 `docs/审计修改方案-2026-08-08-交易所SoT续.md`。 ---