fix真盈亏只认历史币盈亏不再另扣费,刷新按固定量价重算;修模拟到期币盈亏。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-13 11:30:07 +08:00
parent aa8b16ce27
commit dd979d9dd0
7 changed files with 278 additions and 128 deletions
@@ -38,9 +38,10 @@
− (权利金 × 开仓价 − 权利金 × 平仓卖回价)
```
等价(无手续费时):`收回×卖回价 − 已付×开仓价`。
含义:**计入权利金的 USDT 波动**;**忽略现货多买缓冲**。
有开/平手续费时:开仓费按开仓价、平仓费按卖回价折 U 后扣除。
- **币量**只用期权腿:`realized_pnl`(历史币盈亏,**已含**开/平手续费)、`premium_paid`(权利金);**不含**现货多买/多卖数量
- **价格**只用开仓买币 VWAP、平仓卖回 VWAP;现货多余仓位只参与均价,**数量不进公式**
- 真盈亏**不再另扣**手续费(费已在历史币盈亏里);复盘「期权手续费」列仅展示
- 无手续费时:`期权收回权利金×卖回价 − 权利金×开仓价`(收回=`premium_received`,不是账户全部现货)
**固定量 vs 一次冻结价:**
@@ -50,11 +51,11 @@
| `realized_pnl`(期权腿币盈亏) | 平仓后**固定**,不再改 |
| 开仓价 | 买币完成时算一次 VWAP 写入 `open_index_px`,**冻住** |
| 卖回价 | 平仓卖回全部完成时算一次 VWAP 写入 `close_index_px`,**冻住** |
| `realized_pnl_usdt` | 卖回完成后按上式算一次,`pnl_settled=1` 后**只读** |
| `realized_pnl_usdt` | 按上式计算;**刷新时用固定量+价重算**(可纠正旧错账) |
- 卖回未完成 → 「待卖回」,不算真盈亏
- 缺价 → 「缺价未核」
- 复盘刷新**不得**改已冻结的开仓价/卖回价/真盈亏/权利金币量/币盈亏
- 复盘刷新:**不得**改权利金币量/币盈亏/已冻开仓价/卖回价;**要**按公式重算真盈亏 U
- **消费者**:期权复盘「期权交易记录」盈亏列、复盘 KPI、`realized_pnl_usdt` 冻结
- **不含**:现货多买差分当盈亏 / 账户 after−before
+29 -41
View File
@@ -181,14 +181,15 @@ def true_pnl_usdt_premium_move(
open_index_px: Any,
sell_back_px: Any,
) -> float | None:
"""复盘真盈亏(U):计入权利金 USDT 波动,忽略现货多买.
"""复盘真盈亏(U):计入权利金 USDT 波动;现货多买数量不进公式.
真盈亏 = 期权历史盈亏(币)×平仓卖回价
− (权利金×开仓价 − 权利金×平仓卖回价)
= 历史币盈亏×卖回价 − 权利金×(开仓价 − 卖回价)
无手续费时等价于: 收回×卖回价 − 已付×开仓价.
币盈亏须已含期权开/平手续费(与期权历史同口径).
币量=期权腿(realized_pnl / premium_paid);价=买卖 VWAP.
无手续费时 ≡ 期权收回权利金×卖回价 − 权利金×开仓价
(收回≠账户卖掉的全部现货;多余仓只影响均价).
"""
pnl = _safe_float(coin_pnl)
paid = _safe_float(premium_paid)
@@ -274,12 +275,13 @@ def resolve_true_pnl_usdt_from_trade(
返回 (pnl_u, source): premium_move | stable | pending_sell | none
"""
del fee_ccy # 费率折 U 已体现在历史币盈亏里;本函数不重扣
del fee_ccy
pc = str(premium_ccy or "").strip().upper()
paid = _safe_float(premium_paid)
recv = _safe_float(premium_received)
ofee = abs(float(_safe_float(open_fee) or 0.0))
cfee = abs(float(_safe_float(close_fee) or 0.0))
# 期权历史币盈亏已含开/平手续费;真盈亏不再另扣费
coin = _safe_float(realized_pnl)
if coin is None and paid is not None and recv is not None:
coin = round(float(recv) - float(paid) - ofee - cfee, 8)
@@ -297,7 +299,7 @@ def resolve_true_pnl_usdt_from_trade(
)
if gate == "pending_sell":
return None, "pending_sell"
# 优先严格按用户式:历史币盈亏×卖回价 − 权利金×(开仓价−卖回价)
# 只用:历史币盈亏×卖回价 − 权利金×(开仓价−卖回价)
usdt = true_pnl_usdt_premium_move(
coin_pnl=coin,
premium_paid=paid,
@@ -306,28 +308,6 @@ def resolve_true_pnl_usdt_from_trade(
)
if usdt is not None:
return usdt, "premium_move"
# 无历史币盈亏时回退收回/已付精确折 U
if (
paid is not None
and recv is not None
and open_px is not None
and open_px > 0
and close_px is not None
and close_px > 0
):
settled = settle_sim_coin_pnl_usdt(
premium_paid=paid,
premium_received=recv,
open_fee=ofee,
close_fee=cfee,
open_index_px=open_px,
sell_back_px=close_px,
premium_ccy=pc,
)
usdt2 = _safe_float(settled.get("realized_pnl_usdt"))
if usdt2 is not None:
return usdt2, "premium_move"
return None, "none"
@@ -342,14 +322,13 @@ def settle_sim_coin_pnl_usdt(
fee_ccy: str | None = None,
premium_ccy: str = "ETH",
) -> dict[str, float | None]:
"""币本位真盈亏(U):含权利金 USDT 波动,不含现货多买.
"""由毛权利金生成净币盈亏,再按用户式折 U(费只扣进币盈亏一次).
= 历史币盈亏×卖回价 − (权利金×开仓价 − 权利金×卖回价)
= 收回币×卖回价 − 已付币×开仓指数 − 手续费U
不用买币预算当成本(预算含缓冲).
币盈亏 = 收回 − 已付 − 开/平手续费
真盈亏U = 币盈亏×卖回价 − 权利金×(开仓价−卖回价)
不再用「毛收回折U后再减手续费U」(避免与历史币盈亏口径两套算法).
"""
del fee_ccy # 手续费按权利金币种折 U
del fee_ccy
paid = float(_safe_float(premium_paid) or 0.0)
recv = float(_safe_float(premium_received) or 0.0)
ofee = abs(float(_safe_float(open_fee) or 0.0))
@@ -357,6 +336,11 @@ def settle_sim_coin_pnl_usdt(
open_idx = _safe_float(open_index_px)
sell_px = _safe_float(sell_back_px)
net_coin = round(recv - paid - ofee - cfee, 8)
fee_u = None
if open_idx is not None and open_idx > 0 and sell_px is not None and sell_px > 0:
fee_u = round(ofee * float(open_idx) + cfee * float(sell_px), 4)
elif sell_px is not None and sell_px > 0 and (ofee > 0 or cfee > 0):
fee_u = round((ofee + cfee) * float(sell_px), 4)
if open_idx is None or open_idx <= 0 or sell_px is None or sell_px <= 0:
return {
"realized_pnl": net_coin,
@@ -366,13 +350,17 @@ def settle_sim_coin_pnl_usdt(
"total_fee": round(ofee + cfee, 8),
"initial_premium_usdt": None,
"proceeds_usdt": None,
"fee_usdt": fee_u,
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
}
# 权利金btc+盈利btc = 平仓收回币
proceeds_u = float(recv) * float(sell_px)
initial_u = float(paid) * float(open_idx)
# 开仓费按开仓指数、平仓费按卖回价折 U
fee_u = ofee * float(open_idx) + cfee * float(sell_px)
pnl_u = round(proceeds_u - initial_u - fee_u, 4)
proceeds_u = float(recv) * float(sell_px)
pnl_u = true_pnl_usdt_premium_move(
coin_pnl=net_coin,
premium_paid=paid,
open_index_px=open_idx,
sell_back_px=sell_px,
)
return {
"realized_pnl": net_coin,
"close_index_px": float(sell_px),
@@ -381,7 +369,7 @@ def settle_sim_coin_pnl_usdt(
"total_fee": round(ofee + cfee, 8),
"initial_premium_usdt": round(initial_u, 4),
"proceeds_usdt": round(proceeds_u, 4),
"fee_usdt": round(fee_u, 4),
"fee_usdt": fee_u,
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
}
@@ -403,8 +391,8 @@ def settle_closed_pnl(
币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次).
币本位 U:
收回×卖回价(index_px) − 已付×开仓指数 − 手续费U
(开仓指数缺失时回退为净币×平仓指数)
期权收回权利金×卖回价 − 权利金×开仓价 − 手续费U
(开仓价缺失时回退为净币×卖回价;币量不含现货多余仓)
USDC: 净额原样为 U.
注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲.
+21 -18
View File
@@ -555,11 +555,6 @@ def sync_options_from_local_trades(
ccy = str(r["premium_ccy"] or "").strip().upper()
if not ccy:
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
frozen_usdt = _safe_float(r["realized_pnl_usdt"])
try:
settled = int(r["pnl_settled"] or 0) == 1
except Exception:
settled = False
close_idx = _safe_float(r["close_index_px"])
open_idx = _safe_float(r["open_index_px"])
idx = None
@@ -580,17 +575,24 @@ def sync_options_from_local_trades(
close_fee=cfee,
premium_ccy=ccy,
)
# 权利金币量/币盈亏固定;开仓价与卖回价各自冻一次后真盈亏只读
# 权利金币量/币盈亏/已冻均价不变;刷新时按公式重算真盈亏U
open_idx_for_prem = open_idx if (open_idx is not None and open_idx > 0) else idx
sell_idx = close_idx if (close_idx is not None and close_idx > 0) else None
from lib.options.options_pnl_settle_lib import resolve_true_pnl_usdt_from_trade
from lib.options.options_pnl_settle_lib import (
resolve_spot_vwap_prices,
resolve_true_pnl_usdt_from_trade,
)
status_label = "已平"
pnl_u = None
if settled and frozen_usdt is not None:
# 已冻结:只读展示,禁止同步反复改写
pnl_u = round(float(frozen_usdt), 4)
else:
# 价已冻则只用库内价;缺价才问桥 VWAP(避免刷新改均价)
prices_frozen = (
open_idx is not None
and open_idx > 0
and sell_idx is not None
and sell_idx > 0
)
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"],
@@ -601,31 +603,31 @@ def sync_options_from_local_trades(
close_fee=cfee,
premium_ccy=ccy,
fee_ccy=str(r["premium_ccy"] or ccy or ""),
conn=conn,
bridge_id=r["bridge_id"],
conn=conn if use_bridge is not None else None,
bridge_id=use_bridge,
)
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:
from lib.options.options_pnl_settle_lib import resolve_spot_vwap_prices
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每次刷新按固定量+价重算;均价只补缺不覆盖
conn.execute(
"""
UPDATE options_trades
SET realized_pnl_usdt = ?,
open_index_px = COALESCE(open_index_px, ?),
close_index_px = COALESCE(?, close_index_px),
close_index_px = COALESCE(close_index_px, ?),
pnl_settled = 1
WHERE id = ? AND COALESCE(pnl_settled, 0)=0
WHERE id = ?
""",
(pnl_u, opx, cpx, trade_id),
)
@@ -641,6 +643,7 @@ def sync_options_from_local_trades(
prem_u = _to_usdt(prem, ccy=ccy, idx=open_idx_for_prem)
if ccy in ("ETH", "BTC") and prem_u is None:
prem_u = prem
# 手续费列仅展示(已含在历史币盈亏内,真盈亏不再扣)
fee_u = None
if ccy in ("ETH", "BTC"):
fee_parts = 0.0
+50 -9
View File
@@ -790,24 +790,63 @@ class SimBroker:
prem_ccy = _premium_ccy_for_inst(inst_id)
# 币本位到期兑付用币数量: 实值/指数 × 张数 × 乘数
# 注意: option_expiry_pnl 的 intrinsic 是 U,减权利金时币本位不能直接用
if prem_ccy in ("ETH", "BTC") and float(spot) > 0:
settle_recv = round(
max(0.0, (intrinsic_u / float(spot)) * sheets * ct_mult),
8,
)
# 币盈亏 = 兑付币 − 仓位权利金币(与 options_trades.premium_paid 同口径)
pnl = round(float(settle_recv) - float(prem), 8)
else:
settle_recv = round(max(0.0, prem + pnl), 4)
pnl = round(float(pnl), 4)
conn = self.get_db()
try:
conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,))
try:
closed_at = _now()
open_ids = [
int(r["id"])
for r in conn.execute(
"SELECT id FROM options_trades WHERE inst_id=? AND status='open'",
open_rows = conn.execute(
"""
SELECT id, premium_paid, open_fee, close_fee
FROM options_trades
WHERE inst_id=? AND status='open'
""",
(inst_id,),
).fetchall()
]
open_ids = [int(r["id"]) for r in open_rows]
total_paid = 0.0
for r in open_rows:
try:
total_paid += float(r["premium_paid"] or 0)
except (TypeError, ValueError):
pass
n_open = len(open_rows) or 1
for r in open_rows:
paid_i = 0.0
try:
paid_i = float(r["premium_paid"] or 0)
except (TypeError, ValueError):
paid_i = 0.0
ofee_i = 0.0
try:
ofee_i = abs(float(r["open_fee"] or 0))
except (TypeError, ValueError):
ofee_i = 0.0
# 多笔开仓按权利金占比拆兑付;单笔拿全部
if n_open == 1:
recv_i = float(settle_recv)
elif total_paid > 1e-12:
recv_i = float(settle_recv) * (paid_i / total_paid)
else:
recv_i = float(settle_recv) / float(n_open)
if prem_ccy in ("ETH", "BTC"):
recv_i = round(recv_i, 8)
pnl_i = round(recv_i - paid_i - ofee_i, 8)
else:
recv_i = round(recv_i, 4)
pnl_i = round(recv_i - paid_i - ofee_i, 4)
conn.execute(
"""
UPDATE options_trades
@@ -815,20 +854,22 @@ class SimBroker:
close_quote = ?,
premium_received = ?,
realized_pnl = ?,
close_index_px = COALESCE(close_index_px, ?),
closed_at = COALESCE(closed_at, ?),
signal_note = CASE
WHEN signal_note IS NULL OR TRIM(signal_note) = ''
THEN '到期结算'
ELSE signal_note
END
WHERE inst_id = ? AND status = 'open'
WHERE id = ? AND status = 'open'
""",
(
round(intrinsic_u, 6),
settle_recv,
round(pnl, 4),
recv_i,
pnl_i,
float(spot),
closed_at,
inst_id,
int(r["id"]),
),
)
if open_ids:
+25 -3
View File
@@ -75,7 +75,7 @@ class SettleClosedPnlTests(unittest.TestCase):
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), expect, places=2)
def test_sim_loss_remaining_minus_fees(self):
"""亏损:剩余权利金U − 手续费U − 初始U(同一式)."""
"""亏损:费先扣进币盈亏,再按用户式折 U(不另扣手续费U)."""
s = settle_closed_pnl(
premium_paid=0.01,
premium_received=0.004,
@@ -85,8 +85,30 @@ class SettleClosedPnlTests(unittest.TestCase):
index_px=2500,
open_index_px=3000,
)
# 0.004*2500 - 0.01*3000 - (0.0001*3000+0.0001*2500) = 10 - 30 - 0.55 = -20.55
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), -20.55, places=2)
# net_coin=-0.0062; U=-0.0062*2500 - 0.01*(3000-2500)=-20.5
self.assertAlmostEqual(float(s["realized_pnl"]), -0.0062, places=8)
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), -20.5, places=2)
def test_resolve_loss_does_not_deduct_fee_again(self):
"""历史币盈亏已含费;resolve 只跑用户式,不再用 open_fee/close_fee 扣第二遍."""
from lib.options.options_pnl_settle_lib import resolve_true_pnl_usdt_from_trade
# coin 已是 recv-paid-fees=-0.0062
pnl, src = resolve_true_pnl_usdt_from_trade(
premium_paid=0.01,
premium_received=0.004,
realized_pnl=-0.0062,
open_index_px=3000,
close_index_px=2500,
open_fee=0.0001,
close_fee=0.0001,
premium_ccy="ETH",
)
self.assertEqual(src, "premium_move")
# -0.0062*2500 - 0.01*(3000-2500) = -15.5 - 5 = -20.5
self.assertAlmostEqual(float(pnl), -20.5, places=2)
# 若再扣一遍费会更亏于 -20.5
self.assertGreater(float(pnl), -20.55)
def test_budget_ignored_for_usdt(self):
# budget 不得抬高开仓成本
+4 -3
View File
@@ -6,8 +6,8 @@ import unittest
class ReviewLocalSyncFreezeTests(unittest.TestCase):
def test_settled_true_pnl_not_rewritten_on_resync(self):
"""已冻结真盈亏只读;同步不得因桥/指数再改写."""
def test_refresh_recomputes_u_keeps_frozen_qty_px(self):
"""刷新按公式重算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
@@ -29,7 +29,7 @@ class ReviewLocalSyncFreezeTests(unittest.TestCase):
) VALUES (
'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01,
0.01, 0.02, 0.01, 'BTC',
0, 0, 'closed', 700.0,
0, 0, 'closed', -51.0,
90000.0, 80000.0, 1,
'2026-10-05 05:11:30', '2026-10-07 10:03:53'
)
@@ -41,6 +41,7 @@ class ReviewLocalSyncFreezeTests(unittest.TestCase):
trade = conn.execute(
"SELECT realized_pnl_usdt, open_index_px, close_index_px, pnl_settled, realized_pnl, premium_paid FROM options_trades WHERE id=1"
).fetchone()
# 0.01*80000 - 0.01*(90000-80000)=700;旧错账 -51 被刷新纠正
self.assertAlmostEqual(float(trade["realized_pnl_usdt"]), 700.0, places=2)
self.assertAlmostEqual(float(trade["open_index_px"]), 90000.0, places=2)
self.assertAlmostEqual(float(trade["close_index_px"]), 80000.0, places=2)
+94
View File
@@ -0,0 +1,94 @@
"""模拟盘到期结算:币本位盈亏须用币,不能把 U 实值减权利金币."""
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 SimExpiryCoinPnlTests(unittest.TestCase):
def test_coin_expiry_pnl_not_usd_minus_coin(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
init_sim_tables(conn)
# 251001 = 2025-10-01 08:00 UTC 已过期
inst = "BTC-USD-251001-100000-P"
prem = 0.001
sheets = 1.0
ct_mult = 0.01
spot = 90000.0
conn.execute(
"""
INSERT INTO sim_option_positions(
inst_id, side, sheets, entry_px, ct_mult, premium_paid_usdc, updated_at
) VALUES (?,?,?,?,?,?,?)
""",
(inst, "buy", sheets, 0.1, ct_mult, prem, "2025-09-01 10:00:00"),
)
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, sheets, eth_amount,
premium_paid, status, premium_ccy, open_index_px, open_fee,
created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
inst,
"BTC",
"P",
100000,
int(sheets),
sheets * ct_mult,
prem,
"open",
"BTC",
95000.0,
0.0,
"2025-09-01 10:00:00",
),
)
conn.commit()
class _Proxy:
def __getattr__(self, name):
return getattr(conn, name)
def close(self):
return None
broker = SimBroker(lambda: _Proxy()) # type: ignore[arg-type]
broker._index_px_for_option = lambda *_a, **_k: spot # type: ignore[method-assign]
# 跳过卖回(本测只核币盈亏)
import lib.options.options_spot_bridge_lib as bridge
bridge.sell_residual_after_option_flat = lambda *_a, **_k: {"ok": True, "skipped": True}
# 2025-10-02 UTC
now_ms = 1759363200000
out = broker.settle_expired_option_positions(None, now_ms=now_ms)
self.assertEqual(len(out), 1)
intrinsic_u = 100000.0 - spot # 10000
settle_recv = (intrinsic_u / spot) * sheets * ct_mult
expected_pnl = settle_recv - prem
# 旧 bug: option_expiry_pnl = intrinsic_u*sheets*ct_mult - prem ≈ 99.999
wrong_old = intrinsic_u * sheets * ct_mult - prem
self.assertGreater(abs(wrong_old - expected_pnl), 1.0)
row = conn.execute(
"SELECT premium_received, realized_pnl, close_index_px, status FROM options_trades WHERE id=1"
).fetchone()
self.assertEqual(str(row["status"]), "closed")
self.assertAlmostEqual(float(row["premium_received"]), settle_recv, places=8)
self.assertAlmostEqual(float(row["realized_pnl"]), expected_pnl, places=8)
self.assertAlmostEqual(float(row["close_index_px"]), spot, places=2)
self.assertLess(abs(float(row["realized_pnl"])), 1.0) # 币量级,绝不是 ~100 U
if __name__ == "__main__":
unittest.main()