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