From a03c208a5f1a623b66f84557810661a3933ac558 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 24 Sep 2026 21:25:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A8=A1=E6=8B=9F=E7=9B=98?= =?UTF-8?q?=E6=9C=9F=E6=9D=83=E5=8E=86=E5=8F=B2=E5=88=86=E6=89=B9=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E7=9B=88=E4=BA=8F=EF=BC=9A=E7=94=A8=E5=8D=96=E5=B9=B3?= =?UTF-8?q?=E6=88=90=E4=BA=A4=E9=87=8D=E7=AE=97=E5=B7=B2=E5=B9=B3=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加载历史前按 sim_option_orders(或钱包流水)合计各批收回,纠正只记最后一笔的旧数据。 Co-authored-by: Cursor --- lib/sim/broker_lib.py | 200 +++++++++++++++++++++ tests/test_sim_repair_partial_close_pnl.py | 95 ++++++++++ 2 files changed, 295 insertions(+) create mode 100644 tests/test_sim_repair_partial_close_pnl.py diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index 9561ad0..1b109e2 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -902,6 +902,200 @@ class SimBroker: rows.append(row) return rows + @staticmethod + def _sells_from_ledger( + conn: Any, + *, + inst: str, + opened: str, + closed: str, + ) -> list[Any]: + """从 sim_ledger_entries 的 option_close 备注解析卖平成交.""" + import re + + try: + params: list[Any] = [f"%sell {inst} %"] + sql = """ + SELECT note, ts AS created_at + FROM sim_ledger_entries + WHERE kind = 'option_close' AND note LIKE ? + """ + if opened: + sql += " AND ts >= ?" + params.append(opened[:19]) + if closed: + sql += " AND ts <= ?" + params.append((closed[:16] + ":59") if len(closed) >= 16 else closed) + sql += " ORDER BY ts ASC, id ASC" + rows = conn.execute(sql, params).fetchall() + except Exception: + return [] + out: list[dict[str, Any]] = [] + pat = re.compile( + rf"sell\s+{re.escape(inst)}\s+x(?P[0-9.]+)@(?P[0-9.]+)", + re.I, + ) + for i, r in enumerate(rows): + note = str(r["note"] or "") + m = pat.search(note) + if not m: + continue + try: + sh = float(m.group("sheets")) + px = float(m.group("px")) + except (TypeError, ValueError): + continue + if sh <= 0 or px <= 0: + continue + out.append( + { + "ord_id": f"ledger-{i}", + "sheets": sh, + "avg_px": px, + "created_at": r["created_at"], + } + ) + return out + + def repair_closed_options_pnl_from_sim_orders(self, *, limit: int = 300) -> int: + """用 sim_option_orders 卖平成交重算已平仓收回(修复分批平仓只记最后一笔). + + 返回更新条数.历史页/复盘合成前调用,使旧数据也能自愈. + """ + from lib.options.options_db import init_options_tables + from lib.options.options_pricing_lib import total_premium + from lib.sim.db_lib import init_sim_tables + + conn = self.get_db() + updated = 0 + try: + init_options_tables(conn) + init_sim_tables(conn) + # 表不存在时跳过(极旧库) + try: + conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone() + except Exception: + return 0 + + trades = conn.execute( + """ + SELECT id, inst_id, sheets, eth_amount, premium_paid, premium_received, + realized_pnl, close_quote, created_at, closed_at, close_ord_id + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT ? + """, + (int(max(1, min(int(limit), 500))),), + ).fetchall() + for tr in trades: + inst = str(tr["inst_id"] or "").strip() + if not inst: + continue + want_sheets = float(tr["sheets"] or 0) + if want_sheets <= 0: + continue + eth_amt = float(tr["eth_amount"] or 0) + ct_mult = (eth_amt / want_sheets) if eth_amt > 0 else 0.01 + if ct_mult <= 0: + ct_mult = 0.01 + opened = str(tr["created_at"] or "").strip() + closed = str(tr["closed_at"] or "").strip() + # 时间窗:开仓后、平仓后 2 分钟内(含下单延迟) + params: list[Any] = [inst] + sql = """ + SELECT ord_id, sheets, avg_px, created_at + FROM sim_option_orders + WHERE inst_id = ? AND lower(side) = 'sell' + """ + if opened: + sql += " AND created_at >= ?" + params.append(opened[:19]) + if closed: + # 字符串时间比较;闭区间放宽到同分钟及之后的短尾 + sql += " AND created_at <= ?" + params.append((closed[:16] + ":59") if len(closed) >= 16 else closed) + sql += " ORDER BY created_at ASC, ord_id ASC" + sells = conn.execute(sql, params).fetchall() + if not sells: + # 回退:从钱包流水 note 解析 sell INST x张数@价 + sells = self._sells_from_ledger( + conn, inst=inst, opened=opened, closed=closed + ) + if not sells: + continue + selected: list[Any] = [] + sheets_sum = 0.0 + for o in sells: + selected.append(o) + sheets_sum += float(o["sheets"] or 0) + if sheets_sum + 1e-9 >= want_sheets: + break + if sheets_sum + 1e-6 < want_sheets * 0.5: + # 成交张数明显不足,不瞎改 + continue + recv = 0.0 + notional = 0.0 + oids: list[str] = [] + for o in selected: + sh = float(o["sheets"] or 0) + px = float(o["avg_px"] or 0) + if sh <= 0 or px <= 0: + continue + recv += total_premium(px, sh * ct_mult) + notional += px * sh + oid = str(o["ord_id"] or "").strip() + if oid and oid not in oids: + oids.append(oid) + if not oids: + continue + total_recv = round(recv, 8) + paid = float(tr["premium_paid"] or 0) if tr["premium_paid"] is not None else None + if paid is None: + continue + new_pnl = round(total_recv - paid, 8) + close_px = round(notional / sheets_sum, 8) if sheets_sum > 0 else None + old_recv = ( + float(tr["premium_received"]) + if tr["premium_received"] is not None + else None + ) + old_pnl = float(tr["realized_pnl"]) if tr["realized_pnl"] is not None else None + # 已与合计一致则跳过 + if ( + old_recv is not None + and old_pnl is not None + and abs(old_recv - total_recv) < 1e-8 + and abs(old_pnl - new_pnl) < 1e-8 + ): + continue + # 仅当卖笔数≥2 或合计收回明显高于本地(漏记)时覆盖 + if len(selected) < 2 and old_recv is not None and total_recv <= old_recv + 1e-10: + continue + conn.execute( + """ + UPDATE options_trades + SET premium_received = ?, + realized_pnl = ?, + close_quote = COALESCE(?, close_quote), + close_ord_id = ? + WHERE id = ? + """, + ( + total_recv, + new_pnl, + close_px, + ",".join(oids) if oids else tr["close_ord_id"], + int(tr["id"]), + ), + ) + updated += 1 + if updated: + conn.commit() + finally: + conn.close() + return updated + def option_positions_history_okx_rows( self, exchange: Any = None, @@ -913,6 +1107,12 @@ class SimBroker: 实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据. """ + # 先按卖平成交修复分批漏计,再合成展示 + try: + self.repair_closed_options_pnl_from_sim_orders(limit=max(int(limit), 200)) + except Exception: + pass + from datetime import datetime from zoneinfo import ZoneInfo diff --git a/tests/test_sim_repair_partial_close_pnl.py b/tests/test_sim_repair_partial_close_pnl.py new file mode 100644 index 0000000..fc2d6eb --- /dev/null +++ b/tests/test_sim_repair_partial_close_pnl.py @@ -0,0 +1,95 @@ +"""模拟盘:用分批卖平成交修复已平仓漏计盈亏.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.options.options_db import init_options_tables +from lib.sim.broker_lib import SimBroker +from lib.sim.db_lib import init_sim_tables + + +class RepairSimPartialClosePnlTests(unittest.TestCase): + def test_repair_sums_two_sell_orders(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + init_sim_tables(conn) + inst = "ETH-USD-260927-2700-P" + # 开仓 228 张,权利金 0.399;旧逻辑只记了第二笔收回 0.2449 → pnl=-0.1541 + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, premium_received, realized_pnl, + status, premium_ccy, created_at, closed_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + inst, + "ETH", + "P", + 2700, + 228, + 2.28, + 0.175, + 0.399, + 0.2449, + -0.1541, + "closed", + "ETH", + "2026-09-24 07:09:19", + "2026-09-24 20:59:20", + ), + ) + # 两笔卖平各 114 张 + conn.execute( + """ + INSERT INTO sim_option_orders( + ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at + ) VALUES (?,?,?,?,?,'filled',?,?) + """, + ("sim-a", inst, "sell", 114, 0.12, 114, "2026-09-24 15:00:00"), + ) + conn.execute( + """ + INSERT INTO sim_option_orders( + ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at + ) VALUES (?,?,?,?,?,'filled',?,?) + """, + ("sim-b", inst, "sell", 114, 0.1074, 114, "2026-09-24 20:59:10"), + ) + conn.commit() + + def get_db(): + return conn + + # SimBroker 每次 close 自己的连接;测试里复用同一 :memory: 需禁止 close + class _Proxy: + def __getattr__(self, name): + return getattr(conn, name) + + def close(self): + return None + + broker = SimBroker(lambda: _Proxy()) # type: ignore[arg-type] + n = broker.repair_closed_options_pnl_from_sim_orders() + self.assertEqual(n, 1) + row = conn.execute( + "SELECT premium_received, realized_pnl, close_ord_id FROM options_trades WHERE inst_id=?", + (inst,), + ).fetchone() + # ct_mult=2.28/228=0.01; recv=114*0.12*0.01 + 114*0.1074*0.01 = 0.1368+0.122436=0.259236 + self.assertAlmostEqual(float(row["premium_received"]), 0.259236, places=6) + self.assertAlmostEqual(float(row["realized_pnl"]), 0.259236 - 0.399, places=6) + self.assertIn("sim-a", str(row["close_ord_id"])) + self.assertIn("sim-b", str(row["close_ord_id"])) + + # 历史合成应读到修复后的盈亏 + hist = broker.option_positions_history_okx_rows(limit=10) + self.assertEqual(len(hist), 1) + self.assertAlmostEqual(float(hist[0]["realizedPnl"]), 0.259236 - 0.399, places=6) + + +if __name__ == "__main__": + unittest.main()