diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index 3acf341..fb67169 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -451,6 +451,11 @@ var badgeExtra = reviewed ? ' 已复盘' : ""; + var needsMark = String(t.status_raw || "") === "缺价未核"; + if (needsMark) { + badgeExtra += + ' 缺价未核'; + } return ( '' + - fmtPnl(t.realized_pnl_total) + + (needsMark ? '—' : fmtPnl(t.realized_pnl_total)) + "" + '' + "" + escapeHtml(t.source_label || t.source_type) + - "" + + "" + + (needsMarkR + ? ' 缺价未核' + : "") + + "" + "" + escapeHtml(tradeContractLabel(t)) + "" + @@ -584,7 +594,7 @@ '' + - fmtPnl(t.realized_pnl_total) + + (needsMarkR ? '—' : fmtPnl(t.realized_pnl_total)) + "" + ' 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 ""), ) - if ( - pnl_u is not None - and pnl_src != "none" - and (frozen_usdt is None or abs(float(frozen_usdt) - float(pnl_u)) > 0.05) - ): + # 能算新公式则写回;算不出则清空旧冻结并标记「缺价未核」 + status_label = "已平" + if pnl_u is not None and pnl_src != "none": + if frozen_usdt is None or abs(float(frozen_usdt) - float(pnl_u)) > 0.05: + try: + conn.execute( + """ + UPDATE options_trades + SET realized_pnl_usdt = ?, pnl_settled = 1 + WHERE id = ? + """, + (pnl_u, trade_id), + ) + except Exception: + pass + else: + status_label = "缺价未核" + pnl_u = None try: conn.execute( """ UPDATE options_trades - SET realized_pnl_usdt = ?, pnl_settled = 1 + SET realized_pnl_usdt = NULL, pnl_settled = 0 WHERE id = ? + AND ( + COALESCE(pnl_settled, 0) = 1 + OR realized_pnl_usdt IS NOT NULL + ) """, - (pnl_u, trade_id), + (trade_id,), ) except Exception: pass @@ -654,7 +671,7 @@ def sync_options_from_local_trades( "option_fee_usdt": fee_u, "created_at": opened_at, "closed_at": closed_at, - "status_label": "已平", + "status_label": status_label, }, ) if action == "inserted": diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html index 797c364..18a8348 100644 --- a/lib/options/templates/options_review_panel.html +++ b/lib/options/templates/options_review_panel.html @@ -229,7 +229,7 @@
期权交易记录
-

真盈亏=历史币盈亏×卖回价 − 权利金×(开仓价−卖回价);计入权利金 USDT 波动,忽略现货多买。期权历史仅记币收益。

+

真盈亏=历史币盈亏×卖回价 − 权利金×(开仓价−卖回价)。缺开仓/卖回价的旧单标记「缺价未核」,不沿用旧冻结。期权历史仅记币收益。

diff --git a/scripts/repair_options_dual_pnl.py b/scripts/repair_options_dual_pnl.py index aac38ed..97d1844 100644 --- a/scripts/repair_options_dual_pnl.py +++ b/scripts/repair_options_dual_pnl.py @@ -51,12 +51,15 @@ def _ensure_columns(conn: sqlite3.Connection) -> None: def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: + """按最新口径 A 重算历史:币盈亏×卖回价 − 权利金×(开仓价−卖回价).""" + from lib.options.options_pnl_settle_lib import resolve_true_pnl_usdt_from_trade + _ensure_columns(conn) rows = conn.execute( """ SELECT id, inst_id, premium_paid, premium_received, realized_pnl, - open_index_px, close_index_px, budget_usdt, - trading_usdt_before, trading_usdt_after, + open_index_px, close_index_px, budget_usdt, open_fee, close_fee, + premium_ccy, trading_usdt_before, trading_usdt_after, realized_pnl_usdt, pnl_settled, bridge_id, initial_premium_usdt FROM options_trades WHERE status='closed' @@ -72,107 +75,55 @@ def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: coin_pnl = _safe_float(r["realized_pnl"]) open_idx = _safe_float(r["open_index_px"]) close_idx = _safe_float(r["close_index_px"]) - before = _safe_float(r["trading_usdt_before"]) - after = _safe_float(r["trading_usdt_after"]) settled_u = _safe_float(r["realized_pnl_usdt"]) settled = int(r["pnl_settled"] or 0) == 1 initial = _safe_float(r["initial_premium_usdt"]) + ccy = str(r["premium_ccy"] or "").strip().upper() or None new_initial = initial if new_initial is None and open_idx and paid and open_idx > 0 and paid > 0: new_initial = round(open_idx * paid, 4) + # 口径 B:历史币×卖回价(数据统计) stats_u = None if coin_pnl is not None and close_idx is not None and close_idx > 0: stats_u = round(coin_pnl * close_idx, 4) elif coin_pnl is not None and open_idx is not None and open_idx > 0: stats_u = round(coin_pnl * open_idx, 4) - # 错账:币盈亏为正、账户冻结为负(典型卖回封顶) - suspicious = bool( - settled - and coin_pnl is not None - and coin_pnl > 0 - and settled_u is not None - and settled_u < 0 + # 口径 A:权利金 USDT 波动式(忽略现货多买/账户差分) + # 真盈亏必须同时有开仓价与卖回价;缺一则标记缺价未核(不用开仓价冒充卖回价) + true_a, src = resolve_true_pnl_usdt_from_trade( + premium_paid=paid, + premium_received=recv, + realized_pnl=coin_pnl, + open_index_px=open_idx if open_idx and open_idx > 0 else None, + close_index_px=close_idx if close_idx and close_idx > 0 else None, + open_fee=r["open_fee"], + close_fee=r["close_fee"], + premium_ccy=ccy, ) - # 桥侧:coin_sold ≈ premium 且远小于 recv → 历史上曾按权利金封顶卖 - bridge_hint = "" - bid = r["bridge_id"] - coin_bought = coin_sold = None - if bid is not None: - try: - b = conn.execute( - "SELECT coin_bought, coin_sold FROM options_spot_bridge WHERE id=?", - (int(bid),), - ).fetchone() - if b: - coin_bought = _safe_float(b["coin_bought"]) - coin_sold = _safe_float(b["coin_sold"]) - if ( - paid - and coin_sold is not None - and recv - and coin_sold <= paid * 1.05 - and recv > paid * 1.2 - ): - bridge_hint = "sell_capped_at_premium" - except Exception: - pass - action = "ok" new_settled_u = settled_u new_pnl_settled = 1 if settled else 0 - # A 已为正且与口径 B 接近 → 视为已恢复,勿因桥历史反复 unsettle - a_ok_near_b = ( - settled_u is not None - and settled_u > 0 - and stats_u is not None - and stats_u > 0 - and abs(settled_u - stats_u) / max(abs(stats_u), 1.0) <= 0.15 - ) - - # 桥真盈亏=卖回累计−买币预算(口径 A 回退;禁止用 B 冒充 A) - bridge_true_a = None - if bid is not None: - try: - b2 = conn.execute( - "SELECT status, budget_usdt, usdt_recovered FROM options_spot_bridge WHERE id=?", - (int(bid),), - ).fetchone() - if b2 and str(b2["status"] or "") == "closed": - spent = _safe_float(b2["budget_usdt"]) - recovered = _safe_float(b2["usdt_recovered"]) - if spent is not None and recovered is not None: - bridge_true_a = round(float(recovered) - float(spent), 4) - except Exception: - bridge_true_a = None - - if before is not None and after is not None and (not settled or suspicious): - action = "settle_from_snapshots" - new_settled_u = round(after - before, 4) - new_pnl_settled = 1 - elif bridge_true_a is not None and (not settled or suspicious): - action = "settle_from_bridge_cash" - new_settled_u = bridge_true_a - new_pnl_settled = 1 - elif suspicious and not a_ok_near_b: - # 负冻结且无快照/桥可重建:解除错误冻结 - action = "unsettle_bad_account_freeze" + status_label = "已平" + if true_a is not None and src != "none": + if settled_u is None or abs(float(settled_u) - float(true_a)) > 0.05: + action = "rewrite_A_premium_move" + new_settled_u = round(float(true_a), 4) + new_pnl_settled = 1 + else: + action = "ok_premium_move" + new_settled_u = round(float(true_a), 4) + new_pnl_settled = 1 + else: + # 缺开仓价/卖回价/币盈亏等:无法按新公式更新 → 标记「缺价未核」 + action = "mark_uncomputable_A" new_pnl_settled = 0 new_settled_u = None - elif ( - a_ok_near_b - and settled_u is not None - and settled_u > 0 - and bridge_hint == "sell_capped_at_premium" - ): - # 仅当 A 已为正且接近 B 时保留,不再用 B 回填假 A - action = "accept_recovered_account_A" - new_settled_u = round(float(settled_u), 4) - new_pnl_settled = 1 + status_label = "缺价未核" profit_rate = None a_for_rate = new_settled_u if new_pnl_settled == 1 else None @@ -183,8 +134,7 @@ def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: "trade_id": tid, "inst_id": r["inst_id"], "action": action, - "suspicious": suspicious, - "bridge_hint": bridge_hint, + "pnl_source": src, "coin_pnl": coin_pnl, "stats_pnl_usdt_B": stats_u, "old_realized_pnl_usdt_A": settled_u, @@ -193,10 +143,10 @@ def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: "new_pnl_settled": new_pnl_settled, "initial_premium_usdt": new_initial, "profit_rate": profit_rate, - "coin_bought": coin_bought, - "coin_sold": coin_sold, "premium_paid": paid, "premium_received": recv, + "open_index_px": open_idx, + "close_index_px": close_idx, } report.append(rec) @@ -208,20 +158,16 @@ def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: "UPDATE options_trades SET initial_premium_usdt=? WHERE id=?", (new_initial, tid), ) - if action == "unsettle_bad_account_freeze": + if action == "mark_uncomputable_A": conn.execute( """ UPDATE options_trades - SET pnl_settled=0, realized_pnl_usdt=NULL, trading_usdt_after=NULL + SET pnl_settled=0, realized_pnl_usdt=NULL WHERE id=? """, (tid,), ) - elif action in ( - "settle_from_snapshots", - "settle_from_bridge_cash", - "accept_recovered_account_A", - ) and new_settled_u is not None: + elif action in ("rewrite_A_premium_move", "ok_premium_move") and new_settled_u is not None: conn.execute( """ UPDATE options_trades @@ -231,7 +177,7 @@ def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: (new_settled_u, tid), ) - # 复盘表 + # 复盘表同步最新 A;无法更新的打「缺价未核」 hk = f"local_opt:{tid}" rev = conn.execute( "SELECT id FROM options_review_trades WHERE history_key=?", (hk,) @@ -253,7 +199,7 @@ def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: pnl_total, pnl_total, profit_rate, - "待结算" if new_pnl_settled == 0 and before is not None else "已平", + status_label, hk, ), ) @@ -305,13 +251,13 @@ def main() -> int: finally: conn.close() - bad = [x for x in report if x.get("suspicious") or x.get("action") != "ok"] + bad = [x for x in report if x.get("action") not in ("ok", "ok_premium_move")] print(f"scanned={len(report)} need_attention={len(bad)} apply={bool(args.apply)}") for x in bad[:50]: print( f" #{x['trade_id']} {x['inst_id']} action={x['action']} " f"A:{x['old_realized_pnl_usdt_A']}->{x['new_realized_pnl_usdt_A']} " - f"B={x['stats_pnl_usdt_B']} hint={x['bridge_hint']}" + f"B={x['stats_pnl_usdt_B']} src={x.get('pnl_source')}" ) if args.csv: out = Path(args.csv) diff --git a/tests/test_options_review_pnl_freeze.py b/tests/test_options_review_pnl_freeze.py index 7960759..c60dcb5 100644 --- a/tests/test_options_review_pnl_freeze.py +++ b/tests/test_options_review_pnl_freeze.py @@ -54,6 +54,48 @@ class ReviewLocalSyncFreezeTests(unittest.TestCase): self.assertAlmostEqual(float(healed["realized_pnl_usdt"]), 700.0, places=2) conn.close() + def test_uncomputable_old_trade_marked(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, pnl_settled, + created_at, closed_at + ) VALUES ( + 'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01, + 0.01, 0.02, 0.01, 'BTC', + 'closed', -51.0, 1, + '2026-10-05 05:11:30', '2026-10-07 10:03:53' + ) + """ + ) + conn.commit() + sync_options_from_local_trades(conn, None) + row = conn.execute( + "SELECT realized_pnl_total, status_raw FROM options_review_trades WHERE history_key=?", + ("local_opt:1",), + ).fetchone() + self.assertEqual(str(row["status_raw"]), "缺价未核") + self.assertIsNone(row["realized_pnl_total"]) + trade = conn.execute( + "SELECT realized_pnl_usdt, pnl_settled FROM options_trades WHERE id=1" + ).fetchone() + self.assertIsNone(trade["realized_pnl_usdt"]) + self.assertEqual(int(trade["pnl_settled"] or 0), 0) + conn.close() + def test_same_open_close_px_equals_coin_times_close(self): from lib.options.options_db import init_options_tables from lib.options.options_review_lib import sync_options_from_local_trades