diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index 32cb5f7..631f918 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -199,7 +199,10 @@ def backfill_closed_options_realized_pnl_from_history( if not rows: return 0 - from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl + from lib.options.options_pnl_settle_lib import ( + resolve_close_index_px, + settle_exchange_realized_pnl, + ) groups: dict[str, list[Any]] = {} for row in rows: @@ -262,18 +265,17 @@ def backfill_closed_options_realized_pnl_from_history( ord_ok = (not matched_pos) or (local_ord == matched_pos) if pnl_ok and quote_ok and ord_ok: continue - prem_recv = round(paid + share, 4) - ofee = float(_safe_float(r["open_fee"]) or 0.0) - cfee = float(_safe_float(r["close_fee"]) or 0.0) + # OKX realizedPnl 已含手续费,只冻结折 U,禁止再减 open_fee/close_fee prem_ccy = str(r["premium_ccy"] or "").strip() or "ETH" - settled = settle_closed_pnl( - premium_paid=paid, - premium_received=prem_recv, - open_fee=ofee, - close_fee=cfee, + settled = settle_exchange_realized_pnl( + realized_pnl=share, premium_ccy=prem_ccy, index_px=close_idx, ) + # 展示用收回≈已付+净盈亏+已记手续费(毛额近似,不参与再扣费) + ofee = float(_safe_float(r["open_fee"]) or 0.0) + cfee = float(_safe_float(r["close_fee"]) or 0.0) + prem_recv = round(paid + share + ofee + cfee, 4) conn.execute( """ UPDATE options_trades @@ -321,7 +323,11 @@ def sync_open_options_trades( updated = 0 closed_inst_ids: list[str] = [] now_ms = int(time.time() * 1000) - from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl + from lib.options.options_pnl_settle_lib import ( + resolve_close_index_px, + settle_closed_pnl, + settle_exchange_realized_pnl, + ) for row in rows: inst_id = str(row["inst_id"] or "") @@ -345,8 +351,6 @@ def sync_open_options_trades( close_quote = close_info.get("close_quote") realized_pnl = close_info.get("realized_pnl") close_ord_id = close_info.get("pos_id") - if realized_pnl is not None: - prem_recv = round(paid + float(realized_pnl), 4) close_ms = close_info.get("close_ms") if close_ms: closed_at = ms_to_options_app_str(close_ms) @@ -367,17 +371,27 @@ def sync_open_options_trades( prem_ccy = str(row["premium_ccy"] or "").strip() or "ETH" underly = str(row["underlying"] or "").strip() close_idx = resolve_close_index_px(None, inst_id=inst_id, underlying=underly) - # 交易所 pnl 多为权利金差;再扣本地开仓费后冻结(平仓费此处无单号,记 0) - if prem_recv is None and realized_pnl is not None: - prem_recv = round(paid + float(realized_pnl), 4) - settled = settle_closed_pnl( - premium_paid=paid, - premium_received=prem_recv if prem_recv is not None else (paid + float(realized_pnl or 0)), - open_fee=open_fee, - close_fee=0, - premium_ccy=prem_ccy, - index_px=close_idx, - ) + if close_reason == "exchange" and realized_pnl is not None: + # OKX realizedPnl 已含费,禁止再减 open_fee + settled = settle_exchange_realized_pnl( + realized_pnl=realized_pnl, + premium_ccy=prem_ccy, + index_px=close_idx, + fee_hint=open_fee, + ) + prem_recv = round(paid + float(realized_pnl) + open_fee, 4) + else: + # 到期归零:本地权利金口径,只扣一次开仓费 + if prem_recv is None: + prem_recv = 0.0 + settled = settle_closed_pnl( + premium_paid=paid, + premium_received=prem_recv, + open_fee=open_fee, + close_fee=0, + premium_ccy=prem_ccy, + index_px=close_idx, + ) realized_pnl = settled.get("realized_pnl") pnl_usdt = settled.get("realized_pnl_usdt") diff --git a/lib/options/options_pnl_settle_lib.py b/lib/options/options_pnl_settle_lib.py index 8f972d6..0ffaad6 100644 --- a/lib/options/options_pnl_settle_lib.py +++ b/lib/options/options_pnl_settle_lib.py @@ -114,6 +114,40 @@ def sum_option_order_fees_in_premium( return round(total, 8) +def freeze_pnl_to_usdt( + net_pnl: float, + *, + premium_ccy: str, + index_px: float | None, + total_fee: float = 0.0, +) -> dict[str, float | None]: + """把已是「净额」的权利金盈亏冻结为 U(不再扣费).""" + net = round(float(net_pnl), 8) + pc = str(premium_ccy or "").strip().upper() + idx = _safe_float(index_px) + fee_total = abs(float(total_fee or 0.0)) + if pc in ("ETH", "BTC"): + if idx is None or idx <= 0: + return { + "realized_pnl": net, + "close_index_px": None, + "realized_pnl_usdt": None, + "total_fee": round(fee_total, 8), + } + return { + "realized_pnl": net, + "close_index_px": float(idx), + "realized_pnl_usdt": round(net * float(idx), 4), + "total_fee": round(fee_total, 8), + } + return { + "realized_pnl": net, + "close_index_px": float(idx) if idx is not None and idx > 0 else None, + "realized_pnl_usdt": round(net, 4), + "total_fee": round(fee_total, 8), + } + + def settle_closed_pnl( *, premium_paid: Any, @@ -124,8 +158,9 @@ def settle_closed_pnl( index_px: float | None, ) -> dict[str, float | None]: """ - 净盈亏 = 收回 − 已付 − 开/平手续费(权利金单位). - 币本位再乘平仓指数得到冻结 U;USDC/USDT 直接冻结. + 由「毛权利金」结算:净盈亏 = 收回 − 已付 − 开/平手续费(权利金单位). + 仅用于本地开平仓权利金口径;不要对 OKX realizedPnl 再调此函数扣费 + (交易所 realizedPnl = pnl + fee + ..., 已含手续费). """ paid = float(_safe_float(premium_paid) or 0.0) recv = float(_safe_float(premium_received) or 0.0) @@ -136,29 +171,39 @@ def settle_closed_pnl( if cfee < 0: cfee = abs(cfee) net = round(recv - paid - ofee - cfee, 8) - pc = str(premium_ccy or "").strip().upper() - idx = _safe_float(index_px) - if pc in ("ETH", "BTC"): - if idx is None or idx <= 0: - return { - "realized_pnl": net, - "close_index_px": None, - "realized_pnl_usdt": None, - "total_fee": round(ofee + cfee, 8), - } + return freeze_pnl_to_usdt( + net, + premium_ccy=premium_ccy, + index_px=index_px, + total_fee=ofee + cfee, + ) + + +def settle_exchange_realized_pnl( + *, + realized_pnl: Any, + premium_ccy: str, + index_px: float | None, + fee_hint: Any = None, +) -> dict[str, float | None]: + """ + 冻结交易所 positions-history 的 realizedPnl. + 该字段已含手续费,此处只折 U,不再减 fee. + """ + net = _safe_float(realized_pnl) + if net is None: return { - "realized_pnl": net, - "close_index_px": float(idx), - "realized_pnl_usdt": round(net * float(idx), 4), - "total_fee": round(ofee + cfee, 8), + "realized_pnl": None, + "close_index_px": None, + "realized_pnl_usdt": None, + "total_fee": fee_amount_abs(fee_hint), } - # USDC / USDT 权利金 - return { - "realized_pnl": net, - "close_index_px": float(idx) if idx is not None and idx > 0 else None, - "realized_pnl_usdt": round(net, 4), - "total_fee": round(ofee + cfee, 8), - } + return freeze_pnl_to_usdt( + float(net), + premium_ccy=premium_ccy, + index_px=index_px, + total_fee=fee_amount_abs(fee_hint), + ) def resolve_close_index_px( diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 3fcea27..e08b806 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -308,13 +308,16 @@ _CLOSED_STATS_SELECT = """ def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int: - """旧已平仓缺冻结 U 时按 close_index/当前指数写一次,之后不再跟盘变.""" + """旧已平仓缺冻结 U 时按 close_index/当前指数写一次,之后不再跟盘变. + + 注意:已有 realized_pnl 视为净额直接冻结,不再二次扣 open_fee/close_fee + (避免对 OKX realizedPnl 或已扣费本地净额重复减费). + """ init_options_tables(conn) rows = conn.execute( """ SELECT id, realized_pnl, realized_pnl_usdt, close_index_px, - inst_id, underlying, premium_ccy, premium_paid, premium_received, - open_fee, close_fee + inst_id, underlying, premium_ccy FROM options_trades WHERE status = 'closed' AND realized_pnl_usdt IS NULL @@ -325,7 +328,10 @@ def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int: ).fetchall() if not rows: return 0 - from lib.options.options_pnl_settle_lib import settle_closed_pnl, resolve_close_index_px + from lib.options.options_pnl_settle_lib import ( + freeze_pnl_to_usdt, + resolve_close_index_px, + ) updated = 0 for row in rows: @@ -337,48 +343,106 @@ def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int: inst_id=str(_row_get(row, "inst_id") or ""), underlying=str(_row_get(row, "underlying") or ""), ) - # 有手续费列时按净额重算;否则用已有 realized_pnl 冻结 - ofee = _safe_float(_row_get(row, "open_fee")) or 0.0 - cfee = _safe_float(_row_get(row, "close_fee")) or 0.0 - paid = _row_get(row, "premium_paid") - recv = _row_get(row, "premium_received") - if paid is not None and recv is not None and (ofee > 0 or cfee > 0): - settled = settle_closed_pnl( - premium_paid=paid, - premium_received=recv, - open_fee=ofee, - close_fee=cfee, - premium_ccy=ccy or "USDC", - index_px=idx, - ) - pnl = settled.get("realized_pnl") - usdt = settled.get("realized_pnl_usdt") - close_idx = settled.get("close_index_px") - else: - pnl = _safe_float(_row_get(row, "realized_pnl")) - if pnl is None: - continue - settled = settle_closed_pnl( - premium_paid=0, - premium_received=pnl, - open_fee=0, - close_fee=0, - premium_ccy=ccy or "USDC", - index_px=idx, - ) - usdt = settled.get("realized_pnl_usdt") - close_idx = settled.get("close_index_px") + pnl = _safe_float(_row_get(row, "realized_pnl")) + if pnl is None: + continue + settled = freeze_pnl_to_usdt(pnl, premium_ccy=ccy or "USDC", index_px=idx) + usdt = settled.get("realized_pnl_usdt") + close_idx = settled.get("close_index_px") if usdt is None: continue conn.execute( """ UPDATE options_trades SET realized_pnl_usdt = ?, - close_index_px = COALESCE(close_index_px, ?), - realized_pnl = COALESCE(?, realized_pnl) + close_index_px = COALESCE(close_index_px, ?) WHERE id = ? AND realized_pnl_usdt IS NULL """, - (float(usdt), close_idx, pnl, int(row["id"])), + (float(usdt), close_idx, int(row["id"])), + ) + updated += 1 + return updated + + +def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int: + """修复「交易所净盈亏上又减了一次手续费」导致的偏亏冻结值. + + 判定:存在开/平手续费,且 realized_pnl ≈ (recv-paid) - 2×fees,则改回只扣一次. + 若无可靠 recv/paid,但 usdt ≈ (realized + fees)×index 的反向不符则跳过. + """ + init_options_tables(conn) + rows = conn.execute( + """ + SELECT id, realized_pnl, realized_pnl_usdt, close_index_px, + inst_id, underlying, premium_ccy, premium_paid, premium_received, + open_fee, close_fee + FROM options_trades + WHERE status = 'closed' + AND (COALESCE(open_fee, 0) > 0 OR COALESCE(close_fee, 0) > 0) + AND premium_paid IS NOT NULL + AND premium_received IS NOT NULL + ORDER BY id DESC + LIMIT 300 + """ + ).fetchall() + if not rows: + return 0 + from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl + + updated = 0 + for row in rows: + paid = _safe_float(_row_get(row, "premium_paid")) + recv = _safe_float(_row_get(row, "premium_received")) + ofee = abs(_safe_float(_row_get(row, "open_fee")) or 0.0) + cfee = abs(_safe_float(_row_get(row, "close_fee")) or 0.0) + fees = ofee + cfee + if paid is None or recv is None or fees <= 0: + continue + gross = float(recv) - float(paid) + single = round(gross - fees, 8) + double = round(gross - 2.0 * fees, 8) + cur = _safe_float(_row_get(row, "realized_pnl")) + usdt = _safe_float(_row_get(row, "realized_pnl_usdt")) + ccy = str(_row_get(row, "premium_ccy") or "").strip().upper() or "ETH" + idx = _safe_float(_row_get(row, "close_index_px")) + if idx is None or idx <= 0: + idx = resolve_close_index_px( + ex, + inst_id=str(_row_get(row, "inst_id") or ""), + underlying=str(_row_get(row, "underlying") or ""), + ) + # 币数量层面像双重扣费,或 U 层面像双重扣费 + coin_double = cur is not None and abs(cur - double) <= max(1e-8, abs(fees) * 1e-6) + usdt_double = False + if usdt is not None and idx is not None and idx > 0 and ccy in ("ETH", "BTC"): + usdt_double = abs(usdt - double * float(idx)) <= max(0.02, abs(fees * float(idx)) * 0.05) + if not coin_double and not usdt_double: + # 若当前已是单次扣费则不动 + continue + settled = settle_closed_pnl( + premium_paid=paid, + premium_received=recv, + open_fee=ofee, + close_fee=cfee, + premium_ccy=ccy, + index_px=idx, + ) + if settled.get("realized_pnl_usdt") is None and settled.get("realized_pnl") is None: + continue + conn.execute( + """ + UPDATE options_trades + SET realized_pnl = ?, + close_index_px = COALESCE(?, close_index_px), + realized_pnl_usdt = ? + WHERE id = ? + """, + ( + settled.get("realized_pnl"), + settled.get("close_index_px"), + settled.get("realized_pnl_usdt"), + int(row["id"]), + ), ) updated += 1 return updated @@ -387,6 +451,10 @@ def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int: def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]: """本地已平(含冻结 U/手续费净额) + 当前 open,供统计/中控窗口复用.""" init_options_tables(conn) + try: + repair_double_counted_fee_pnl(conn, ex) + except Exception: + pass try: freeze_missing_realized_pnl_usdt(conn, ex) except Exception: diff --git a/tests/test_options_pnl_settle.py b/tests/test_options_pnl_settle.py index 95bdbc2..596b9ff 100644 --- a/tests/test_options_pnl_settle.py +++ b/tests/test_options_pnl_settle.py @@ -6,6 +6,7 @@ import unittest from lib.options.options_pnl_settle_lib import ( fee_to_premium_ccy, settle_closed_pnl, + settle_exchange_realized_pnl, ) from lib.options.options_stats_lib import ( compute_options_stats_from_history, @@ -57,6 +58,52 @@ class SettleClosedPnlTests(unittest.TestCase): self.assertAlmostEqual(float(s["realized_pnl"]), 1.8, places=4) self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 1.8, places=4) + def test_exchange_realized_not_fee_again(self): + # OKX realizedPnl 已含费:只折 U,不得再减 fee + s = settle_exchange_realized_pnl( + realized_pnl=0.009, + premium_ccy="ETH", + index_px=3000, + fee_hint=0.001, + ) + self.assertAlmostEqual(float(s["realized_pnl"]), 0.009, places=8) + self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4) + + +class RepairDoubleFeeTests(unittest.TestCase): + def test_repair_detects_double_fee(self): + import sqlite3 + + from lib.options.options_db import init_options_tables + from lib.options.options_stats_lib import repair_double_counted_fee_pnl + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + # recv-paid=0.01, fees=0.001 → 单次净 0.009;错误写成双重 0.008 + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, open_fee, close_fee, + premium_ccy, status, close_index_px, realized_pnl_usdt + ) VALUES ( + 'ETH-USD-260701-2500-C', 'ETH', 'C', 1, 0.01, + 0.01, 0.02, 0.008, 0.0005, 0.0005, + 'ETH', 'closed', 3000, 24.0 + ) + """ + ) + conn.commit() + n = repair_double_counted_fee_pnl(conn, None) + self.assertEqual(n, 1) + row = conn.execute( + "SELECT realized_pnl, realized_pnl_usdt FROM options_trades WHERE id=1" + ).fetchone() + self.assertAlmostEqual(float(row["realized_pnl"]), 0.009, places=8) + self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 27.0, places=4) + conn.close() + class FrozenStatsTests(unittest.TestCase): def test_frozen_usdt_ignores_live_index(self):