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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-15 21:13:43 +08:00
parent c025c06fac
commit c2a0cea3f4
7 changed files with 387 additions and 38 deletions
+30 -9
View File
@@ -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 @@
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.auto_close_blocked || closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.auto_close_blocked || closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.auto_close_blocked || closePreview.bid_invalid
? '<span class="muted">禁用以残档自动平</span>'
(closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, p.premium_paid)) + "</span></div>" +
"</div>" +
(closePreview.auto_close_blocked || closePreview.bid_invalid
? '<div class="muted opt-bid-invalid-hint">' + (closePreview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓") + "</div>"
: "") +
(function () {
const hint = closeGateHint(closePreview);
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
})() +
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;
+21 -8
View File
@@ -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 @@
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.auto_close_blocked || closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.auto_close_blocked || closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.auto_close_blocked || closePreview.bid_invalid
? '<span class="muted">禁用以残档自动平</span>'
(closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, p.premium_paid, hub)) + "</span></div>" +
"</div>" +
(closePreview.auto_close_blocked || closePreview.bid_invalid
? '<div class="muted opt-bid-invalid-hint">' + (closePreview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓") + "</div>"
: "") +
(function () {
const hint = closeGateHint(closePreview);
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
})() +
(p.target_index != null
? (function () {
const eth = p.eth_amount != null ? Number(p.eth_amount)
+159
View File
@@ -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"),
)
+24 -1
View File
@@ -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,
+37
View File
@@ -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)
+73 -20
View File
@@ -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:
+43
View File
@@ -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()