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()