fix模拟盘盈亏:收回×卖回价 − 开仓U权利金 − 手续费,历史按U展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-08 23:56:23 +08:00
parent a0ee1fa8a0
commit 70ebfb10e8
6 changed files with 179 additions and 45 deletions
+7 -4
View File
@@ -2833,12 +2833,15 @@
list.forEach(function (h) { list.forEach(function (h) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const premCcy = posPremiumCcy(h); const premCcy = posPremiumCcy(h);
const unit = premCcy === "USDC" ? "U" : premCcy; const premUnit = premCcy === "USDC" || premCcy === "USDT" ? "U" : premCcy;
// 模拟盘盈亏已折 U 时用 pnl_display_ccy,勿再标 BTC
const pnlCcy = String(h.pnl_display_ccy || premCcy).toUpperCase();
const pnlUnit = pnlCcy === "USDC" || pnlCcy === "USDT" ? "U" : pnlCcy;
const premCore = fmtDisplay( const premCore = fmtDisplay(
h.premium_paid_fmt, h.premium_paid_fmt,
h.premium_paid != null ? fmtPremiumAmt(h.premium_paid, premCcy) : null h.premium_paid != null ? fmtPremiumAmt(h.premium_paid, premCcy) : null
); );
const premTxt = premCore === "—" ? "—" : premCore + " " + unit; const premTxt = premCore === "—" ? "—" : premCore + " " + premUnit;
const isOpen = h.status === "open"; const isOpen = h.status === "open";
const pnl = isOpen ? null : h.realized_pnl; const pnl = isOpen ? null : h.realized_pnl;
let pnlTxt = "—"; let pnlTxt = "—";
@@ -2846,9 +2849,9 @@
const n = Number(pnl); const n = Number(pnl);
const absCore = fmtDisplay( const absCore = fmtDisplay(
null, null,
fmtPremiumAmt(Math.abs(n), premCcy) pnlUnit === "U" ? fmt(Math.abs(n), 2) : fmtPremiumAmt(Math.abs(n), pnlCcy)
); );
pnlTxt = (n > 0 ? "+" : n < 0 ? "-" : "") + absCore + " " + unit; pnlTxt = (n > 0 ? "+" : n < 0 ? "-" : "") + absCore + " " + pnlUnit;
} }
const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : ""; const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : "";
const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19);
+13 -1
View File
@@ -1467,6 +1467,7 @@ def format_option_history_row(
realized = _safe_float(raw.get("realizedPnl")) realized = _safe_float(raw.get("realizedPnl"))
if realized is None: if realized is None:
realized = _safe_float(raw.get("pnl")) realized = _safe_float(raw.get("pnl"))
pnl_usdt = _safe_float(raw.get("_sim_realized_pnl_usdt"))
pnl_ratio = _safe_float(raw.get("pnlRatio")) pnl_ratio = _safe_float(raw.get("pnlRatio"))
close_type = str(raw.get("type") or "").strip() close_type = str(raw.get("type") or "").strip()
utime = _safe_float(raw.get("uTime")) utime = _safe_float(raw.get("uTime"))
@@ -1477,6 +1478,13 @@ def format_option_history_row(
premium_ccy = str(raw.get("_sim_premium_ccy") or "").strip().upper() or premium_ccy_for_mode( premium_ccy = str(raw.get("_sim_premium_ccy") or "").strip().upper() or premium_ccy_for_mode(
row_mode, uly or "ETH" row_mode, uly or "ETH"
) )
# 模拟盘已冻结 U 盈亏时,盈亏列按 U 展示(权利金仍为币)
pnl_display_ccy = str(raw.get("_sim_pnl_display_ccy") or "").strip().upper()
if pnl_usdt is not None:
realized = float(pnl_usdt)
pnl_display_ccy = "USDT"
elif not pnl_display_ccy:
pnl_display_ccy = premium_ccy
if close_type in ("3", "4"): if close_type in ("3", "4"):
status_label = "强平" status_label = "强平"
else: else:
@@ -1505,9 +1513,13 @@ def format_option_history_row(
"premium_paid": premium_paid, "premium_paid": premium_paid,
"premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy), "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"premium_ccy": premium_ccy, "premium_ccy": premium_ccy,
"pnl_display_ccy": pnl_display_ccy,
"margin_mode": row_mode, "margin_mode": row_mode,
"realized_pnl": realized, "realized_pnl": realized,
"realized_pnl_fmt": format_premium_amount(realized, ccy=premium_ccy), "realized_pnl_usdt": pnl_usdt if pnl_usdt is not None else (
float(realized) if pnl_display_ccy == "USDT" and realized is not None else None
),
"realized_pnl_fmt": format_premium_amount(realized, ccy=pnl_display_ccy),
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
"status": "closed", "status": "closed",
"status_label": status_label, "status_label": status_label,
+86 -4
View File
@@ -174,6 +174,62 @@ def fee_to_usdt(
return round(amt, 4) return round(amt, 4)
def settle_sim_coin_pnl_usdt(
*,
premium_paid: Any,
premium_received: Any,
open_fee: Any = 0,
close_fee: Any = 0,
open_index_px: float | None,
sell_back_px: float | None,
fee_ccy: str | None = None,
premium_ccy: str = "ETH",
) -> dict[str, float | None]:
"""模拟盘币本位盈亏(U).
盈利: (权利金btc+盈利btc)×卖回价 − 初始U权利金 − 手续费
= 收回币×卖回价 − 已付币×开仓指数 − 手续费U
亏损: 剩余权利金U − 手续费U − 初始U权利金(同一式,结果为负)
不用买币预算当成本(预算含缓冲).
"""
del fee_ccy # 手续费按权利金币种折 U
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))
cfee = abs(float(_safe_float(close_fee) or 0.0))
open_idx = _safe_float(open_index_px)
sell_px = _safe_float(sell_back_px)
net_coin = round(recv - paid - ofee - cfee, 8)
if open_idx is None or open_idx <= 0 or sell_px is None or sell_px <= 0:
return {
"realized_pnl": net_coin,
"close_index_px": float(sell_px) if sell_px is not None and sell_px > 0 else None,
"open_index_px": float(open_idx) if open_idx is not None and open_idx > 0 else None,
"realized_pnl_usdt": None,
"total_fee": round(ofee + cfee, 8),
"initial_premium_usdt": None,
"proceeds_usdt": None,
}
# 权利金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)
return {
"realized_pnl": net_coin,
"close_index_px": float(sell_px),
"open_index_px": float(open_idx),
"realized_pnl_usdt": pnl_u,
"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),
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
}
def settle_closed_pnl( def settle_closed_pnl(
*, *,
premium_paid: Any, premium_paid: Any,
@@ -189,13 +245,16 @@ def settle_closed_pnl(
""" """
由「毛权利金」结算. 由「毛权利金」结算.
币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次). 币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次).
冻结 U(币本位) = 净盈亏 × 平仓指数.
注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲,多余币会卖回, 币本位 U:
拿全额预算当成本会把亏损算大. open_index/budget 参数保留兼容,不参与 U 计算. 收回×卖回价(index_px) − 已付×开仓指数 − 手续费U
(开仓指数缺失时回退为净币×平仓指数)
USDC: 净额原样为 U.
注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲.
不要对 OKX realizedPnl 再调此函数扣费. 不要对 OKX realizedPnl 再调此函数扣费.
""" """
del open_index_px, budget_usdt, fee_ccy # 兼容旧调用,不参与结算 del budget_usdt # 买币预算不是权利金成本
paid = float(_safe_float(premium_paid) or 0.0) paid = float(_safe_float(premium_paid) or 0.0)
recv = float(_safe_float(premium_received) or 0.0) recv = float(_safe_float(premium_received) or 0.0)
ofee = float(_safe_float(open_fee) or 0.0) ofee = float(_safe_float(open_fee) or 0.0)
@@ -205,6 +264,29 @@ def settle_closed_pnl(
if cfee < 0: if cfee < 0:
cfee = abs(cfee) cfee = abs(cfee)
net = round(recv - paid - ofee - cfee, 8) net = round(recv - paid - ofee - cfee, 8)
pc = str(premium_ccy or "").strip().upper()
if pc in ("ETH", "BTC"):
open_idx = _safe_float(open_index_px)
sell_px = _safe_float(index_px)
if open_idx is not None and open_idx > 0 and sell_px is not None and sell_px > 0:
out = settle_sim_coin_pnl_usdt(
premium_paid=paid,
premium_received=recv,
open_fee=ofee,
close_fee=cfee,
open_index_px=open_idx,
sell_back_px=sell_px,
fee_ccy=fee_ccy,
premium_ccy=pc,
)
return out
# 无开仓指数:回退净币×平仓价
return freeze_pnl_to_usdt(
net,
premium_ccy=pc,
index_px=index_px,
total_fee=ofee + cfee,
)
out = freeze_pnl_to_usdt( out = freeze_pnl_to_usdt(
net, net,
premium_ccy=premium_ccy, premium_ccy=premium_ccy,
+25 -30
View File
@@ -512,11 +512,10 @@ def _sim_net_usdt_converts_for_trade(
def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int: def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
"""模拟盘:按权利金净盈亏冻结 U(收回−已付−费)×平仓指数. """模拟盘:收回×卖回价 − 初始U权利金 − 手续费U.
禁止再用「卖回 USDT − 买币预算」:买币预算含权利金本金与缓冲,卖回差额会把 盈利=(权利金币+盈利币)×卖回价−开仓U权利金−费;
权利金本金也算进盈利,导致合计盈亏虚高(约翻倍). 亏损=剩余权利金U−费−开仓U(同一式).不用买币预算差额.
函数名保留兼容旧调用.
""" """
init_options_tables(conn) init_options_tables(conn)
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl
@@ -551,45 +550,40 @@ def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
recv = float(total_premium(cq, eth)) recv = float(total_premium(cq, eth))
except Exception: except Exception:
recv = None recv = None
sell_px = _safe_float(_row_get(row, "close_index_px"))
open_idx = _safe_float(_row_get(row, "open_index_px"))
if sell_px is None or sell_px <= 0:
sell_px = resolve_close_index_px(
ex,
inst_id=str(_row_get(row, "inst_id") or ""),
underlying=underly,
hint=open_idx,
)
if open_idx is None or open_idx <= 0:
# 无开仓指数时用卖回价兜底(等价旧净币×价)
open_idx = sell_px
if paid is None or recv is None: if paid is None or recv is None:
# 无毛权利金时回退:已有币数量净盈亏 × 冻结指数
net = _safe_float(_row_get(row, "realized_pnl")) net = _safe_float(_row_get(row, "realized_pnl"))
if net is None: if net is None or sell_px is None or sell_px <= 0:
continue continue
idx = _safe_float(_row_get(row, "close_index_px")) or _safe_float( usdt = round(float(net) * float(sell_px), 4)
_row_get(row, "open_index_px") close_idx = float(sell_px)
)
if idx is None or idx <= 0:
idx = resolve_close_index_px(
ex,
inst_id=str(_row_get(row, "inst_id") or ""),
underlying=underly,
)
if idx is None or idx <= 0:
continue
usdt = round(float(net) * float(idx), 4)
close_idx = float(idx)
new_pnl = float(net) new_pnl = float(net)
open_write = open_idx
else: else:
idx = _safe_float(_row_get(row, "close_index_px"))
if idx is None or idx <= 0:
idx = resolve_close_index_px(
ex,
inst_id=str(_row_get(row, "inst_id") or ""),
underlying=underly,
hint=_row_get(row, "open_index_px"),
)
settled = settle_closed_pnl( settled = settle_closed_pnl(
premium_paid=paid, premium_paid=paid,
premium_received=recv, premium_received=recv,
open_fee=_row_get(row, "open_fee"), open_fee=_row_get(row, "open_fee"),
close_fee=_row_get(row, "close_fee"), close_fee=_row_get(row, "close_fee"),
premium_ccy=ccy or underly or "ETH", premium_ccy=ccy or underly or "ETH",
index_px=idx, index_px=sell_px,
open_index_px=open_idx,
) )
usdt = settled.get("realized_pnl_usdt") usdt = settled.get("realized_pnl_usdt")
close_idx = settled.get("close_index_px") close_idx = settled.get("close_index_px") or sell_px
new_pnl = settled.get("realized_pnl") new_pnl = settled.get("realized_pnl")
open_write = settled.get("open_index_px") or open_idx
if usdt is None: if usdt is None:
continue continue
old = _safe_float(_row_get(row, "realized_pnl_usdt")) old = _safe_float(_row_get(row, "realized_pnl_usdt"))
@@ -600,10 +594,11 @@ def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
UPDATE options_trades UPDATE options_trades
SET realized_pnl_usdt = ?, SET realized_pnl_usdt = ?,
close_index_px = COALESCE(close_index_px, ?), close_index_px = COALESCE(close_index_px, ?),
open_index_px = COALESCE(open_index_px, ?),
realized_pnl = COALESCE(?, realized_pnl) realized_pnl = COALESCE(?, realized_pnl)
WHERE id = ? WHERE id = ?
""", """,
(float(usdt), close_idx, new_pnl, int(row["id"])), (float(usdt), close_idx, open_write, new_pnl, int(row["id"])),
) )
updated += 1 updated += 1
return updated return updated
+17 -5
View File
@@ -1220,10 +1220,17 @@ class SimBroker:
conn = self.get_db() conn = self.get_db()
try: try:
init_options_tables(conn) init_options_tables(conn)
try:
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
sync_sim_cash_realized_pnl_usdt(conn, exchange)
conn.commit()
except Exception:
pass
sql = """ sql = """
SELECT id, inst_id, underlying, sheets, open_quote, close_quote, SELECT id, inst_id, underlying, sheets, open_quote, close_quote,
premium_paid, premium_received, realized_pnl, premium_ccy, premium_paid, premium_received, realized_pnl, realized_pnl_usdt,
created_at, closed_at premium_ccy, created_at, closed_at
FROM options_trades FROM options_trades
WHERE status = 'closed' WHERE status = 'closed'
""" """
@@ -1250,6 +1257,7 @@ class SimBroker:
paid = float(r["premium_paid"] or 0) if r["premium_paid"] is not None else None paid = float(r["premium_paid"] or 0) if r["premium_paid"] is not None else None
recv = float(r["premium_received"] or 0) if r["premium_received"] is not None else None recv = float(r["premium_received"] or 0) if r["premium_received"] is not None else None
pnl = float(r["realized_pnl"]) if r["realized_pnl"] is not None else None pnl = float(r["realized_pnl"]) if r["realized_pnl"] is not None else None
pnl_usdt = float(r["realized_pnl_usdt"]) if r["realized_pnl_usdt"] is not None else None
if pnl is None and paid is not None and recv is not None: if pnl is None and paid is not None and recv is not None:
pnl = round(recv - paid, 8) pnl = round(recv - paid, 8)
if pnl is None: if pnl is None:
@@ -1283,16 +1291,20 @@ class SimBroker:
"closeAvgPx": str(close_px) if close_px is not None else "", "closeAvgPx": str(close_px) if close_px is not None else "",
"closeTotalPos": str(sheets), "closeTotalPos": str(sheets),
"openMaxPos": str(sheets), "openMaxPos": str(sheets),
"realizedPnl": str(round(pnl, 8)), # 展示用 U 盈亏;币数量仍放 raw 供对账
"pnl": str(round(pnl, 8)), "realizedPnl": str(round(pnl_usdt, 4)) if pnl_usdt is not None else str(round(pnl, 8)),
"pnl": str(round(pnl_usdt, 4)) if pnl_usdt is not None else str(round(pnl, 8)),
"pnlRatio": str(round(pnl_ratio, 8)) if pnl_ratio is not None else "", "pnlRatio": str(round(pnl_ratio, 8)) if pnl_ratio is not None else "",
"cTime": str(open_ms or ""), "cTime": str(open_ms or ""),
"uTime": str(close_ms or ""), "uTime": str(close_ms or ""),
"type": "2", "type": "2",
"optType": opt_type or "", "optType": opt_type or "",
"stk": str(strike) if strike is not None else "", "stk": str(strike) if strike is not None else "",
"_sim_premium_ccy": ccy,
"_sim_premium_paid": paid, "_sim_premium_paid": paid,
"_sim_premium_ccy": ccy,
"_sim_realized_pnl_coin": pnl,
"_sim_realized_pnl_usdt": pnl_usdt,
"_sim_pnl_display_ccy": "USDT" if pnl_usdt is not None else ccy,
} }
) )
return out return out
+30
View File
@@ -26,6 +26,7 @@ class FeeConvertTests(unittest.TestCase):
class SettleClosedPnlTests(unittest.TestCase): class SettleClosedPnlTests(unittest.TestCase):
def test_sim_net_times_close_index(self): def test_sim_net_times_close_index(self):
# 无开仓指数时回退净币×平仓价
s = settle_closed_pnl( s = settle_closed_pnl(
premium_paid=0.01, premium_paid=0.01,
premium_received=0.02, premium_received=0.02,
@@ -37,6 +38,35 @@ class SettleClosedPnlTests(unittest.TestCase):
self.assertAlmostEqual(float(s["realized_pnl"]), 0.009, places=8) self.assertAlmostEqual(float(s["realized_pnl"]), 0.009, places=8)
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4) self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4)
def test_sim_proceeds_minus_open_premium_usdt(self):
"""盈利=(收回币)×卖回价 − 已付币×开仓指数 − 手续费U."""
s = settle_closed_pnl(
premium_paid=0.011005,
premium_received=0.02270864,
open_fee=0,
close_fee=0,
premium_ccy="BTC",
index_px=81000, # 卖回价
open_index_px=86000, # 初始 U 权利金用开仓指数
)
# 0.02270864*81000 - 0.011005*86000
expect = 0.02270864 * 81000 - 0.011005 * 86000
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), expect, places=2)
def test_sim_loss_remaining_minus_fees(self):
"""亏损:剩余权利金U − 手续费U − 初始U(同一式)."""
s = settle_closed_pnl(
premium_paid=0.01,
premium_received=0.004,
open_fee=0.0001,
close_fee=0.0001,
premium_ccy="ETH",
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)
def test_budget_ignored_for_usdt(self): def test_budget_ignored_for_usdt(self):
# budget 不得抬高开仓成本 # budget 不得抬高开仓成本
s = settle_closed_pnl( s = settle_closed_pnl(