From bb9d14dd7345926cc01cb8cbbc26163c4f0bb7ca Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 8 Oct 2026 23:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E7=9B=98=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=8E=B0=E8=B4=A7=E4=B9=B0=E5=B8=81/?= =?UTF-8?q?=E5=8D=96=E5=9B=9E=E7=9A=84=E7=9C=9F=E5=AE=9E=20USDT=20?= =?UTF-8?q?=E5=87=80=E7=9B=88=E4=BA=8F=EF=BC=8C=E5=AF=B9=E9=BD=90=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/options/options_coin_open_lib.py | 31 +++++- lib/options/options_spot_bridge_lib.py | 56 +++++++++++ lib/options/options_stats_lib.py | 130 +++++++++++++++++++++++++ tests/test_options_pnl_settle.py | 97 ++++++++++++++++++ 4 files changed, 312 insertions(+), 2 deletions(-) diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index e8ff8c1..f1f5f98 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -59,9 +59,21 @@ def _sell_excess_bridge_coin( sell_amt = min(float(avail), excess_cap) if not _spot_bridge.is_sellable_spot_coin(ex, underlying, sell_amt): return None - return _spot_bridge.spot_market_sell_coin_to_usdt( + sell = _spot_bridge.spot_market_sell_coin_to_usdt( ex, underlying=underlying, coin_amount=sell_amt ) + if sell and sell.get("ok"): + try: + recovered = float(sell.get("usdt_recovered") or 0) + except (TypeError, ValueError): + recovered = 0.0 + if recovered > 0: + try: + _spot_bridge.add_bridge_usdt_recovered(conn, int(bridge_id), recovered) + conn.commit() + except Exception: + pass + return sell def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]: @@ -650,12 +662,27 @@ def maybe_sell_spot_after_close( pass parts = inst_id.split("-") underlying = (parts[0] if parts else "ETH").upper() - return _spot_bridge.sell_residual_after_option_flat( + result = _spot_bridge.sell_residual_after_option_flat( cfg["get_db"], ex, underlying=underlying, inst_id=inst_id, ) + # 模拟盘:卖回后按桥上真实 USDT 净额回写已实现盈亏 + try: + from lib.sim.mode_lib import is_sim_mode + from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt + + if is_sim_mode(cfg["get_db"]): + conn = cfg["get_db"]() + try: + sync_sim_cash_realized_pnl_usdt(conn, ex) + conn.commit() + finally: + conn.close() + except Exception: + pass + return result def sweep_spot_after_options_flat( diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index 2aa7dcc..de0a7a4 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -151,6 +151,59 @@ def insert_bridge( return int(cur.lastrowid) +def add_bridge_usdt_recovered( + conn: sqlite3.Connection, + bridge_id: int, + delta_usdt: float | None, +) -> float | None: + """累加桥上卖币收回的 USDT(开仓多余卖回 + 平仓后卖回).""" + ensure_bridge_table(conn) + try: + d = float(delta_usdt or 0) + except (TypeError, ValueError): + return None + if d <= 0 or bridge_id is None: + return None + row = conn.execute( + "SELECT usdt_recovered FROM options_spot_bridge WHERE id=?", + (int(bridge_id),), + ).fetchone() + if not row: + return None + prev = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[0]) or 0.0 + new_v = round(float(prev) + d, 8) + conn.execute( + """ + UPDATE options_spot_bridge + SET usdt_recovered = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (new_v, int(bridge_id)), + ) + return new_v + + +def cash_pnl_usdt_from_bridge(conn: sqlite3.Connection, bridge_id: int | None) -> float | None: + """现金盈亏 = 卖回 USDT 合计 − 开仓买币花费(budget_usdt).""" + if bridge_id is None: + return None + ensure_bridge_table(conn) + row = conn.execute( + """ + SELECT budget_usdt, usdt_recovered + FROM options_spot_bridge WHERE id = ? + """, + (int(bridge_id),), + ).fetchone() + if not row: + return None + spent = _safe_float(row["budget_usdt"] if hasattr(row, "keys") else row[0]) + recovered = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[1]) + if spent is None or recovered is None: + return None + return round(float(recovered) - float(spent), 4) + + def update_bridge( conn: sqlite3.Connection, bridge_id: int, @@ -540,6 +593,9 @@ def sell_residual_after_option_flat( "bridge_id": bid, "bridge_status": BRIDGE_PENDING_SELL, } + recovered = _safe_float(sell.get("usdt_recovered")) + if recovered is not None and recovered > 0: + add_bridge_usdt_recovered(c, bid, recovered) update_bridge( c, bid, diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index cf1b94c..13c9991 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -465,9 +465,139 @@ def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int: return recompute_closed_realized_pnl_usdt(conn, ex) +def _sim_net_usdt_converts_for_trade( + conn, + *, + underlying: str, + start_ts: str, + end_ts: str, +) -> float | None: + """从 sim_ledger 汇总该标的现货兑换的 USDT 净变动(买币为负、卖回为正).""" + coin = (underlying or "").strip().upper() + if coin not in ("ETH", "BTC") or not start_ts or not end_ts: + return None + try: + rows = conn.execute( + """ + SELECT amount, note FROM sim_ledger_entries + WHERE kind = 'convert' + AND UPPER(ccy) = 'USDT' + AND account = 'trading' + AND ts >= ? + AND ts <= ? + """, + (str(start_ts), str(end_ts)), + ).fetchall() + except Exception: + return None + total = 0.0 + n = 0 + for row in rows or []: + note = str(_row_get(row, "note") or "").upper() + amt = _safe_float(_row_get(row, "amount")) + if amt is None: + continue + related = ( + f"{coin}/USDT" in note + or f"{coin}-USDT" in note + or note.strip() == f"FROM {coin}" + ) + if not related: + continue + total += float(amt) + n += 1 + if n < 1: + return None + return round(total, 4) + + +def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int: + """模拟盘:用现货买币/卖回的真实 USDT 净额覆盖冻结盈亏(对齐钱包).""" + del ex # 现金口径不依赖实时指数 + init_options_tables(conn) + try: + from lib.options.options_spot_bridge_lib import ( + cash_pnl_usdt_from_bridge, + ensure_bridge_table, + ) + + ensure_bridge_table(conn) + except Exception: + return 0 + rows = conn.execute( + """ + SELECT id, bridge_id, underlying, budget_usdt, premium_ccy, + created_at, closed_at, realized_pnl_usdt + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT 300 + """ + ).fetchall() + updated = 0 + for row in rows: + ccy = str(_row_get(row, "premium_ccy") or "").strip().upper() + underly = str(_row_get(row, "underlying") or "").strip().upper() + if ccy not in ("ETH", "BTC") and underly not in ("ETH", "BTC"): + continue + underly = underly or ccy + cash = None + bid = _row_get(row, "bridge_id") + try: + if bid is not None: + cash = cash_pnl_usdt_from_bridge(conn, int(bid)) + except Exception: + cash = None + if cash is None: + # 旧桥未记 usdt_recovered:用流水回补(开仓前1分钟~平仓后30分钟) + start = str(_row_get(row, "created_at") or "") + end = str(_row_get(row, "closed_at") or "") + if start and end: + from datetime import datetime, timedelta + + def _shift(ts: str, minutes: int) -> str: + try: + dt = datetime.fromisoformat(str(ts).strip().replace(" ", "T", 1)) + return (dt + timedelta(minutes=minutes)).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return ts + + cash = _sim_net_usdt_converts_for_trade( + conn, + underlying=underly, + start_ts=_shift(start, -2), + end_ts=_shift(end, 30), + ) + if cash is None: + continue + old = _safe_float(_row_get(row, "realized_pnl_usdt")) + if old is not None and abs(float(old) - float(cash)) < 1e-6: + continue + conn.execute( + """ + UPDATE options_trades + SET realized_pnl_usdt = ? + WHERE id = ? + """, + (float(cash), int(row["id"])), + ) + updated += 1 + return updated + + def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]: """本地已平(模拟盘口径) + 当前 open.""" init_options_tables(conn) + try: + from lib.sim.mode_lib import is_sim_mode + + # get_db 不可用时仍尝试现金同步(conn 已打开) + sync_sim_cash_realized_pnl_usdt(conn, ex) + except Exception: + try: + sync_sim_cash_realized_pnl_usdt(conn, ex) + except Exception: + pass try: freeze_missing_realized_pnl_usdt(conn, ex) except Exception: diff --git a/tests/test_options_pnl_settle.py b/tests/test_options_pnl_settle.py index 3e29a8a..e56528a 100644 --- a/tests/test_options_pnl_settle.py +++ b/tests/test_options_pnl_settle.py @@ -73,6 +73,103 @@ class SettleClosedPnlTests(unittest.TestCase): self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4) +class SimCashPnlStatsTests(unittest.TestCase): + def test_sim_cash_from_bridge_overrides_index_pnl(self): + import sqlite3 + + from lib.options.options_db import init_options_tables + from lib.options.options_spot_bridge_lib import ensure_bridge_table + from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + ensure_bridge_table(conn) + conn.execute( + """ + INSERT INTO options_spot_bridge( + id, underlying, status, budget_usdt, usdt_recovered + ) VALUES (1, 'ETH', 'closed', 1000.0, 1952.96) + """ + ) + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, premium_ccy, + status, bridge_id, budget_usdt, realized_pnl_usdt, + created_at, closed_at + ) VALUES ( + 'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01, + 0.01, 0.02, 0.01, 'ETH', + 'closed', 1, 1000.0, 884.54, + '2026-10-06 10:00:00', '2026-10-08 14:00:00' + ) + """ + ) + conn.commit() + n = sync_sim_cash_realized_pnl_usdt(conn, None) + self.assertEqual(n, 1) + row = conn.execute( + "SELECT realized_pnl_usdt FROM options_trades WHERE id=1" + ).fetchone() + self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 952.96, places=2) + conn.close() + + def test_sim_cash_from_ledger_when_bridge_missing_recovered(self): + import sqlite3 + + from lib.options.options_db import init_options_tables + from lib.options.options_spot_bridge_lib import ensure_bridge_table + from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt + from lib.sim.db_lib import init_sim_tables + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + ensure_bridge_table(conn) + init_sim_tables(conn) + conn.execute( + """ + INSERT INTO options_spot_bridge( + id, underlying, status, budget_usdt, usdt_recovered + ) VALUES (1, 'ETH', 'closed', 1000.0, NULL) + """ + ) + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, premium_ccy, + status, bridge_id, budget_usdt, realized_pnl_usdt, + created_at, closed_at + ) VALUES ( + 'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01, + 0.01, 0.02, 0.01, 'ETH', + 'closed', 1, 1000.0, 884.54, + '2026-10-06 10:00:00', '2026-10-08 14:00:00' + ) + """ + ) + # 买币 -1000U, 卖回 +1952.96U + conn.execute( + """ + INSERT INTO sim_ledger_entries(kind, amount, ccy, account, note, ts) + VALUES + ('convert', -1000.0, 'USDT', 'trading', 'ETH/USDT mkt 2500', '2026-10-06 10:00:05'), + ('convert', 1952.96, 'USDT', 'trading', 'from eth', '2026-10-08 14:00:10') + """ + ) + conn.commit() + n = sync_sim_cash_realized_pnl_usdt(conn, None) + self.assertEqual(n, 1) + row = conn.execute( + "SELECT realized_pnl_usdt FROM options_trades WHERE id=1" + ).fetchone() + self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 952.96, places=2) + conn.close() + + class ExchangeFreezeStatsTests(unittest.TestCase): def test_freeze_uses_exchange_coin_pnl(self): import sqlite3