From c2a0cea3f4997b1a99cfb96aee1895a14e08415e Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 15 Jul 2026 21:13:43 +0800 Subject: [PATCH] Gate option closes behind 2x recycle sustained for 2 minutes. Require bid-side recoverable premium at least 2x cost continuously before target auto-close or depth close can fire. Co-authored-by: Cursor --- lib/common/static/options_panel.js | 39 +++-- lib/common/static/options_position_cards.js | 29 +++- lib/options/options_close_gate_lib.py | 159 ++++++++++++++++++++ lib/options/options_positions_lib.py | 25 ++- lib/options/options_register.py | 37 +++++ lib/options/options_target_lib.py | 93 +++++++++--- tests/test_options_close_gate_lib.py | 43 ++++++ 7 files changed, 387 insertions(+), 38 deletions(-) create mode 100644 lib/options/options_close_gate_lib.py create mode 100644 tests/test_options_close_gate_lib.py diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index e9a3bc2..b6a321c 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -374,7 +374,7 @@ /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ function fmtCloseLevels(preview, tickSz) { - if (preview && (preview.auto_close_blocked || preview.bid_invalid)) { + if (preview && preview.bid_invalid) { return "暂无有效买盘"; } const levels = ((preview && preview.levels) || []).slice(0, 5); @@ -392,6 +392,18 @@ }).join(" · "); } + function closeGateHint(preview) { + if (!preview) return ""; + if (preview.bid_invalid) { + return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓"; + } + const gate = preview.close_gate || {}; + if (preview.close_gate_blocked || gate.ready === false) { + return preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟后才可平仓"; + } + return ""; + } + function netPnlFromPos(p) { const preview = (p && p.close_preview) || {}; if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { @@ -888,18 +900,19 @@ '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
净盈亏' + - (closePreview.auto_close_blocked || closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
" + + (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "" + '
收益率' + - (closePreview.auto_close_blocked || closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
" + + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + '
按买盘回收' + - (closePreview.auto_close_blocked || closePreview.bid_invalid - ? '禁用以残档自动平' + (closePreview.bid_invalid + ? '暂无有效买盘' : fmtClosePreview(closePreview, p.premium_paid)) + "
" + "" + - (closePreview.auto_close_blocked || closePreview.bid_invalid - ? '
' + (closePreview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓") + "
" - : "") + + (function () { + const hint = closeGateHint(closePreview); + return hint ? '
' + hint + "
" : ""; + })() + renderTargetDelegateRow(p) ); } @@ -1193,10 +1206,18 @@ return; } const preview = q.close_preview || {}; - if (preview.auto_close_blocked || preview.bid_invalid) { + if (preview.bid_invalid) { alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止按买盘自动平仓。请到 OKX App 自行挂限价/市价。"); return; } + if (preview.close_gate_blocked || (preview.close_gate && preview.close_gate.ready === false)) { + alert( + preview.close_gate_msg || + (preview.close_gate && preview.close_gate.msg) || + "可回收需≥2×权利金,并持续满2分钟后才可平仓" + ); + return; + } if (!preview.covered_sheets || preview.covered_sheets <= 0) { alert("暂无可用买盘深度,请稍后在 OKX App 平仓或等盘口恢复"); return; diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index e8408f9..b5609b9 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -52,7 +52,7 @@ /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ function fmtCloseLevels(preview, tickSz) { - if (preview && (preview.auto_close_blocked || preview.bid_invalid)) { + if (preview && preview.bid_invalid) { return "暂无有效买盘"; } const levels = ((preview && preview.levels) || []).slice(0, 5); @@ -64,6 +64,18 @@ }).join(" · "); } + function closeGateHint(preview) { + if (!preview) return ""; + if (preview.bid_invalid) { + return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓"; + } + const gate = preview.close_gate || {}; + if (preview.close_gate_blocked || gate.ready === false) { + return preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟后才可平仓"; + } + return ""; + } + function netPnlFromPos(p) { const preview = (p && p.close_preview) || {}; if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { @@ -150,18 +162,19 @@ '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
净盈亏' + - (closePreview.auto_close_blocked || closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
" + + (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "" + '
收益率' + - (closePreview.auto_close_blocked || closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
" + + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + '
按买盘回收' + - (closePreview.auto_close_blocked || closePreview.bid_invalid - ? '禁用以残档自动平' + (closePreview.bid_invalid + ? '暂无有效买盘' : fmtClosePreview(closePreview, p.premium_paid, hub)) + "
" + "" + - (closePreview.auto_close_blocked || closePreview.bid_invalid - ? '
' + (closePreview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓") + "
" - : "") + + (function () { + const hint = closeGateHint(closePreview); + return hint ? '
' + hint + "
" : ""; + })() + (p.target_index != null ? (function () { const eth = p.eth_amount != null ? Number(p.eth_amount) diff --git a/lib/options/options_close_gate_lib.py b/lib/options/options_close_gate_lib.py new file mode 100644 index 0000000..bb75fee --- /dev/null +++ b/lib/options/options_close_gate_lib.py @@ -0,0 +1,159 @@ +"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓.""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 +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} +_gates: dict[str, dict[str, Any]] = {} + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def clear_close_gate(inst_id: str | None = None) -> None: + with _lock: + if inst_id: + _gates.pop(str(inst_id).strip(), None) + else: + _gates.clear() + + +def update_close_gate( + inst_id: str, + *, + recycle_usdc: float | None, + premium_paid: float | None, + now: float | None = None, + min_mult: float | None = None, + hold_seconds: float | None = None, +) -> dict[str, Any]: + """ + 根据当前买盘可回收金额刷新门控. + 条件不满足时重置计时;满足时从首次满足起累计持续时间. + """ + inst = (inst_id or "").strip() + if not inst: + return { + "ok": False, + "ready": False, + "recycle_ok": False, + "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 + if hold < 0: + hold = 0.0 + + prem = _safe_float(premium_paid) + recv = _safe_float(recycle_usdc) + need = round(prem * mult, 4) 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 + ) + + with _lock: + prev = _gates.get(inst) or {} + ok_since = prev.get("ok_since") + if recycle_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) + state = { + "ok_since": ok_since, + "recycle": recv, + "premium": prem, + "need": need, + "updated": ts, + "min_mult": mult, + "hold_seconds": hold, + } + _gates[inst] = state + + remain = max(0.0, hold - held) if recycle_ok and not ready else None + if prem is None or prem <= 0: + msg = "缺少权利金,无法校验平仓门控" + elif recv is None: + msg = "暂无有效买盘可回收金额" + elif not recycle_ok: + msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),暂不可平仓" + elif not ready: + msg = ( + f"可回收已达×{mult:g}({recv:.4f}/{need:.4f})," + f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)" + ) + else: + msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,允许按买盘平仓" + + return { + "ok": True, + "ready": ready, + "recycle_ok": recycle_ok, + "recycle_usdc": recv, + "premium_paid": prem, + "need_recycle_usdc": need, + "min_mult": mult, + "hold_seconds": hold, + "held_seconds": round(held, 1) if recycle_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": not ready, + "close_gate_blocked": not ready, + } + + +def check_close_gate( + inst_id: str, + *, + recycle_usdc: float | None = None, + premium_paid: 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: + with _lock: + prev = _gates.get(inst) or {} + if recycle_usdc is None: + recycle_usdc = prev.get("recycle") + if premium_paid is None: + premium_paid = prev.get("premium") + return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) + with _lock: + prev = _gates.get(inst) + if not prev: + return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) + 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"), + ) diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index 5c05d78..dd1e374 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -5,6 +5,7 @@ from typing import Any from lib.options.options_db import init_options_tables from lib.options.options_history_lib import enrich_position_row_display +from lib.options.options_close_gate_lib import clear_close_gate, update_close_gate from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit @@ -40,7 +41,7 @@ def attach_close_preview( _safe_float(row.get("strike") or row.get("stk")), _safe_float(row.get("idx_px") or row.get("idxPx")), ) - row["close_preview"] = estimate_close_by_bids( + preview = estimate_close_by_bids( row["bid_depth"], target_sheets, ct_mult=ct_mult, @@ -48,9 +49,31 @@ def attach_close_preview( mark_px=mark_px, intrinsic_px=intrinsic, ) + # 残档时不累计 2×权利金门控;有效回收时刷新持续计时 + if preview.get("bid_invalid") or preview.get("auto_close_blocked"): + gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid) + preview["close_gate"] = gate + preview["close_gate_blocked"] = True + preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg") + else: + gate = update_close_gate( + inst_id, + recycle_usdc=_safe_float(preview.get("total_received")), + premium_paid=paid, + ) + preview["close_gate"] = gate + preview["close_gate_blocked"] = not gate.get("ready") + preview["close_gate_msg"] = gate.get("msg") + if not gate.get("ready"): + preview["auto_close_blocked"] = True + row["close_preview"] = preview return row +def forget_close_gate_for_inst(inst_id: str) -> None: + clear_close_gate(inst_id) + + def build_display_option_positions( cfg: dict[str, Any], ex: Any, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 390e704..9f56b3c 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -11,6 +11,7 @@ from jinja2 import ChoiceLoader, FileSystemLoader from lib.options.options_db import init_options_tables from lib.options.options_monitor_lib import options_monitor_loop +from lib.options.options_close_gate_lib import clear_close_gate, update_close_gate from lib.options.options_pricing_lib import ( calc_order_size, close_ref_prices, @@ -731,6 +732,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: usable0, stub_only0, stub_reason0 = filter_bids_for_close( book0.get("bids") or [], mark_px=mark_px, intrinsic_px=intrinsic_px ) + paid = _open_premium_paid(cfg, inst_id) if stub_only0 or not usable0: bid_chk = None if book0.get("bids"): @@ -738,6 +740,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: bid_chk = bid_chk or _safe_float(bid) stub, stub_reason = is_stub_bid_px(bid_chk, mark_px=mark_px, intrinsic_px=intrinsic_px) if stub or stub_only0: + update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid) return jsonify( { "ok": False, @@ -746,6 +749,39 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "auto_close_blocked": True, } ) + preview_gate = estimate_close_by_bids( + book0.get("bids") or [], + close_sheets, + ct_mult=ct_mult, + premium_paid=paid, + mark_px=mark_px, + intrinsic_px=intrinsic_px, + ) + if preview_gate.get("bid_invalid"): + update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid) + return jsonify( + { + "ok": False, + "msg": preview_gate.get("bid_invalid_reason") or "暂无有效买盘,禁止按买盘自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } + ) + gate = update_close_gate( + inst_id, + recycle_usdc=_safe_float(preview_gate.get("total_received")), + premium_paid=paid, + ) + if not gate.get("ready"): + return jsonify( + { + "ok": False, + "msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续2分钟)", + "stopped_reason": "close_gate", + "auto_close_blocked": True, + "close_gate": gate, + } + ) remaining = close_sheets submitted_sheets = 0 filled_or_reduced_sheets = 0 @@ -826,6 +862,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: bid = (total_received / (submitted_sheets * ct_mult)) if submitted_sheets > 0 and ct_mult > 0 else 0 prem_recv = round(total_received, 4) fully_submitted = submitted_sheets >= close_sheets and stopped_reason is None + clear_close_gate(inst_id) conn = cfg["get_db"]() try: init_options_tables(conn) diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index b9cced2..1bc7322 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -6,6 +6,7 @@ import time from typing import Any, Callable from lib.options.options_db import init_options_tables +from lib.options.options_close_gate_lib import update_close_gate from lib.options.options_pricing_lib import ( close_ref_prices, estimate_close_by_bids, @@ -333,28 +334,74 @@ def close_option_by_bid_depth( except Exception: pass - # 残档买盘:禁止自动按买盘平仓(并撤掉可能已挂的异常低价卖单) + # 残档买盘 / 回收未达 2×权利金持续门槛:禁止自动按买盘平仓 + premium_paid = None try: - book0 = cfg["fetch_option_book_depth"](ex, inst_id, 5) - usable0, stub_only0, stub_reason0 = filter_bids_for_close( - book0.get("bids") or [], mark_px=mark_px, intrinsic_px=intrinsic_px - ) - raw_bid0 = None - if book0.get("bids"): - raw_bid0 = _safe_float((book0.get("bids") or [{}])[0].get("px")) - if not usable0: - bid_chk = raw_bid0 or _safe_float(q.get("bid")) - stub, stub_reason = is_stub_bid_px(bid_chk, mark_px=mark_px, intrinsic_px=intrinsic_px) - if stub or stub_only0: - _cancel_sell_pending() - return { - "ok": False, - "msg": stub_reason0 or stub_reason or "暂无有效买盘,禁止自动平仓", - "stopped_reason": "stub_bid", - "auto_close_blocked": True, - } + conn_p = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn_p) + prow = conn_p.execute( + "SELECT premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1", + (inst_id,), + ).fetchone() + if prow and prow["premium_paid"] is not None: + premium_paid = float(prow["premium_paid"]) + finally: + conn_p.close() except Exception: - pass + premium_paid = _safe_float(pos.get("premium_paid")) + + book0 = cfg["fetch_option_book_depth"](ex, inst_id, 5) + usable0, stub_only0, stub_reason0 = filter_bids_for_close( + book0.get("bids") or [], mark_px=mark_px, intrinsic_px=intrinsic_px + ) + raw_bid0 = None + if book0.get("bids"): + raw_bid0 = _safe_float((book0.get("bids") or [{}])[0].get("px")) + if not usable0: + bid_chk = raw_bid0 or _safe_float(q.get("bid")) + stub, stub_reason = is_stub_bid_px(bid_chk, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub or stub_only0: + _cancel_sell_pending() + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": stub_reason0 or stub_reason or "暂无有效买盘,禁止自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } + preview0 = estimate_close_by_bids( + book0.get("bids") or [], + close_sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + mark_px=mark_px, + intrinsic_px=intrinsic_px, + ) + if preview0.get("bid_invalid"): + _cancel_sell_pending() + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": preview0.get("bid_invalid_reason") or "暂无有效买盘,禁止自动平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + } + gate0 = update_close_gate( + inst_id, + recycle_usdc=_safe_float(preview0.get("total_received")), + premium_paid=premium_paid, + ) + if not gate0.get("ready"): + return { + "ok": False, + "msg": gate0.get("msg") or "平仓门控未就绪", + "stopped_reason": "close_gate", + "auto_close_blocked": True, + "close_gate": gate0, + } # 已有未成交卖平单时先等成交,避免每轮撤单重挂反复推送/吃档 try: @@ -372,6 +419,9 @@ def close_option_by_bid_depth( return {"ok": False, "msg": "获取期权持仓失败"} pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) if not pos or _avail(pos) < 1: + from lib.options.options_close_gate_lib import clear_close_gate + + clear_close_gate(inst_id) return { "ok": True, "already_flat": True, @@ -564,6 +614,9 @@ def close_option_by_bid_depth( prem_recv = round(total_received, 4) fully_submitted = submitted_sheets >= close_sheets and stopped_reason is None close_ord_id = ",".join(ord_ids) if ord_ids else None + from lib.options.options_close_gate_lib import clear_close_gate + + clear_close_gate(inst_id) conn = cfg["get_db"]() try: diff --git a/tests/test_options_close_gate_lib.py b/tests/test_options_close_gate_lib.py new file mode 100644 index 0000000..4ebba33 --- /dev/null +++ b/tests/test_options_close_gate_lib.py @@ -0,0 +1,43 @@ +"""期权平仓门控:可回收≥2×权利金且持续持有.""" +from __future__ import annotations + +import unittest + +from lib.options.options_close_gate_lib import clear_close_gate, update_close_gate + + +class OptionsCloseGateTests(unittest.TestCase): + def setUp(self): + clear_close_gate() + + def tearDown(self): + clear_close_gate() + + def test_below_2x_not_ready(self): + g = update_close_gate("ETH-X", recycle_usdc=15.0, premium_paid=10.0, now=1000.0) + self.assertFalse(g["recycle_ok"]) + self.assertFalse(g["ready"]) + + def test_meets_2x_needs_hold(self): + g1 = update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, 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) + self.assertTrue(g2["ready"]) + self.assertGreaterEqual(g2["held_seconds"], 120.0) + + 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) + self.assertFalse(g_break["recycle_ok"]) + g_again = update_close_gate("ETH-X", recycle_usdc=22.0, premium_paid=10.0, now=1111.0) + self.assertTrue(g_again["recycle_ok"]) + self.assertFalse(g_again["ready"]) + self.assertAlmostEqual(g_again["held_seconds"], 0.0) + + +if __name__ == "__main__": + unittest.main()