修复 OKX 期权翻倍出场与币本位浮盈:中控/看板按指数折 U,避免 BTC 币数量当美金。

翻倍出场按全仓买一判定且对冲表异常不再整段跳过;面板按当前倍数展示需回收公式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-09-30 22:17:02 +08:00
parent bd4adc9eca
commit 32746b0a34
9 changed files with 212 additions and 71 deletions
+17 -1
View File
@@ -1977,6 +1977,21 @@
return fmt(n, 2) + "倍";
}
function profitExitHint(mult, req, premCcy) {
const n = Number(mult);
const m = Number.isFinite(n) && n > 0 ? n : 1;
const needFactor = 1 + m;
const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9
? String(Math.round(needFactor))
: fmt(needFactor, 2);
let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2)
+ " → 需回收≥权利金×" + factorTxt;
if (req != null) {
hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy;
}
return hint;
}
function renderProfitExitRow(p) {
const inst = p.inst_id || "";
if (p.hedge_plan_target) {
@@ -1997,6 +2012,7 @@
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
const req = p.profit_exit_required_recycle;
const premCcy = posPremiumCcy(p);
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
return (
@@ -2013,7 +2029,7 @@
'<span class="opt-target-armed">' + statusTxt + "</span>" +
'<span class="muted opt-target-row-hint">' +
(enabled
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
? profitExitHint(hintMult, req, premCcy)
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
"</span>" +
"</div>"
+11 -59
View File
@@ -74,71 +74,30 @@ def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str
def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]:
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U."""
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U.
优先逐仓 display_pnl_usdt_from_option_row(已含 BTC/ETH × 指数);
回退 upl_total_usdc 时按「已是 USDT」处理——实例 hub 快照合计已折 U,
不可再 × 指数,否则币本位会双计.
"""
snap = options_snap if isinstance(options_snap, dict) else {}
if snap.get("enabled") is False or snap.get("ok") is False:
return None
def _safe(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _index_px() -> float | None:
px = _safe(snap.get("options_index_px") or snap.get("index_px"))
if px is not None and px > 0:
return px
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
px = _safe(p.get("idx_px") or p.get("idxPx"))
if px is not None and px > 0:
return px
return None
def _row_is_coin(p: dict[str, Any]) -> bool:
ccy = str(p.get("premium_ccy") or "").strip().upper()
if ccy in ("ETH", "BTC"):
return True
if str(p.get("margin_mode") or "").strip().lower() == "coin":
return True
mid = str(p.get("inst_id") or "")
return "-USD-" in mid.upper() and "_UM" not in mid.upper()
mode = str(snap.get("options_margin_mode") or snap.get("margin_mode") or "").strip().lower()
snap_coin = mode == "coin"
idx = _index_px()
try:
from lib.options.options_positions_lib import display_pnl_from_option_row
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
total_u = 0.0
found = False
missing_fx = False
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
pnl = display_pnl_from_option_row(p)
pnl = display_pnl_usdt_from_option_row(p)
if pnl is None:
continue
found = True
if snap_coin or _row_is_coin(p):
px = _safe(p.get("idx_px") or p.get("idxPx"))
if px is None or px <= 0:
px = idx
if px is None or px <= 0:
missing_fx = True
continue
total_u += float(pnl) * float(px)
else:
total_u += float(pnl)
if found and not missing_fx:
return round(total_u, 4)
if found and missing_fx and abs(total_u) > 1e-12:
# 部分腿已换算成功时仍返回可得合计
total_u += float(pnl)
if found:
return round(total_u, 4)
except Exception:
pass
@@ -146,16 +105,9 @@ def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[floa
upl = snap.get("upl_total_usdc")
if upl is not None:
try:
raw = float(upl)
return round(float(upl), 4)
except (TypeError, ValueError):
return None
if snap_coin or any(
isinstance(p, dict) and _row_is_coin(p) for p in (snap.get("positions") or [])
):
if idx is None or idx <= 0:
return None
return round(raw * float(idx), 4)
return round(raw, 4)
return None
+3 -3
View File
@@ -162,12 +162,12 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
# 看板期权列:优先买一净盈亏,残档回退交易所 upl
# 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数)
pnl = None
try:
from lib.options.options_positions_lib import display_pnl_from_option_row
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
pnl = display_pnl_from_option_row(p)
pnl = display_pnl_usdt_from_option_row(p)
except Exception:
pnl = None
pos = _safe_float(p.get("pos"))
+3 -3
View File
@@ -85,13 +85,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn.close()
except Exception:
target_monitors = []
from lib.options.options_positions_lib import display_pnl_from_option_row
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
upl_total = 0.0
has_upl = False
for p in positions:
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
pnl = display_pnl_from_option_row(p)
# 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_usdt_from_option_row(p)
if pnl is None:
continue
has_upl = True
+32 -4
View File
@@ -124,14 +124,42 @@ def display_pnl_from_option_row(row: dict[str, Any]) -> float | None:
return _safe_float(row.get("upl"))
def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
"""顶栏/看板/中控「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.
BTC/ETH 币本位若不折算,会被界面标成 U 而数值仍是币数量(BTC 尤为离谱).
"""
pnl = display_pnl_from_option_row(row)
if pnl is None:
return None
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
if not ccy or ccy == "USDC":
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U
inst = str(row.get("inst_id") or row.get("instId") or "")
mode = str(row.get("margin_mode") or "").strip().lower()
if mode == "coin" or (
"-USD-" in inst.upper() and "_UM" not in inst.upper()
):
underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "") or "").upper()
if underly in ("ETH", "BTC"):
ccy = underly
if ccy in ("ETH", "BTC"):
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if idx is None or idx <= 0:
# 无指数时无法折算,跳过该仓以免把「币数量」当成 U
return None
return float(pnl) * float(idx)
return float(pnl)
def sum_options_net_pnl_usdc(
cfg: dict[str, Any],
ex: Any,
raw_positions: list[dict[str, Any]] | None = None,
) -> float | None:
"""
期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」:
各仓买一可回收 − 权利金之和;残档则回退该仓交易所 upl.
期权浮盈合计(USDT/USDC 口径),与顶栏实时盈亏/中控对齐:
各仓买一可回收 − 权利金;币本位再 × 指数折成 U;残档回退该仓 upl(同样折算).
获取失败返回 None;无持仓返回 0.
"""
raw = raw_positions
@@ -145,12 +173,12 @@ def sum_options_net_pnl_usdc(
total = 0.0
found = False
for p in positions:
pnl = display_pnl_from_option_row(p)
pnl = display_pnl_usdt_from_option_row(p)
if pnl is None:
continue
found = True
total += float(pnl)
return round(total, 8) if found else (0.0 if not positions else None)
return round(total, 4) if found else (0.0 if not positions else None)
def build_display_option_positions(
+23 -1
View File
@@ -211,13 +211,34 @@ def _estimate_recycle(
pos: dict[str, Any],
premium_paid: float | None,
) -> float | None:
"""翻倍出场用可回收估算.
用买一价 × 全部持仓张数(不吃深度上限):深度不够时仍应触发分批平仓;
残档/无效买一则返回 None(禁止按假买盘自动平).
"""
from lib.options.options_positions_lib import attach_close_preview
from lib.options.options_pricing_lib import total_premium
row = dict(pos)
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"):
return None
levels = preview.get("levels") or []
ct_mult = float(row.get("ct_mult") or 0.01)
sheets = int(
abs(
_safe_float(row.get("avail_pos") if row.get("avail_pos") is not None else row.get("pos"))
or 0
)
)
if levels and sheets > 0 and ct_mult > 0:
try:
px = float(levels[0].get("px") or 0)
except (TypeError, ValueError, AttributeError):
px = 0.0
if px > 0:
return round(total_premium(px, sheets * ct_mult), 8)
return _safe_float(preview.get("total_received"))
@@ -303,7 +324,8 @@ def run_options_profit_exits(
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
return 0
# 对冲表异常时不得整段跳过翻倍出场
hedge_managed = set()
rules = profit_exit_by_inst(conn)
triggered = 0
+40
View File
@@ -79,12 +79,52 @@ class HubOptionsFundsLibTests(TestCase):
"premium_ccy": "ETH",
"upl": 0.0016,
"idx_px": 2280.0,
"close_preview": {"bid_invalid": True},
}
],
}
out = options_float_pnl_usdt(snap)
self.assertAlmostEqual(out, round(0.0016 * 2280.0, 4), places=4)
def test_options_float_pnl_usdt_btc_coin_times_btc_index(self):
from lib.hub.hub_options_funds_lib import options_float_pnl_usdt
snap = {
"ok": True,
"enabled": True,
"options_margin_mode": "coin",
# 默认指数若误用 ETH 也不应覆盖逐仓 BTC idx
"options_index_px": 3000.0,
"upl_total_usdc": -100.0,
"positions": [
{
"inst_id": "BTC-USD-260327-100000-P",
"margin_mode": "coin",
"premium_ccy": "BTC",
"upl": -0.001,
"idx_px": 100000.0,
"close_preview": {"bid_invalid": True},
}
],
}
out = options_float_pnl_usdt(snap)
self.assertAlmostEqual(out, -100.0, places=4)
def test_options_float_pnl_usdt_fallback_already_usdt_no_double_fx(self):
"""实例 hub 合计已折 U 时,回退不可再 × 指数."""
from lib.hub.hub_options_funds_lib import options_float_pnl_usdt
snap = {
"ok": True,
"enabled": True,
"options_margin_mode": "coin",
"options_index_px": 100000.0,
"upl_total_usdc": -100.0,
"positions": [],
}
out = options_float_pnl_usdt(snap)
self.assertAlmostEqual(out, -100.0, places=4)
def test_repair_double_counted_fund_entry(self):
raw = {
"funding_usdt": 586.82,
+44
View File
@@ -0,0 +1,44 @@
"""币本位期权浮盈折 U(BTC/ETH 不得把币数量直接当 U)."""
from __future__ import annotations
import unittest
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
class DisplayPnlUsdtTests(unittest.TestCase):
def test_btc_coin_pnl_times_index(self):
row = {
"inst_id": "BTC-USD-260327-100000-P",
"premium_ccy": "BTC",
"margin_mode": "coin",
"idx_px": 100000.0,
"upl": -0.001,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -100.0, places=4)
def test_btc_infers_ccy_when_premium_ccy_missing(self):
row = {
"inst_id": "BTC-USD-260327-100000-C",
"margin_mode": "coin",
"underlying": "BTC",
"idx_px": 95000.0,
"upl": 0.002,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), 190.0, places=4)
def test_usdc_unchanged(self):
row = {
"inst_id": "BTC-USD_UM-260327-100000-C",
"premium_ccy": "USDC",
"margin_mode": "usdc",
"upl": -12.5,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4)
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
"""翻倍出场:买一价×全仓张数判定达标(不受买一深度截断)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from lib.options.options_profit_exit_lib import _estimate_recycle, profit_exit_hit
class ProfitExitHitTests(unittest.TestCase):
def test_hit_at_0_3_mult(self):
self.assertTrue(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.3, mult=0.3))
self.assertFalse(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.29, mult=0.3))
def test_estimate_uses_full_sheets_not_depth(self):
pos = {"inst_id": "ETH-USD-260927-2700-P", "pos": 100, "ct_mult": 0.01}
preview = {
"bid_invalid": False,
"levels": [{"px": 0.2, "sheets": 10}],
"total_received": 0.02,
}
def fake_attach(cfg, ex, row, premium_paid=None):
row["close_preview"] = preview
row["ct_mult"] = 0.01
row["pos"] = 100
return row
with patch(
"lib.options.options_positions_lib.attach_close_preview",
side_effect=fake_attach,
):
recv = _estimate_recycle({}, object(), pos, 1.0)
self.assertAlmostEqual(float(recv), 0.2, places=6)
self.assertTrue(profit_exit_hit(premium_paid=0.15, recycle_usdc=float(recv), mult=0.3))
if __name__ == "__main__":
unittest.main()