From 37f787e6a924ccde043f1120cc6879e7b4ec3e2f Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 25 Aug 2026 09:00:39 +0800 Subject: [PATCH] Make options target close gate configurable via env. Support premium multiplier (coin default 1.05) or net-PnL USDT threshold with index conversion. Co-authored-by: Cursor --- .env.example | 7 + lib/common/static/options_panel.js | 2 +- lib/common/static/options_position_cards.js | 2 +- lib/env/env_schema.py | 9 ++ lib/env/env_ui_manifest.py | 30 ++++ lib/options/options_close_exec_lib.py | 34 ++++- lib/options/options_close_gate_lib.py | 146 +++++++++++++++++--- lib/options/options_positions_lib.py | 10 +- 8 files changed, 211 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index e62cf9f..de374b6 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,13 @@ OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_ALLOW_MARKET_CLOSE=false # 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 +# 目标平仓门控(目标位触发后按买一平仓前需满足) +OKX_OPTIONS_CLOSE_GATE_MODE=premium +# premium:可回收≥权利金×倍数;net_pnl:净盈亏(买一可回收−权利金)折USDT≥下限 +OKX_OPTIONS_CLOSE_RECYCLE_MULT=2 +OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05 +OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT=0 +OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 # ============================================================================= # 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 8dd6e68..cba1fab 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -832,7 +832,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 || "目标平仓门控未过"); } return ""; } diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index e1079f4..be407ab 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -118,7 +118,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 || "目标平仓门控未过"); } return ""; } diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 2d2b56d..7645eb9 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -106,6 +106,11 @@ HOT_RELOAD_EXACT = frozenset({ "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "OKX_OPTIONS_COIN_MAX_USDT", "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", + "OKX_OPTIONS_CLOSE_GATE_MODE", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT", + "OKX_OPTIONS_CLOSE_HOLD_SECONDS", "OKX_TRADE_MODE", "MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_LIVE_ORDER", @@ -171,6 +176,10 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = { ("coin", "币本位(USDT买币桥)"), ("usdc", "USDC(USDⓈ权利金)"), ), + "OKX_OPTIONS_CLOSE_GATE_MODE": ( + ("premium", "权利金倍数"), + ("net_pnl", "净盈亏(USDT)"), + ), "HEDGE_PLAN_OPTION_PRIMARY": ( ("true", "以期权为主"), ("false", "保险模式"), diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 18cc3c5..57e12f5 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -150,6 +150,31 @@ _OPTIONS_SECTION: dict[str, Any] = { "链上仅显示有卖一", "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", ), + ( + "OKX_OPTIONS_CLOSE_GATE_MODE", + "目标平仓门控模式", + "premium=可回收≥权利金×倍数(币本位默认×1.05);net_pnl=净盈亏折USDT≥阈值", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT", + "门控权利金倍数(USDC)", + "仅 premium 模式;USDC 权利金仓默认 2", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", + "门控权利金倍数(币本位)", + "仅 premium 模式;ETH/BTC 权利金仓默认 1.05", + ), + ( + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT", + "门控净盈亏下限(U)", + "仅 net_pnl 模式;买一可回收−权利金折 USDT 后需≥此值;0 或 1 等", + ), + ( + "OKX_OPTIONS_CLOSE_HOLD_SECONDS", + "门控持续秒数", + "条件达标后需持续满足的秒数,默认 120", + ), ], } @@ -259,6 +284,11 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "OKX_OPTIONS_MAX_DTE_DAYS": "2", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0", "OKX_OPTIONS_MARGIN_MODE": "coin", + "OKX_OPTIONS_CLOSE_GATE_MODE": "premium", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT": "2", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05", + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT": "0", + "OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120", "OKX_TRADE_MODE": "options", "HEDGE_PLAN_ENABLED": "false", "MAX_ACTIVE_HEDGE_PLANS": "1", diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index edd161f..f37d42b 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -145,6 +145,10 @@ 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")) + from lib.options.options_notify_lib import resolve_premium_ccy + + premium_ccy = resolve_premium_ccy(pos.get("premium_ccy"), inst_id=inst_id) + index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px")) # 已有未成交卖平单:等成交,不撤不重挂 try: @@ -194,7 +198,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 +218,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 +247,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 "暂无有效买盘,禁止平仓", @@ -240,16 +262,18 @@ def close_option_by_bid1( "liquidity_blocked": True, } - # 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性 + # 自动平仓:目标门控(首次);通过后同仓续批只验流动性 gate = update_close_gate( 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 { "ok": False, - "msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续一段时间)", + "msg": gate.get("msg") or "平仓门控未就绪(需满足目标门控并持续一段时间)", "stopped_reason": "close_gate", "auto_close_blocked": True, "close_gate": gate, diff --git a/lib/options/options_close_gate_lib.py b/lib/options/options_close_gate_lib.py index 743eebe..8c4bdad 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,12 +14,32 @@ def _env_float(key: str, default: float) -> float: return default -# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 +def _close_gate_mode() -> str: + raw = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower() + return raw if raw in ("premium", "net_pnl") else "premium" + + +def _close_recycle_mult(premium_ccy: str) -> float: + ccy = (premium_ccy or "USDC").strip().upper() + if ccy in ("ETH", "BTC"): + return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05) + return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) + + +def _close_hold_seconds() -> float: + return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) + + +def _close_net_pnl_min_usdt() -> float: + return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT", 0.0) + + +# 兼容旧引用 CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) _lock = threading.Lock() -# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float} +# inst_id -> gate state _gates: dict[str, dict[str, Any]] = {} @@ -41,7 +61,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 @@ -68,6 +88,30 @@ def _fmt_gate_amt(v: float, *, ccy: str) -> str: return f"{float(v):.4f}" +def _fmt_usdt(v: float) -> str: + sign = "+" if v >= 0 else "" + return f"{sign}{float(v):.2f}" + + +def _net_pnl_usdt( + *, + recv: float | None, + prem: float | None, + premium_ccy: str, + index_px: float | None, +) -> float | None: + if recv is None or prem is None: + return None + net = float(recv) - float(prem) + ccy = (premium_ccy or "USDC").strip().upper() or "USDC" + if ccy in ("ETH", "BTC"): + idx = _safe_float(index_px) + if idx is None or idx <= 0: + return None + return net * float(idx) + return net + + def update_close_gate( inst_id: str, *, @@ -77,9 +121,14 @@ def update_close_gate( min_mult: float | None = None, hold_seconds: float | None = None, premium_ccy: str | None = None, + index_px: float | None = None, + gate_mode: str | None = None, + net_pnl_min_usdt: float | None = None, ) -> dict[str, Any]: """ 根据当前买盘可回收金额刷新门控. + premium: 可回收 ≥ 权利金×倍数(币本位默认×1.05,USDC 默认×2) + net_pnl: 净盈亏(买一可回收−权利金)折 USDT ≥ 阈值(默认 0U) 条件不满足时重置计时;满足时从首次满足起累计持续时间. """ inst = (inst_id or "").strip() @@ -91,15 +140,16 @@ 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) - hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) - if mult <= 0: - mult = 2.0 + mode = (gate_mode or _close_gate_mode()).strip().lower() + if mode not in ("premium", "net_pnl"): + mode = "premium" + hold = float(hold_seconds if hold_seconds is not None else _close_hold_seconds()) if hold < 0: hold = 0.0 with _lock: - prev_ccy = (_gates.get(inst) or {}).get("premium_ccy") + prev = _gates.get(inst) or {} + prev_ccy = prev.get("premium_ccy") ccy = (premium_ccy or prev_ccy or "USDC").strip().upper() or "USDC" if ccy not in ("ETH", "BTC", "USDC"): ccy = "USDC" @@ -107,21 +157,44 @@ def update_close_gate( prem = _safe_float(premium_paid) recv = _safe_float(recycle_usdc) + mult = float(min_mult if min_mult is not None else _close_recycle_mult(ccy)) + if mult <= 0: + mult = _close_recycle_mult(ccy) + min_pnl_u = float(net_pnl_min_usdt if net_pnl_min_usdt is not None else _close_net_pnl_min_usdt()) + idx = _safe_float(index_px) + if idx is None: + idx = _safe_float(prev.get("index_px")) + 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 - ) + net_pnl_u = _net_pnl_usdt(recv=recv, prem=prem, premium_ccy=ccy, index_px=idx) + + if mode == "net_pnl": + if prem is None or prem <= 0: + condition_ok = False + elif recv is None: + condition_ok = False + elif net_pnl_u is None: + condition_ok = False + else: + condition_ok = bool(net_pnl_u + 1e-9 >= min_pnl_u) + else: + condition_ok = bool( + prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need + ) with _lock: prev = _gates.get(inst) or {} + prev_mode = prev.get("gate_mode") ok_since = prev.get("ok_since") - if recycle_ok: + if prev_mode != mode: + ok_since = None + if condition_ok: if ok_since is None: ok_since = ts else: ok_since = None held = (ts - float(ok_since)) if ok_since is not None else 0.0 - ready = bool(recycle_ok and held + 1e-9 >= hold) + ready = bool(condition_ok and held + 1e-9 >= hold) prev_passed = bool(prev.get("passed")) passed = prev_passed or ready state = { @@ -134,15 +207,38 @@ def update_close_gate( "hold_seconds": hold, "passed": passed, "premium_ccy": ccy, + "gate_mode": mode, + "net_pnl_usdt": net_pnl_u, + "net_pnl_min_usdt": min_pnl_u, + "index_px": idx, } _gates[inst] = state - remain = max(0.0, hold - held) if recycle_ok and not ready else None - if prem is None or prem <= 0: + remain = max(0.0, hold - held) if condition_ok and not ready else None + if mode == "net_pnl": + if prem is None or prem <= 0: + msg = "缺少权利金,无法校验平仓门控" + elif recv is None: + msg = "暂无有效买盘可回收金额" + elif net_pnl_u is None: + msg = "缺少指数价,无法将币本位净盈亏折算为 USDT" + elif not condition_ok: + msg = ( + f"净盈亏 {_fmt_usdt(net_pnl_u)} U(估) < {min_pnl_u:g} U," + f"目标平仓门控未过" + ) + elif not ready: + msg = ( + f"净盈亏 {_fmt_usdt(net_pnl_u)} U(估) ≥ {min_pnl_u:g} U," + f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" + ) + else: + msg = f"净盈亏 ≥ {min_pnl_u:g} U 且持续≥{hold:.0f}s,目标触达后可按买一平仓" + elif prem is None or prem <= 0: msg = "缺少权利金,无法校验平仓门控" elif recv is None: msg = "暂无有效买盘可回收金额" - elif not recycle_ok: + elif not condition_ok: msg = ( f"可回收 {_fmt_gate_amt(recv, ccy=ccy)} {ccy} < 权利金×{mult:g}" f"({_fmt_gate_amt(need, ccy=ccy)}),目标平仓门控未过" @@ -160,19 +256,23 @@ def update_close_gate( "ok": True, "ready": ready, "passed": passed, - "recycle_ok": recycle_ok, + "recycle_ok": condition_ok, "recycle_usdc": recv, "premium_paid": prem, "need_recycle_usdc": need, "premium_ccy": ccy, "min_mult": mult, "hold_seconds": hold, - "held_seconds": round(held, 1) if recycle_ok else 0.0, + "held_seconds": round(held, 1) if condition_ok else 0.0, "remain_seconds": round(remain, 1) if remain is not None else None, "ok_since": ok_since, "msg": msg, "auto_close_blocked": auto_blocked, "close_gate_blocked": auto_blocked, + "gate_mode": mode, + "net_pnl_usdt": net_pnl_u, + "net_pnl_min_usdt": min_pnl_u, + "index_px": idx, } @@ -182,12 +282,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 +297,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 +314,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 6081933..e97a762 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") or row.get("index_px")) + # 残档时不累计门控;有效买一时刷新计时(仅自动平仓需要) 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