From 7943cd21678dc42643e3df6cfed508495b4739ef Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 8 Oct 2026 23:20:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=9B=98=E6=95=B0=E6=8D=AE=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=94=B9=E7=94=A8=E6=9C=9F=E6=9D=83=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=89=80=E7=9B=88=E4=BA=8F=EF=BC=9B=E6=A8=A1?= =?UTF-8?q?=E6=8B=9F=E7=9B=98=E4=BB=8D=E8=B5=B0=E6=9C=AC=E5=9C=B0=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/hub/hub_bridge_register.py | 17 +++- lib/options/options_db.py | 13 +++ lib/options/options_pnl_settle_lib.py | 69 +++---------- lib/options/options_register.py | 6 +- lib/options/options_stats_lib.py | 118 ++++++++++++++++++++-- tests/test_options_pnl_settle.py | 138 +++++++++++++------------- 6 files changed, 222 insertions(+), 139 deletions(-) diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index 8f5943c..32e6dbc 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -288,6 +288,7 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None: from lib.options.options_margin_mode_lib import is_coin_margin_mode from lib.options.options_positions_lib import sum_options_net_pnl_usdc from lib.options.options_stats_lib import ( + apply_exchange_pnl_freeze, compute_options_stats_for_window, history_pnl_to_usdt, load_local_options_stats_history, @@ -302,11 +303,23 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None: raw_live = cfg["fetch_option_positions"](ex) if raw_live is None: return jsonify({"ok": False, "msg": "获取期权持仓失败", "version": 1}) - # 与实例统计一致:本地已平(扣费+冻结 U),不跟实时指数漂移 + # 模拟盘本地结算;实盘与期权历史同一交易所盈亏 conn = cfg["get_db"]() try: init_options_tables(conn) - history = load_local_options_stats_history(conn, ex) + use_sim = False + try: + from lib.sim.mode_lib import is_sim_mode + + use_sim = is_sim_mode(cfg["get_db"]) + except Exception: + use_sim = False + if use_sim: + history = load_local_options_stats_history(conn, ex) + else: + from lib.options.options_history_lib import load_options_history + + history = apply_exchange_pnl_freeze(conn, load_options_history(ex, cfg), ex) try: conn.commit() except Exception: diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 6fd1ba3..d5f2460 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -95,6 +95,19 @@ def init_options_tables(conn: sqlite3.Connection) -> None: ON options_target_monitors(status) """ ) + # 实盘统计:按期权历史 history_key 冻结交易所盈亏折 U,避免跟实时指数漂移 + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_exchange_pnl_freeze ( + history_key TEXT PRIMARY KEY, + realized_pnl REAL, + realized_pnl_usdt REAL, + close_index_px REAL, + premium_ccy TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) for ddl in ( "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", diff --git a/lib/options/options_pnl_settle_lib.py b/lib/options/options_pnl_settle_lib.py index 5cf06ee..0bc4f94 100644 --- a/lib/options/options_pnl_settle_lib.py +++ b/lib/options/options_pnl_settle_lib.py @@ -188,13 +188,14 @@ def settle_closed_pnl( ) -> dict[str, float | None]: """ 由「毛权利金」结算. - 币数量净盈亏 = 收回 − 已付 − 开/平手续费. - 冻结 U(币本位): - 平仓收回×平仓指数 − 开仓成本U − 手续费U - 开仓成本优先 budget_usdt,否则 已付×开仓指数(缺省才用平仓指数). - 这样不会把开仓成本也按平仓价重估,避免和余额对不上. + 币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次). + 冻结 U(币本位) = 净盈亏 × 平仓指数. + + 注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲,多余币会卖回, + 拿全额预算当成本会把亏损算大. open_index/budget 参数保留兼容,不参与 U 计算. 不要对 OKX realizedPnl 再调此函数扣费. """ + del open_index_px, budget_usdt, fee_ccy # 兼容旧调用,不参与结算 paid = float(_safe_float(premium_paid) or 0.0) recv = float(_safe_float(premium_received) or 0.0) ofee = float(_safe_float(open_fee) or 0.0) @@ -204,59 +205,13 @@ def settle_closed_pnl( if cfee < 0: cfee = abs(cfee) net = round(recv - paid - ofee - cfee, 8) - 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 + out = freeze_pnl_to_usdt( + net, + premium_ccy=premium_ccy, + index_px=index_px, + total_fee=ofee + cfee, ) - # 若开仓成本已是 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), - } + return out def settle_exchange_realized_pnl( diff --git a/lib/options/options_register.py b/lib/options/options_register.py index cc7455d..c1f0bc8 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1280,13 +1280,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": False, "msg": err}) from lib.options.options_margin_mode_lib import is_coin_margin_mode from lib.options.options_positions_lib import sum_options_net_pnl_usdc - from lib.options.options_stats_lib import compute_options_stats + from lib.options.options_stats_lib import compute_options_stats_auto raw_live = cfg["fetch_option_positions"](ex) if raw_live is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - # 已平仓走本地库:扣手续费 + 平仓时冻结 U,不跟实时指数漂移 - stats = compute_options_stats(cfg["get_db"], ex) + # 实盘:期权历史交易所原生盈亏;模拟盘:本地结算 + stats = compute_options_stats_auto(cfg, ex) open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 total_pnl = None diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index f896702..cf1b94c 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -466,12 +466,8 @@ def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int: def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]: - """本地已平(含冻结 U/手续费净额) + 当前 open,供统计/中控窗口复用.""" + """本地已平(模拟盘口径) + 当前 open.""" init_options_tables(conn) - try: - recompute_closed_realized_pnl_usdt(conn, ex) - except Exception: - pass try: freeze_missing_realized_pnl_usdt(conn, ex) except Exception: @@ -486,7 +482,86 @@ def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any return history +def apply_exchange_pnl_freeze( + conn, + history: list[dict[str, Any]], + ex: Any = None, +) -> list[dict[str, Any]]: + """实盘:以期权历史里的交易所 realized_pnl 为准,首次折 U 后按 history_key 冻结.""" + from lib.options.options_pnl_settle_lib import settle_exchange_realized_pnl + from lib.options.options_time_lib import options_app_now_str + + init_options_tables(conn) + out: list[dict[str, Any]] = [] + for row in history or []: + r = dict(row) + if r.get("status") == "open": + out.append(r) + continue + key = str(r.get("history_key") or "").strip() + pnl = _safe_float(r.get("realized_pnl")) + if not key or pnl is None: + out.append(r) + continue + frozen = conn.execute( + """ + SELECT realized_pnl_usdt, close_index_px, realized_pnl + FROM options_exchange_pnl_freeze WHERE history_key = ? + """, + (key,), + ).fetchone() + if frozen is not None and _safe_float(_row_get(frozen, "realized_pnl_usdt")) is not None: + r["realized_pnl_usdt"] = _row_get(frozen, "realized_pnl_usdt") + r["close_index_px"] = _row_get(frozen, "close_index_px") + # 币数量仍以当前交易所行为准(防历史修订);U 用冻结值 + out.append(r) + continue + ccy = str(r.get("premium_ccy") or "").strip().upper() + underly = str(r.get("underlying") or "").strip() + inst = str(r.get("inst_id") or "") + if not underly and inst: + underly = inst.split("-")[0] + from lib.options.options_pnl_settle_lib import resolve_close_index_px + + idx = resolve_close_index_px(ex, inst_id=inst, underlying=underly) + settled = settle_exchange_realized_pnl( + realized_pnl=pnl, + premium_ccy=ccy or "ETH", + index_px=idx, + ) + usdt = settled.get("realized_pnl_usdt") + close_idx = settled.get("close_index_px") + if usdt is not None: + conn.execute( + """ + INSERT INTO options_exchange_pnl_freeze( + history_key, realized_pnl, realized_pnl_usdt, close_index_px, + premium_ccy, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(history_key) DO UPDATE SET + realized_pnl = excluded.realized_pnl, + realized_pnl_usdt = excluded.realized_pnl_usdt, + close_index_px = excluded.close_index_px, + premium_ccy = excluded.premium_ccy, + updated_at = excluded.updated_at + """, + ( + key, + float(pnl), + float(usdt), + close_idx, + ccy or "", + options_app_now_str(), + ), + ) + r["realized_pnl_usdt"] = float(usdt) + r["close_index_px"] = close_idx + out.append(r) + return out + + def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]: + """模拟盘:本地交易结算.实盘请用 compute_options_stats_live_exchange.""" conn = get_db() try: history = load_local_options_stats_history(conn, ex) @@ -494,12 +569,43 @@ def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]: conn.commit() except Exception: pass - # 优先冻结 U;缺省再按指数折(与 /api/options/stats 一致) return compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) finally: conn.close() +def compute_options_stats_live_exchange(cfg: dict[str, Any], ex: Any) -> dict[str, Any]: + """实盘:与「期权历史」同一数据源(交易所 positions-history),盈亏以交易所为准.""" + from lib.options.options_history_lib import load_options_history + + history = load_options_history(ex, cfg) + conn = cfg["get_db"]() + try: + history = apply_exchange_pnl_freeze(conn, history, ex) + try: + conn.commit() + except Exception: + pass + finally: + conn.close() + return compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) + + +def compute_options_stats_auto(cfg: dict[str, Any], ex: Any = None) -> dict[str, Any]: + """模拟盘走本地结算;实盘走交易所期权历史原生盈亏.""" + get_db = cfg.get("get_db") + try: + from lib.sim.mode_lib import is_sim_mode + + if callable(get_db) and is_sim_mode(get_db): + return compute_options_stats(get_db, ex) + except Exception: + pass + if ex is None: + ex = cfg.get("exchange_options") + return compute_options_stats_live_exchange(cfg, ex) + + def _stats_from_option_trade_rows(closed_rows, open_rows, *, ex: Any = None) -> dict[str, Any]: """兼容旧调用;内部统一折 U 后走 compute_options_stats_from_history.""" history = _closed_trade_dicts(closed_rows) diff --git a/tests/test_options_pnl_settle.py b/tests/test_options_pnl_settle.py index 25fd10e..3e29a8a 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 @@ -9,9 +9,9 @@ from lib.options.options_pnl_settle_lib import ( settle_exchange_realized_pnl, ) from lib.options.options_stats_lib import ( + apply_exchange_pnl_freeze, compute_options_stats_from_history, history_pnl_to_usdt, - recompute_closed_realized_pnl_usdt, ) @@ -23,17 +23,9 @@ class FeeConvertTests(unittest.TestCase): places=8, ) - def test_fee_usdt_to_eth(self): - self.assertAlmostEqual( - fee_to_premium_ccy(3.0, "USDT", premium_ccy="ETH", index_px=3000), - 0.001, - places=8, - ) - class SettleClosedPnlTests(unittest.TestCase): - def test_same_index_matches_net_times_index(self): - # 开平指数相同:(recv-paid-fees)*idx + def test_sim_net_times_close_index(self): s = settle_closed_pnl( premium_paid=0.01, premium_received=0.02, @@ -41,40 +33,22 @@ 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) - 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 偏亏) + def test_budget_ignored_for_usdt(self): + # budget 不得抬高开仓成本 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, + open_fee=0, + close_fee=0, 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) + self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 30.0, places=4) def test_usdc_freeze_is_net(self): s = settle_closed_pnl( @@ -99,7 +73,65 @@ class SettleClosedPnlTests(unittest.TestCase): self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4) -class FrozenStatsTests(unittest.TestCase): +class ExchangeFreezeStatsTests(unittest.TestCase): + def test_freeze_uses_exchange_coin_pnl(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) + + class _Ex: + def public_get_market_ticker(self, params): + inst = (params or {}).get("instId") or "" + if inst.startswith("BTC"): + return {"data": [{"last": "100000"}]} + return {"data": [{"last": "2500"}]} + + history = [ + { + "status": "closed", + "source": "exchange", + "history_key": "ex|BTC-USD-261009-84000-P|1", + "inst_id": "BTC-USD-261009-84000-P", + "underlying": "BTC", + "premium_ccy": "BTC", + "realized_pnl": 0.000138, + "created_at": "2026-10-08 10:00:00", + "closed_at": "2026-10-08 22:22:56", + }, + { + "status": "closed", + "source": "exchange", + "history_key": "ex|ETH-USD-261006-2700-P|2", + "inst_id": "ETH-USD-261006-2700-P", + "underlying": "ETH", + "premium_ccy": "ETH", + "realized_pnl": -0.00648, + "created_at": "2026-10-05 10:00:00", + "closed_at": "2026-10-06 16:00:27", + }, + ] + frozen = apply_exchange_pnl_freeze(conn, history, _Ex()) + converted = history_pnl_to_usdt(frozen, _Ex()) + # 0.000138*100000=13.8; -0.00648*2500=-16.2; net=-2.4 + self.assertAlmostEqual(float(converted[0]["realized_pnl"]), 13.8, places=4) + self.assertAlmostEqual(float(converted[1]["realized_pnl"]), -16.2, places=4) + stats = compute_options_stats_from_history(converted) + self.assertAlmostEqual(float(stats["net_realized_pnl"]), -2.4, places=4) + + # 指数再变也不改冻结 U + class _Ex2: + def public_get_market_ticker(self, params): + return {"data": [{"last": "999999"}]} + + frozen2 = apply_exchange_pnl_freeze(conn, history, _Ex2()) + converted2 = history_pnl_to_usdt(frozen2, _Ex2()) + self.assertAlmostEqual(float(converted2[0]["realized_pnl"]), 13.8, places=4) + conn.close() + def test_frozen_usdt_ignores_live_index(self): history = [ { @@ -121,42 +153,6 @@ 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__":