fix卖回VWAP必须落库并覆盖指数占位价,禁止预算反推开仓价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-13 11:55:42 +08:00
parent dd979d9dd0
commit 11a083470d
7 changed files with 378 additions and 37 deletions
+10 -3
View File
@@ -495,9 +495,16 @@ def close_option_by_bid1(
row_budget = _safe_float(row["budget_usdt"]) row_budget = _safe_float(row["budget_usdt"])
row_open_idx = _safe_float(row["open_index_px"]) 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: # 禁止 budget/premium 反推开仓价(会把缓冲算进均价,开仓价被抬飞)
# 无开仓指数时用预算/已付反推 if (row_open_idx is None or row_open_idx <= 0) and row.get("bridge_id") is not None:
row_open_idx = float(row_budget) / paid 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( settled = settle_closed_pnl(
premium_paid=paid, premium_paid=paid,
premium_received=recv, premium_received=recv,
+10 -8
View File
@@ -511,6 +511,16 @@ def open_coin_option_buy_full(
except Exception: except Exception:
pass 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( _spot_bridge.update_bridge(
conn, conn,
bridge_id, bridge_id,
@@ -520,14 +530,6 @@ def open_coin_option_buy_full(
message="期权持仓中", 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( trade_id = _insert_coin_trade(
conn, conn,
inst_id=inst_id, inst_id=inst_id,
+18 -22
View File
@@ -585,52 +585,48 @@ def sync_options_from_local_trades(
status_label = "已平" status_label = "已平"
pnl_u = None pnl_u = None
# 价已冻则只用库内价;缺价才问桥 VWAP(避免刷新改均价) # 始终问桥:买/卖回 VWAP 优先于库内指数占位价(防预算反推开仓价、平仓指数冒充卖回价)
prices_frozen = ( opx, cpx, _gate = resolve_spot_vwap_prices(
open_idx is not None conn,
and open_idx > 0 bridge_id=r["bridge_id"],
and sell_idx is not None open_index_px=open_idx,
and sell_idx > 0 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( pnl_u, pnl_src = resolve_true_pnl_usdt_from_trade(
premium_paid=prem, premium_paid=prem,
premium_received=r["premium_received"], premium_received=r["premium_received"],
realized_pnl=r["realized_pnl"], realized_pnl=r["realized_pnl"],
open_index_px=open_idx if (open_idx is not None and open_idx > 0) else None, open_index_px=opx,
close_index_px=sell_idx, close_index_px=cpx,
open_fee=ofee, open_fee=ofee,
close_fee=cfee, close_fee=cfee,
premium_ccy=ccy, premium_ccy=ccy,
fee_ccy=str(r["premium_ccy"] or ccy or ""), fee_ccy=str(r["premium_ccy"] or ccy or ""),
conn=conn if use_bridge is not None else None, conn=conn,
bridge_id=use_bridge, bridge_id=r["bridge_id"],
) )
if pnl_src == "pending_sell": if pnl_src == "pending_sell":
status_label = "待卖回" status_label = "待卖回"
pnl_u = None pnl_u = None
elif pnl_u is not None and pnl_src not in ("none", "pending_sell"): elif pnl_u is not None and pnl_src not in ("none", "pending_sell"):
try: try:
opx, cpx = open_idx, sell_idx # VWAP 非空时覆盖指数占位;币量字段不改
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每次刷新按固定量+价重算;均价只补缺不覆盖
conn.execute( conn.execute(
""" """
UPDATE options_trades UPDATE options_trades
SET realized_pnl_usdt = ?, SET realized_pnl_usdt = ?,
open_index_px = COALESCE(open_index_px, ?), open_index_px = COALESCE(?, open_index_px),
close_index_px = COALESCE(close_index_px, ?), close_index_px = COALESCE(?, close_index_px),
pnl_settled = 1 pnl_settled = 1
WHERE id = ? WHERE id = ?
""", """,
(pnl_u, opx, cpx, trade_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: except Exception:
pass pass
else: else:
+22 -1
View File
@@ -778,6 +778,13 @@ def sell_residual_after_option_flat(
} }
recovered = _safe_float(sell.get("usdt_recovered")) recovered = _safe_float(sell.get("usdt_recovered"))
sold_amt = _safe_float(sell.get("coin_sold")) 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;开仓多余卖回不走这里 # 平仓后卖回(可多笔):累加 close_sell_* 供卖回 VWAP;开仓多余卖回不走这里
add_bridge_close_sell_fill( add_bridge_close_sell_fill(
c, c,
@@ -809,6 +816,7 @@ def sell_residual_after_option_flat(
"bridge_id": bid, "bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL, "bridge_status": BRIDGE_PENDING_SELL,
} }
sell_vwap = bridge_sell_vwap(c, bid)
update_bridge( update_bridge(
c, c,
bid, bid,
@@ -818,13 +826,26 @@ def sell_residual_after_option_flat(
message="期权已平,币已卖回 USDT", message="期权已平,币已卖回 USDT",
close=True, 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 { return {
"ok": True, "ok": True,
"sell": sell, "sell": sell,
"bridge_id": bid, "bridge_id": bid,
"bridge_status": BRIDGE_CLOSED, "bridge_status": BRIDGE_CLOSED,
"sell_complete": True, "sell_complete": True,
"sell_vwap": bridge_sell_vwap(c, bid), "sell_vwap": sell_vwap,
} }
if get_db is not None: if get_db is not None:
+12 -1
View File
@@ -421,6 +421,10 @@ def _patch_spot_bridge_lib() -> None:
if not is_sellable_spot_coin(ex, coin, amt): if not is_sellable_spot_coin(ex, coin, amt):
min_sz = spot_min_sell_sz(ex, coin) min_sz = spot_min_sell_sz(ex, coin)
return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"} return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"}
# 平仓卖回(coin_amount=None)一次卖光;限量卖才留粉尘余量
if coin_amount is None:
sell_sz = float(amt)
else:
sell_sz = max(0.0, float(amt) * 0.999) sell_sz = max(0.0, float(amt) * 0.999)
if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz): if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz):
return {"ok": False, "msg": f"{coin} 可卖数量过小"} return {"ok": False, "msg": f"{coin} 可卖数量过小"}
@@ -445,13 +449,20 @@ def _patch_spot_bridge_lib() -> None:
notify_instance_balance_changed() notify_instance_balance_changed()
except Exception: except Exception:
pass 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 { return {
"ok": True, "ok": True,
"inst_id": result.get("inst_id") or "", "inst_id": result.get("inst_id") or "",
"ord_id": f"sim-spot-sell-{result.get('fill_px') or 0}", "ord_id": f"sim-spot-sell-{result.get('fill_px') or 0}",
"coin_sold": float(sell_sz), "coin_sold": float(sell_sz),
"fill_px": result.get("fill_px"),
"sim": True, "sim": True,
"usdt_recovered": result.get("usdt_recovered"), "usdt_recovered": recovered,
"data": {"sCode": "0", "sMsg": "sim filled"}, "data": {"sCode": "0", "sMsg": "sim filled"},
} }
except Exception as e: except Exception as e:
@@ -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())
+55 -1
View File
@@ -7,7 +7,7 @@ import unittest
class ReviewLocalSyncFreezeTests(unittest.TestCase): class ReviewLocalSyncFreezeTests(unittest.TestCase):
def test_refresh_recomputes_u_keeps_frozen_qty_px(self): 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_db import init_options_tables
from lib.options.options_review_lib import sync_options_from_local_trades 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.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) self.assertAlmostEqual(float(row["realized_pnl_total"]), 700.0, places=2)
conn.close() 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): def test_premium_usdt_move_formula(self):
from lib.options.options_db import init_options_tables 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_review_lib import sync_options_from_local_trades