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 @@
1
期权交易记录
- 真盈亏=历史币盈亏×卖回价 − 权利金×(开仓价−卖回价);计入权利金 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
|