diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index 4fa4e5f..17e02af 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -284,17 +284,38 @@ def sync_options_from_local_trades( ) -> dict[str, Any]: """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所). - 币本位权利金/盈亏按指数折算成 USDT(U) 写入,复盘页统一按 U 展示. + 盈亏优先用已冻结 realized_pnl_usdt(模拟盘现金口径 / 平仓时指数), + 禁止每次同步用实时指数重算,否则复盘数字会跟盘跳动. """ init_options_review_tables(conn) from lib.options.options_db import init_options_tables from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode init_options_tables(conn) + # 模拟盘先把现金盈亏写进 realized_pnl_usdt,复盘与持仓统计同一口径. + # 不可 is_sim_mode(lambda: conn): with_db 会 close 掉调用方连接. + try: + from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_get + from lib.sim.mode_lib import MODE_SIM, default_trading_mode + + ensure_runtime_settings_table(conn) + mode = runtime_get(conn, "trading.mode") + mode_s = str(mode or "").strip().lower() or default_trading_mode() + if mode_s == MODE_SIM: + from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt + + sync_sim_cash_realized_pnl_usdt(conn, ex) + try: + conn.commit() + except Exception: + pass + except Exception: + pass rows = conn.execute( """ SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, open_quote, close_quote, premium_paid, premium_received, realized_pnl, + realized_pnl_usdt, close_index_px, open_index_px, budget_usdt, premium_ccy, created_at, closed_at, signal_note, status FROM options_trades WHERE status = 'closed' @@ -325,9 +346,9 @@ def sync_options_from_local_trades( for pub in pubs: try: if hasattr(pub, "public_get_market_ticker"): - rows = (pub.public_get_market_ticker({"instId": inst}) or {}).get("data") or [] - if rows: - last = rows[0].get("last") or rows[0].get("lastPx") + tick_rows = (pub.public_get_market_ticker({"instId": inst}) or {}).get("data") or [] + if tick_rows: + last = tick_rows[0].get("last") or tick_rows[0].get("lastPx") if last is not None and float(last) > 0: return float(last) except Exception: @@ -363,6 +384,12 @@ def sync_options_from_local_trades( idx_cache: dict[str, float | None] = {} need_idx: set[str] = set() for r in rows: + # 已有冻结 U 或平仓指数时不再打实时行情 + if _safe_float(r["realized_pnl_usdt"]) is not None: + continue + frozen_idx = _safe_float(r["close_index_px"]) or _safe_float(r["open_index_px"]) + if frozen_idx is not None and frozen_idx > 0: + continue inst0 = str(r["inst_id"] or "") underly0 = str(r["underlying"] or (inst0.split("-")[0] if inst0 else "ETH") or "ETH") ccy0 = str(r["premium_ccy"] or "").strip().upper() @@ -381,7 +408,17 @@ def sync_options_from_local_trades( ccy = str(r["premium_ccy"] or "").strip().upper() if not ccy: ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly) - idx = idx_cache.get(underly) + frozen_usdt = _safe_float(r["realized_pnl_usdt"]) + close_idx = _safe_float(r["close_index_px"]) + open_idx = _safe_float(r["open_index_px"]) + # 折算指数:平仓冻结 > 开仓 > 实时(仅无冻结时兜底) + idx = None + if close_idx is not None and close_idx > 0: + idx = close_idx + elif open_idx is not None and open_idx > 0: + idx = open_idx + else: + idx = idx_cache.get(underly) pnl = _safe_float(r["realized_pnl"]) if pnl is None: paid0 = _safe_float(r["premium_paid"]) @@ -389,10 +426,15 @@ def sync_options_from_local_trades( if paid0 is not None and recv0 is not None: pnl = recv0 - paid0 prem = _safe_float(r["premium_paid"]) - pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx) + if frozen_usdt is not None: + pnl_u = round(float(frozen_usdt), 4) + else: + pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx) + # 权利金用平仓/开仓冻结指数折算,不用实时价 prem_u = _to_usdt(prem, ccy=ccy, idx=idx) - if ccy in ("ETH", "BTC") and idx is None: + if ccy in ("ETH", "BTC") and pnl_u is None: pnl_u = pnl + if ccy in ("ETH", "BTC") and prem_u is None: prem_u = prem opened_at = r["created_at"] closed_at = r["closed_at"] @@ -664,7 +706,18 @@ def ensure_local_review_synced( conn.commit() except Exception: pass - if backfill_exchange_pnl and ex is not None: + # 模拟盘用现金冻结,勿再用交易所历史+实时指数覆盖 + sim_mode = False + try: + from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_get + from lib.sim.mode_lib import MODE_SIM, default_trading_mode + + ensure_runtime_settings_table(conn) + mode = runtime_get(conn, "trading.mode") + sim_mode = (str(mode or "").strip().lower() or default_trading_mode()) == MODE_SIM + except Exception: + sim_mode = False + if backfill_exchange_pnl and ex is not None and not sim_mode: try: from lib.exchange.okx_options_lib import fetch_all_option_positions_history from lib.options.options_monitor_lib import ( diff --git a/tests/test_options_review_pnl_freeze.py b/tests/test_options_review_pnl_freeze.py new file mode 100644 index 0000000..dd1e1e1 --- /dev/null +++ b/tests/test_options_review_pnl_freeze.py @@ -0,0 +1,108 @@ +"""复盘导入须使用冻结 U,不得用实时指数改写.""" +from __future__ import annotations + +import sqlite3 +import unittest + + +class ReviewLocalSyncFreezeTests(unittest.TestCase): + def test_prefers_realized_pnl_usdt_over_live_index(self): + from lib.options.options_db import init_options_tables + from lib.options.options_review_lib import sync_options_from_local_trades + from lib.sim.mode_lib import MODE_SIM, TRADING_MODE_KEY + from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_set + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + ensure_runtime_settings_table(conn) + runtime_set(conn, TRADING_MODE_KEY, MODE_SIM) + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, premium_ccy, + status, realized_pnl_usdt, close_index_px, + created_at, closed_at + ) VALUES ( + 'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01, + 0.01, 0.02, 0.01172, 'BTC', + 'closed', 1947.05, 85000.0, + '2026-10-05 05:11:30', '2026-10-07 10:03:53' + ) + """ + ) + conn.commit() + + class _Ex: + def public_get_market_ticker(self, params): + return {"data": [{"last": "999999"}]} + + out = sync_options_from_local_trades(conn, _Ex()) + self.assertTrue(out.get("ok")) + row = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key=?", + ("local_opt:1",), + ).fetchone() + self.assertIsNotNone(row) + # 必须是冻结现金 1947.05,不是 0.01172*999999 + self.assertAlmostEqual(float(row["realized_pnl_total"]), 1947.05, places=2) + conn.close() + + def test_second_sync_stable_when_index_moves(self): + from lib.options.options_db import init_options_tables + from lib.options.options_review_lib import sync_options_from_local_trades + from lib.sim.mode_lib import MODE_SIM, TRADING_MODE_KEY + from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_set + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + ensure_runtime_settings_table(conn) + runtime_set(conn, TRADING_MODE_KEY, MODE_SIM) + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, premium_ccy, + status, realized_pnl_usdt, close_index_px, + created_at, closed_at + ) VALUES ( + 'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01, + 0.01, 0.02, 0.01, 'BTC', + 'closed', 952.96, 80000.0, + '2026-10-05 05:11:30', '2026-10-07 10:03:53' + ) + """ + ) + conn.commit() + + class _Ex1: + def public_get_market_ticker(self, params): + return {"data": [{"last": "70000"}]} + + class _Ex2: + def public_get_market_ticker(self, params): + return {"data": [{"last": "120000"}]} + + sync_options_from_local_trades(conn, _Ex1()) + v1 = float( + conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key=?", + ("local_opt:1",), + ).fetchone()["realized_pnl_total"] + ) + sync_options_from_local_trades(conn, _Ex2()) + v2 = float( + conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key=?", + ("local_opt:1",), + ).fetchone()["realized_pnl_total"] + ) + self.assertAlmostEqual(v1, 952.96, places=2) + self.assertAlmostEqual(v2, v1, places=6) + conn.close() + + +if __name__ == "__main__": + unittest.main()