diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index c9635f0..1d1e1e7 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -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 @@
'' + statusTxt + "" +
'' +
(enabled
- ? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
+ ? profitExitHint(hintMult, req, premCcy)
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
"" +
""
diff --git a/lib/hub/hub_options_funds_lib.py b/lib/hub/hub_options_funds_lib.py
index 6c030a3..1ee1126 100644
--- a/lib/hub/hub_options_funds_lib.py
+++ b/lib/hub/hub_options_funds_lib.py
@@ -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
diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py
index f4c4c29..e90397b 100644
--- a/lib/instance/instance_dashboard_lib.py
+++ b/lib/instance/instance_dashboard_lib.py
@@ -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"))
diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py
index e3b151f..a09294b 100644
--- a/lib/options/options_hub_lib.py
+++ b/lib/options/options_hub_lib.py
@@ -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
diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py
index d9fe3df..4931b64 100644
--- a/lib/options/options_positions_lib.py
+++ b/lib/options/options_positions_lib.py
@@ -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(
diff --git a/lib/options/options_profit_exit_lib.py b/lib/options/options_profit_exit_lib.py
index be1d60f..d8fbbe7 100644
--- a/lib/options/options_profit_exit_lib.py
+++ b/lib/options/options_profit_exit_lib.py
@@ -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
diff --git a/tests/test_hub_options_funds_lib.py b/tests/test_hub_options_funds_lib.py
index dcf9b23..d5b0f24 100644
--- a/tests/test_hub_options_funds_lib.py
+++ b/tests/test_hub_options_funds_lib.py
@@ -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,
diff --git a/tests/test_options_btc_pnl_usdt.py b/tests/test_options_btc_pnl_usdt.py
new file mode 100644
index 0000000..0df5c7a
--- /dev/null
+++ b/tests/test_options_btc_pnl_usdt.py
@@ -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()
diff --git a/tests/test_options_profit_exit_recycle.py b/tests/test_options_profit_exit_recycle.py
new file mode 100644
index 0000000..afedbd6
--- /dev/null
+++ b/tests/test_options_profit_exit_recycle.py
@@ -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()