From d05629c362446801bfa5d33d4ecc26cffa7c5bf5 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 8 Oct 2026 23:11:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B8=81=E6=9C=AC=E4=BD=8D?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=EF=BC=9A=E5=BC=80=E4=BB=93=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E6=8C=89=E9=A2=84=E7=AE=97/=E5=BC=80=E4=BB=93=E6=8C=87?= =?UTF-8?q?=E6=95=B0=E8=AE=A1=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=94=A8=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E4=BB=B7=E9=87=8D=E4=BC=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/options/options_close_exec_lib.py | 17 +++- lib/options/options_coin_open_lib.py | 16 ++- lib/options/options_db.py | 1 + lib/options/options_monitor_lib.py | 28 ++++-- lib/options/options_pnl_settle_lib.py | 96 ++++++++++++++++-- lib/options/options_stats_lib.py | 83 +++++++++------- tests/test_options_pnl_settle.py | 137 ++++++++++++++------------ 7 files changed, 258 insertions(+), 120 deletions(-) diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index d6b39d8..84d87d0 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -378,7 +378,8 @@ def close_option_by_bid1( open_rows = conn.execute( """ SELECT id, premium_paid, open_fee, premium_ccy, underlying, - exchange_ord_id + exchange_ord_id, budget_usdt, open_index_px, fee_ccy, + eth_amount, open_quote FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id ASC @@ -492,6 +493,11 @@ def close_option_by_bid1( if stored_ofee <= 0: allocated_ofee += row_ofee + row_budget = _safe_float(row["budget_usdt"]) + row_open_idx = _safe_float(row["open_index_px"]) + if (row_open_idx is None or row_open_idx <= 0) and row_budget and paid > 1e-12: + # 无开仓指数时用预算/已付反推 + row_open_idx = float(row_budget) / paid settled = settle_closed_pnl( premium_paid=paid, premium_received=recv, @@ -499,12 +505,15 @@ def close_option_by_bid1( close_fee=row_cfee, premium_ccy=str(row["premium_ccy"] or row_ccy or premium_ccy), index_px=close_idx, + open_index_px=row_open_idx, + budget_usdt=row_budget, + fee_ccy=str(row["fee_ccy"] or row["premium_ccy"] or row_ccy or ""), ) pnl = settled.get("realized_pnl") pnl_usdt = settled.get("realized_pnl_usdt") note_sql = "" - # 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/idx/pnl_u/[note]/id + # 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/close_idx/open_idx/pnl_u/[note]/id params: list[Any] = [ close_px, recv, @@ -515,6 +524,7 @@ def close_option_by_bid1( row_cfee, str(row["premium_ccy"] or row_ccy or premium_ccy or ""), settled.get("close_index_px"), + settled.get("open_index_px") or row_open_idx, pnl_usdt, ] if signal_note and i == len(open_rows) - 1: @@ -531,7 +541,8 @@ def close_option_by_bid1( SET status = 'closed', close_quote = ?, premium_received = ?, realized_pnl = ?, close_ord_id = ?, closed_at = ?, open_fee = ?, close_fee = ?, fee_ccy = ?, - close_index_px = ?, realized_pnl_usdt = ?, + close_index_px = ?, open_index_px = COALESCE(open_index_px, ?), + realized_pnl_usdt = ?, close_recv_accum = 0, close_sheets_accum = 0, close_notional_accum = 0 {note_sql} diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index 442c9f5..e8ff8c1 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -426,6 +426,7 @@ def open_coin_option_buy_full( # 开仓手续费(折权利金单位);优先成交回包,否则查单 open_fee = 0.0 + idx_open = None try: from lib.options.options_pnl_settle_lib import ( extract_order_fee, @@ -438,7 +439,7 @@ def open_coin_option_buy_full( ex, inst_id=inst_id, underlying=underlying, - hint=(q.get("index_px") if isinstance(q, dict) else None), + hint=(q.get("index_px") if isinstance(q, dict) else None) or idx, ) fee_hint, fee_ccy_hint = extract_order_fee(order if isinstance(order, dict) else None) if fee_hint <= 0 and isinstance(fill, dict): @@ -500,6 +501,7 @@ def open_coin_option_buy_full( budget_usdt=buy_usdt, premium_ccy=premium_ccy, open_fee=open_fee, + open_index_px=idx_open, profit_exit_enabled=profit_exit_enabled, profit_exit_mult=profit_exit_mult, ) @@ -573,15 +575,22 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: if open_fee < 0: open_fee = abs(open_fee) fee_ccy = str(kwargs.get("premium_ccy") or "ETH") + open_index_px = kwargs.get("open_index_px") + try: + open_index_px = float(open_index_px) if open_index_px is not None else None + if open_index_px is not None and open_index_px <= 0: + open_index_px = None + except (TypeError, ValueError): + open_index_px = None cur = conn.execute( """ INSERT INTO options_trades( inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, open_quote, premium_paid, status, signal_note, exchange_ord_id, margin_mode, premium_ccy, bridge_id, budget_usdt, - open_fee, fee_ccy, + open_fee, fee_ccy, open_index_px, profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( kwargs["inst_id"], @@ -600,6 +609,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: kwargs.get("budget_usdt"), open_fee, fee_ccy, + open_index_px, pe, pe_mult, pe_state, diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 8faeee4..6fd1ba3 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -114,6 +114,7 @@ def init_options_tables(conn: sqlite3.Connection) -> None: "ALTER TABLE options_trades ADD COLUMN close_fee REAL DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN fee_ccy TEXT", "ALTER TABLE options_trades ADD COLUMN close_index_px REAL", + "ALTER TABLE options_trades ADD COLUMN open_index_px REAL", "ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt REAL", ): try: diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index 631f918..271f174 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -315,7 +315,8 @@ def sync_open_options_trades( rows = conn.execute( """ SELECT id, inst_id, premium_paid, exp_time, created_at, - open_fee, premium_ccy, underlying, exchange_ord_id + open_fee, premium_ccy, underlying, exchange_ord_id, + budget_usdt, open_index_px, fee_ccy FROM options_trades WHERE status = 'open' """ @@ -326,7 +327,6 @@ def sync_open_options_trades( from lib.options.options_pnl_settle_lib import ( resolve_close_index_px, settle_closed_pnl, - settle_exchange_realized_pnl, ) for row in rows: @@ -371,17 +371,24 @@ 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) + budget = _safe_float(row["budget_usdt"]) + open_idx = _safe_float(row["open_index_px"]) + fee_ccy = str(row["fee_ccy"] or prem_ccy or "") if close_reason == "exchange" and realized_pnl is not None: - # OKX realizedPnl 已含费,禁止再减 open_fee - settled = settle_exchange_realized_pnl( - realized_pnl=realized_pnl, + # OKX realizedPnl 已含费 → 反推毛收回,再按开仓成本折 U(不再二次扣费) + prem_recv = round(paid + float(realized_pnl) + open_fee, 4) + 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, - fee_hint=open_fee, + open_index_px=open_idx, + budget_usdt=budget, + fee_ccy=fee_ccy, ) - prem_recv = round(paid + float(realized_pnl) + open_fee, 4) else: - # 到期归零:本地权利金口径,只扣一次开仓费 if prem_recv is None: prem_recv = 0.0 settled = settle_closed_pnl( @@ -391,6 +398,9 @@ def sync_open_options_trades( close_fee=0, premium_ccy=prem_ccy, index_px=close_idx, + open_index_px=open_idx, + budget_usdt=budget, + fee_ccy=fee_ccy, ) realized_pnl = settled.get("realized_pnl") pnl_usdt = settled.get("realized_pnl_usdt") @@ -405,6 +415,7 @@ def sync_open_options_trades( close_ord_id = COALESCE(?, close_ord_id), closed_at = COALESCE(?, closed_at), close_index_px = COALESCE(?, close_index_px), + open_index_px = COALESCE(open_index_px, ?), realized_pnl_usdt = COALESCE(?, realized_pnl_usdt), fee_ccy = COALESCE(fee_ccy, ?), signal_note = CASE @@ -421,6 +432,7 @@ def sync_open_options_trades( close_ord_id, closed_at, settled.get("close_index_px"), + settled.get("open_index_px"), pnl_usdt, prem_ccy, close_reason, diff --git a/lib/options/options_pnl_settle_lib.py b/lib/options/options_pnl_settle_lib.py index 0ffaad6..5cf06ee 100644 --- a/lib/options/options_pnl_settle_lib.py +++ b/lib/options/options_pnl_settle_lib.py @@ -148,6 +148,32 @@ def freeze_pnl_to_usdt( } +def fee_to_usdt( + fee_amt: Any, + fee_ccy: Any, + *, + index_px: float | None, +) -> float: + """手续费折 USDT.""" + amt = fee_amount_abs(fee_amt) + if amt <= 0: + return 0.0 + fc = str(fee_ccy or "").strip().upper() + if fc in ("USDT", "USDC", "USD", ""): + # 空币种时按稳定币近似,避免把小数额 ETH 费当成整 U + if fc == "" and amt < 0.05: + idx = _safe_float(index_px) + if idx is not None and idx > 100: + return round(amt * float(idx), 4) + return round(amt, 4) + if fc in ("ETH", "BTC"): + idx = _safe_float(index_px) + if idx is None or idx <= 0: + return 0.0 + return round(amt * float(idx), 4) + return round(amt, 4) + + def settle_closed_pnl( *, premium_paid: Any, @@ -156,11 +182,18 @@ def settle_closed_pnl( close_fee: Any = 0, premium_ccy: str, index_px: float | None, + open_index_px: float | None = None, + budget_usdt: float | None = None, + fee_ccy: str | None = None, ) -> dict[str, float | None]: """ - 由「毛权利金」结算:净盈亏 = 收回 − 已付 − 开/平手续费(权利金单位). - 仅用于本地开平仓权利金口径;不要对 OKX realizedPnl 再调此函数扣费 - (交易所 realizedPnl = pnl + fee + ..., 已含手续费). + 由「毛权利金」结算. + 币数量净盈亏 = 收回 − 已付 − 开/平手续费. + 冻结 U(币本位): + 平仓收回×平仓指数 − 开仓成本U − 手续费U + 开仓成本优先 budget_usdt,否则 已付×开仓指数(缺省才用平仓指数). + 这样不会把开仓成本也按平仓价重估,避免和余额对不上. + 不要对 OKX realizedPnl 再调此函数扣费. """ paid = float(_safe_float(premium_paid) or 0.0) recv = float(_safe_float(premium_received) or 0.0) @@ -171,12 +204,59 @@ def settle_closed_pnl( if cfee < 0: cfee = abs(cfee) net = round(recv - paid - ofee - cfee, 8) - return freeze_pnl_to_usdt( - net, - premium_ccy=premium_ccy, - index_px=index_px, - total_fee=ofee + cfee, + pc = str(premium_ccy or "").strip().upper() + close_idx = _safe_float(index_px) + open_idx = _safe_float(open_index_px) + budget = _safe_float(budget_usdt) + fc = str(fee_ccy or pc or "").strip().upper() + + if pc not in ("ETH", "BTC"): + return freeze_pnl_to_usdt( + net, + premium_ccy=premium_ccy, + index_px=close_idx, + total_fee=ofee + cfee, + ) + + if close_idx is None or close_idx <= 0: + return { + "realized_pnl": net, + "close_index_px": None, + "open_index_px": float(open_idx) if open_idx and open_idx > 0 else None, + "realized_pnl_usdt": None, + "total_fee": round(ofee + cfee, 8), + } + + # 开仓成本 U:预算(真实花出去的 USDT) > 已付×开仓指数 > 已付×平仓指数 + if budget is not None and budget > 0: + entry_u = float(budget) + if open_idx is None or open_idx <= 0: + open_idx = (entry_u / paid) if paid > 1e-12 else close_idx + elif open_idx is not None and open_idx > 0: + entry_u = paid * float(open_idx) + else: + entry_u = paid * float(close_idx) + open_idx = close_idx + + exit_u = recv * float(close_idx) + # 开仓费按开仓指数、平仓费按平仓指数折 U + fee_u = fee_to_usdt(ofee, fc or pc, index_px=open_idx) + fee_to_usdt( + cfee, fc or pc, index_px=close_idx ) + # 若开仓成本已是 budget(含买币),ETH 开仓费已体现在币变少/卖回变少,不再重复扣 ETH 开仓费 + if budget is not None and budget > 0 and (fc or pc) in ("ETH", "BTC"): + fee_u = fee_to_usdt(cfee, fc or pc, index_px=close_idx) + if str(fee_ccy or "").strip().upper() in ("USDT", "USDC", "USD"): + fee_u += fee_to_usdt(ofee, fee_ccy, index_px=open_idx) + + usdt = round(exit_u - entry_u - fee_u, 4) + return { + "realized_pnl": net, + "close_index_px": float(close_idx), + "open_index_px": float(open_idx) if open_idx and open_idx > 0 else None, + "realized_pnl_usdt": usdt, + "total_fee": round(ofee + cfee, 8), + } def settle_exchange_realized_pnl( diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index e08b806..f896702 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -364,23 +364,22 @@ def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int: return updated -def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int: - """修复「交易所净盈亏上又减了一次手续费」导致的偏亏冻结值. +def recompute_closed_realized_pnl_usdt(conn, ex: Any = None) -> int: + """强制按「平仓收回×平仓指数 − 开仓成本U − 手续费」重算已平冻结 U. - 判定:存在开/平手续费,且 realized_pnl ≈ (recv-paid) - 2×fees,则改回只扣一次. - 若无可靠 recv/paid,但 usdt ≈ (realized + fees)×index 的反向不符则跳过. + 旧逻辑用 (收回−已付)×平仓指数,等于把开仓成本也按平仓价重估,ETH 上涨时会偏亏, + 导致合计盈亏与余额对不上.有 budget_usdt/open_index_px 时优先用真实开仓成本. """ init_options_tables(conn) rows = conn.execute( """ - SELECT id, realized_pnl, realized_pnl_usdt, close_index_px, + SELECT id, realized_pnl, realized_pnl_usdt, close_index_px, open_index_px, inst_id, underlying, premium_ccy, premium_paid, premium_received, - open_fee, close_fee + open_fee, close_fee, fee_ccy, budget_usdt, close_quote, eth_amount, + open_quote 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 """ @@ -388,59 +387,72 @@ def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int: if not rows: return 0 from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl + from lib.options.options_pricing_lib import total_premium updated = 0 for row in rows: paid = _safe_float(_row_get(row, "premium_paid")) recv = _safe_float(_row_get(row, "premium_received")) + if recv is None: + cq = _safe_float(_row_get(row, "close_quote")) + eth = _safe_float(_row_get(row, "eth_amount")) + if cq is not None and eth is not None and eth > 0: + recv = float(total_premium(cq, eth)) + if paid is None or recv is None: + continue 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( + close_idx = _safe_float(_row_get(row, "close_index_px")) + open_idx = _safe_float(_row_get(row, "open_index_px")) + budget = _safe_float(_row_get(row, "budget_usdt")) + if close_idx is None or close_idx <= 0: + close_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 + if (open_idx is None or open_idx <= 0) and budget and paid > 1e-12: + open_idx = float(budget) / float(paid) settled = settle_closed_pnl( premium_paid=paid, premium_received=recv, open_fee=ofee, close_fee=cfee, premium_ccy=ccy, - index_px=idx, + index_px=close_idx, + open_index_px=open_idx, + budget_usdt=budget, + fee_ccy=str(_row_get(row, "fee_ccy") or ccy), ) - if settled.get("realized_pnl_usdt") is None and settled.get("realized_pnl") is None: + new_usdt = settled.get("realized_pnl_usdt") + if new_usdt is None: + continue + old_usdt = _safe_float(_row_get(row, "realized_pnl_usdt")) + new_pnl = settled.get("realized_pnl") + # 有变化才写,避免无意义刷库 + if ( + old_usdt is not None + and abs(float(old_usdt) - float(new_usdt)) < 1e-6 + and _safe_float(_row_get(row, "realized_pnl")) is not None + and new_pnl is not None + and abs(float(_row_get(row, "realized_pnl")) - float(new_pnl)) < 1e-10 + ): continue conn.execute( """ UPDATE options_trades - SET realized_pnl = ?, + SET realized_pnl = COALESCE(?, realized_pnl), close_index_px = COALESCE(?, close_index_px), + open_index_px = COALESCE(open_index_px, ?), realized_pnl_usdt = ? WHERE id = ? """, ( - settled.get("realized_pnl"), + new_pnl, settled.get("close_index_px"), - settled.get("realized_pnl_usdt"), + settled.get("open_index_px"), + float(new_usdt), int(row["id"]), ), ) @@ -448,11 +460,16 @@ def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int: return updated +def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int: + """兼容旧名:改为强制按开仓成本重算冻结 U.""" + return recompute_closed_realized_pnl_usdt(conn, ex) + + 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) + recompute_closed_realized_pnl_usdt(conn, ex) except Exception: pass try: diff --git a/tests/test_options_pnl_settle.py b/tests/test_options_pnl_settle.py index 596b9ff..25fd10e 100644 --- a/tests/test_options_pnl_settle.py +++ b/tests/test_options_pnl_settle.py @@ -1,4 +1,4 @@ -"""期权已实现盈亏:扣手续费 + 平仓冻结 U 不跟实时指数变.""" +"""期权已实现盈亏:扣手续费 + 按开仓成本冻结 U(不跟平仓价重估开仓成本).""" from __future__ import annotations import unittest @@ -11,6 +11,7 @@ from lib.options.options_pnl_settle_lib import ( from lib.options.options_stats_lib import ( compute_options_stats_from_history, history_pnl_to_usdt, + recompute_closed_realized_pnl_usdt, ) @@ -31,9 +32,8 @@ class FeeConvertTests(unittest.TestCase): class SettleClosedPnlTests(unittest.TestCase): - def test_net_of_fees_and_freeze_usdt(self): - # 收回 0.02 ETH,已付 0.01,开+平手续费各 0.0005 → 净 0.009 - # × 3000 = 27 U + def test_same_index_matches_net_times_index(self): + # 开平指数相同:(recv-paid-fees)*idx s = settle_closed_pnl( premium_paid=0.01, premium_received=0.02, @@ -41,10 +41,40 @@ class SettleClosedPnlTests(unittest.TestCase): close_fee=0.0005, premium_ccy="ETH", index_px=3000, + open_index_px=3000, ) self.assertAlmostEqual(float(s["realized_pnl"]), 0.009, places=8) self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4) - self.assertAlmostEqual(float(s["close_index_px"]), 3000.0, places=4) + + def test_open_cost_not_revalued_at_close(self): + # 开仓 2500、平仓 3000:不得把已付也按 3000 计 + # exit=0.02*3000=60, entry=0.01*2500=25, fee=0.0005*2500+0.0005*3000=2.75 + # usdt=60-25-2.75=32.25 (旧口径 0.009*3000=27 偏亏) + s = settle_closed_pnl( + premium_paid=0.01, + premium_received=0.02, + open_fee=0.0005, + close_fee=0.0005, + premium_ccy="ETH", + index_px=3000, + open_index_px=2500, + ) + self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 32.25, places=4) + + def test_budget_usdt_as_entry(self): + # budget=26 作为真实开仓 USDT 成本;ETH 开仓费不再重复扣 + s = settle_closed_pnl( + premium_paid=0.01, + premium_received=0.02, + open_fee=0.0005, + close_fee=0.0005, + premium_ccy="ETH", + index_px=3000, + budget_usdt=26.0, + fee_ccy="ETH", + ) + # exit=60, entry=26, close_fee_u=1.5 → 32.5 + self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 32.5, places=4) def test_usdc_freeze_is_net(self): s = settle_closed_pnl( @@ -59,7 +89,6 @@ class SettleClosedPnlTests(unittest.TestCase): 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", @@ -70,67 +99,8 @@ class SettleClosedPnlTests(unittest.TestCase): 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): - history = [ - { - "status": "closed", - "inst_id": "ETH-USD-260701-2500-C", - "underlying": "ETH", - "premium_ccy": "ETH", - "realized_pnl": 0.01, # 币数量 - "realized_pnl_usdt": 25.0, # 平仓时冻结 - "close_index_px": 2500.0, - "created_at": "2026-10-01 10:00:00", - "closed_at": "2026-10-01 12:00:00", - } - ] - - class _Ex: - def public_get_market_ticker(self, params): - return {"data": [{"last": "99999"}]} # 实时价再变也不影响 - - converted = history_pnl_to_usdt(history, _Ex()) - self.assertAlmostEqual(float(converted[0]["realized_pnl"]), 25.0, places=4) - stats = compute_options_stats_from_history(converted) - self.assertAlmostEqual(float(stats["net_realized_pnl"]), 25.0, places=4) - - def test_close_index_used_when_no_frozen_usdt(self): history = [ { "status": "closed", @@ -138,6 +108,7 @@ class FrozenStatsTests(unittest.TestCase): "underlying": "ETH", "premium_ccy": "ETH", "realized_pnl": 0.01, + "realized_pnl_usdt": 25.0, "close_index_px": 2500.0, "created_at": "2026-10-01 10:00:00", "closed_at": "2026-10-01 12:00:00", @@ -150,6 +121,42 @@ class FrozenStatsTests(unittest.TestCase): converted = history_pnl_to_usdt(history, _Ex()) self.assertAlmostEqual(float(converted[0]["realized_pnl"]), 25.0, places=4) + stats = compute_options_stats_from_history(converted) + self.assertAlmostEqual(float(stats["net_realized_pnl"]), 25.0, places=4) + + +class RecomputeClosedUsdtTests(unittest.TestCase): + def test_recompute_uses_budget_not_close_revalue(self): + import sqlite3 + + from lib.options.options_db import init_options_tables + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + # 旧错误冻结: (0.02-0.01)*3000=30 + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, + premium_ccy, status, budget_usdt, close_index_px, realized_pnl_usdt + ) VALUES ( + 'ETH-USD-260701-2500-C', 'ETH', 'C', 1, 0.01, + 0.01, 0.02, 0.01, + 'ETH', 'closed', 26.0, 3000, 30.0 + ) + """ + ) + conn.commit() + n = recompute_closed_realized_pnl_usdt(conn, None) + self.assertEqual(n, 1) + row = conn.execute( + "SELECT realized_pnl_usdt FROM options_trades WHERE id=1" + ).fetchone() + # exit 60 - budget 26 = 34 (无手续费列) + self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 34.0, places=4) + conn.close() if __name__ == "__main__":