From 11a083470d02c081ae41737d61f10f03e2a08369 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 13 Oct 2026 11:55:42 +0800 Subject: [PATCH] =?UTF-8?q?fix=E5=8D=96=E5=9B=9EVWAP=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E5=B9=B6=E8=A6=86=E7=9B=96=E6=8C=87=E6=95=B0?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E4=BB=B7=EF=BC=8C=E7=A6=81=E6=AD=A2=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E5=8F=8D=E6=8E=A8=E5=BC=80=E4=BB=93=E4=BB=B7=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_close_exec_lib.py | 13 +- lib/options/options_coin_open_lib.py | 18 +- lib/options/options_review_lib.py | 40 ++- lib/options/options_spot_bridge_lib.py | 23 +- lib/sim/hooks.py | 15 +- ...epair_options_spot_vwap_from_sim_ledger.py | 250 ++++++++++++++++++ tests/test_options_review_pnl_freeze.py | 56 +++- 7 files changed, 378 insertions(+), 37 deletions(-) create mode 100644 scripts/repair_options_spot_vwap_from_sim_ledger.py diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index 2f87a27..1b1c60e 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -495,9 +495,16 @@ def close_option_by_bid1( row_budget = _safe_float(row["budget_usdt"]) row_open_idx = _safe_float(row["open_index_px"]) - if (row_open_idx is None or row_open_idx <= 0) and row_budget and paid > 1e-12: - # 无开仓指数时用预算/已付反推 - row_open_idx = float(row_budget) / paid + # 禁止 budget/premium 反推开仓价(会把缓冲算进均价,开仓价被抬飞) + if (row_open_idx is None or row_open_idx <= 0) and row.get("bridge_id") is not None: + try: + from lib.options.options_spot_bridge_lib import bridge_buy_vwap + + bv = bridge_buy_vwap(conn, int(row["bridge_id"])) + if bv is not None and float(bv) > 0: + row_open_idx = float(bv) + except Exception: + pass settled = settle_closed_pnl( premium_paid=paid, premium_received=recv, diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index b5d7dd2..34a6c64 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -511,6 +511,16 @@ def open_coin_option_buy_full( except Exception: pass + # 开仓价必须在缩减 coin_bought 之前按买币 VWAP 冻住 + # (buy_usdt_spent/buy_coin_accum;禁止事后 budget/缩水权利金量反推) + 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 + _spot_bridge.update_bridge( conn, bridge_id, @@ -520,14 +530,6 @@ 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, diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index 63fc833..21e7949 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -585,52 +585,48 @@ def sync_options_from_local_trades( status_label = "已平" pnl_u = None - # 价已冻则只用库内价;缺价才问桥 VWAP(避免刷新改均价) - prices_frozen = ( - open_idx is not None - and open_idx > 0 - and sell_idx is not None - and sell_idx > 0 + # 始终问桥:买/卖回 VWAP 优先于库内指数占位价(防预算反推开仓价、平仓指数冒充卖回价) + opx, cpx, _gate = resolve_spot_vwap_prices( + conn, + bridge_id=r["bridge_id"], + open_index_px=open_idx, + close_index_px=sell_idx, ) - use_bridge = None if prices_frozen else r["bridge_id"] 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_index_px=opx, + close_index_px=cpx, open_fee=ofee, close_fee=cfee, premium_ccy=ccy, fee_ccy=str(r["premium_ccy"] or ccy or ""), - conn=conn if use_bridge is not None else None, - bridge_id=use_bridge, + 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: - opx, cpx = open_idx, sell_idx - if not prices_frozen: - opx, cpx, _g = resolve_spot_vwap_prices( - conn, - bridge_id=r["bridge_id"], - open_index_px=open_idx, - close_index_px=sell_idx, - ) - # 真盈亏U每次刷新按固定量+价重算;均价只补缺不覆盖 + # VWAP 非空时覆盖指数占位;币量字段不改 conn.execute( """ UPDATE options_trades SET realized_pnl_usdt = ?, - open_index_px = COALESCE(open_index_px, ?), - close_index_px = COALESCE(close_index_px, ?), + open_index_px = COALESCE(?, open_index_px), + close_index_px = COALESCE(?, close_index_px), pnl_settled = 1 WHERE id = ? """, (pnl_u, opx, cpx, trade_id), ) + if opx is not None and opx > 0: + open_idx = opx + open_idx_for_prem = opx + if cpx is not None and cpx > 0: + sell_idx = cpx except Exception: pass else: diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index 65dc7ec..1c4c4d2 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -778,6 +778,13 @@ def sell_residual_after_option_flat( } recovered = _safe_float(sell.get("usdt_recovered")) sold_amt = _safe_float(sell.get("coin_sold")) + # 缺 USDT 回报时用成交价×币量补,保证 close_sell VWAP 能落库 + if (recovered is None or recovered <= 0) and sold_amt is not None and sold_amt > 0: + fill_px = _safe_float(sell.get("fill_px")) + if fill_px is not None and fill_px > 0: + recovered = round(float(sold_amt) * float(fill_px), 8) + sell = dict(sell) + sell["usdt_recovered"] = recovered # 平仓后卖回(可多笔):累加 close_sell_* 供卖回 VWAP;开仓多余卖回不走这里 add_bridge_close_sell_fill( c, @@ -809,6 +816,7 @@ def sell_residual_after_option_flat( "bridge_id": bid, "bridge_status": BRIDGE_PENDING_SELL, } + sell_vwap = bridge_sell_vwap(c, bid) update_bridge( c, bid, @@ -818,13 +826,26 @@ def sell_residual_after_option_flat( message="期权已平,币已卖回 USDT", close=True, ) + # 卖回 VWAP 写入关联已平仓(覆盖平仓时指数占位价) + if sell_vwap is not None and float(sell_vwap) > 0: + try: + c.execute( + """ + UPDATE options_trades + SET close_index_px = ? + WHERE bridge_id = ? AND status = 'closed' + """, + (float(sell_vwap), int(bid)), + ) + except Exception: + pass return { "ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED, "sell_complete": True, - "sell_vwap": bridge_sell_vwap(c, bid), + "sell_vwap": sell_vwap, } if get_db is not None: diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py index 9cc334b..6e019fa 100644 --- a/lib/sim/hooks.py +++ b/lib/sim/hooks.py @@ -421,7 +421,11 @@ def _patch_spot_bridge_lib() -> None: if not is_sellable_spot_coin(ex, coin, amt): min_sz = spot_min_sell_sz(ex, coin) return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"} - sell_sz = max(0.0, float(amt) * 0.999) + # 平仓卖回(coin_amount=None)一次卖光;限量卖才留粉尘余量 + if coin_amount is None: + sell_sz = float(amt) + else: + sell_sz = max(0.0, float(amt) * 0.999) if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz): return {"ok": False, "msg": f"{coin} 可卖数量过小"} pub = _sim_public_exchange(ex) @@ -445,13 +449,20 @@ def _patch_spot_bridge_lib() -> None: notify_instance_balance_changed() except Exception: pass + recovered = result.get("usdt_recovered") + if recovered is None and result.get("fill_px") is not None: + try: + recovered = round(float(sell_sz) * float(result.get("fill_px")), 8) + except (TypeError, ValueError): + recovered = None return { "ok": True, "inst_id": result.get("inst_id") or "", "ord_id": f"sim-spot-sell-{result.get('fill_px') or 0}", "coin_sold": float(sell_sz), + "fill_px": result.get("fill_px"), "sim": True, - "usdt_recovered": result.get("usdt_recovered"), + "usdt_recovered": recovered, "data": {"sCode": "0", "sMsg": "sim filled"}, } except Exception as e: diff --git a/scripts/repair_options_spot_vwap_from_sim_ledger.py b/scripts/repair_options_spot_vwap_from_sim_ledger.py new file mode 100644 index 0000000..1bcc8af --- /dev/null +++ b/scripts/repair_options_spot_vwap_from_sim_ledger.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""从模拟盘流水重建买/卖回 VWAP,并按真盈亏公式重算 realized_pnl_usdt. + +用于 close_sell_* 漏记、开仓价被 budget/premium 抬飞的历史单. +默认 dry-run;加 --apply 才写库. + + python scripts/repair_options_spot_vwap_from_sim_ledger.py --db crypto.db + python scripts/repair_options_spot_vwap_from_sim_ledger.py --db crypto.db --apply +""" +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path +from typing import Any + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _ledger_buy_sell( + conn: sqlite3.Connection, + *, + underly: str, + inst_id: str, + open_ts: str, + close_ts: str | None, +) -> tuple[float | None, float | None, float | None, float | None]: + """在开仓前后窗口内汇总 USDT→币 与 平仓后币→USDT.""" + u = (underly or "").upper() + inst = str(inst_id or "").strip() + buys = conn.execute( + """ + SELECT amount, ts FROM sim_ledger_entries + WHERE ccy='USDT' AND account='trading' AND kind='convert' + AND amount < 0 AND note LIKE ? + AND ts >= datetime(?, '-14 hour') + AND ts <= datetime(?, '+14 hour') + ORDER BY id + """, + (f"{u}/USDT%", open_ts, open_ts), + ).fetchall() + buy_usdt = 0.0 + buy_coin = 0.0 + for row in buys: + coin_row = conn.execute( + """ + SELECT amount FROM sim_ledger_entries + WHERE ccy=? AND account='trading' AND kind='convert' AND amount > 0 + AND ts=? AND note LIKE 'from usdt%' + ORDER BY id LIMIT 1 + """, + (u, row["ts"]), + ).fetchone() + if not coin_row: + continue + buy_usdt += abs(float(row["amount"])) + buy_coin += float(coin_row["amount"]) + sell_usdt = 0.0 + sell_coin = 0.0 + last_close = None + if inst: + last_close = conn.execute( + """ + SELECT ts FROM sim_ledger_entries + WHERE kind='option_close' AND note LIKE ? + ORDER BY id DESC LIMIT 1 + """, + (f"%{inst}%",), + ).fetchone() + if last_close or close_ts: + close_anchor = str(last_close["ts"]) if last_close else str(close_ts) + sells = conn.execute( + """ + SELECT amount, ts FROM sim_ledger_entries + WHERE ccy=? AND account='trading' AND kind='convert' + AND amount < 0 AND note LIKE ? + AND ts >= ? + ORDER BY id + """, + (u, f"{u}/USDT%", close_anchor), + ).fetchall() + for row in sells: + usdt_row = conn.execute( + """ + SELECT amount FROM sim_ledger_entries + WHERE ccy='USDT' AND account='trading' AND kind='convert' + AND amount > 0 AND ts=? AND note LIKE 'from %' + ORDER BY id LIMIT 1 + """, + (row["ts"],), + ).fetchone() + if not usdt_row: + continue + sell_coin += abs(float(row["amount"])) + sell_usdt += float(usdt_row["amount"]) + return ( + buy_usdt if buy_usdt > 0 else None, + buy_coin if buy_coin > 0 else None, + sell_usdt if sell_usdt > 0 else None, + sell_coin if sell_coin > 0 else None, + ) + + +def repair(conn: sqlite3.Connection, *, apply: bool) -> list[dict[str, Any]]: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from lib.options.options_pnl_settle_lib import true_pnl_usdt_premium_move + from lib.options.options_spot_bridge_lib import ensure_bridge_table + + ensure_bridge_table(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, premium_paid, premium_received, realized_pnl, + open_index_px, close_index_px, realized_pnl_usdt, bridge_id, + created_at, closed_at, budget_usdt + FROM options_trades + WHERE status='closed' + ORDER BY id + """ + ).fetchall() + report: list[dict[str, Any]] = [] + for r in rows: + tid = int(r["id"]) + underly = str(r["underlying"] or (str(r["inst_id"] or "").split("-")[0]) or "").upper() + open_ts = str(r["created_at"] or "") + close_ts = str(r["closed_at"] or "") or None + buy_u, buy_c, sell_u, sell_c = _ledger_buy_sell( + conn, + underly=underly, + inst_id=str(r["inst_id"] or ""), + open_ts=open_ts, + close_ts=close_ts, + ) + open_px = (buy_u / buy_c) if buy_u and buy_c and buy_c > 0 else None + close_px = (sell_u / sell_c) if sell_u and sell_c and sell_c > 0 else None + paid = _safe_float(r["premium_paid"]) + coin = _safe_float(r["realized_pnl"]) + old_open = _safe_float(r["open_index_px"]) + old_close = _safe_float(r["close_index_px"]) + old_u = _safe_float(r["realized_pnl_usdt"]) + new_u = true_pnl_usdt_premium_move( + coin_pnl=coin, + premium_paid=paid, + open_index_px=open_px or old_open, + sell_back_px=close_px or old_close, + ) + item = { + "id": tid, + "inst_id": r["inst_id"], + "buy_vwap": round(open_px, 4) if open_px else None, + "sell_vwap": round(close_px, 4) if close_px else None, + "old_open": old_open, + "old_close": old_close, + "old_u": old_u, + "new_u": new_u, + "bridge_id": r["bridge_id"], + "sell_usdt": sell_u, + "sell_coin": sell_c, + "buy_usdt": buy_u, + "buy_coin": buy_c, + } + report.append(item) + if not apply: + continue + bid = r["bridge_id"] + if bid is not None and buy_u and buy_c: + conn.execute( + """ + UPDATE options_spot_bridge + SET buy_usdt_spent = ?, buy_coin_accum = ? + WHERE id = ? + """, + (float(buy_u), float(buy_c), int(bid)), + ) + if bid is not None and sell_u and sell_c: + conn.execute( + """ + UPDATE options_spot_bridge + SET close_sell_usdt = ?, close_sell_coin = ?, + usdt_recovered = ?, coin_sold = ? + WHERE id = ? + """, + (float(sell_u), float(sell_c), float(sell_u), float(sell_c), int(bid)), + ) + conn.execute( + """ + UPDATE options_trades + SET open_index_px = COALESCE(?, open_index_px), + close_index_px = COALESCE(?, close_index_px), + realized_pnl_usdt = COALESCE(?, realized_pnl_usdt), + initial_premium_usdt = CASE + WHEN ? IS NOT NULL AND premium_paid IS NOT NULL + THEN round(premium_paid * ?, 4) + ELSE initial_premium_usdt + END, + pnl_settled = 1 + WHERE id = ? + """, + ( + open_px, + close_px, + new_u, + open_px, + open_px, + tid, + ), + ) + if apply: + conn.commit() + # 复盘行跟着刷 + try: + from lib.options.options_review_lib import sync_options_from_local_trades + + sync_options_from_local_trades(conn, None) + conn.commit() + except Exception as e: + print("review_sync_warn", e) + return report + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--db", required=True) + ap.add_argument("--apply", action="store_true") + args = ap.parse_args() + path = Path(args.db) + if not path.is_file(): + print("db missing", path) + return 1 + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + report = repair(conn, apply=bool(args.apply)) + conn.close() + import json + + print(json.dumps(report, ensure_ascii=False, indent=2)) + print("apply" if args.apply else "dry-run", "rows", len(report)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_options_review_pnl_freeze.py b/tests/test_options_review_pnl_freeze.py index 3da1e40..bec5adc 100644 --- a/tests/test_options_review_pnl_freeze.py +++ b/tests/test_options_review_pnl_freeze.py @@ -7,7 +7,7 @@ import unittest class ReviewLocalSyncFreezeTests(unittest.TestCase): def test_refresh_recomputes_u_keeps_frozen_qty_px(self): - """刷新按公式重算U;权利金币/币盈亏/开仓卖回价不变.""" + """刷新按公式重算U;权利金币/币盈亏不变.""" 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 @@ -55,6 +55,60 @@ class ReviewLocalSyncFreezeTests(unittest.TestCase): self.assertAlmostEqual(float(row["realized_pnl_total"]), 700.0, places=2) conn.close() + def test_refresh_prefers_bridge_sell_vwap_over_wrong_index(self): + """库内卖回价若是指数占位,刷新须用桥上卖回 VWAP 纠正.""" + from lib.options.options_db import init_options_tables + from lib.options.options_review_lib import sync_options_from_local_trades + from lib.options.options_spot_bridge_lib import ( + BRIDGE_CLOSED, + add_bridge_buy_fill, + add_bridge_close_sell_fill, + ensure_bridge_table, + insert_bridge, + update_bridge, + ) + 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_bridge_table(conn) + ensure_runtime_settings_table(conn) + runtime_set(conn, TRADING_MODE_KEY, MODE_SIM) + bid = insert_bridge(conn, underlying="BTC", status="holding", budget_usdt=990.0) + add_bridge_buy_fill(conn, bid, usdt_spent=840.10, coin_bought=0.01) + add_bridge_close_sell_fill(conn, bid, usdt_recovered=814.18, coin_sold=0.01) + update_bridge(conn, bid, status=BRIDGE_CLOSED, close=True) + 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, bridge_id, + created_at, closed_at + ) VALUES ( + 'BTC-USD-261015-84000-P', 'BTC', 'P', 1, 0.01, + 0.01, 0.02, 0.01, 'BTC', + 0, 0, 'closed', 1544.0, + 88086.0, 85400.0, 1, ?, + '2026-10-12 01:46:50', '2026-10-13 08:07:35' + ) + """, + (bid,), + ) + conn.commit() + sync_options_from_local_trades(conn, None) + trade = conn.execute( + "SELECT realized_pnl_usdt, open_index_px, close_index_px FROM options_trades WHERE id=1" + ).fetchone() + self.assertAlmostEqual(float(trade["open_index_px"]), 84010.0, places=2) + self.assertAlmostEqual(float(trade["close_index_px"]), 81418.0, places=2) + # A = 0.01*81418 - 0.01*(84010-81418) = 814.18 - 25.92 = 788.26 + self.assertAlmostEqual(float(trade["realized_pnl_usdt"]), 788.26, 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