From 70ebfb10e85050d6da5ef4e685af31234f41ce7c Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 8 Oct 2026 23:56:23 +0800 Subject: [PATCH] =?UTF-8?q?fix=E6=A8=A1=E6=8B=9F=E7=9B=98=E7=9B=88?= =?UTF-8?q?=E4=BA=8F=EF=BC=9A=E6=94=B6=E5=9B=9E=C3=97=E5=8D=96=E5=9B=9E?= =?UTF-8?q?=E4=BB=B7=20=E2=88=92=20=E5=BC=80=E4=BB=93U=E6=9D=83=E5=88=A9?= =?UTF-8?q?=E9=87=91=20=E2=88=92=20=E6=89=8B=E7=BB=AD=E8=B4=B9=EF=BC=8C?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=8C=89U=E5=B1=95=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/common/static/options_panel.js | 11 ++-- lib/exchange/okx_options_lib.py | 14 ++++- lib/options/options_pnl_settle_lib.py | 90 +++++++++++++++++++++++++-- lib/options/options_stats_lib.py | 57 ++++++++--------- lib/sim/broker_lib.py | 22 +++++-- tests/test_options_pnl_settle.py | 30 +++++++++ 6 files changed, 179 insertions(+), 45 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index c3008cc..073cefe 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2833,12 +2833,15 @@ list.forEach(function (h) { const tr = document.createElement("tr"); 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( h.premium_paid_fmt, 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 pnl = isOpen ? null : h.realized_pnl; let pnlTxt = "—"; @@ -2846,9 +2849,9 @@ const n = Number(pnl); const absCore = fmtDisplay( 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 timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 71afbce..337fc7c 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -1467,6 +1467,7 @@ def format_option_history_row( realized = _safe_float(raw.get("realizedPnl")) if realized is None: realized = _safe_float(raw.get("pnl")) + pnl_usdt = _safe_float(raw.get("_sim_realized_pnl_usdt")) pnl_ratio = _safe_float(raw.get("pnlRatio")) close_type = str(raw.get("type") or "").strip() 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( 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"): status_label = "强平" else: @@ -1505,9 +1513,13 @@ def format_option_history_row( "premium_paid": premium_paid, "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy), "premium_ccy": premium_ccy, + "pnl_display_ccy": pnl_display_ccy, "margin_mode": row_mode, "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, "status": "closed", "status_label": status_label, diff --git a/lib/options/options_pnl_settle_lib.py b/lib/options/options_pnl_settle_lib.py index 0bc4f94..f6a12b8 100644 --- a/lib/options/options_pnl_settle_lib.py +++ b/lib/options/options_pnl_settle_lib.py @@ -174,6 +174,62 @@ def fee_to_usdt( 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( *, premium_paid: Any, @@ -189,13 +245,16 @@ def settle_closed_pnl( """ 由「毛权利金」结算. 币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次). - 冻结 U(币本位) = 净盈亏 × 平仓指数. - 注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲,多余币会卖回, - 拿全额预算当成本会把亏损算大. open_index/budget 参数保留兼容,不参与 U 计算. + 币本位 U: + 收回×卖回价(index_px) − 已付×开仓指数 − 手续费U + (开仓指数缺失时回退为净币×平仓指数) + USDC: 净额原样为 U. + + 注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲. 不要对 OKX realizedPnl 再调此函数扣费. """ - del open_index_px, budget_usdt, fee_ccy # 兼容旧调用,不参与结算 + del budget_usdt # 买币预算不是权利金成本 paid = float(_safe_float(premium_paid) or 0.0) recv = float(_safe_float(premium_received) or 0.0) ofee = float(_safe_float(open_fee) or 0.0) @@ -205,6 +264,29 @@ def settle_closed_pnl( if cfee < 0: cfee = abs(cfee) 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( net, premium_ccy=premium_ccy, diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 3ddeebb..134dab2 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -512,11 +512,10 @@ def _sim_net_usdt_converts_for_trade( def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int: - """模拟盘:按权利金净盈亏冻结 U(收回−已付−费)×平仓指数. + """模拟盘:收回×卖回价 − 初始U权利金 − 手续费U. - 禁止再用「卖回 USDT − 买币预算」:买币预算含权利金本金与缓冲,卖回差额会把 - 权利金本金也算进盈利,导致合计盈亏虚高(约翻倍). - 函数名保留兼容旧调用. + 盈利=(权利金币+盈利币)×卖回价−开仓U权利金−费; + 亏损=剩余权利金U−费−开仓U(同一式).不用买币预算差额. """ init_options_tables(conn) 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)) except Exception: recv = None - if paid is None or recv is None: - # 无毛权利金时回退:已有币数量净盈亏 × 冻结指数 - net = _safe_float(_row_get(row, "realized_pnl")) - if net is None: - continue - idx = _safe_float(_row_get(row, "close_index_px")) or _safe_float( - _row_get(row, "open_index_px") + 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 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: + if open_idx is None or open_idx <= 0: + # 无开仓指数时用卖回价兜底(等价旧净币×价) + open_idx = sell_px + if paid is None or recv is None: + net = _safe_float(_row_get(row, "realized_pnl")) + if net is None or sell_px is None or sell_px <= 0: continue - usdt = round(float(net) * float(idx), 4) - close_idx = float(idx) + usdt = round(float(net) * float(sell_px), 4) + close_idx = float(sell_px) new_pnl = float(net) + open_write = open_idx 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( premium_paid=paid, premium_received=recv, open_fee=_row_get(row, "open_fee"), close_fee=_row_get(row, "close_fee"), 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") - close_idx = settled.get("close_index_px") + close_idx = settled.get("close_index_px") or sell_px new_pnl = settled.get("realized_pnl") + open_write = settled.get("open_index_px") or open_idx if usdt is None: continue 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 SET realized_pnl_usdt = ?, close_index_px = COALESCE(close_index_px, ?), + open_index_px = COALESCE(open_index_px, ?), realized_pnl = COALESCE(?, realized_pnl) WHERE id = ? """, - (float(usdt), close_idx, new_pnl, int(row["id"])), + (float(usdt), close_idx, open_write, new_pnl, int(row["id"])), ) updated += 1 return updated diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index 806e6ce..0d8de1d 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -1220,10 +1220,17 @@ class SimBroker: conn = self.get_db() try: 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 = """ SELECT id, inst_id, underlying, sheets, open_quote, close_quote, - premium_paid, premium_received, realized_pnl, premium_ccy, - created_at, closed_at + premium_paid, premium_received, realized_pnl, realized_pnl_usdt, + premium_ccy, created_at, closed_at FROM options_trades 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 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_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: pnl = round(recv - paid, 8) if pnl is None: @@ -1283,16 +1291,20 @@ class SimBroker: "closeAvgPx": str(close_px) if close_px is not None else "", "closeTotalPos": str(sheets), "openMaxPos": str(sheets), - "realizedPnl": str(round(pnl, 8)), - "pnl": str(round(pnl, 8)), + # 展示用 U 盈亏;币数量仍放 raw 供对账 + "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 "", "cTime": str(open_ms or ""), "uTime": str(close_ms or ""), "type": "2", "optType": opt_type or "", "stk": str(strike) if strike is not None else "", - "_sim_premium_ccy": ccy, "_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 diff --git a/tests/test_options_pnl_settle.py b/tests/test_options_pnl_settle.py index 2dbfda5..f9bc9e7 100644 --- a/tests/test_options_pnl_settle.py +++ b/tests/test_options_pnl_settle.py @@ -26,6 +26,7 @@ class FeeConvertTests(unittest.TestCase): class SettleClosedPnlTests(unittest.TestCase): def test_sim_net_times_close_index(self): + # 无开仓指数时回退净币×平仓价 s = settle_closed_pnl( premium_paid=0.01, 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_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): # budget 不得抬高开仓成本 s = settle_closed_pnl(