diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index f1511f4..1dcf5d7 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -143,6 +143,13 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_ALLOW_MARKET_CLOSE=false +# 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值) +# OKX_OPTIONS_CLOSE_GATE_MODE=premium +# OKX_OPTIONS_CLOSE_RECYCLE_MULT= +# OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05 +# OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2 +# OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0 +# OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 # 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 9b597d7..c9635f0 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -1101,7 +1101,7 @@ const gate = preview.close_gate || {}; // 2× 只是目标平仓门控,本身不会自动平;手动买一平不拦截 if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { - return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); + return "目标门控: " + (preview.close_gate_msg || gate.msg || "门控未过(见 env 目标平仓门控)"); } return ""; } diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index 3452cfe..3e45b4a 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -151,7 +151,7 @@ } const gate = preview.close_gate || {}; if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { - return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); + return "目标门控: " + (preview.close_gate_msg || gate.msg || "门控未过(见 env 目标平仓门控)"); } return ""; } diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 7e8c5ac..848a089 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -215,6 +215,36 @@ _OPTIONS_SECTION: dict[str, Any] = { "链上仅显示有卖一", "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", ), + ( + "OKX_OPTIONS_CLOSE_GATE_MODE", + "目标平仓门控模式", + "premium=可回收(U)≥权利金(U)×倍数;net_pnl=净盈亏(U)大于阈值。币本位按指数换算为U", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT", + "门控权利金倍数(全局)", + "可选;填写则覆盖下方分本位默认值", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", + "门控权利金倍数(币本位)", + "默认 1.05;premium 模式下 recyclable(U)≥premium(U)×本值", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", + "门控权利金倍数(USDC)", + "默认 2;premium 模式下 recyclable(U)≥premium(U)×本值", + ), + ( + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", + "门控净盈亏下限(U)", + "net_pnl 模式;净盈亏(估)须大于本值,如 0 或 1", + ), + ( + "OKX_OPTIONS_CLOSE_HOLD_SECONDS", + "门控持续秒数", + "达标后须持续本秒数才通过,默认 120", + ), ], } diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index edd161f..07171e3 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -106,7 +106,7 @@ def close_option_by_bid1( - 限价 = 校验通过时锁定的买一价 - 永不市价 - 始终校验有效流动性(残档买一禁止) - - require_recycle_gate=True 时:首次还需可回收≥2×权利金并持续 hold 秒; + - require_recycle_gate=True 时:首次还需目标门控(权利金×倍数或净盈亏,U 口径)并持续 hold 秒; 一旦通过后对同仓续批只验流动性 """ from lib.exchange.okx_options_lib import ( @@ -145,6 +145,8 @@ def close_option_by_bid1( premium_paid = _open_premium_paid(cfg, inst_id) if premium_paid is None: premium_paid = _safe_float(pos.get("premium_paid")) + premium_ccy = str(q.get("premium_ccy") or pos.get("premium_ccy") or "USDC").strip().upper() or "USDC" + index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px")) # 已有未成交卖平单:等成交,不撤不重挂 try: @@ -194,7 +196,13 @@ def close_option_by_bid1( ) if preview.get("bid_invalid") or preview.get("auto_close_blocked"): # 不撤他人挂单:仅拒绝本轮下单 - update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + update_close_gate( + inst_id, + recycle_usdc=None, + premium_paid=premium_paid, + premium_ccy=premium_ccy, + index_px=index_px, + ) return { "ok": False, "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓", @@ -208,7 +216,13 @@ def close_option_by_bid1( bid_px = _safe_float(q.get("bid")) stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) if stub or bid_px is None or bid_px <= 0: - update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + update_close_gate( + inst_id, + recycle_usdc=None, + premium_paid=premium_paid, + premium_ccy=premium_ccy, + index_px=index_px, + ) return { "ok": False, "msg": stub_reason or "暂无买一,无法限价平仓", @@ -231,7 +245,13 @@ def close_option_by_bid1( stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) if stub_lv: - update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + update_close_gate( + inst_id, + recycle_usdc=None, + premium_paid=premium_paid, + premium_ccy=premium_ccy, + index_px=index_px, + ) return { "ok": False, "msg": stub_lv_reason or "暂无有效买盘,禁止平仓", @@ -245,6 +265,8 @@ def close_option_by_bid1( inst_id, recycle_usdc=_safe_float(preview.get("total_received")), premium_paid=premium_paid, + premium_ccy=premium_ccy, + index_px=index_px, ) if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"): return { diff --git a/lib/options/options_close_gate_lib.py b/lib/options/options_close_gate_lib.py index 743eebe..46c44a6 100644 --- a/lib/options/options_close_gate_lib.py +++ b/lib/options/options_close_gate_lib.py @@ -1,4 +1,4 @@ -"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓.""" +"""期权按买盘平仓门控:可回收/净盈亏换算为 USDT 后校验,并持续 hold 秒才允许平仓.""" from __future__ import annotations import os @@ -14,10 +14,44 @@ def _env_float(key: str, default: float) -> float: return default -# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 -CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) +def _env_optional_float(key: str) -> float | None: + raw = os.getenv(key) + if raw is None or str(raw).strip() == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) + +def close_net_pnl_min_u() -> float: + return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", 0.0) + + +def close_gate_mode() -> str: + m = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower() + return m if m in ("premium", "net_pnl") else "premium" + + +def resolve_close_recycle_mult(premium_ccy: str | None, min_mult: float | None = None) -> float: + if min_mult is not None: + m = float(min_mult) + return m if m > 0 else 1.05 + global_mult = _env_optional_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT") + if global_mult is not None and global_mult > 0: + return global_mult + ccy = (premium_ccy or "USDC").strip().upper() or "USDC" + if ccy in ("ETH", "BTC"): + return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05) + return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", 2.0) + + +# 兼容旧引用 +CLOSE_RECYCLE_MIN_MULT = resolve_close_recycle_mult("USDC") + _lock = threading.Lock() # inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float} _gates: dict[str, dict[str, Any]] = {} @@ -32,6 +66,39 @@ def _safe_float(v: Any) -> float | None: return None +def _normalize_premium_ccy(premium_ccy: str | None) -> str: + ccy = (premium_ccy or "USDC").strip().upper() or "USDC" + if ccy not in ("ETH", "BTC", "USDC"): + ccy = "USDC" + return ccy + + +def _to_usdt(amount: float | None, ccy: str, index_px: float | None) -> float | None: + if amount is None: + return None + unit = _normalize_premium_ccy(ccy) + if unit in ("ETH", "BTC"): + idx = _safe_float(index_px) + if idx is None or idx <= 0: + return None + return float(amount) * float(idx) + return float(amount) + + +def _fmt_gate_amt(v: float, *, ccy: str) -> str: + unit = _normalize_premium_ccy(ccy) + if unit in ("ETH", "BTC"): + txt = f"{float(v):.8f}".rstrip("0").rstrip(".") + return txt or "0" + return f"{float(v):.4f}" + + +def _fmt_usdt(v: float | None) -> str: + if v is None: + return "—" + return f"{float(v):.2f}" + + def clear_close_gate(inst_id: str | None = None) -> None: with _lock: if inst_id: @@ -41,7 +108,7 @@ def clear_close_gate(inst_id: str | None = None) -> None: def mark_close_gate_passed(inst_id: str) -> None: - """标记同仓已通过 2× 门控,续批平仓只验流动性.""" + """标记同仓已通过门控,续批平仓只验流动性.""" inst = (inst_id or "").strip() if not inst: return @@ -60,14 +127,6 @@ def is_close_gate_passed(inst_id: str) -> bool: return bool((_gates.get(inst) or {}).get("passed")) -def _fmt_gate_amt(v: float, *, ccy: str) -> str: - unit = (ccy or "USDC").strip().upper() or "USDC" - if unit in ("ETH", "BTC"): - txt = f"{float(v):.8f}".rstrip("0").rstrip(".") - return txt or "0" - return f"{float(v):.4f}" - - def update_close_gate( inst_id: str, *, @@ -77,10 +136,12 @@ def update_close_gate( min_mult: float | None = None, hold_seconds: float | None = None, premium_ccy: str | None = None, + index_px: float | None = None, ) -> dict[str, Any]: """ - 根据当前买盘可回收金额刷新门控. - 条件不满足时重置计时;满足时从首次满足起累计持续时间. + 根据当前买盘可回收金额刷新门控(比较口径均为 USDT 估值). + premium 模式:可回收(U) ≥ 权利金(U) × 倍数 + net_pnl 模式:净盈亏(U) > OKX_OPTIONS_CLOSE_NET_PNL_MIN_U """ inst = (inst_id or "").strip() if not inst: @@ -91,26 +152,38 @@ def update_close_gate( "msg": "缺少合约", } ts = float(now if now is not None else time.time()) - mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT) + mode = close_gate_mode() hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) - if mult <= 0: - mult = 2.0 if hold < 0: hold = 0.0 with _lock: prev_ccy = (_gates.get(inst) or {}).get("premium_ccy") - ccy = (premium_ccy or prev_ccy or "USDC").strip().upper() or "USDC" - if ccy not in ("ETH", "BTC", "USDC"): - ccy = "USDC" - need_decimals = 8 if ccy in ("ETH", "BTC") else 4 + ccy = _normalize_premium_ccy(premium_ccy or prev_ccy) + mult = resolve_close_recycle_mult(ccy, min_mult) + min_pnl_u = close_net_pnl_min_u() prem = _safe_float(premium_paid) recv = _safe_float(recycle_usdc) - need = round(prem * mult, need_decimals) if prem is not None and prem > 0 else None - recycle_ok = bool( - prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need - ) + recv_u = _to_usdt(recv, ccy, index_px) + prem_u = _to_usdt(prem, ccy, index_px) + net_u = round(recv_u - prem_u, 4) if recv_u is not None and prem_u is not None else None + need_u = round(prem_u * mult, 4) if prem_u is not None and prem_u > 0 and mode == "premium" else None + + missing_index = ccy in ("ETH", "BTC") and (index_px is None or _safe_float(index_px) is None or _safe_float(index_px) <= 0) + + if missing_index and prem is not None and prem > 0 and recv is not None: + recycle_ok = False + elif mode == "net_pnl": + recycle_ok = bool(net_u is not None and net_u > min_pnl_u + 1e-9) + else: + recycle_ok = bool( + prem_u is not None + and prem_u > 0 + and recv_u is not None + and need_u is not None + and recv_u + 1e-9 >= need_u + ) with _lock: prev = _gates.get(inst) or {} @@ -128,12 +201,19 @@ def update_close_gate( "ok_since": ok_since, "recycle": recv, "premium": prem, - "need": need, + "need": need_u, "updated": ts, "min_mult": mult, "hold_seconds": hold, "passed": passed, "premium_ccy": ccy, + "gate_mode": mode, + "index_px": _safe_float(index_px), + "recycle_usdt": recv_u, + "premium_usdt": prem_u, + "need_recycle_usdt": need_u, + "net_pnl_usdt": net_u, + "net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None, } _gates[inst] = state @@ -142,18 +222,40 @@ def update_close_gate( msg = "缺少权利金,无法校验平仓门控" elif recv is None: msg = "暂无有效买盘可回收金额" + elif missing_index: + msg = "缺少指数价,无法按 USDT 校验目标平仓门控" + elif mode == "net_pnl": + if not recycle_ok: + msg = ( + f"净盈亏 {_fmt_usdt(net_u)}U(估) ≤ {_fmt_usdt(min_pnl_u)}U," + f"目标平仓门控未过" + ) + elif not ready: + msg = ( + f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U," + f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" + ) + else: + msg = ( + f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U" + f"且持续≥{hold:.0f}s,目标触达后可按买一平仓" + ) elif not recycle_ok: msg = ( - f"可回收 {_fmt_gate_amt(recv, ccy=ccy)} {ccy} < 权利金×{mult:g}" - f"({_fmt_gate_amt(need, ccy=ccy)}),目标平仓门控未过" + f"可回收 {_fmt_usdt(recv_u)}U(估) < 权利金×{mult:g}" + f"({_fmt_usdt(need_u)}U),目标平仓门控未过" ) elif not ready: msg = ( - f"可回收已达×{mult:g}({_fmt_gate_amt(recv, ccy=ccy)}/{_fmt_gate_amt(need, ccy=ccy)} {ccy})," + f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}" + f"({_fmt_usdt(recv_u)}/{_fmt_usdt(need_u)}U)," f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" ) else: - msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,目标触达后可按买一平仓" + msg = ( + f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}且持续≥{hold:.0f}s," + f"目标触达后可按买一平仓" + ) auto_blocked = not (ready or passed) return { @@ -163,7 +265,7 @@ def update_close_gate( "recycle_ok": recycle_ok, "recycle_usdc": recv, "premium_paid": prem, - "need_recycle_usdc": need, + "need_recycle_usdc": need_u, "premium_ccy": ccy, "min_mult": mult, "hold_seconds": hold, @@ -171,6 +273,13 @@ def update_close_gate( "remain_seconds": round(remain, 1) if remain is not None else None, "ok_since": ok_since, "msg": msg, + "gate_mode": mode, + "index_px": _safe_float(index_px), + "recycle_usdt": recv_u, + "premium_usdt": prem_u, + "need_recycle_usdt": need_u, + "net_pnl_usdt": net_u, + "net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None, "auto_close_blocked": auto_blocked, "close_gate_blocked": auto_blocked, } @@ -182,12 +291,13 @@ def check_close_gate( recycle_usdc: float | None = None, premium_paid: float | None = None, premium_ccy: str | None = None, + index_px: float | None = None, refresh: bool = True, ) -> dict[str, Any]: """检查是否允许平仓;默认先用最新回收/权利金刷新.""" inst = (inst_id or "").strip() if refresh: - if recycle_usdc is None or premium_paid is None or premium_ccy is None: + if recycle_usdc is None or premium_paid is None or premium_ccy is None or index_px is None: with _lock: prev = _gates.get(inst) or {} if recycle_usdc is None: @@ -196,11 +306,14 @@ def check_close_gate( premium_paid = prev.get("premium") if premium_ccy is None: premium_ccy = prev.get("premium_ccy") + if index_px is None: + index_px = prev.get("index_px") return update_close_gate( inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid, premium_ccy=premium_ccy, + index_px=index_px, ) with _lock: prev = _gates.get(inst) @@ -210,10 +323,12 @@ def check_close_gate( recycle_usdc=recycle_usdc, premium_paid=premium_paid, premium_ccy=premium_ccy, + index_px=index_px, ) return update_close_gate( inst, recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"), premium_paid=premium_paid if premium_paid is not None else prev.get("premium"), premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"), + index_px=index_px if index_px is not None else prev.get("index_px"), ) diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index 653481b..d9fe3df 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -54,10 +54,15 @@ def attach_close_preview( max_levels=1, ) premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" - # 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要) + index_px = _safe_float(row.get("idx_px") or row.get("idxPx")) + # 残档时不累计门控;有效买一时刷新计时(仅自动平仓需要) if preview.get("bid_invalid") or preview.get("auto_close_blocked"): gate = update_close_gate( - inst_id, recycle_usdc=None, premium_paid=paid, premium_ccy=premium_ccy + inst_id, + recycle_usdc=None, + premium_paid=paid, + premium_ccy=premium_ccy, + index_px=index_px, ) preview["close_gate"] = gate preview["close_gate_blocked"] = True @@ -70,6 +75,7 @@ def attach_close_preview( recycle_usdc=_safe_float(preview.get("total_received")), premium_paid=paid, premium_ccy=premium_ccy, + index_px=index_px, ) passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready")) preview["close_gate"] = gate diff --git a/tests/test_options_close_gate_lib.py b/tests/test_options_close_gate_lib.py index 1d28bdd..688b00b 100644 --- a/tests/test_options_close_gate_lib.py +++ b/tests/test_options_close_gate_lib.py @@ -1,6 +1,7 @@ -"""期权平仓门控:可回收≥2×权利金且持续持有.""" +"""期权平仓门控:USDT 口径(权利金×倍数 / 净盈亏阈值)且持续持有.""" from __future__ import annotations +import os import unittest from lib.options.options_close_gate_lib import ( @@ -14,43 +15,169 @@ from lib.options.options_close_gate_lib import ( class OptionsCloseGateTests(unittest.TestCase): def setUp(self): clear_close_gate() + self._env_backup = { + k: os.environ.get(k) + for k in ( + "OKX_OPTIONS_CLOSE_GATE_MODE", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", + ) + } + for k in self._env_backup: + os.environ.pop(k, None) def tearDown(self): clear_close_gate() + for k, v in self._env_backup.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v - def test_below_2x_not_ready(self): - g = update_close_gate("ETH-X", recycle_usdc=15.0, premium_paid=10.0, now=1000.0) + def test_usdc_below_2x_not_ready(self): + g = update_close_gate( + "ETH-X", + recycle_usdc=15.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) self.assertFalse(g["recycle_ok"]) self.assertFalse(g["ready"]) + self.assertIn("U(估)", g["msg"]) - def test_meets_2x_needs_hold(self): - g1 = update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) + def test_usdc_meets_2x_needs_hold(self): + g1 = update_close_gate( + "ETH-X", + recycle_usdc=20.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) self.assertTrue(g1["recycle_ok"]) self.assertFalse(g1["ready"]) self.assertAlmostEqual(g1["remain_seconds"], 120.0) - g2 = update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1120.0) + g2 = update_close_gate( + "ETH-X", + recycle_usdc=21.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1120.0, + ) self.assertTrue(g2["ready"]) - self.assertGreaterEqual(g2["held_seconds"], 120.0) + + def test_coin_premium_gate_uses_usdt_and_default_105(self): + # 0.0384 ETH * 2500 = 96U; ×1.05 = 100.8U + g = update_close_gate( + "ETH-P", + recycle_usdc=0.036, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertFalse(g["recycle_ok"]) + self.assertAlmostEqual(g["premium_usdt"], 96.0) + self.assertAlmostEqual(g["need_recycle_usdt"], 100.8) + self.assertIn("U(估)", g["msg"]) + + g_ok = update_close_gate( + "ETH-P", + recycle_usdc=0.041, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertTrue(g_ok["recycle_ok"]) + self.assertAlmostEqual(g_ok["recycle_usdt"], 102.5) + + def test_net_pnl_gate_mode(self): + os.environ["OKX_OPTIONS_CLOSE_GATE_MODE"] = "net_pnl" + os.environ["OKX_OPTIONS_CLOSE_NET_PNL_MIN_U"] = "1" + g = update_close_gate( + "ETH-N", + recycle_usdc=0.039, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertTrue(g["recycle_ok"]) + self.assertAlmostEqual(g["net_pnl_usdt"], 1.5) + + g2 = update_close_gate( + "ETH-N2", + recycle_usdc=0.0385, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertFalse(g2["recycle_ok"]) def test_break_resets_timer(self): - update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) - update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1100.0) - g_break = update_close_gate("ETH-X", recycle_usdc=12.0, premium_paid=10.0, now=1110.0) + update_close_gate( + "ETH-X", + recycle_usdc=20.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) + update_close_gate( + "ETH-X", + recycle_usdc=21.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1100.0, + ) + g_break = update_close_gate( + "ETH-X", + recycle_usdc=12.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1110.0, + ) self.assertFalse(g_break["recycle_ok"]) - g_again = update_close_gate("ETH-X", recycle_usdc=22.0, premium_paid=10.0, now=1111.0) + g_again = update_close_gate( + "ETH-X", + recycle_usdc=22.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1111.0, + ) self.assertTrue(g_again["recycle_ok"]) self.assertFalse(g_again["ready"]) self.assertAlmostEqual(g_again["held_seconds"], 0.0) def test_passed_latches_after_ready(self): - update_close_gate("ETH-Y", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) - g_ready = update_close_gate("ETH-Y", recycle_usdc=21.0, premium_paid=10.0, now=1120.0) + update_close_gate( + "ETH-Y", + recycle_usdc=20.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) + g_ready = update_close_gate( + "ETH-Y", + recycle_usdc=21.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1120.0, + ) self.assertTrue(g_ready["ready"]) self.assertTrue(g_ready["passed"]) self.assertTrue(is_close_gate_passed("ETH-Y")) - # 后续回收跌破 2×:计时重置,但 passed 仍保留供续批只验流动性 - g_drop = update_close_gate("ETH-Y", recycle_usdc=5.0, premium_paid=10.0, now=1130.0) + g_drop = update_close_gate( + "ETH-Y", + recycle_usdc=5.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1130.0, + ) self.assertFalse(g_drop["recycle_ok"]) self.assertTrue(g_drop["passed"]) self.assertFalse(g_drop["auto_close_blocked"])