diff --git a/docs/规格-期权交易账户盈亏结算.md b/docs/规格-期权交易账户盈亏结算.md index 0ed808e..6800943 100644 --- a/docs/规格-期权交易账户盈亏结算.md +++ b/docs/规格-期权交易账户盈亏结算.md @@ -42,15 +42,19 @@ 含义:**计入权利金的 USDT 波动**;**忽略现货多买缓冲**。 有开/平手续费时:开仓费按开仓价、平仓费按卖回价折 U 后扣除。 -**多笔成交(VWAP):** +**固定量 vs 一次冻结价:** -| 价格 | 定义 | +| 字段 | 性质 | |------|------| -| 开仓价 | 买币多笔加权:`Σ买币花费USDT / Σ买入币量`(缓冲买单计入均价,公式仍只乘 `premium_paid`) | -| 卖回价 | 平仓后卖回多笔加权:`Σ收回USDT / Σ卖出币量`(**不含**开仓多余卖回) | +| `premium_paid`(权利金币) | 开仓后**固定**,不再改 | +| `realized_pnl`(期权腿币盈亏) | 平仓后**固定**,不再改 | +| 开仓价 | 买币完成时算一次 VWAP 写入 `open_index_px`,**冻住** | +| 卖回价 | 平仓卖回全部完成时算一次 VWAP 写入 `close_index_px`,**冻住** | +| `realized_pnl_usdt` | 卖回完成后按上式算一次,`pnl_settled=1` 后**只读** | -- 卖回未完成(仍有可卖)→ 复盘标「待卖回」,不冻结真盈亏 -- 无 VWAP 时回退冻结指数;仍缺则「缺价未核」 +- 卖回未完成 → 「待卖回」,不算真盈亏 +- 缺价 → 「缺价未核」 +- 复盘刷新**不得**改已冻结的开仓价/卖回价/真盈亏/权利金币量/币盈亏 - **消费者**:期权复盘「期权交易记录」盈亏列、复盘 KPI、`realized_pnl_usdt` 冻结 - **不含**:现货多买差分当盈亏 / 账户 after−before diff --git a/lib/options/options_account_pnl_lib.py b/lib/options/options_account_pnl_lib.py index 8c033e8..b2b0d2f 100644 --- a/lib/options/options_account_pnl_lib.py +++ b/lib/options/options_account_pnl_lib.py @@ -308,12 +308,13 @@ def settle_closed_trades_from_trading_account( ) if pnl_u is None or src in ("none", "pending_sell"): continue + # 卖回价一次冻结为卖回 VWAP;开仓价已在开仓时冻住,仅缺省时补 conn.execute( """ UPDATE options_trades SET trading_usdt_after = COALESCE(?, trading_usdt_after), realized_pnl_usdt = ?, - open_index_px = COALESCE(?, open_index_px), + open_index_px = COALESCE(open_index_px, ?), close_index_px = COALESCE(?, close_index_px), pnl_settled = 1 WHERE id = ? AND COALESCE(pnl_settled, 0)=0 diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index 924a5c9..b5d7dd2 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -520,6 +520,14 @@ def open_coin_option_buy_full( message="期权持仓中", ) + # 开仓价一次冻结:优先买币 VWAP(之后不再改);权利金币量 premium_paid 固定 + open_px_freeze = idx_open + try: + bv = _spot_bridge.bridge_buy_vwap(conn, bridge_id) + if bv is not None and float(bv) > 0: + open_px_freeze = float(bv) + except Exception: + pass trade_id = _insert_coin_trade( conn, inst_id=inst_id, @@ -537,7 +545,7 @@ def open_coin_option_buy_full( budget_usdt=buy_usdt, premium_ccy=premium_ccy, open_fee=open_fee, - open_index_px=idx_open, + open_index_px=open_px_freeze, trading_usdt_before=trading_usdt_before, profit_exit_enabled=profit_exit_enabled, profit_exit_mult=profit_exit_mult, diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index bcdc41f..8fc6743 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -556,6 +556,10 @@ def sync_options_from_local_trades( if not ccy: ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly) frozen_usdt = _safe_float(r["realized_pnl_usdt"]) + try: + settled = int(r["pnl_settled"] or 0) == 1 + except Exception: + settled = False close_idx = _safe_float(r["close_index_px"]) open_idx = _safe_float(r["open_index_px"]) idx = None @@ -576,43 +580,35 @@ def sync_options_from_local_trades( close_fee=cfee, premium_ccy=ccy, ) - # 初始权利金可用开仓价,缺则兜底指数;真盈亏必须用冻结的开仓价+卖回价,禁止实时价冒充 + # 权利金币量/币盈亏固定;开仓价与卖回价各自冻一次后真盈亏只读 open_idx_for_prem = open_idx if (open_idx is not None and open_idx > 0) else idx sell_idx = close_idx if (close_idx is not None and close_idx > 0) else None from lib.options.options_pnl_settle_lib import resolve_true_pnl_usdt_from_trade - pnl_u, pnl_src = resolve_true_pnl_usdt_from_trade( - premium_paid=prem, - premium_received=r["premium_received"], - realized_pnl=r["realized_pnl"], - open_index_px=open_idx if (open_idx is not None and open_idx > 0) else None, - close_index_px=sell_idx, - open_fee=ofee, - close_fee=cfee, - premium_ccy=ccy, - fee_ccy=str(r["premium_ccy"] or ccy or ""), - conn=conn, - bridge_id=r["bridge_id"], - ) - # 能算新公式则写回;卖回未完成→待卖回;算不出→缺价未核 status_label = "已平" - if pnl_src == "pending_sell": - status_label = "待卖回" - pnl_u = None - # 清掉曾用指数提前冻上的假真盈亏 - try: - conn.execute( - """ - UPDATE options_trades - SET realized_pnl_usdt = NULL, pnl_settled = 0 - WHERE id = ? AND COALESCE(pnl_settled, 0) = 1 - """, - (trade_id,), - ) - except Exception: - pass - elif pnl_u is not None and pnl_src not in ("none", "pending_sell"): - if frozen_usdt is None or abs(float(frozen_usdt) - float(pnl_u)) > 0.05: + pnl_u = None + if settled and frozen_usdt is not None: + # 已冻结:只读展示,禁止同步反复改写 + pnl_u = round(float(frozen_usdt), 4) + else: + pnl_u, pnl_src = resolve_true_pnl_usdt_from_trade( + premium_paid=prem, + premium_received=r["premium_received"], + realized_pnl=r["realized_pnl"], + open_index_px=open_idx if (open_idx is not None and open_idx > 0) else None, + close_index_px=sell_idx, + open_fee=ofee, + close_fee=cfee, + premium_ccy=ccy, + fee_ccy=str(r["premium_ccy"] or ccy or ""), + conn=conn, + bridge_id=r["bridge_id"], + ) + if pnl_src == "pending_sell": + status_label = "待卖回" + pnl_u = None + elif pnl_u is not None and pnl_src not in ("none", "pending_sell"): + # 未冻结且可算:只写一次 try: from lib.options.options_pnl_settle_lib import resolve_spot_vwap_prices @@ -626,33 +622,18 @@ def sync_options_from_local_trades( """ UPDATE options_trades SET realized_pnl_usdt = ?, - open_index_px = COALESCE(?, open_index_px), + open_index_px = COALESCE(open_index_px, ?), close_index_px = COALESCE(?, close_index_px), pnl_settled = 1 - WHERE id = ? + WHERE id = ? AND COALESCE(pnl_settled, 0)=0 """, (pnl_u, opx, cpx, trade_id), ) except Exception: pass - else: - status_label = "缺价未核" - pnl_u = None - try: - conn.execute( - """ - UPDATE options_trades - SET realized_pnl_usdt = NULL, pnl_settled = 0 - WHERE id = ? - AND ( - COALESCE(pnl_settled, 0) = 1 - OR realized_pnl_usdt IS NOT NULL - ) - """, - (trade_id,), - ) - except Exception: - pass + else: + status_label = "缺价未核" + pnl_u = None # 初始权利金(U)=开仓指数×权利金币量;不含多买 stored_initial = _safe_float(r["initial_premium_usdt"]) prem_u = stored_initial diff --git a/tests/test_options_review_pnl_freeze.py b/tests/test_options_review_pnl_freeze.py index c60dcb5..7371c51 100644 --- a/tests/test_options_review_pnl_freeze.py +++ b/tests/test_options_review_pnl_freeze.py @@ -6,6 +6,54 @@ import unittest class ReviewLocalSyncFreezeTests(unittest.TestCase): + def test_settled_true_pnl_not_rewritten_on_resync(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, + open_fee, close_fee, status, realized_pnl_usdt, + open_index_px, close_index_px, pnl_settled, + created_at, closed_at + ) VALUES ( + 'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01, + 0.01, 0.02, 0.01, 'BTC', + 0, 0, 'closed', 700.0, + 90000.0, 80000.0, 1, + '2026-10-05 05:11:30', '2026-10-07 10:03:53' + ) + """ + ) + conn.commit() + sync_options_from_local_trades(conn, None) + sync_options_from_local_trades(conn, None) + trade = conn.execute( + "SELECT realized_pnl_usdt, open_index_px, close_index_px, pnl_settled, realized_pnl, premium_paid FROM options_trades WHERE id=1" + ).fetchone() + self.assertAlmostEqual(float(trade["realized_pnl_usdt"]), 700.0, places=2) + self.assertAlmostEqual(float(trade["open_index_px"]), 90000.0, places=2) + self.assertAlmostEqual(float(trade["close_index_px"]), 80000.0, places=2) + self.assertEqual(int(trade["pnl_settled"]), 1) + self.assertAlmostEqual(float(trade["realized_pnl"]), 0.01, places=8) + self.assertAlmostEqual(float(trade["premium_paid"]), 0.01, places=8) + row = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key=?", + ("local_opt:1",), + ).fetchone() + self.assertAlmostEqual(float(row["realized_pnl_total"]), 700.0, places=2) + conn.close() + def test_premium_usdt_move_formula(self): from lib.options.options_db import init_options_tables from lib.options.options_review_lib import sync_options_from_local_trades @@ -32,9 +80,9 @@ class ReviewLocalSyncFreezeTests(unittest.TestCase): ) VALUES ( 'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01, 0.01, 0.02, 0.01, 'BTC', - 0, 0, 'closed', -51.0, + 0, 0, 'closed', NULL, 90000.0, 80000.0, - 1000.0, 2506.0, 1, + 1000.0, 2506.0, 0, '2026-10-05 05:11:30', '2026-10-07 10:03:53' ) """ @@ -65,7 +113,7 @@ class ReviewLocalSyncFreezeTests(unittest.TestCase): init_options_tables(conn) ensure_runtime_settings_table(conn) runtime_set(conn, TRADING_MODE_KEY, MODE_SIM) - # 无开仓/卖回价 → 无法按新公式更新 + # 未冻结且无开仓/卖回价 → 缺价未核(不瞎写) conn.execute( """ INSERT INTO options_trades( @@ -76,7 +124,7 @@ class ReviewLocalSyncFreezeTests(unittest.TestCase): ) VALUES ( 'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01, 0.01, 0.02, 0.01, 'BTC', - 'closed', -51.0, 1, + 'closed', NULL, 0, '2026-10-05 05:11:30', '2026-10-07 10:03:53' ) """