"""期权按买盘平仓门控:可回收/净盈亏换算为 USDT 后校验,并持续 hold 秒才允许平仓.""" 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 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]] = {} 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 _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: _gates.pop(str(inst_id).strip(), None) else: _gates.clear() def mark_close_gate_passed(inst_id: str) -> None: """标记同仓已通过门控,续批平仓只验流动性.""" inst = (inst_id or "").strip() if not inst: return with _lock: st = _gates.get(inst) or {} st["passed"] = True st["updated"] = time.time() _gates[inst] = st def is_close_gate_passed(inst_id: str) -> bool: inst = (inst_id or "").strip() if not inst: return False with _lock: return bool((_gates.get(inst) or {}).get("passed")) 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, 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: return { "ok": False, "ready": False, "recycle_ok": False, "msg": "缺少合约", } ts = float(now if now is not None else time.time()) mode = close_gate_mode() hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) if hold < 0: hold = 0.0 with _lock: prev_ccy = (_gates.get(inst) or {}).get("premium_ccy") 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) 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 {} 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) prev_passed = bool(prev.get("passed")) passed = prev_passed or ready state = { "ok_since": ok_since, "recycle": recv, "premium": prem, "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 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 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_usdt(recv_u)}U(估) < 权利金×{mult:g}" f"({_fmt_usdt(need_u)}U),目标平仓门控未过" ) elif not ready: msg = ( 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"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}且持续≥{hold:.0f}s," f"目标触达后可按买一平仓" ) auto_blocked = not (ready or passed) return { "ok": True, "ready": ready, "passed": passed, "recycle_ok": recycle_ok, "recycle_usdc": recv, "premium_paid": prem, "need_recycle_usdc": need_u, "premium_ccy": ccy, "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, "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, } def check_close_gate( inst_id: str, *, 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 or index_px 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") 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) if not prev: return update_close_gate( inst, 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"), )