diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py
new file mode 100644
index 0000000..4c96f0d
--- /dev/null
+++ b/lib/options/options_close_exec_lib.py
@@ -0,0 +1,371 @@
+"""期权平仓执行:只锁买一限价卖出;永不市价."""
+from __future__ import annotations
+
+import time
+from typing import Any
+
+from lib.options.options_close_gate_lib import (
+ clear_close_gate,
+ is_close_gate_passed,
+ mark_close_gate_passed,
+ update_close_gate,
+)
+from lib.options.options_pricing_lib import (
+ estimate_close_by_bids,
+ fetch_option_mark_px,
+ is_stub_bid_px,
+ total_premium,
+)
+
+
+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 _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
+ try:
+ conn = cfg["get_db"]()
+ try:
+ from lib.options.options_db import init_options_tables
+
+ init_options_tables(conn)
+ row = conn.execute(
+ "SELECT premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
+ (inst_id,),
+ ).fetchone()
+ if row and row["premium_paid"] is not None:
+ return float(row["premium_paid"])
+ finally:
+ conn.close()
+ except Exception:
+ pass
+ return None
+
+
+def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
+ from lib.exchange.okx_options_lib import option_fields_from_inst_id
+ from lib.options.options_pricing_lib import close_ref_prices
+
+ inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
+ mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
+ if mark is None:
+ mark = fetch_option_mark_px(ex, inst_id)
+ opt_type = pos.get("optType") or (quote or {}).get("opt_type")
+ strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
+ if not opt_type or strike is None:
+ pt, ps = option_fields_from_inst_id(inst_id)
+ opt_type = opt_type or pt
+ if strike is None:
+ strike = ps
+ idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
+ return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
+
+
+def _avail_sheets(pos: dict[str, Any]) -> int:
+ avail = _safe_float(pos.get("availPos"))
+ if avail is None or avail <= 0:
+ avail = abs(_safe_float(pos.get("pos")) or 0)
+ return max(0, int(avail or 0))
+
+
+def _cancel_sell_pending(ex: Any, inst_id: str) -> None:
+ try:
+ pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
+ for o in pending.get("data") or []:
+ if str(o.get("side") or "").lower() != "sell":
+ continue
+ oid = o.get("ordId")
+ if not oid:
+ continue
+ try:
+ ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid})
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+
+def close_option_by_bid1(
+ cfg: dict[str, Any],
+ ex: Any,
+ inst_id: str,
+ *,
+ sheets: int | None = None,
+ require_recycle_gate: bool = False,
+ signal_note: str | None = None,
+) -> dict[str, Any]:
+ """
+ 本轮只吃买一深度:
+ - 本批张数 = min(请求张数, 持仓, 买一深度)
+ - 限价 = 校验通过时锁定的买一价
+ - 永不市价
+ - 始终校验有效流动性(残档买一禁止)
+ - require_recycle_gate=True 时:首次还需可回收≥2×权利金并持续 hold 秒;
+ 一旦通过后对同仓续批只验流动性
+ """
+ from lib.exchange.okx_options_lib import (
+ _pos_side_from_position,
+ invalidate_option_positions_cache,
+ )
+
+ inst_id = (inst_id or "").strip()
+ if not inst_id:
+ return {"ok": False, "msg": "缺少 inst_id"}
+
+ q = cfg["quote_option_contract"](ex, inst_id)
+ if not q.get("ok"):
+ return {"ok": False, "msg": q.get("msg") or "报价失败"}
+ tick_sz = q.get("tick_sz")
+ ct_mult = float(q.get("ct_mult") or 0.01)
+
+ raw_positions = cfg["fetch_option_positions"](ex)
+ if raw_positions is None:
+ return {"ok": False, "msg": "获取期权持仓失败"}
+ pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
+ if not pos:
+ clear_close_gate(inst_id)
+ return {"ok": False, "msg": "未找到持仓", "already_flat": True}
+
+ avail = _avail_sheets(pos)
+ want = int(sheets) if sheets else avail
+ want = min(want, avail)
+ if want < 1:
+ clear_close_gate(inst_id)
+ return {"ok": False, "msg": "可平张数不足", "already_flat": True}
+
+ td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated")
+ pos_side = _pos_side_from_position(pos) or "net"
+ mark_px, intrinsic_px = _pos_close_refs(ex, pos, q)
+ premium_paid = _open_premium_paid(cfg, inst_id)
+ if premium_paid is None:
+ premium_paid = _safe_float(pos.get("premium_paid"))
+
+ # 已有未成交卖平单:等成交,不撤不重挂
+ try:
+ pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
+ sell_pending = [
+ o
+ for o in (pending.get("data") or [])
+ if str(o.get("side") or "").lower() == "sell" and o.get("ordId")
+ ]
+ if sell_pending:
+ time.sleep(0.5)
+ invalidate_option_positions_cache()
+ raw_positions = cfg["fetch_option_positions"](ex)
+ if raw_positions is None:
+ 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_sheets(pos) < 1:
+ clear_close_gate(inst_id)
+ return {
+ "ok": True,
+ "already_flat": True,
+ "msg": "已有限价卖单成交",
+ "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
+ "fully_closed": True,
+ "submitted_sheets": want,
+ "remaining_sheets": 0,
+ "mode": "bid1",
+ }
+ return {
+ "ok": False,
+ "msg": "等待已有买一限价卖单成交",
+ "stopped_reason": "pending_close_order",
+ "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
+ }
+ except Exception:
+ pass
+
+ book = cfg["fetch_option_book_depth"](ex, inst_id, 1)
+ preview = estimate_close_by_bids(
+ book.get("bids") or [],
+ want,
+ ct_mult=ct_mult,
+ premium_paid=premium_paid,
+ mark_px=mark_px,
+ intrinsic_px=intrinsic_px,
+ max_levels=1,
+ )
+ if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
+ _cancel_sell_pending(ex, inst_id)
+ update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
+ return {
+ "ok": False,
+ "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
+ "stopped_reason": "stub_bid",
+ "auto_close_blocked": True,
+ "liquidity_blocked": True,
+ }
+
+ levels = preview.get("levels") or []
+ if not levels:
+ 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)
+ return {
+ "ok": False,
+ "msg": stub_reason or "暂无买一,无法限价平仓",
+ "stopped_reason": "stub_bid" if stub else "no_bid",
+ "auto_close_blocked": True,
+ "liquidity_blocked": True,
+ }
+ return {
+ "ok": False,
+ "msg": "暂无买一深度,无法平仓",
+ "stopped_reason": "no_bid_depth",
+ "liquidity_blocked": True,
+ }
+
+ level = levels[0]
+ level_sheets = int(level.get("sheets") or 0)
+ level_px = float(level.get("px") or 0)
+ if level_sheets <= 0 or level_px <= 0:
+ return {"ok": False, "msg": "买一深度无效", "stopped_reason": "invalid_bid_depth"}
+
+ 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)
+ return {
+ "ok": False,
+ "msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
+ "stopped_reason": "stub_bid",
+ "auto_close_blocked": True,
+ "liquidity_blocked": True,
+ }
+
+ # 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性
+ gate = update_close_gate(
+ inst_id,
+ recycle_usdc=_safe_float(preview.get("total_received")),
+ premium_paid=premium_paid,
+ )
+ 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×权利金并持续一段时间)",
+ "stopped_reason": "close_gate",
+ "auto_close_blocked": True,
+ "close_gate": gate,
+ }
+ if gate.get("ready"):
+ mark_close_gate_passed(inst_id)
+
+ locked_bid_px = level_px
+ before_avail = avail
+ order = cfg["place_option_limit_order"](
+ ex,
+ inst_id=inst_id,
+ side="sell",
+ sheets=level_sheets,
+ price=locked_bid_px,
+ td_mode=td_mode,
+ tick_sz=tick_sz,
+ reduce_only=True,
+ pos_side=pos_side,
+ )
+ if not order.get("ok"):
+ return {
+ "ok": False,
+ "msg": order.get("msg") or "买一限价平仓失败",
+ "stopped_reason": "order_failed",
+ "locked_bid_px": locked_bid_px,
+ "batch_sheets": level_sheets,
+ }
+
+ px = float(order.get("px", locked_bid_px))
+ oid = str((order.get("data") or {}).get("ordId") or "")
+ prem_recv = round(total_premium(px, level_sheets * ct_mult), 4)
+ time.sleep(0.6)
+ invalidate_option_positions_cache()
+ raw2 = cfg["fetch_option_positions"](ex)
+ after_avail = 0
+ if raw2 is not None:
+ after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
+ after_avail = _avail_sheets(after_pos) if after_pos else 0
+ reduced = max(0, before_avail - after_avail) if raw2 is not None else 0
+ remaining_pos = after_avail if raw2 is not None else max(0, before_avail - level_sheets)
+ fully_closed = remaining_pos < 1
+
+ if fully_closed:
+ clear_close_gate(inst_id)
+ conn = cfg["get_db"]()
+ try:
+ from lib.options.options_db import init_options_tables
+
+ init_options_tables(conn)
+ row = conn.execute(
+ "SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
+ (inst_id,),
+ ).fetchone()
+ if row:
+ paid = float(row["premium_paid"] or 0)
+ pnl = prem_recv - paid
+ note_sql = ""
+ params: list[Any] = [px, prem_recv, pnl, oid or None]
+ if signal_note:
+ note_sql = """,
+ signal_note = CASE
+ WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN ?
+ ELSE signal_note
+ END"""
+ params.append(signal_note)
+ params.append(int(row["id"]))
+ conn.execute(
+ f"""
+ UPDATE options_trades
+ SET status = 'closed', close_quote = ?, premium_received = ?,
+ realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
+ {note_sql}
+ WHERE id = ?
+ """,
+ tuple(params),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ elif require_recycle_gate:
+ # 自动平已挂过单:同仓续批只验流动性
+ mark_close_gate_passed(inst_id)
+
+ return {
+ "ok": True,
+ "mode": "bid1",
+ "orders": [{"order": order, "px": px, "sheets": level_sheets}],
+ "bid": px,
+ "locked_bid_px": locked_bid_px,
+ "submitted_sheets": level_sheets,
+ "filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0,
+ "remaining_sheets": remaining_pos,
+ "premium_received": prem_recv,
+ "stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
+ "close_ord_id": oid or None,
+ "fully_closed": fully_closed,
+ "msg": (
+ f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
+ + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
+ ),
+ }
+
+
+# 兼容旧名
+def close_option_by_bid_depth(
+ cfg: dict[str, Any],
+ ex: Any,
+ inst_id: str,
+ *,
+ sheets: int | None = None,
+) -> dict[str, Any]:
+ return close_option_by_bid1(
+ cfg,
+ ex,
+ inst_id,
+ sheets=sheets,
+ require_recycle_gate=True,
+ signal_note="目标位平仓",
+ )
diff --git a/lib/options/options_close_gate_lib.py b/lib/options/options_close_gate_lib.py
index bb75fee..4d3d1ab 100644
--- a/lib/options/options_close_gate_lib.py
+++ b/lib/options/options_close_gate_lib.py
@@ -40,6 +40,26 @@ def clear_close_gate(inst_id: str | None = None) -> None:
_gates.clear()
+def mark_close_gate_passed(inst_id: str) -> None:
+ """标记同仓已通过 2× 门控,续批平仓只验流动性."""
+ 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,
*,
@@ -86,6 +106,8 @@ def update_close_gate(
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,
@@ -94,6 +116,7 @@ def update_close_gate(
"updated": ts,
"min_mult": mult,
"hold_seconds": hold,
+ "passed": passed,
}
_gates[inst] = state
@@ -103,18 +126,20 @@ def update_close_gate(
elif recv is None:
msg = "暂无有效买盘可回收金额"
elif not recycle_ok:
- msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),暂不可平仓"
+ 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)"
+ f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)后才可自动平仓"
)
else:
- msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,允许按买盘平仓"
+ msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,允许自动按买一平仓"
+ auto_blocked = not (ready or passed)
return {
"ok": True,
"ready": ready,
+ "passed": passed,
"recycle_ok": recycle_ok,
"recycle_usdc": recv,
"premium_paid": prem,
@@ -125,8 +150,8 @@ def update_close_gate(
"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,
+ "auto_close_blocked": auto_blocked,
+ "close_gate_blocked": auto_blocked,
}
diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py
index dd1e374..cf84f9a 100644
--- a/lib/options/options_positions_lib.py
+++ b/lib/options/options_positions_lib.py
@@ -5,7 +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_close_gate_lib import clear_close_gate, is_close_gate_passed, update_close_gate
from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit
@@ -41,6 +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")),
)
+ # 与实盘一致:只按买一估算本轮可平
preview = estimate_close_by_bids(
row["bid_depth"],
target_sheets,
@@ -48,23 +49,29 @@ def attach_close_preview(
premium_paid=paid,
mark_px=mark_px,
intrinsic_px=intrinsic,
+ max_levels=1,
)
- # 残档时不累计 2×权利金门控;有效回收时刷新持续计时
+ # 残档时不累计 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")
+ preview["manual_close_blocked"] = True
+ preview["liquidity_ok"] = False
else:
gate = update_close_gate(
inst_id,
recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid,
)
+ passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
preview["close_gate"] = gate
- preview["close_gate_blocked"] = not gate.get("ready")
+ preview["close_gate_blocked"] = not passed
preview["close_gate_msg"] = gate.get("msg")
- if not gate.get("ready"):
+ preview["manual_close_blocked"] = False
+ preview["liquidity_ok"] = True
+ if not passed:
preview["auto_close_blocked"] = True
row["close_preview"] = preview
return row
diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py
index 8022781..b86117d 100644
--- a/lib/options/options_pricing_lib.py
+++ b/lib/options/options_pricing_lib.py
@@ -166,12 +166,14 @@ def estimate_close_by_bids(
mark_px: float | None = None,
intrinsic_px: float | None = None,
min_bid_ratio: float = BID_CLOSE_MIN_RATIO,
+ max_levels: int = 1,
) -> dict[str, Any]:
- """按买一到买N逐档估算限价卖出可收回金额;残档买盘不参与估算与自动平仓."""
+ """按买盘估算限价卖出可收回金额;默认只估算买一(与实盘平仓一致);残档不参与."""
target = max(0, int(float(sheets or 0)))
remaining = target
total_received = 0.0
levels: list[dict[str, Any]] = []
+ max_lv = max(1, int(max_levels or 1))
empty = {
"levels": [],
"covered_sheets": 0,
@@ -183,6 +185,7 @@ def estimate_close_by_bids(
"bid_invalid": False,
"bid_invalid_reason": None,
"auto_close_blocked": False,
+ "max_levels": max_lv,
}
if target <= 0 or ct_mult <= 0:
return empty
@@ -196,7 +199,7 @@ def estimate_close_by_bids(
out["auto_close_blocked"] = True
out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None
return out
- for i, level in enumerate(usable, start=1):
+ for i, level in enumerate(usable[:max_lv], start=1):
if remaining <= 0:
break
try:
@@ -223,7 +226,7 @@ def estimate_close_by_bids(
remaining -= take
covered = target - remaining
avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None
- # 净盈亏 = 按买盘可回收 − 全部权利金(与「可落袋」口径一致;买一不够会展开更多档)
+ # 净盈亏 = 本轮买盘可回收 − 全部权利金(买一不够时剩余张数计入 uncovered)
estimated_pnl = None
estimated_pnl_ratio_pct = None
if premium_paid is not None and covered > 0:
@@ -242,6 +245,7 @@ def estimate_close_by_bids(
"bid_invalid": False,
"bid_invalid_reason": None,
"auto_close_blocked": False,
+ "max_levels": max_lv,
}
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index b29ba95..6ba216c 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -1,1337 +1,1083 @@
-"""OKX 期权模块:Flask 路由注册."""
-from __future__ import annotations
-
-import os
-import threading
-import time
-from typing import Any
-
-from flask import Flask, jsonify, redirect, request, url_for
-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,
- ct_mult_from_meta,
- estimate_close_by_bids,
- fetch_option_mark_px,
- filter_bids_for_close,
- is_stub_bid_px,
- min_sz_from_meta,
- premium_per_sheet,
- total_premium,
-)
-from lib.exchange.okx_options_lib import _pos_side_from_position, _safe_float, td_mode_for_option_buy
-
-
-def _env_bool(key: str, default: bool = False) -> bool:
- raw = (os.getenv(key) or "").strip().lower()
- if not raw:
- return default
- return raw in ("1", "true", "yes", "on")
-
-
-def _env_float(key: str, default: float) -> float:
- try:
- return float(os.getenv(key, str(default)))
- except (TypeError, ValueError):
- return default
-
-
-def attach_options_templates(app: Flask, repo_root: str) -> None:
- tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
- if not os.path.isdir(tpl_dir):
- return
- existing = app.jinja_loader
- loaders = [FileSystemLoader(tpl_dir)]
- if existing is not None:
- if isinstance(existing, ChoiceLoader):
- loaders = list(existing.loaders) + loaders
- else:
- loaders.insert(0, existing)
- app.jinja_loader = ChoiceLoader(loaders)
-
-
-def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None:
- enabled = _env_bool("OKX_OPTIONS_ENABLED", False)
- attach_options_templates(app, repo_root)
- cfg = _build_cfg(app_module)
- app.extensions["options_cfg"] = cfg
- register_options_routes(app, cfg)
- _register_options_hub_bridge(app, cfg)
- if enabled:
- _start_monitor_thread(app, cfg)
-
-
-def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None:
- from lib.options.options_hub_lib import build_options_hub_snapshot
-
- def snapshot_fn():
- return build_options_hub_snapshot(cfg)
-
- hub_ctx = dict(app.config.get("HUB_CTX") or {})
- hub_ctx["options_snapshot_fn"] = snapshot_fn
- app.config["HUB_CTX"] = hub_ctx
-
-
-def _build_cfg(app_module: Any) -> dict[str, Any]:
- from lib.exchange.okx_options_lib import (
- build_option_chain,
- estimate_usdt_to_usdc,
- execute_convert,
- fetch_option_book_depth,
- fetch_option_positions,
- fetch_options_balances,
- format_position_row,
- options_api_ready,
- cancel_option_order,
- fetch_option_pending_orders,
- place_option_limit_order,
- place_option_market_order,
- quote_option_contract,
- spot_market_swap_usdt_usdc,
- transfer_ccy,
- transfer_main_sub_account,
- )
-
- return {
- "enabled": _env_bool("OKX_OPTIONS_ENABLED", False),
- "sub_account_name": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(),
- "get_db": app_module.get_db,
- "login_required": app_module.login_required,
- "exchange_options": getattr(app_module, "exchange_options", None),
- "send_wechat": app_module.send_wechat_msg,
- "render_main_page": app_module.render_main_page,
- "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
- "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
- "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
- "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
- "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
- "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0),
- "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
- "allow_market_close": _env_bool("OKX_OPTIONS_ALLOW_MARKET_CLOSE", False),
- "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0),
- "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0),
- "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(),
- "build_option_chain": build_option_chain,
- "quote_option_contract": quote_option_contract,
- "fetch_option_book_depth": fetch_option_book_depth,
- "place_option_limit_order": place_option_limit_order,
- "place_option_market_order": place_option_market_order,
- "fetch_option_pending_orders": fetch_option_pending_orders,
- "cancel_option_order": cancel_option_order,
- "fetch_option_positions": fetch_option_positions,
- "fetch_options_balances": fetch_options_balances,
- "format_position_row": format_position_row,
- "estimate_usdt_to_usdc": estimate_usdt_to_usdc,
- "execute_convert": execute_convert,
- "transfer_ccy": transfer_ccy,
- "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc,
- "transfer_main_sub_account": transfer_main_sub_account,
- "options_api_ready": options_api_ready,
- "app_module": app_module,
- }
-
-
-def _mark_balances_stale(cfg: dict[str, Any]) -> None:
- from lib.exchange.okx_options_lib import invalidate_options_balance_cache
- from lib.instance.instance_live_push_lib import notify_instance_balance_changed
-
- invalidate_options_balance_cache()
- app_mod = cfg.get("app_module")
- if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"):
- app_mod.invalidate_account_balance_cache()
- try:
- notify_instance_balance_changed()
- except Exception:
- pass
-
-
-def _require_options_ex(cfg: dict[str, Any]):
- if not cfg.get("enabled"):
- return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2"
- ex = cfg.get("exchange_options")
- ok, reason = cfg["options_api_ready"](ex)
- if not ok:
- return None, reason or "期权 API 未配置"
- return ex, ""
-
-
-def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
- """交易账户 USDC 可用余额(由 calc_order_size 再乘 budget_buffer 留余量)."""
- from lib.exchange.okx_options_lib import fetch_options_trading_usdc
-
- raw = fetch_options_trading_usdc(ex)
- if raw is None or float(raw) <= 0:
- return None, "交易账户 USDC 可用余额不足"
- return float(raw), ""
-
-
-def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- rec = conn.execute(
- """
- SELECT premium_paid FROM options_trades
- WHERE inst_id = ? AND status = 'open'
- ORDER BY id DESC LIMIT 1
- """,
- (inst_id,),
- ).fetchone()
- if rec and rec["premium_paid"] is not None:
- return round(float(rec["premium_paid"]), 4)
- finally:
- conn.close()
- return None
-
-
-def _position_avail_sheets(pos: dict[str, Any]) -> int:
- avail = _safe_float(pos.get("availPos"))
- if avail is None or avail <= 0:
- avail = abs(_safe_float(pos.get("pos")) or 0)
- return max(0, int(avail or 0))
-
-
-def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None:
- return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None)
-
-
-def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None:
- from lib.exchange.okx_options_lib import invalidate_option_positions_cache
-
- invalidate_option_positions_cache()
- raw = cfg["fetch_option_positions"](ex)
- if raw is None:
- return None
- pos = _find_position(raw, inst_id)
- if not pos:
- return 0
- return _position_avail_sheets(pos)
-
-
-def _enrich_position_row_display(
- cfg: dict[str, Any],
- ex: Any,
- raw_pos: dict[str, Any],
- *,
- meta_cache: dict[str, dict[str, Any] | None] | None = None,
- premium_override: float | None = None,
-) -> dict[str, Any]:
- from lib.options.options_history_lib import enrich_position_row_display
-
- return enrich_position_row_display(
- cfg,
- ex,
- raw_pos,
- meta_cache=meta_cache,
- premium_override=premium_override,
- )
-
-
-def _attach_close_preview(
- cfg: dict[str, Any],
- ex: Any,
- row: dict[str, Any],
- *,
- sheets: int | None = None,
- premium_paid: float | None = None,
-) -> dict[str, Any]:
- from lib.options.options_positions_lib import attach_close_preview
-
- return attach_close_preview(
- cfg,
- ex,
- row,
- sheets=sheets,
- premium_paid=premium_paid,
- )
-
-
-_OPTIONS_SYNC_LOCK = threading.Lock()
-_OPTIONS_SYNC_LAST_AT = 0.0
-_OPTIONS_SYNC_INTERVAL_SEC = 15.0
-
-
-def _sync_options_trades(
- cfg: dict[str, Any],
- *,
- raw_positions: list[dict[str, Any]] | None = None,
- force: bool = False,
-) -> None:
- global _OPTIONS_SYNC_LAST_AT
- ex = cfg.get("exchange_options")
- if ex is None:
- return
- now = time.time()
- with _OPTIONS_SYNC_LOCK:
- if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
- return
- _OPTIONS_SYNC_LAST_AT = now
- from lib.exchange.okx_options_lib import fetch_option_position_history
- from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades
-
- if raw_positions is None:
- raw = cfg["fetch_option_positions"](ex)
- if raw is None:
- return
- else:
- raw = raw_positions
- live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
-
- def _hist(inst_id: str):
- return fetch_option_position_history(ex, inst_id)
-
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- reconcile_live_open_trades(conn, live_inst_ids=live_ids)
- sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
- conn.commit()
- finally:
- conn.close()
-
-
-def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
- lr = cfg["login_required"]
-
- @app.route("/api/options/balances")
- @lr
- def api_options_balances():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
- scope = (request.args.get("scope") or "main").strip().lower()
- bal = cfg["fetch_options_balances"](
- ex,
- force=force,
- scope=scope,
- sub_acct=cfg.get("sub_account_name") or "",
- )
- return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]})
-
- @app.route("/api/options/chain")
- @lr
- def api_options_chain():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- u = (request.args.get("underlying") or cfg["default_underly"]).upper()
- chain = cfg["build_option_chain"](
- ex,
- u,
- max_dte_days=cfg["chain_max_dte_days"],
- itm_only=False,
- itm_max_dist_usd=cfg["itm_max_dist"],
- )
- return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]})
-
- @app.route("/api/options/quote")
- @lr
- def api_options_quote():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- inst_id = (request.args.get("inst_id") or "").strip()
- if not inst_id:
- return jsonify({"ok": False, "msg": "缺少 inst_id"})
- q = cfg["quote_option_contract"](ex, inst_id)
- if not q.get("ok"):
- return jsonify(q)
- ask = q.get("ask")
- ct_mult = q.get("ct_mult") or 0.01
- min_sz = q.get("min_sz") or 1
- mode = (request.args.get("mode") or "budget_full").strip()
- sheet_count = None
- try:
- if request.args.get("sheets"):
- sheet_count = int(request.args.get("sheets"))
- except (TypeError, ValueError):
- pass
- if mode == "close_preview":
- paid = _open_premium_paid(cfg, inst_id)
- target = sheet_count if sheet_count is not None else 0
- return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
- budget = cfg["trade_budget"]
- budget_cap = cfg["trade_budget"]
- available_usdc = None
- if mode == "budget_full":
- budget, budget_err = _budget_full_usdc(cfg, ex)
- if budget is None:
- return jsonify({"ok": False, "msg": budget_err})
- budget_cap = budget
- from lib.exchange.okx_options_lib import fetch_options_trading_usdc
-
- available_usdc = fetch_options_trading_usdc(ex)
- eth_amount = None
- try:
- if request.args.get("eth_amount"):
- eth_amount = float(request.args.get("eth_amount"))
- except (TypeError, ValueError):
- pass
- if ask is None or ask <= 0:
- return jsonify({**q, "ok": False, "msg": "暂无卖一价"})
- sizing = calc_order_size(
- quote_per_unit=float(ask),
- ct_mult=float(ct_mult),
- min_sz=int(min_sz),
- budget_usdc=budget if mode == "budget_full" else None,
- budget_buffer=cfg["budget_buffer"],
- eth_amount=eth_amount if mode == "eth_amount" else None,
- sheets=sheet_count if mode == "sheets" else None,
- budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
- )
- q = _attach_close_preview(
- cfg,
- ex,
- q,
- sheets=int(sizing.get("sheets") or sheet_count or 0),
- premium_paid=_open_premium_paid(cfg, inst_id),
- )
- return jsonify(
- {
- **q,
- "quote_per_unit": ask,
- "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
- "sizing": sizing,
- "available_usdc": available_usdc,
- "budget_full_usdc": budget if mode == "budget_full" else None,
- }
- )
-
- @app.route("/api/options/open", methods=["POST"])
- @lr
- def api_options_open():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- inst_id = (data.get("inst_id") or "").strip()
- mode = (data.get("mode") or "budget_full").strip()
- signal_note = (data.get("signal_note") or "").strip()
- target_index = None
- raw_target = data.get("target_index")
- if raw_target is not None and str(raw_target).strip() != "":
- try:
- target_index = float(raw_target)
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "目标位无效"})
- if target_index <= 0:
- return jsonify({"ok": False, "msg": "目标位无效"})
- if not inst_id:
- return jsonify({"ok": False, "msg": "缺少 inst_id"})
- q = cfg["quote_option_contract"](ex, inst_id)
- if not q.get("ok"):
- return jsonify(q)
- ask = q.get("ask")
- if ask is None or ask <= 0:
- return jsonify({"ok": False, "msg": "暂无卖一价,无法买入"})
- ct_mult = float(q.get("ct_mult") or 0.01)
- min_sz = int(q.get("min_sz") or 1)
- eth_amount = None
- sheet_count = None
- if mode == "eth_amount":
- try:
- eth_amount = float(data.get("eth_amount"))
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "ETH 数量无效"})
- elif mode == "sheets":
- try:
- sheet_count = int(data.get("sheets"))
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "张数无效"})
- budget = cfg["trade_budget"]
- budget_cap = cfg["trade_budget"]
- if mode == "budget_full":
- budget, budget_err = _budget_full_usdc(cfg, ex)
- if budget is None:
- return jsonify({"ok": False, "msg": budget_err})
- budget_cap = budget
- sizing = calc_order_size(
- quote_per_unit=float(ask),
- ct_mult=ct_mult,
- min_sz=min_sz,
- budget_usdc=budget if mode == "budget_full" else None,
- budget_buffer=cfg["budget_buffer"],
- eth_amount=eth_amount,
- sheets=sheet_count,
- budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
- )
- if not sizing.get("ok"):
- return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
- sheets = int(sizing["sheets"])
- tick_sz = q.get("tick_sz")
- order = cfg["place_option_limit_order"](
- ex,
- inst_id=inst_id,
- side="buy",
- sheets=sheets,
- price=float(ask),
- td_mode=td_mode_for_option_buy(cfg["td_mode"]),
- tick_sz=tick_sz,
- )
- if not order.get("ok"):
- return jsonify(order)
- conn = cfg["get_db"]()
- trade_id = None
- target_mon = None
- try:
- init_options_tables(conn)
- meta = q.get("meta") or {}
- u = str(meta.get("uly") or inst_id).split("-")[0]
- opt_type = meta.get("optType")
- cur = conn.execute(
- """
- INSERT INTO options_trades
- (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
- open_quote, premium_paid, status, signal_note, exchange_ord_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
- """,
- (
- inst_id,
- u,
- opt_type,
- q.get("strike"),
- str(q.get("exp_time") or ""),
- sheets,
- sizing["eth_amount"],
- float(ask),
- sizing["total_premium"],
- signal_note,
- (order.get("data") or {}).get("ordId"),
- ),
- )
- trade_id = int(cur.lastrowid)
- if target_index is not None:
- from lib.options.options_target_lib import upsert_target_monitor
-
- target_mon = upsert_target_monitor(
- conn,
- inst_id=inst_id,
- target_index=target_index,
- underlying=u,
- opt_type=str(opt_type) if opt_type else None,
- trade_id=trade_id,
- sheets=sheets,
- )
- conn.commit()
- finally:
- conn.close()
- from lib.exchange.okx_options_lib import invalidate_option_positions_cache
-
- invalidate_option_positions_cache()
- _sync_options_trades(cfg, force=True)
- return jsonify(
- {
- "ok": True,
- "order": order,
- "sizing": sizing,
- "trade_id": trade_id,
- "target_monitor": target_mon,
- }
- )
-
- @app.route("/api/options/orders/pending")
- @lr
- def api_options_orders_pending():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- inst_id = (request.args.get("inst_id") or "").strip() or None
- try:
- orders = cfg["fetch_option_pending_orders"](ex, inst_id)
- except Exception as e:
- return jsonify({"ok": False, "msg": f"获取委托失败: {e}"})
- return jsonify({"ok": True, "orders": orders, "count": len(orders)})
-
- @app.route("/api/options/orders/cancel", methods=["POST"])
- @lr
- def api_options_orders_cancel():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- inst_id = (data.get("inst_id") or "").strip()
- ord_id = (data.get("ord_id") or "").strip()
- if not inst_id or not ord_id:
- return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"})
- out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id)
- if out.get("ok"):
- from lib.exchange.okx_options_lib import invalidate_option_positions_cache
-
- invalidate_option_positions_cache()
- # 本地未成交开仓记录标记取消,避免假 open
- try:
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- conn.execute(
- """
- UPDATE options_trades
- SET status = 'cancelled',
- signal_note = CASE
- WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销'
- ELSE signal_note
- END,
- closed_at = CURRENT_TIMESTAMP
- WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open'
- """,
- (inst_id, ord_id),
- )
- conn.commit()
- finally:
- conn.close()
- except Exception:
- pass
- _sync_options_trades(cfg, force=True)
- return jsonify(out), (200 if out.get("ok") else 400)
-
- @app.route("/api/options/positions")
- @lr
- def api_options_positions():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- raw = cfg["fetch_option_positions"](ex)
- if raw is None:
- return jsonify({"ok": False, "msg": "获取期权持仓失败"})
- _sync_options_trades(cfg, raw_positions=raw)
- meta_cache: dict[str, dict[str, Any] | None] = {}
- conn = cfg["get_db"]()
- try:
- from lib.options.options_target_lib import targets_by_inst
-
- tgt_map = targets_by_inst(conn)
- rows = []
- for p in raw:
- inst = str(p.get("instId") or "").strip()
- premium_override = None
- if inst:
- rec = conn.execute(
- """
- SELECT premium_paid FROM options_trades
- WHERE inst_id = ? AND status = 'open'
- ORDER BY id DESC LIMIT 1
- """,
- (inst,),
- ).fetchone()
- if rec and rec["premium_paid"] is not None:
- premium_override = float(rec["premium_paid"])
- row = _enrich_position_row_display(
- cfg,
- ex,
- p,
- meta_cache=meta_cache,
- premium_override=premium_override,
- )
- _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
- mon = tgt_map.get(inst)
- if mon:
- row["target_index"] = mon.get("target_index")
- row["target_monitor_id"] = mon.get("id")
- row["target_monitor"] = mon
- rows.append(row)
- finally:
- conn.close()
- return jsonify({"ok": True, "positions": rows})
-
- @app.route("/api/options/targets")
- @lr
- def api_options_targets():
- conn = cfg["get_db"]()
- try:
- from lib.options.options_target_lib import list_active_targets, list_closing_targets
-
- return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)})
- finally:
- conn.close()
-
- @app.route("/api/options/target", methods=["POST"])
- @lr
- def api_options_target_set():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- inst_id = (data.get("inst_id") or "").strip()
- if not inst_id:
- return jsonify({"ok": False, "msg": "缺少 inst_id"})
- try:
- target_index = float(data.get("target_index"))
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "目标位无效"})
- if target_index <= 0:
- return jsonify({"ok": False, "msg": "目标位无效"})
- raw = cfg["fetch_option_positions"](ex)
- if raw is None:
- return jsonify({"ok": False, "msg": "获取期权持仓失败"})
- pos = _find_position(raw, inst_id)
- if not pos:
- return jsonify({"ok": False, "msg": "未找到持仓"})
- from lib.options.options_target_lib import upsert_target_monitor
-
- fmt = cfg["format_position_row"](pos)
- conn = cfg["get_db"]()
- try:
- trade = conn.execute(
- """
- SELECT id, sheets, opt_type, underlying FROM options_trades
- WHERE inst_id = ? AND status = 'open'
- ORDER BY id DESC LIMIT 1
- """,
- (inst_id,),
- ).fetchone()
- trade_id = int(trade["id"]) if trade else None
- sheets = int(trade["sheets"]) if trade and trade["sheets"] is not None else int(fmt.get("pos") or 0)
- opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type")
- underlying = (trade["underlying"] if trade else None) or fmt.get("underlying")
- out = upsert_target_monitor(
- conn,
- inst_id=inst_id,
- target_index=target_index,
- underlying=str(underlying) if underlying else None,
- opt_type=str(opt_type) if opt_type else None,
- trade_id=trade_id,
- sheets=sheets,
- )
- conn.commit()
- return jsonify(out)
- finally:
- conn.close()
-
- @app.route("/api/options/target/cancel", methods=["POST"])
- @lr
- def api_options_target_cancel():
- data = request.get_json(silent=True) or {}
- inst_id = (data.get("inst_id") or "").strip() or None
- monitor_id = data.get("id")
- try:
- mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "监控 id 无效"})
- if not inst_id and mid is None:
- return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"})
- from lib.options.options_target_lib import cancel_target_monitor
-
- conn = cfg["get_db"]()
- try:
- n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid)
- conn.commit()
- return jsonify({"ok": True, "cancelled": n})
- finally:
- conn.close()
-
- @app.route("/api/options/close", methods=["POST"])
- @lr
- def api_options_close():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- inst_id = (data.get("inst_id") or "").strip()
- use_market = bool(data.get("market")) and cfg["allow_market_close"]
- close_mode = (data.get("mode") or "").strip()
- depth_split = close_mode == "depth_split" and not use_market
- if not inst_id:
- return jsonify({"ok": False, "msg": "缺少 inst_id"})
- sheets = data.get("sheets")
- q = cfg["quote_option_contract"](ex, inst_id)
- bid = q.get("bid")
- if not use_market and not depth_split and (bid is None or bid <= 0):
- return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"})
- raw_positions = cfg["fetch_option_positions"](ex)
- if raw_positions is None:
- return jsonify({"ok": False, "msg": "获取期权持仓失败"})
- pos = _find_position(raw_positions, inst_id)
- if not pos:
- return jsonify({"ok": False, "msg": "未找到持仓"})
- avail = _position_avail_sheets(pos)
- close_sheets = int(sheets) if sheets else int(avail)
- close_sheets = min(close_sheets, int(avail))
- if close_sheets < 1:
- return jsonify({"ok": False, "msg": "可平张数不足"})
- td_mode = str(pos.get("mgnMode") or cfg["td_mode"])
- pos_side = _pos_side_from_position(pos) or "net"
- tick_sz = q.get("tick_sz")
- if use_market:
- order = cfg["place_option_market_order"](
- ex,
- inst_id=inst_id,
- side="sell",
- sheets=close_sheets,
- td_mode=td_mode,
- reduce_only=True,
- pos_side=pos_side,
- )
- if not order.get("ok"):
- return jsonify(order)
- elif depth_split:
- ct_mult = float(q.get("ct_mult") or 0.01)
- from lib.exchange.okx_options_lib import option_fields_from_inst_id
-
- mark_px = _safe_float(pos.get("markPx")) or _safe_float(q.get("mark_px") or q.get("mark"))
- if mark_px is None:
- mark_px = fetch_option_mark_px(ex, inst_id)
- opt_type = pos.get("optType") or q.get("opt_type")
- strike = _safe_float(pos.get("stk")) or _safe_float(q.get("strike"))
- if not opt_type or strike is None:
- pt, ps = option_fields_from_inst_id(inst_id)
- opt_type = opt_type or pt
- if strike is None:
- strike = ps
- idx_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px"))
- mark_px, intrinsic_px = close_ref_prices(
- mark_px=mark_px, opt_type=str(opt_type or ""), strike=strike, index_px=idx_px
- )
- 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
- )
- paid = _open_premium_paid(cfg, inst_id)
- if stub_only0 or not usable0:
- bid_chk = None
- if book0.get("bids"):
- bid_chk = _safe_float((book0.get("bids") or [{}])[0].get("px"))
- 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,
- "msg": stub_reason0 or stub_reason or "暂无有效买盘,禁止按买盘自动平仓",
- "stopped_reason": "stub_bid",
- "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
- total_received = 0.0
- orders: list[dict[str, Any]] = []
- stopped_reason = None
- for _ in range(5):
- if remaining <= 0:
- break
- current_avail = _refresh_position_avail(cfg, ex, inst_id)
- if current_avail is None:
- stopped_reason = "refresh_position_failed"
- break
- if current_avail <= 0:
- filled_or_reduced_sheets = close_sheets
- remaining = 0
- break
- remaining = min(remaining, current_avail)
- book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
- preview = estimate_close_by_bids(
- book.get("bids") or [],
- remaining,
- ct_mult=ct_mult,
- mark_px=mark_px,
- intrinsic_px=intrinsic_px,
- )
- if preview.get("auto_close_blocked") or preview.get("bid_invalid"):
- return jsonify(
- {
- "ok": False,
- "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止按买盘自动平仓",
- "stopped_reason": "stub_bid",
- "auto_close_blocked": True,
- }
- )
- levels = preview.get("levels") or []
- if not levels:
- stopped_reason = "no_bid_depth"
- break
- level = levels[0]
- level_sheets = int(level.get("sheets") or 0)
- level_px = float(level.get("px") or 0)
- if level_sheets <= 0 or level_px <= 0:
- stopped_reason = "invalid_bid_depth"
- break
- before_avail = current_avail
- order = cfg["place_option_limit_order"](
- ex,
- inst_id=inst_id,
- side="sell",
- sheets=level_sheets,
- price=level_px,
- td_mode=td_mode,
- tick_sz=tick_sz,
- reduce_only=True,
- pos_side=pos_side,
- )
- if not order.get("ok"):
- stopped_reason = order.get("msg") or "order_failed"
- break
- px = float(order.get("px", level_px))
- orders.append({"order": order, "px": px, "sheets": level_sheets})
- submitted_sheets += level_sheets
- total_received += total_premium(px, level_sheets * ct_mult)
- time.sleep(0.6)
- after_avail = _refresh_position_avail(cfg, ex, inst_id)
- if after_avail is None:
- stopped_reason = "refresh_position_failed"
- break
- reduced = max(0, before_avail - after_avail)
- if reduced <= 0:
- stopped_reason = "order_not_filled"
- break
- filled_or_reduced_sheets += min(reduced, level_sheets)
- remaining = max(0, close_sheets - filled_or_reduced_sheets)
- if not orders:
- return jsonify({"ok": False, "msg": "暂无可用买盘深度,无法拆分平仓", "stopped_reason": stopped_reason})
- 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)
- row = conn.execute(
- "SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
- (inst_id,),
- ).fetchone()
- if row and fully_submitted:
- paid = float(row["premium_paid"] or 0)
- pnl = prem_recv - paid
- conn.execute(
- """
- UPDATE options_trades
- SET status = 'closed', close_quote = ?, premium_received = ?,
- realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
- WHERE id = ?
- """,
- (
- bid,
- prem_recv,
- pnl,
- ",".join(str((o.get("order", {}).get("data") or {}).get("ordId") or "") for o in orders),
- int(row["id"]),
- ),
- )
- conn.commit()
- finally:
- conn.close()
- from lib.exchange.okx_options_lib import invalidate_option_positions_cache
-
- invalidate_option_positions_cache()
- _sync_options_trades(cfg, force=True)
- try:
- from lib.options.options_target_lib import cancel_target_monitor
-
- conn2 = cfg["get_db"]()
- try:
- cancel_target_monitor(conn2, inst_id=inst_id)
- conn2.commit()
- finally:
- conn2.close()
- except Exception:
- pass
- return jsonify(
- {
- "ok": True,
- "mode": "depth_split",
- "orders": orders,
- "bid": bid,
- "submitted_sheets": submitted_sheets,
- "filled_or_reduced_sheets": filled_or_reduced_sheets,
- "remaining_sheets": max(0, close_sheets - filled_or_reduced_sheets),
- "premium_received": prem_recv,
- "stopped_reason": stopped_reason,
- }
- )
- else:
- from lib.exchange.okx_options_lib import option_fields_from_inst_id
-
- mark_px = _safe_float(pos.get("markPx")) or _safe_float(q.get("mark_px") or q.get("mark"))
- if mark_px is None:
- mark_px = fetch_option_mark_px(ex, inst_id)
- opt_type = pos.get("optType") or q.get("opt_type")
- strike = _safe_float(pos.get("stk")) or _safe_float(q.get("strike"))
- if not opt_type or strike is None:
- pt, ps = option_fields_from_inst_id(inst_id)
- opt_type = opt_type or pt
- if strike is None:
- strike = ps
- idx_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px"))
- mark_px, intrinsic_px = close_ref_prices(
- mark_px=mark_px, opt_type=str(opt_type or ""), strike=strike, index_px=idx_px
- )
- close_px = float(bid)
- stub, stub_reason = is_stub_bid_px(close_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
- if stub:
- return jsonify(
- {
- "ok": False,
- "msg": stub_reason or "暂无有效买盘,禁止按买盘自动平仓",
- "stopped_reason": "stub_bid",
- "auto_close_blocked": True,
- }
- )
- order = cfg["place_option_limit_order"](
- ex,
- inst_id=inst_id,
- side="sell",
- sheets=close_sheets,
- price=close_px,
- td_mode=td_mode,
- tick_sz=tick_sz,
- reduce_only=True,
- pos_side=pos_side,
- )
- if not order.get("ok"):
- return jsonify(order)
- bid = order.get("px", close_px)
- prem_recv = total_premium(float(bid or 0), close_sheets * float(q.get("ct_mult") or 0.01))
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- row = conn.execute(
- "SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
- (inst_id,),
- ).fetchone()
- if row:
- paid = float(row["premium_paid"] or 0)
- pnl = prem_recv - paid
- conn.execute(
- """
- UPDATE options_trades
- SET status = 'closed', close_quote = ?, premium_received = ?,
- realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
- WHERE id = ?
- """,
- (
- bid,
- prem_recv,
- pnl,
- (order.get("data") or {}).get("ordId"),
- int(row["id"]),
- ),
- )
- conn.commit()
- finally:
- conn.close()
- from lib.exchange.okx_options_lib import invalidate_option_positions_cache
-
- invalidate_option_positions_cache()
- _sync_options_trades(cfg, force=True)
- try:
- from lib.options.options_target_lib import cancel_target_monitor
-
- conn2 = cfg["get_db"]()
- try:
- cancel_target_monitor(conn2, inst_id=inst_id)
- conn2.commit()
- finally:
- conn2.close()
- except Exception:
- pass
- return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets})
-
- @app.route("/api/options/convert/quote", methods=["POST"])
- @lr
- def api_options_convert_quote():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- try:
- amount = float(data.get("amount"))
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "数量无效"})
- return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount))
-
- @app.route("/api/options/convert/execute", methods=["POST"])
- @lr
- def api_options_convert_execute():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- quote_id = (data.get("quote_id") or "").strip()
- result = cfg["execute_convert"](ex, quote_id)
- if result.get("ok"):
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- conn.execute(
- """
- INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message)
- VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '')
- """,
- (
- data.get("rfq_sz"),
- (result.get("data") or {}).get("baseSz"),
- quote_id,
- ),
- )
- conn.commit()
- finally:
- conn.close()
- return jsonify(result)
-
- @app.route("/api/options/transfer", methods=["POST"])
- @lr
- def api_options_transfer():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- ccy = (data.get("ccy") or "USDC").upper()
- from_acct = (data.get("from") or "funding").strip()
- to_acct = (data.get("to") or "trading").strip()
- try:
- amount = float(data.get("amount"))
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "数量无效"})
- result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct)
- if result.get("ok"):
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- conn.execute(
- """
- INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
- VALUES (?, ?, ?, ?, 'ok', '')
- """,
- (ccy, amount, from_acct, to_acct),
- )
- conn.commit()
- finally:
- conn.close()
- _mark_balances_stale(cfg)
- return jsonify(result)
-
- @app.route("/api/options/spot/swap", methods=["POST"])
- @lr
- def api_options_spot_swap():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- direction = (data.get("direction") or "usdt_to_usdc").strip()
- try:
- amount = float(data.get("amount"))
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "数量无效"})
- result = cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount)
- if result.get("ok"):
- _mark_balances_stale(cfg)
- return jsonify(result)
-
- @app.route("/api/options/cross-transfer", methods=["POST"])
- @lr
- def api_options_cross_transfer():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- data = request.get_json(silent=True) or {}
- ccy = (data.get("ccy") or "USDT").upper()
- direction = (data.get("direction") or "sub_to_main").strip()
- from_account = (data.get("from_account") or data.get("account") or "funding").strip()
- to_account = (data.get("to_account") or data.get("account") or "funding").strip()
- try:
- amount = float(data.get("amount"))
- except (TypeError, ValueError):
- return jsonify({"ok": False, "msg": "数量无效"})
- main_to_sub = direction == "main_to_sub"
- result = cfg["transfer_main_sub_account"](
- ex,
- ccy=ccy,
- amount=amount,
- sub_acct=cfg.get("sub_account_name") or "",
- main_to_sub=main_to_sub,
- from_account=from_account,
- to_account=to_account,
- )
- if result.get("ok"):
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- conn.execute(
- """
- INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
- VALUES (?, ?, ?, ?, 'ok', ?)
- """,
- (
- ccy,
- amount,
- ("main" if main_to_sub else "sub") + ":" + from_account,
- ("sub" if main_to_sub else "main") + ":" + to_account,
- "cross",
- ),
- )
- conn.commit()
- finally:
- conn.close()
- _mark_balances_stale(cfg)
- return jsonify(result)
-
- @app.route("/api/options/history")
- @lr
- def api_options_history():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- from lib.options.options_history_lib import load_options_history
-
- raw_live = cfg["fetch_option_positions"](ex)
- if raw_live is None:
- return jsonify({"ok": False, "msg": "获取期权持仓失败"})
- history = load_options_history(ex, cfg)
- live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"}
- return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)})
-
- @app.route("/api/options/stats")
- @lr
- def api_options_stats():
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- from lib.options.options_history_lib import load_options_history
- from lib.options.options_stats_lib import compute_options_stats_from_history
-
- raw_live = cfg["fetch_option_positions"](ex)
- if raw_live is None:
- return jsonify({"ok": False, "msg": "获取期权持仓失败"})
- history = load_options_history(ex, cfg)
- return jsonify({"ok": True, **compute_options_stats_from_history(history)})
-
- @app.route("/api/options/history/
", methods=["DELETE"])
- @lr
- def api_options_history_delete(history_key: str):
- ex, err = _require_options_ex(cfg)
- if ex is None:
- return jsonify({"ok": False, "msg": err})
- key = (history_key or "").strip()
- if not key:
- return jsonify({"ok": False, "msg": "缺少 history_key"})
- conn = cfg["get_db"]()
- try:
- init_options_tables(conn)
- conn.execute(
- "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)",
- (key,),
- )
- conn.commit()
- finally:
- conn.close()
- return jsonify({"ok": True})
-
-
-def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
- if app.extensions.get("options_monitor_started"):
- return
- app.extensions["options_monitor_started"] = True
-
- def _bid(inst_id: str) -> float | None:
- ex = cfg.get("exchange_options")
- if ex is None:
- return None
- try:
- q = cfg["quote_option_contract"](ex, inst_id)
- return q.get("bid")
- except Exception:
- return None
-
- def _positions():
- ex = cfg.get("exchange_options")
- if ex is None:
- return []
- raw = cfg["fetch_option_positions"](ex)
- if raw is None:
- return []
- return [cfg["format_position_row"](p) for p in raw]
-
- def _sync(conn):
- from lib.exchange.okx_options_lib import fetch_option_position_history
- from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades
-
- ex = cfg.get("exchange_options")
- if ex is None:
- return 0
- raw = cfg["fetch_option_positions"](ex)
- if raw is None:
- return 0
- live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
- reconcile_live_open_trades(conn, live_inst_ids=live_ids)
- return sync_open_options_trades(
- conn,
- live_inst_ids=live_ids,
- fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id),
- )
-
- def _target_close(inst_id: str) -> dict[str, Any]:
- from lib.options.options_target_lib import close_option_by_bid_depth
-
- ex = cfg.get("exchange_options")
- if ex is None:
- return {"ok": False, "msg": "期权 exchange 未就绪"}
- result = close_option_by_bid_depth(cfg, ex, inst_id)
- if result.get("ok"):
- try:
- _sync_options_trades(cfg, force=True)
- except Exception:
- pass
- try:
- _mark_balances_stale(cfg)
- except Exception:
- pass
- return result
-
- t = threading.Thread(
- target=options_monitor_loop,
- kwargs={
- "enabled": True,
- "poll_seconds": cfg["poll_seconds"],
- "get_db": cfg["get_db"],
- "fetch_positions": _positions,
- "ticker_bid_fn": _bid,
- "send_wechat": cfg["send_wechat"],
- "account_label": cfg["account_label"],
- "profit_ratio": cfg["profit_ratio"],
- "sync_trades_fn": _sync,
- "target_close_fn": _target_close,
- },
- daemon=True,
- name="options-monitor",
- )
- t.start()
+"""OKX 期权模块:Flask 路由注册."""
+from __future__ import annotations
+
+import os
+import threading
+import time
+from typing import Any
+
+from flask import Flask, jsonify, redirect, request, url_for
+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_pricing_lib import (
+ calc_order_size,
+ ct_mult_from_meta,
+ min_sz_from_meta,
+ premium_per_sheet,
+)
+from lib.exchange.okx_options_lib import _safe_float, td_mode_for_option_buy
+
+
+def _env_bool(key: str, default: bool = False) -> bool:
+ raw = (os.getenv(key) or "").strip().lower()
+ if not raw:
+ return default
+ return raw in ("1", "true", "yes", "on")
+
+
+def _env_float(key: str, default: float) -> float:
+ try:
+ return float(os.getenv(key, str(default)))
+ except (TypeError, ValueError):
+ return default
+
+
+def attach_options_templates(app: Flask, repo_root: str) -> None:
+ tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
+ if not os.path.isdir(tpl_dir):
+ return
+ existing = app.jinja_loader
+ loaders = [FileSystemLoader(tpl_dir)]
+ if existing is not None:
+ if isinstance(existing, ChoiceLoader):
+ loaders = list(existing.loaders) + loaders
+ else:
+ loaders.insert(0, existing)
+ app.jinja_loader = ChoiceLoader(loaders)
+
+
+def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None:
+ enabled = _env_bool("OKX_OPTIONS_ENABLED", False)
+ attach_options_templates(app, repo_root)
+ cfg = _build_cfg(app_module)
+ app.extensions["options_cfg"] = cfg
+ register_options_routes(app, cfg)
+ _register_options_hub_bridge(app, cfg)
+ if enabled:
+ _start_monitor_thread(app, cfg)
+
+
+def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None:
+ from lib.options.options_hub_lib import build_options_hub_snapshot
+
+ def snapshot_fn():
+ return build_options_hub_snapshot(cfg)
+
+ hub_ctx = dict(app.config.get("HUB_CTX") or {})
+ hub_ctx["options_snapshot_fn"] = snapshot_fn
+ app.config["HUB_CTX"] = hub_ctx
+
+
+def _build_cfg(app_module: Any) -> dict[str, Any]:
+ from lib.exchange.okx_options_lib import (
+ build_option_chain,
+ estimate_usdt_to_usdc,
+ execute_convert,
+ fetch_option_book_depth,
+ fetch_option_positions,
+ fetch_options_balances,
+ format_position_row,
+ options_api_ready,
+ cancel_option_order,
+ fetch_option_pending_orders,
+ place_option_limit_order,
+ place_option_market_order,
+ quote_option_contract,
+ spot_market_swap_usdt_usdc,
+ transfer_ccy,
+ transfer_main_sub_account,
+ )
+
+ return {
+ "enabled": _env_bool("OKX_OPTIONS_ENABLED", False),
+ "sub_account_name": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(),
+ "get_db": app_module.get_db,
+ "login_required": app_module.login_required,
+ "exchange_options": getattr(app_module, "exchange_options", None),
+ "send_wechat": app_module.send_wechat_msg,
+ "render_main_page": app_module.render_main_page,
+ "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
+ "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
+ "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
+ "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
+ "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
+ "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0),
+ "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
+ # 市价平仓已硬关闭(忽略 env),仅买一限价
+ "allow_market_close": False,
+ "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0),
+ "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0),
+ "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(),
+ "build_option_chain": build_option_chain,
+ "quote_option_contract": quote_option_contract,
+ "fetch_option_book_depth": fetch_option_book_depth,
+ "place_option_limit_order": place_option_limit_order,
+ "place_option_market_order": place_option_market_order,
+ "fetch_option_pending_orders": fetch_option_pending_orders,
+ "cancel_option_order": cancel_option_order,
+ "fetch_option_positions": fetch_option_positions,
+ "fetch_options_balances": fetch_options_balances,
+ "format_position_row": format_position_row,
+ "estimate_usdt_to_usdc": estimate_usdt_to_usdc,
+ "execute_convert": execute_convert,
+ "transfer_ccy": transfer_ccy,
+ "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc,
+ "transfer_main_sub_account": transfer_main_sub_account,
+ "options_api_ready": options_api_ready,
+ "app_module": app_module,
+ }
+
+
+def _mark_balances_stale(cfg: dict[str, Any]) -> None:
+ from lib.exchange.okx_options_lib import invalidate_options_balance_cache
+ from lib.instance.instance_live_push_lib import notify_instance_balance_changed
+
+ invalidate_options_balance_cache()
+ app_mod = cfg.get("app_module")
+ if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"):
+ app_mod.invalidate_account_balance_cache()
+ try:
+ notify_instance_balance_changed()
+ except Exception:
+ pass
+
+
+def _require_options_ex(cfg: dict[str, Any]):
+ if not cfg.get("enabled"):
+ return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2"
+ ex = cfg.get("exchange_options")
+ ok, reason = cfg["options_api_ready"](ex)
+ if not ok:
+ return None, reason or "期权 API 未配置"
+ return ex, ""
+
+
+def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
+ """交易账户 USDC 可用余额(由 calc_order_size 再乘 budget_buffer 留余量)."""
+ from lib.exchange.okx_options_lib import fetch_options_trading_usdc
+
+ raw = fetch_options_trading_usdc(ex)
+ if raw is None or float(raw) <= 0:
+ return None, "交易账户 USDC 可用余额不足"
+ return float(raw), ""
+
+
+def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ rec = conn.execute(
+ """
+ SELECT premium_paid FROM options_trades
+ WHERE inst_id = ? AND status = 'open'
+ ORDER BY id DESC LIMIT 1
+ """,
+ (inst_id,),
+ ).fetchone()
+ if rec and rec["premium_paid"] is not None:
+ return round(float(rec["premium_paid"]), 4)
+ finally:
+ conn.close()
+ return None
+
+
+def _position_avail_sheets(pos: dict[str, Any]) -> int:
+ avail = _safe_float(pos.get("availPos"))
+ if avail is None or avail <= 0:
+ avail = abs(_safe_float(pos.get("pos")) or 0)
+ return max(0, int(avail or 0))
+
+
+def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None:
+ return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None)
+
+
+def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None:
+ from lib.exchange.okx_options_lib import invalidate_option_positions_cache
+
+ invalidate_option_positions_cache()
+ raw = cfg["fetch_option_positions"](ex)
+ if raw is None:
+ return None
+ pos = _find_position(raw, inst_id)
+ if not pos:
+ return 0
+ return _position_avail_sheets(pos)
+
+
+def _enrich_position_row_display(
+ cfg: dict[str, Any],
+ ex: Any,
+ raw_pos: dict[str, Any],
+ *,
+ meta_cache: dict[str, dict[str, Any] | None] | None = None,
+ premium_override: float | None = None,
+) -> dict[str, Any]:
+ from lib.options.options_history_lib import enrich_position_row_display
+
+ return enrich_position_row_display(
+ cfg,
+ ex,
+ raw_pos,
+ meta_cache=meta_cache,
+ premium_override=premium_override,
+ )
+
+
+def _attach_close_preview(
+ cfg: dict[str, Any],
+ ex: Any,
+ row: dict[str, Any],
+ *,
+ sheets: int | None = None,
+ premium_paid: float | None = None,
+) -> dict[str, Any]:
+ from lib.options.options_positions_lib import attach_close_preview
+
+ return attach_close_preview(
+ cfg,
+ ex,
+ row,
+ sheets=sheets,
+ premium_paid=premium_paid,
+ )
+
+
+_OPTIONS_SYNC_LOCK = threading.Lock()
+_OPTIONS_SYNC_LAST_AT = 0.0
+_OPTIONS_SYNC_INTERVAL_SEC = 15.0
+
+
+def _sync_options_trades(
+ cfg: dict[str, Any],
+ *,
+ raw_positions: list[dict[str, Any]] | None = None,
+ force: bool = False,
+) -> None:
+ global _OPTIONS_SYNC_LAST_AT
+ ex = cfg.get("exchange_options")
+ if ex is None:
+ return
+ now = time.time()
+ with _OPTIONS_SYNC_LOCK:
+ if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
+ return
+ _OPTIONS_SYNC_LAST_AT = now
+ from lib.exchange.okx_options_lib import fetch_option_position_history
+ from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades
+
+ if raw_positions is None:
+ raw = cfg["fetch_option_positions"](ex)
+ if raw is None:
+ return
+ else:
+ raw = raw_positions
+ live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
+
+ def _hist(inst_id: str):
+ return fetch_option_position_history(ex, inst_id)
+
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ reconcile_live_open_trades(conn, live_inst_ids=live_ids)
+ sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
+ lr = cfg["login_required"]
+
+ @app.route("/options/guide")
+ @lr
+ def options_trade_guide():
+ """期权开平仓与监控说明(独立页)."""
+ from pathlib import Path
+
+ from flask import render_template_string
+
+ from lib.hub.hub_strategy_lib import render_markdown_html
+
+ md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md"
+ try:
+ md_text = md_path.read_text(encoding="utf-8")
+ except OSError:
+ md_text = "# 说明文档缺失\n\n未找到 `docs/期权开平仓与监控说明.md`."
+ body = render_markdown_html(md_text)
+ return render_template_string(
+ """
+
+
+
+
+
+ 期权开平仓与监控说明
+
+
+
+ ← 返回期权 · 对冲计划
+ {{ body|safe }}
+
+
+ """,
+ body=body,
+ )
+
+ @app.route("/api/options/balances")
+ @lr
+ def api_options_balances():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
+ scope = (request.args.get("scope") or "main").strip().lower()
+ bal = cfg["fetch_options_balances"](
+ ex,
+ force=force,
+ scope=scope,
+ sub_acct=cfg.get("sub_account_name") or "",
+ )
+ return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]})
+
+ @app.route("/api/options/chain")
+ @lr
+ def api_options_chain():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ u = (request.args.get("underlying") or cfg["default_underly"]).upper()
+ chain = cfg["build_option_chain"](
+ ex,
+ u,
+ max_dte_days=cfg["chain_max_dte_days"],
+ itm_only=False,
+ itm_max_dist_usd=cfg["itm_max_dist"],
+ )
+ return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]})
+
+ @app.route("/api/options/quote")
+ @lr
+ def api_options_quote():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ inst_id = (request.args.get("inst_id") or "").strip()
+ if not inst_id:
+ return jsonify({"ok": False, "msg": "缺少 inst_id"})
+ q = cfg["quote_option_contract"](ex, inst_id)
+ if not q.get("ok"):
+ return jsonify(q)
+ ask = q.get("ask")
+ ct_mult = q.get("ct_mult") or 0.01
+ min_sz = q.get("min_sz") or 1
+ mode = (request.args.get("mode") or "budget_full").strip()
+ sheet_count = None
+ try:
+ if request.args.get("sheets"):
+ sheet_count = int(request.args.get("sheets"))
+ except (TypeError, ValueError):
+ pass
+ if mode == "close_preview":
+ paid = _open_premium_paid(cfg, inst_id)
+ target = sheet_count if sheet_count is not None else 0
+ return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
+ budget = cfg["trade_budget"]
+ budget_cap = cfg["trade_budget"]
+ available_usdc = None
+ if mode == "budget_full":
+ budget, budget_err = _budget_full_usdc(cfg, ex)
+ if budget is None:
+ return jsonify({"ok": False, "msg": budget_err})
+ budget_cap = budget
+ from lib.exchange.okx_options_lib import fetch_options_trading_usdc
+
+ available_usdc = fetch_options_trading_usdc(ex)
+ eth_amount = None
+ try:
+ if request.args.get("eth_amount"):
+ eth_amount = float(request.args.get("eth_amount"))
+ except (TypeError, ValueError):
+ pass
+ if ask is None or ask <= 0:
+ return jsonify({**q, "ok": False, "msg": "暂无卖一价"})
+ sizing = calc_order_size(
+ quote_per_unit=float(ask),
+ ct_mult=float(ct_mult),
+ min_sz=int(min_sz),
+ budget_usdc=budget if mode == "budget_full" else None,
+ budget_buffer=cfg["budget_buffer"],
+ eth_amount=eth_amount if mode == "eth_amount" else None,
+ sheets=sheet_count if mode == "sheets" else None,
+ budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
+ )
+ q = _attach_close_preview(
+ cfg,
+ ex,
+ q,
+ sheets=int(sizing.get("sheets") or sheet_count or 0),
+ premium_paid=_open_premium_paid(cfg, inst_id),
+ )
+ return jsonify(
+ {
+ **q,
+ "quote_per_unit": ask,
+ "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
+ "sizing": sizing,
+ "available_usdc": available_usdc,
+ "budget_full_usdc": budget if mode == "budget_full" else None,
+ }
+ )
+
+ @app.route("/api/options/open", methods=["POST"])
+ @lr
+ def api_options_open():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ inst_id = (data.get("inst_id") or "").strip()
+ mode = (data.get("mode") or "budget_full").strip()
+ signal_note = (data.get("signal_note") or "").strip()
+ target_index = None
+ raw_target = data.get("target_index")
+ if raw_target is not None and str(raw_target).strip() != "":
+ try:
+ target_index = float(raw_target)
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "目标位无效"})
+ if target_index <= 0:
+ return jsonify({"ok": False, "msg": "目标位无效"})
+ if not inst_id:
+ return jsonify({"ok": False, "msg": "缺少 inst_id"})
+ q = cfg["quote_option_contract"](ex, inst_id)
+ if not q.get("ok"):
+ return jsonify(q)
+ ask = q.get("ask")
+ if ask is None or ask <= 0:
+ return jsonify({"ok": False, "msg": "暂无卖一价,无法买入"})
+ ct_mult = float(q.get("ct_mult") or 0.01)
+ min_sz = int(q.get("min_sz") or 1)
+ eth_amount = None
+ sheet_count = None
+ if mode == "eth_amount":
+ try:
+ eth_amount = float(data.get("eth_amount"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "ETH 数量无效"})
+ elif mode == "sheets":
+ try:
+ sheet_count = int(data.get("sheets"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "张数无效"})
+ budget = cfg["trade_budget"]
+ budget_cap = cfg["trade_budget"]
+ if mode == "budget_full":
+ budget, budget_err = _budget_full_usdc(cfg, ex)
+ if budget is None:
+ return jsonify({"ok": False, "msg": budget_err})
+ budget_cap = budget
+ sizing = calc_order_size(
+ quote_per_unit=float(ask),
+ ct_mult=ct_mult,
+ min_sz=min_sz,
+ budget_usdc=budget if mode == "budget_full" else None,
+ budget_buffer=cfg["budget_buffer"],
+ eth_amount=eth_amount,
+ sheets=sheet_count,
+ budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
+ )
+ if not sizing.get("ok"):
+ return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
+ sheets = int(sizing["sheets"])
+ tick_sz = q.get("tick_sz")
+ order = cfg["place_option_limit_order"](
+ ex,
+ inst_id=inst_id,
+ side="buy",
+ sheets=sheets,
+ price=float(ask),
+ td_mode=td_mode_for_option_buy(cfg["td_mode"]),
+ tick_sz=tick_sz,
+ )
+ if not order.get("ok"):
+ return jsonify(order)
+ conn = cfg["get_db"]()
+ trade_id = None
+ target_mon = None
+ try:
+ init_options_tables(conn)
+ meta = q.get("meta") or {}
+ u = str(meta.get("uly") or inst_id).split("-")[0]
+ opt_type = meta.get("optType")
+ cur = conn.execute(
+ """
+ INSERT INTO options_trades
+ (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
+ open_quote, premium_paid, status, signal_note, exchange_ord_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
+ """,
+ (
+ inst_id,
+ u,
+ opt_type,
+ q.get("strike"),
+ str(q.get("exp_time") or ""),
+ sheets,
+ sizing["eth_amount"],
+ float(ask),
+ sizing["total_premium"],
+ signal_note,
+ (order.get("data") or {}).get("ordId"),
+ ),
+ )
+ trade_id = int(cur.lastrowid)
+ if target_index is not None:
+ from lib.options.options_target_lib import upsert_target_monitor
+
+ target_mon = upsert_target_monitor(
+ conn,
+ inst_id=inst_id,
+ target_index=target_index,
+ underlying=u,
+ opt_type=str(opt_type) if opt_type else None,
+ trade_id=trade_id,
+ sheets=sheets,
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ from lib.exchange.okx_options_lib import invalidate_option_positions_cache
+
+ invalidate_option_positions_cache()
+ _sync_options_trades(cfg, force=True)
+ return jsonify(
+ {
+ "ok": True,
+ "order": order,
+ "sizing": sizing,
+ "trade_id": trade_id,
+ "target_monitor": target_mon,
+ }
+ )
+
+ @app.route("/api/options/orders/pending")
+ @lr
+ def api_options_orders_pending():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ inst_id = (request.args.get("inst_id") or "").strip() or None
+ try:
+ orders = cfg["fetch_option_pending_orders"](ex, inst_id)
+ except Exception as e:
+ return jsonify({"ok": False, "msg": f"获取委托失败: {e}"})
+ return jsonify({"ok": True, "orders": orders, "count": len(orders)})
+
+ @app.route("/api/options/orders/cancel", methods=["POST"])
+ @lr
+ def api_options_orders_cancel():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ inst_id = (data.get("inst_id") or "").strip()
+ ord_id = (data.get("ord_id") or "").strip()
+ if not inst_id or not ord_id:
+ return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"})
+ out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id)
+ if out.get("ok"):
+ from lib.exchange.okx_options_lib import invalidate_option_positions_cache
+
+ invalidate_option_positions_cache()
+ # 本地未成交开仓记录标记取消,避免假 open
+ try:
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ conn.execute(
+ """
+ UPDATE options_trades
+ SET status = 'cancelled',
+ signal_note = CASE
+ WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销'
+ ELSE signal_note
+ END,
+ closed_at = CURRENT_TIMESTAMP
+ WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open'
+ """,
+ (inst_id, ord_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ except Exception:
+ pass
+ _sync_options_trades(cfg, force=True)
+ return jsonify(out), (200 if out.get("ok") else 400)
+
+ @app.route("/api/options/positions")
+ @lr
+ def api_options_positions():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ raw = cfg["fetch_option_positions"](ex)
+ if raw is None:
+ return jsonify({"ok": False, "msg": "获取期权持仓失败"})
+ _sync_options_trades(cfg, raw_positions=raw)
+ meta_cache: dict[str, dict[str, Any] | None] = {}
+ conn = cfg["get_db"]()
+ try:
+ from lib.options.options_target_lib import targets_by_inst
+
+ tgt_map = targets_by_inst(conn)
+ rows = []
+ for p in raw:
+ inst = str(p.get("instId") or "").strip()
+ premium_override = None
+ if inst:
+ rec = conn.execute(
+ """
+ SELECT premium_paid FROM options_trades
+ WHERE inst_id = ? AND status = 'open'
+ ORDER BY id DESC LIMIT 1
+ """,
+ (inst,),
+ ).fetchone()
+ if rec and rec["premium_paid"] is not None:
+ premium_override = float(rec["premium_paid"])
+ row = _enrich_position_row_display(
+ cfg,
+ ex,
+ p,
+ meta_cache=meta_cache,
+ premium_override=premium_override,
+ )
+ _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
+ mon = tgt_map.get(inst)
+ if mon:
+ row["target_index"] = mon.get("target_index")
+ row["target_monitor_id"] = mon.get("id")
+ row["target_monitor"] = mon
+ rows.append(row)
+ finally:
+ conn.close()
+ return jsonify({"ok": True, "positions": rows})
+
+ @app.route("/api/options/targets")
+ @lr
+ def api_options_targets():
+ conn = cfg["get_db"]()
+ try:
+ from lib.options.options_target_lib import list_active_targets, list_closing_targets
+
+ return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)})
+ finally:
+ conn.close()
+
+ @app.route("/api/options/target", methods=["POST"])
+ @lr
+ def api_options_target_set():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ inst_id = (data.get("inst_id") or "").strip()
+ if not inst_id:
+ return jsonify({"ok": False, "msg": "缺少 inst_id"})
+ try:
+ target_index = float(data.get("target_index"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "目标位无效"})
+ if target_index <= 0:
+ return jsonify({"ok": False, "msg": "目标位无效"})
+ raw = cfg["fetch_option_positions"](ex)
+ if raw is None:
+ return jsonify({"ok": False, "msg": "获取期权持仓失败"})
+ pos = _find_position(raw, inst_id)
+ if not pos:
+ return jsonify({"ok": False, "msg": "未找到持仓"})
+ from lib.options.options_target_lib import upsert_target_monitor
+
+ fmt = cfg["format_position_row"](pos)
+ conn = cfg["get_db"]()
+ try:
+ trade = conn.execute(
+ """
+ SELECT id, sheets, opt_type, underlying FROM options_trades
+ WHERE inst_id = ? AND status = 'open'
+ ORDER BY id DESC LIMIT 1
+ """,
+ (inst_id,),
+ ).fetchone()
+ trade_id = int(trade["id"]) if trade else None
+ sheets = int(trade["sheets"]) if trade and trade["sheets"] is not None else int(fmt.get("pos") or 0)
+ opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type")
+ underlying = (trade["underlying"] if trade else None) or fmt.get("underlying")
+ out = upsert_target_monitor(
+ conn,
+ inst_id=inst_id,
+ target_index=target_index,
+ underlying=str(underlying) if underlying else None,
+ opt_type=str(opt_type) if opt_type else None,
+ trade_id=trade_id,
+ sheets=sheets,
+ )
+ conn.commit()
+ return jsonify(out)
+ finally:
+ conn.close()
+
+ @app.route("/api/options/target/cancel", methods=["POST"])
+ @lr
+ def api_options_target_cancel():
+ data = request.get_json(silent=True) or {}
+ inst_id = (data.get("inst_id") or "").strip() or None
+ monitor_id = data.get("id")
+ try:
+ mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "监控 id 无效"})
+ if not inst_id and mid is None:
+ return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"})
+ from lib.options.options_target_lib import cancel_target_monitor
+
+ conn = cfg["get_db"]()
+ try:
+ n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid)
+ conn.commit()
+ return jsonify({"ok": True, "cancelled": n})
+ finally:
+ conn.close()
+
+ @app.route("/api/options/close", methods=["POST"])
+ @lr
+ def api_options_close():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ inst_id = (data.get("inst_id") or "").strip()
+ if not inst_id:
+ return jsonify({"ok": False, "msg": "缺少 inst_id"})
+ if data.get("market"):
+ return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"})
+ sheets = data.get("sheets")
+ try:
+ sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "张数无效"})
+ from lib.options.options_close_exec_lib import close_option_by_bid1
+
+ # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓
+ result = close_option_by_bid1(
+ cfg,
+ ex,
+ inst_id,
+ sheets=sheets_i,
+ require_recycle_gate=False,
+ )
+ if result.get("ok"):
+ from lib.exchange.okx_options_lib import invalidate_option_positions_cache
+
+ invalidate_option_positions_cache()
+ _sync_options_trades(cfg, force=True)
+ if result.get("fully_closed"):
+ try:
+ from lib.options.options_target_lib import cancel_target_monitor
+
+ conn2 = cfg["get_db"]()
+ try:
+ cancel_target_monitor(conn2, inst_id=inst_id)
+ conn2.commit()
+ finally:
+ conn2.close()
+ except Exception:
+ pass
+ _mark_balances_stale(cfg)
+ return jsonify(result)
+
+ @app.route("/api/options/convert/quote", methods=["POST"])
+ @lr
+ def api_options_convert_quote():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ try:
+ amount = float(data.get("amount"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "数量无效"})
+ return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount))
+
+ @app.route("/api/options/convert/execute", methods=["POST"])
+ @lr
+ def api_options_convert_execute():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ quote_id = (data.get("quote_id") or "").strip()
+ result = cfg["execute_convert"](ex, quote_id)
+ if result.get("ok"):
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ conn.execute(
+ """
+ INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message)
+ VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '')
+ """,
+ (
+ data.get("rfq_sz"),
+ (result.get("data") or {}).get("baseSz"),
+ quote_id,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ return jsonify(result)
+
+ @app.route("/api/options/transfer", methods=["POST"])
+ @lr
+ def api_options_transfer():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ ccy = (data.get("ccy") or "USDC").upper()
+ from_acct = (data.get("from") or "funding").strip()
+ to_acct = (data.get("to") or "trading").strip()
+ try:
+ amount = float(data.get("amount"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "数量无效"})
+ result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct)
+ if result.get("ok"):
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ conn.execute(
+ """
+ INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
+ VALUES (?, ?, ?, ?, 'ok', '')
+ """,
+ (ccy, amount, from_acct, to_acct),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ _mark_balances_stale(cfg)
+ return jsonify(result)
+
+ @app.route("/api/options/spot/swap", methods=["POST"])
+ @lr
+ def api_options_spot_swap():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ direction = (data.get("direction") or "usdt_to_usdc").strip()
+ try:
+ amount = float(data.get("amount"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "数量无效"})
+ result = cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount)
+ if result.get("ok"):
+ _mark_balances_stale(cfg)
+ return jsonify(result)
+
+ @app.route("/api/options/cross-transfer", methods=["POST"])
+ @lr
+ def api_options_cross_transfer():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ ccy = (data.get("ccy") or "USDT").upper()
+ direction = (data.get("direction") or "sub_to_main").strip()
+ from_account = (data.get("from_account") or data.get("account") or "funding").strip()
+ to_account = (data.get("to_account") or data.get("account") or "funding").strip()
+ try:
+ amount = float(data.get("amount"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "数量无效"})
+ main_to_sub = direction == "main_to_sub"
+ result = cfg["transfer_main_sub_account"](
+ ex,
+ ccy=ccy,
+ amount=amount,
+ sub_acct=cfg.get("sub_account_name") or "",
+ main_to_sub=main_to_sub,
+ from_account=from_account,
+ to_account=to_account,
+ )
+ if result.get("ok"):
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ conn.execute(
+ """
+ INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
+ VALUES (?, ?, ?, ?, 'ok', ?)
+ """,
+ (
+ ccy,
+ amount,
+ ("main" if main_to_sub else "sub") + ":" + from_account,
+ ("sub" if main_to_sub else "main") + ":" + to_account,
+ "cross",
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ _mark_balances_stale(cfg)
+ return jsonify(result)
+
+ @app.route("/api/options/history")
+ @lr
+ def api_options_history():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ from lib.options.options_history_lib import load_options_history
+
+ raw_live = cfg["fetch_option_positions"](ex)
+ if raw_live is None:
+ return jsonify({"ok": False, "msg": "获取期权持仓失败"})
+ history = load_options_history(ex, cfg)
+ live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"}
+ return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)})
+
+ @app.route("/api/options/stats")
+ @lr
+ def api_options_stats():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ from lib.options.options_history_lib import load_options_history
+ from lib.options.options_stats_lib import compute_options_stats_from_history
+
+ raw_live = cfg["fetch_option_positions"](ex)
+ if raw_live is None:
+ return jsonify({"ok": False, "msg": "获取期权持仓失败"})
+ history = load_options_history(ex, cfg)
+ return jsonify({"ok": True, **compute_options_stats_from_history(history)})
+
+ @app.route("/api/options/history/", methods=["DELETE"])
+ @lr
+ def api_options_history_delete(history_key: str):
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ key = (history_key or "").strip()
+ if not key:
+ return jsonify({"ok": False, "msg": "缺少 history_key"})
+ conn = cfg["get_db"]()
+ try:
+ init_options_tables(conn)
+ conn.execute(
+ "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)",
+ (key,),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ return jsonify({"ok": True})
+
+
+def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
+ if app.extensions.get("options_monitor_started"):
+ return
+ app.extensions["options_monitor_started"] = True
+
+ def _bid(inst_id: str) -> float | None:
+ ex = cfg.get("exchange_options")
+ if ex is None:
+ return None
+ try:
+ q = cfg["quote_option_contract"](ex, inst_id)
+ return q.get("bid")
+ except Exception:
+ return None
+
+ def _positions():
+ ex = cfg.get("exchange_options")
+ if ex is None:
+ return []
+ raw = cfg["fetch_option_positions"](ex)
+ if raw is None:
+ return []
+ return [cfg["format_position_row"](p) for p in raw]
+
+ def _sync(conn):
+ from lib.exchange.okx_options_lib import fetch_option_position_history
+ from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades
+
+ ex = cfg.get("exchange_options")
+ if ex is None:
+ return 0
+ raw = cfg["fetch_option_positions"](ex)
+ if raw is None:
+ return 0
+ live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
+ reconcile_live_open_trades(conn, live_inst_ids=live_ids)
+ return sync_open_options_trades(
+ conn,
+ live_inst_ids=live_ids,
+ fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id),
+ )
+
+ def _target_close(inst_id: str) -> dict[str, Any]:
+ from lib.options.options_target_lib import close_option_by_bid_depth
+
+ ex = cfg.get("exchange_options")
+ if ex is None:
+ return {"ok": False, "msg": "期权 exchange 未就绪"}
+ result = close_option_by_bid_depth(cfg, ex, inst_id)
+ if result.get("ok"):
+ try:
+ _sync_options_trades(cfg, force=True)
+ except Exception:
+ pass
+ try:
+ _mark_balances_stale(cfg)
+ except Exception:
+ pass
+ return result
+
+ t = threading.Thread(
+ target=options_monitor_loop,
+ kwargs={
+ "enabled": True,
+ "poll_seconds": cfg["poll_seconds"],
+ "get_db": cfg["get_db"],
+ "fetch_positions": _positions,
+ "ticker_bid_fn": _bid,
+ "send_wechat": cfg["send_wechat"],
+ "account_label": cfg["account_label"],
+ "profit_ratio": cfg["profit_ratio"],
+ "sync_trades_fn": _sync,
+ "target_close_fn": _target_close,
+ },
+ daemon=True,
+ name="options-monitor",
+ )
+ t.start()
diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py
index 1bc7322..d20aef5 100644
--- a/lib/options/options_target_lib.py
+++ b/lib/options/options_target_lib.py
@@ -1,813 +1,443 @@
-"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
-from __future__ import annotations
-
-import sqlite3
-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,
- fetch_option_mark_px,
- filter_bids_for_close,
- is_stub_bid_px,
- total_premium,
-)
-
-
-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 _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
- from lib.exchange.okx_options_lib import option_fields_from_inst_id
-
- inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
- mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
- if mark is None:
- mark = fetch_option_mark_px(ex, inst_id)
- opt_type = pos.get("optType") or (quote or {}).get("opt_type")
- strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
- if not opt_type or strike is None:
- pt, ps = option_fields_from_inst_id(inst_id)
- opt_type = opt_type or pt
- if strike is None:
- strike = ps
- idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
- return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
-
-
-def ensure_target_tables(conn: sqlite3.Connection) -> None:
- init_options_tables(conn)
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS options_target_monitors (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- inst_id TEXT NOT NULL,
- underlying TEXT,
- opt_type TEXT,
- target_index REAL NOT NULL,
- trade_id INTEGER,
- sheets INTEGER,
- status TEXT DEFAULT 'active',
- trigger_idx REAL,
- close_ord_id TEXT,
- message TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- triggered_at TIMESTAMP
- )
- """
- )
- conn.execute(
- """
- CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
- ON options_target_monitors(status)
- """
- )
-
-
-def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
- """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
- ot = (opt_type or "").strip().upper()
- if ot == "P":
- return index_px <= target_index
- return index_px >= target_index
-
-
-def upsert_target_monitor(
- conn: sqlite3.Connection,
- *,
- inst_id: str,
- target_index: float,
- underlying: str | None = None,
- opt_type: str | None = None,
- trade_id: int | None = None,
- sheets: int | None = None,
-) -> dict[str, Any]:
- ensure_target_tables(conn)
- inst_id = (inst_id or "").strip()
- if not inst_id:
- return {"ok": False, "msg": "缺少 inst_id"}
- if target_index is None or float(target_index) <= 0:
- return {"ok": False, "msg": "目标位无效"}
- target_index = float(target_index)
- row = conn.execute(
- """
- SELECT id FROM options_target_monitors
- WHERE inst_id = ? AND status IN ('active', 'closing')
- ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC
- LIMIT 1
- """,
- (inst_id,),
- ).fetchone()
- if row:
- conn.execute(
- """
- UPDATE options_target_monitors
- SET target_index = ?,
- underlying = COALESCE(?, underlying),
- opt_type = COALESCE(?, opt_type),
- trade_id = COALESCE(?, trade_id),
- sheets = COALESCE(?, sheets),
- status = 'active',
- trigger_idx = NULL,
- close_ord_id = NULL,
- message = NULL,
- triggered_at = NULL
- WHERE id = ?
- """,
- (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
- )
- mon_id = int(row["id"])
- # 同一合约其他进行中的委托取消,避免双轨触发重复推送
- conn.execute(
- """
- UPDATE options_target_monitors
- SET status = 'cancelled', message = '被新目标位覆盖'
- WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
- """,
- (inst_id, mon_id),
- )
- else:
- cur = conn.execute(
- """
- INSERT INTO options_target_monitors
- (inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
- VALUES (?, ?, ?, ?, ?, ?, 'active')
- """,
- (inst_id, underlying, opt_type, target_index, trade_id, sheets),
- )
- mon_id = int(cur.lastrowid)
- return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
-
-
-def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
- ensure_target_tables(conn)
- if monitor_id is not None:
- cur = conn.execute(
- """
- UPDATE options_target_monitors
- SET status = 'cancelled', message = '手动取消'
- WHERE id = ? AND status IN ('active', 'closing')
- """,
- (int(monitor_id),),
- )
- return int(cur.rowcount or 0)
- if inst_id:
- cur = conn.execute(
- """
- UPDATE options_target_monitors
- SET status = 'cancelled', message = '手动取消'
- WHERE inst_id = ? AND status IN ('active', 'closing')
- """,
- (inst_id.strip(),),
- )
- return int(cur.rowcount or 0)
- return 0
-
-
-def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
- return {
- "id": int(r["id"]),
- "inst_id": r["inst_id"],
- "underlying": r["underlying"],
- "opt_type": r["opt_type"],
- "target_index": _safe_float(r["target_index"]),
- "trade_id": r["trade_id"],
- "sheets": r["sheets"],
- "status": r["status"],
- "message": r["message"],
- "created_at": r["created_at"],
- }
-
-
-def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
- ensure_target_tables(conn)
- rows = conn.execute(
- """
- SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
- status, message, created_at
- FROM options_target_monitors
- WHERE status = 'active'
- ORDER BY id DESC
- """
- ).fetchall()
- return [_row_to_target(r) for r in rows]
-
-
-def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
- """已挂出平仓单、等待成交的目标(不再重复推送微信)."""
- ensure_target_tables(conn)
- rows = conn.execute(
- """
- SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
- status, message, created_at
- FROM options_target_monitors
- WHERE status = 'closing'
- ORDER BY id DESC
- """
- ).fetchall()
- return [_row_to_target(r) for r in rows]
-
-
-def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
- """UI/持仓挂载:active 与 closing 都算进行中."""
- out: dict[str, dict[str, Any]] = {}
- for t in list_closing_targets(conn) + list_active_targets(conn):
- inst = str(t.get("inst_id") or "")
- if inst and inst not in out:
- out[inst] = t
- return out
-
-
-def mark_monitor(
- conn: sqlite3.Connection,
- monitor_id: int,
- *,
- status: str,
- trigger_idx: float | None = None,
- close_ord_id: str | None = None,
- message: str | None = None,
-) -> None:
- conn.execute(
- """
- UPDATE options_target_monitors
- SET status = ?,
- trigger_idx = COALESCE(?, trigger_idx),
- close_ord_id = COALESCE(?, close_ord_id),
- message = COALESCE(?, message),
- triggered_at = CASE
- WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
- ELSE triggered_at
- END
- WHERE id = ?
- """,
- (status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
- )
-
-
-def cancel_orphans_without_position(
- conn: sqlite3.Connection,
- *,
- live_inst_ids: set[str],
-) -> int:
- """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
- ensure_target_tables(conn)
- rows = list_active_targets(conn) + list_closing_targets(conn)
- n = 0
- for t in rows:
- inst = str(t.get("inst_id") or "")
- if inst and inst not in live_inst_ids:
- mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
- n += 1
- return n
-
-
-def _commit_monitor(conn: sqlite3.Connection) -> None:
- """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
- try:
- conn.commit()
- except Exception:
- pass
-
-
-def close_option_by_bid_depth(
- cfg: dict[str, Any],
- ex: Any,
- inst_id: str,
- *,
- sheets: int | None = None,
-) -> dict[str, Any]:
- """目标触发后仅用买一/买盘限价卖出(最多5档);无买一则等待下次轮询,不用标记价."""
- from lib.exchange.okx_options_lib import (
- _pos_side_from_position,
- invalidate_option_positions_cache,
- )
-
- q = cfg["quote_option_contract"](ex, inst_id)
- if not q.get("ok"):
- return {"ok": False, "msg": q.get("msg") or "报价失败"}
- tick_sz = q.get("tick_sz")
- ct_mult = float(q.get("ct_mult") or 0.01)
- raw_positions = cfg["fetch_option_positions"](ex)
- if raw_positions is None:
- return {"ok": False, "msg": "获取期权持仓失败"}
- pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
- if not pos:
- return {"ok": False, "msg": "未找到持仓", "already_flat": True}
-
- def _avail(p: dict[str, Any]) -> int:
- avail = _safe_float(p.get("availPos"))
- if avail is None or avail <= 0:
- avail = abs(_safe_float(p.get("pos")) or 0)
- return max(0, int(avail or 0))
-
- avail = _avail(pos)
- close_sheets = int(sheets) if sheets else avail
- close_sheets = min(close_sheets, avail)
- if close_sheets < 1:
- return {"ok": False, "msg": "可平张数不足", "already_flat": True}
- td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated")
- pos_side = _pos_side_from_position(pos) or "net"
- mark_px, intrinsic_px = _pos_close_refs(ex, pos, q)
-
- def _cancel_sell_pending() -> None:
- try:
- pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
- for o in pending.get("data") or []:
- if str(o.get("side") or "").lower() != "sell":
- continue
- oid = o.get("ordId")
- if not oid:
- continue
- try:
- ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid})
- except Exception:
- pass
- except Exception:
- pass
-
- # 残档买盘 / 回收未达 2×权利金持续门槛:禁止自动按买盘平仓
- premium_paid = None
- try:
- 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:
- 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:
- pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
- sell_pending = [
- o
- for o in (pending.get("data") or [])
- if str(o.get("side") or "").lower() == "sell" and o.get("ordId")
- ]
- if sell_pending:
- time.sleep(0.5)
- invalidate_option_positions_cache()
- raw_positions = cfg["fetch_option_positions"](ex)
- if raw_positions is None:
- 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,
- "msg": "已有限价卖单成交",
- "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
- "fully_closed": True,
- "submitted_sheets": close_sheets,
- "remaining_sheets": 0,
- }
- # 仍持仓且卖单挂着:本轮不撤不重挂,交给下一轮
- return {
- "ok": False,
- "msg": "等待已有买一限价卖单成交",
- "stopped_reason": "pending_close_order",
- "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
- }
- except Exception:
- pass
-
- # 无挂单时再清理残留卖单(兼容旧路径)并按买一重新挂出
- try:
- pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
- for o in pending.get("data") or []:
- if str(o.get("side") or "").lower() != "sell":
- continue
- oid = o.get("ordId")
- if not oid:
- continue
- try:
- ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid})
- except Exception:
- pass
- time.sleep(0.3)
- invalidate_option_positions_cache()
- raw_positions = cfg["fetch_option_positions"](ex)
- if raw_positions is None:
- return {"ok": False, "msg": "获取期权持仓失败"}
- pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
- if not pos:
- return {"ok": False, "msg": "未找到持仓", "already_flat": True}
- avail = _avail(pos)
- close_sheets = min(close_sheets, avail)
- if close_sheets < 1:
- return {"ok": False, "msg": "可平张数不足", "already_flat": True}
- except Exception:
- pass
-
- remaining = close_sheets
- submitted_sheets = 0
- filled_or_reduced_sheets = 0
- total_received = 0.0
- orders: list[dict[str, Any]] = []
- stopped_reason = None
- ord_ids: list[str] = []
-
- for _ in range(5):
- if remaining <= 0:
- break
- invalidate_option_positions_cache()
- raw = cfg["fetch_option_positions"](ex)
- if raw is None:
- stopped_reason = "refresh_position_failed"
- break
- cur_pos = next((p for p in raw if str(p.get("instId")) == inst_id), None)
- current_avail = _avail(cur_pos) if cur_pos else 0
- if current_avail <= 0:
- filled_or_reduced_sheets = close_sheets
- remaining = 0
- break
- remaining = min(remaining, current_avail)
- book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
- preview = estimate_close_by_bids(
- book.get("bids") or [],
- remaining,
- ct_mult=ct_mult,
- mark_px=mark_px,
- intrinsic_px=intrinsic_px,
- )
- if preview.get("auto_close_blocked") or preview.get("bid_invalid"):
- stopped_reason = "stub_bid"
- _cancel_sell_pending()
- return {
- "ok": False,
- "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止自动平仓",
- "stopped_reason": "stub_bid",
- "auto_close_blocked": True,
- }
- levels = preview.get("levels") or []
- if not levels:
- # 无买盘深度时仅允许真实买一价,不用标记价挂单
- q2 = cfg["quote_option_contract"](ex, inst_id)
- bid_px = _safe_float(q2.get("bid")) or _safe_float(q.get("bid"))
- stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
- if stub:
- stopped_reason = "stub_bid"
- return {
- "ok": False,
- "msg": stub_reason or "暂无有效买盘,禁止自动平仓",
- "stopped_reason": "stub_bid",
- "auto_close_blocked": True,
- }
- if bid_px is None or bid_px <= 0:
- stopped_reason = "no_bid"
- break
- levels = [{"sheets": remaining, "px": bid_px}]
- level = levels[0]
- level_sheets = int(level.get("sheets") or 0)
- level_px = float(level.get("px") or 0)
- if level_sheets <= 0 or level_px <= 0:
- stopped_reason = "invalid_bid_depth"
- break
- stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
- if stub_lv:
- return {
- "ok": False,
- "msg": stub_lv_reason or "暂无有效买盘,禁止自动平仓",
- "stopped_reason": "stub_bid",
- "auto_close_blocked": True,
- }
- before_avail = current_avail
- order = cfg["place_option_limit_order"](
- ex,
- inst_id=inst_id,
- side="sell",
- sheets=level_sheets,
- price=level_px,
- td_mode=td_mode,
- tick_sz=tick_sz,
- reduce_only=True,
- pos_side=pos_side,
- )
- if not order.get("ok"):
- stopped_reason = order.get("msg") or "order_failed"
- break
- px = float(order.get("px", level_px))
- orders.append({"order": order, "px": px, "sheets": level_sheets})
- oid = str((order.get("data") or {}).get("ordId") or "")
- if oid:
- ord_ids.append(oid)
- submitted_sheets += level_sheets
- total_received += total_premium(px, level_sheets * ct_mult)
- time.sleep(0.6)
- invalidate_option_positions_cache()
- raw2 = cfg["fetch_option_positions"](ex)
- if raw2 is None:
- stopped_reason = "refresh_position_failed"
- break
- after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
- after_avail = _avail(after_pos) if after_pos else 0
- reduced = max(0, before_avail - after_avail)
- if reduced <= 0:
- stopped_reason = "order_not_filled"
- break
- filled_or_reduced_sheets += min(reduced, level_sheets)
- remaining = max(0, close_sheets - filled_or_reduced_sheets)
-
- if not orders:
- # 最后兜底:允许市价平仓时用市价
- if cfg.get("allow_market_close"):
- mkt = cfg["place_option_market_order"](
- ex,
- inst_id=inst_id,
- side="sell",
- sheets=close_sheets,
- td_mode=td_mode,
- reduce_only=True,
- pos_side=pos_side,
- )
- if mkt.get("ok"):
- oid = str((mkt.get("data") or {}).get("ordId") or "")
- return {
- "ok": True,
- "mode": "market",
- "orders": [{"order": mkt, "sheets": close_sheets}],
- "submitted_sheets": close_sheets,
- "filled_or_reduced_sheets": close_sheets,
- "remaining_sheets": 0,
- "premium_received": None,
- "close_ord_id": oid or None,
- "fully_closed": True,
- }
- return {"ok": False, "msg": mkt.get("msg") or "市价平仓失败", "stopped_reason": stopped_reason}
- return {
- "ok": False,
- "msg": "暂无买一,等待盘口后按买一限价平仓",
- "stopped_reason": stopped_reason or "no_bid",
- }
-
- avg_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
- 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:
- ensure_target_tables(conn)
- row = conn.execute(
- "SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
- (inst_id,),
- ).fetchone()
- if row and fully_submitted:
- paid = float(row["premium_paid"] or 0)
- pnl = prem_recv - paid
- conn.execute(
- """
- UPDATE options_trades
- SET status = 'closed', close_quote = ?, premium_received = ?,
- realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP,
- signal_note = CASE
- WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '目标位平仓'
- ELSE signal_note
- END
- WHERE id = ?
- """,
- (avg_bid, prem_recv, pnl, close_ord_id, int(row["id"])),
- )
- conn.commit()
- finally:
- conn.close()
-
- invalidate_option_positions_cache()
- return {
- "ok": True,
- "mode": "depth_split",
- "orders": orders,
- "bid": avg_bid,
- "submitted_sheets": submitted_sheets,
- "filled_or_reduced_sheets": filled_or_reduced_sheets,
- "remaining_sheets": max(0, close_sheets - filled_or_reduced_sheets),
- "premium_received": prem_recv,
- "stopped_reason": stopped_reason,
- "close_ord_id": close_ord_id,
- "fully_closed": fully_submitted and remaining == 0,
- }
-
-
-def _notify_target_close(
- send_wechat: Callable[[str], None] | None,
- *,
- account_label: str,
- inst_id: str,
- target: float,
- idx: float,
- result: dict[str, Any],
-) -> None:
- if not send_wechat:
- return
- try:
- send_wechat(
- "\n".join(
- [
- "【OKX期权·目标位平仓】",
- f"账户:{account_label}",
- f"合约:{inst_id}",
- f"目标指数:{target:g}",
- f"触发指数:{idx:g}",
- f"提交张数:{result.get('submitted_sheets') or '—'}",
- f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
- ]
- )
- )
- except Exception:
- pass
-
-
-def _result_fully_done(result: dict[str, Any]) -> bool:
- if result.get("already_flat"):
- return True
- if result.get("fully_closed"):
- return True
- remaining = result.get("remaining_sheets")
- if remaining is not None and int(remaining) <= 0 and result.get("ok"):
- return True
- return False
-
-
-def run_options_target_closes(
- conn: sqlite3.Connection,
- positions: list[dict[str, Any]],
- *,
- close_fn: Callable[[str], dict[str, Any]],
- index_fn: Callable[[dict[str, Any]], float | None] | None = None,
- send_wechat: Callable[[str], None] | None = None,
- account_label: str = "OKX期权",
-) -> int:
- """
- 扫描 active 目标委托;指数到位后限价平仓.
- 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
- 未完全成交进入 closing,仅重试平仓不再推送.
- 返回本次新触发(并推送)的条数.
- """
- ensure_target_tables(conn)
- pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
- live_ids = {k for k in pos_by_inst if k}
- cancel_orphans_without_position(conn, live_inst_ids=live_ids)
- _commit_monitor(conn)
-
- # 先处理已挂单等待成交的,绝不再发微信
- for mon in list_closing_targets(conn):
- inst_id = str(mon.get("inst_id") or "")
- if not inst_id:
- continue
- if inst_id not in pos_by_inst:
- mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
- _commit_monitor(conn)
- continue
- result = close_fn(inst_id)
- idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
- if result.get("already_flat") or _result_fully_done(result):
- mark_monitor(
- conn,
- int(mon["id"]),
- status="triggered",
- trigger_idx=idx,
- close_ord_id=result.get("close_ord_id"),
- message="目标位限价平仓完成",
- )
- _commit_monitor(conn)
- continue
- mark_monitor(
- conn,
- int(mon["id"]),
- status="closing",
- trigger_idx=idx,
- close_ord_id=result.get("close_ord_id"),
- message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
- )
- _commit_monitor(conn)
-
- triggered = 0
- for mon in list_active_targets(conn):
- inst_id = str(mon.get("inst_id") or "")
- target = _safe_float(mon.get("target_index"))
- if not inst_id or target is None:
- continue
- pos = pos_by_inst.get(inst_id)
- if not pos:
- continue
- if index_fn is not None:
- idx = index_fn(pos)
- else:
- idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
- if idx is None:
- continue
- opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
- if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
- continue
-
- result = close_fn(inst_id)
- if result.get("already_flat"):
- mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
- _commit_monitor(conn)
- continue
- if not result.get("ok"):
- mark_monitor(
- conn,
- int(mon["id"]),
- status="active",
- trigger_idx=idx,
- message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
- )
- _commit_monitor(conn)
- continue
-
- done = _result_fully_done(result)
- status = "triggered" if done else "closing"
- mark_monitor(
- conn,
- int(mon["id"]),
- status=status,
- trigger_idx=idx,
- close_ord_id=result.get("close_ord_id"),
- message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
- )
- # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
- _commit_monitor(conn)
- triggered += 1
- _notify_target_close(
- send_wechat,
- account_label=account_label,
- inst_id=inst_id,
- target=target,
- idx=idx,
- result=result,
- )
- return triggered
+"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
+from __future__ import annotations
+
+import sqlite3
+import time
+from typing import Any, Callable
+
+from lib.options.options_db import init_options_tables
+from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
+
+
+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 _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
+ from lib.exchange.okx_options_lib import option_fields_from_inst_id
+
+ inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
+ mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
+ if mark is None:
+ mark = fetch_option_mark_px(ex, inst_id)
+ opt_type = pos.get("optType") or (quote or {}).get("opt_type")
+ strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
+ if not opt_type or strike is None:
+ pt, ps = option_fields_from_inst_id(inst_id)
+ opt_type = opt_type or pt
+ if strike is None:
+ strike = ps
+ idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
+ return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
+
+
+def ensure_target_tables(conn: sqlite3.Connection) -> None:
+ init_options_tables(conn)
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS options_target_monitors (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ inst_id TEXT NOT NULL,
+ underlying TEXT,
+ opt_type TEXT,
+ target_index REAL NOT NULL,
+ trade_id INTEGER,
+ sheets INTEGER,
+ status TEXT DEFAULT 'active',
+ trigger_idx REAL,
+ close_ord_id TEXT,
+ message TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ triggered_at TIMESTAMP
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
+ ON options_target_monitors(status)
+ """
+ )
+
+
+def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
+ """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
+ ot = (opt_type or "").strip().upper()
+ if ot == "P":
+ return index_px <= target_index
+ return index_px >= target_index
+
+
+def upsert_target_monitor(
+ conn: sqlite3.Connection,
+ *,
+ inst_id: str,
+ target_index: float,
+ underlying: str | None = None,
+ opt_type: str | None = None,
+ trade_id: int | None = None,
+ sheets: int | None = None,
+) -> dict[str, Any]:
+ ensure_target_tables(conn)
+ inst_id = (inst_id or "").strip()
+ if not inst_id:
+ return {"ok": False, "msg": "缺少 inst_id"}
+ if target_index is None or float(target_index) <= 0:
+ return {"ok": False, "msg": "目标位无效"}
+ target_index = float(target_index)
+ row = conn.execute(
+ """
+ SELECT id FROM options_target_monitors
+ WHERE inst_id = ? AND status IN ('active', 'closing')
+ ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC
+ LIMIT 1
+ """,
+ (inst_id,),
+ ).fetchone()
+ if row:
+ conn.execute(
+ """
+ UPDATE options_target_monitors
+ SET target_index = ?,
+ underlying = COALESCE(?, underlying),
+ opt_type = COALESCE(?, opt_type),
+ trade_id = COALESCE(?, trade_id),
+ sheets = COALESCE(?, sheets),
+ status = 'active',
+ trigger_idx = NULL,
+ close_ord_id = NULL,
+ message = NULL,
+ triggered_at = NULL
+ WHERE id = ?
+ """,
+ (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
+ )
+ mon_id = int(row["id"])
+ # 同一合约其他进行中的委托取消,避免双轨触发重复推送
+ conn.execute(
+ """
+ UPDATE options_target_monitors
+ SET status = 'cancelled', message = '被新目标位覆盖'
+ WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
+ """,
+ (inst_id, mon_id),
+ )
+ else:
+ cur = conn.execute(
+ """
+ INSERT INTO options_target_monitors
+ (inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
+ VALUES (?, ?, ?, ?, ?, ?, 'active')
+ """,
+ (inst_id, underlying, opt_type, target_index, trade_id, sheets),
+ )
+ mon_id = int(cur.lastrowid)
+ return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
+
+
+def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
+ ensure_target_tables(conn)
+ if monitor_id is not None:
+ cur = conn.execute(
+ """
+ UPDATE options_target_monitors
+ SET status = 'cancelled', message = '手动取消'
+ WHERE id = ? AND status IN ('active', 'closing')
+ """,
+ (int(monitor_id),),
+ )
+ return int(cur.rowcount or 0)
+ if inst_id:
+ cur = conn.execute(
+ """
+ UPDATE options_target_monitors
+ SET status = 'cancelled', message = '手动取消'
+ WHERE inst_id = ? AND status IN ('active', 'closing')
+ """,
+ (inst_id.strip(),),
+ )
+ return int(cur.rowcount or 0)
+ return 0
+
+
+def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
+ return {
+ "id": int(r["id"]),
+ "inst_id": r["inst_id"],
+ "underlying": r["underlying"],
+ "opt_type": r["opt_type"],
+ "target_index": _safe_float(r["target_index"]),
+ "trade_id": r["trade_id"],
+ "sheets": r["sheets"],
+ "status": r["status"],
+ "message": r["message"],
+ "created_at": r["created_at"],
+ }
+
+
+def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
+ ensure_target_tables(conn)
+ rows = conn.execute(
+ """
+ SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
+ status, message, created_at
+ FROM options_target_monitors
+ WHERE status = 'active'
+ ORDER BY id DESC
+ """
+ ).fetchall()
+ return [_row_to_target(r) for r in rows]
+
+
+def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
+ """已挂出平仓单、等待成交的目标(不再重复推送微信)."""
+ ensure_target_tables(conn)
+ rows = conn.execute(
+ """
+ SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
+ status, message, created_at
+ FROM options_target_monitors
+ WHERE status = 'closing'
+ ORDER BY id DESC
+ """
+ ).fetchall()
+ return [_row_to_target(r) for r in rows]
+
+
+def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
+ """UI/持仓挂载:active 与 closing 都算进行中."""
+ out: dict[str, dict[str, Any]] = {}
+ for t in list_closing_targets(conn) + list_active_targets(conn):
+ inst = str(t.get("inst_id") or "")
+ if inst and inst not in out:
+ out[inst] = t
+ return out
+
+
+def mark_monitor(
+ conn: sqlite3.Connection,
+ monitor_id: int,
+ *,
+ status: str,
+ trigger_idx: float | None = None,
+ close_ord_id: str | None = None,
+ message: str | None = None,
+) -> None:
+ conn.execute(
+ """
+ UPDATE options_target_monitors
+ SET status = ?,
+ trigger_idx = COALESCE(?, trigger_idx),
+ close_ord_id = COALESCE(?, close_ord_id),
+ message = COALESCE(?, message),
+ triggered_at = CASE
+ WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
+ ELSE triggered_at
+ END
+ WHERE id = ?
+ """,
+ (status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
+ )
+
+
+def cancel_orphans_without_position(
+ conn: sqlite3.Connection,
+ *,
+ live_inst_ids: set[str],
+) -> int:
+ """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
+ ensure_target_tables(conn)
+ rows = list_active_targets(conn) + list_closing_targets(conn)
+ n = 0
+ for t in rows:
+ inst = str(t.get("inst_id") or "")
+ if inst and inst not in live_inst_ids:
+ mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
+ n += 1
+ return n
+
+
+def _commit_monitor(conn: sqlite3.Connection) -> None:
+ """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
+ try:
+ conn.commit()
+ except Exception:
+ pass
+
+
+def close_option_by_bid_depth(
+ cfg: dict[str, Any],
+ ex: Any,
+ inst_id: str,
+ *,
+ sheets: int | None = None,
+) -> dict[str, Any]:
+ """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
+ from lib.options.options_close_exec_lib import close_option_by_bid1
+
+ return close_option_by_bid1(
+ cfg,
+ ex,
+ inst_id,
+ sheets=sheets,
+ require_recycle_gate=True,
+ signal_note="目标位平仓",
+ )
+
+
+
+def _notify_target_close(
+ send_wechat: Callable[[str], None] | None,
+ *,
+ account_label: str,
+ inst_id: str,
+ target: float,
+ idx: float,
+ result: dict[str, Any],
+) -> None:
+ if not send_wechat:
+ return
+ try:
+ send_wechat(
+ "\n".join(
+ [
+ "【OKX期权·目标位平仓】",
+ f"账户:{account_label}",
+ f"合约:{inst_id}",
+ f"目标指数:{target:g}",
+ f"触发指数:{idx:g}",
+ f"提交张数:{result.get('submitted_sheets') or '—'}",
+ f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
+ ]
+ )
+ )
+ except Exception:
+ pass
+
+
+def _result_fully_done(result: dict[str, Any]) -> bool:
+ if result.get("already_flat"):
+ return True
+ if result.get("fully_closed"):
+ return True
+ remaining = result.get("remaining_sheets")
+ if remaining is not None and int(remaining) <= 0 and result.get("ok"):
+ return True
+ return False
+
+
+def run_options_target_closes(
+ conn: sqlite3.Connection,
+ positions: list[dict[str, Any]],
+ *,
+ close_fn: Callable[[str], dict[str, Any]],
+ index_fn: Callable[[dict[str, Any]], float | None] | None = None,
+ send_wechat: Callable[[str], None] | None = None,
+ account_label: str = "OKX期权",
+) -> int:
+ """
+ 扫描 active 目标委托;指数到位后限价平仓.
+ 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
+ 未完全成交进入 closing,仅重试平仓不再推送.
+ 返回本次新触发(并推送)的条数.
+ """
+ ensure_target_tables(conn)
+ pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
+ live_ids = {k for k in pos_by_inst if k}
+ cancel_orphans_without_position(conn, live_inst_ids=live_ids)
+ _commit_monitor(conn)
+
+ # 先处理已挂单等待成交的,绝不再发微信
+ for mon in list_closing_targets(conn):
+ inst_id = str(mon.get("inst_id") or "")
+ if not inst_id:
+ continue
+ if inst_id not in pos_by_inst:
+ mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
+ _commit_monitor(conn)
+ continue
+ result = close_fn(inst_id)
+ idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
+ if result.get("already_flat") or _result_fully_done(result):
+ mark_monitor(
+ conn,
+ int(mon["id"]),
+ status="triggered",
+ trigger_idx=idx,
+ close_ord_id=result.get("close_ord_id"),
+ message="目标位限价平仓完成",
+ )
+ _commit_monitor(conn)
+ continue
+ mark_monitor(
+ conn,
+ int(mon["id"]),
+ status="closing",
+ trigger_idx=idx,
+ close_ord_id=result.get("close_ord_id"),
+ message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
+ )
+ _commit_monitor(conn)
+
+ triggered = 0
+ for mon in list_active_targets(conn):
+ inst_id = str(mon.get("inst_id") or "")
+ target = _safe_float(mon.get("target_index"))
+ if not inst_id or target is None:
+ continue
+ pos = pos_by_inst.get(inst_id)
+ if not pos:
+ continue
+ if index_fn is not None:
+ idx = index_fn(pos)
+ else:
+ idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
+ if idx is None:
+ continue
+ opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
+ if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
+ continue
+
+ result = close_fn(inst_id)
+ if result.get("already_flat"):
+ mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
+ _commit_monitor(conn)
+ continue
+ if not result.get("ok"):
+ mark_monitor(
+ conn,
+ int(mon["id"]),
+ status="active",
+ trigger_idx=idx,
+ message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
+ )
+ _commit_monitor(conn)
+ continue
+
+ done = _result_fully_done(result)
+ status = "triggered" if done else "closing"
+ mark_monitor(
+ conn,
+ int(mon["id"]),
+ status=status,
+ trigger_idx=idx,
+ close_ord_id=result.get("close_ord_id"),
+ message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
+ )
+ # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
+ _commit_monitor(conn)
+ triggered += 1
+ _notify_target_close(
+ send_wechat,
+ account_label=account_label,
+ inst_id=inst_id,
+ target=target,
+ idx=idx,
+ result=result,
+ )
+ return triggered
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index 336c7b6..7a3c90d 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -6,8 +6,8 @@
-
期权下单
-
报价单位为每 1 ETH/BTC;1 张 = 0.01.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 ~.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.
+
+
报价单位为每 1 ETH/BTC;1 张 = 0.01.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 ~.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.
- 多档平仓规则说明
+ 买一平仓规则说明
-
系统平仓前会重新读取最新买盘,不使用页面缓存。
+
平仓前重新读盘口并校验有效流动性;市价平仓已禁用。
- - 买一数量足够覆盖持仓时,只按买一价提交一笔限价卖单。
- - 买一不够时,先卖买一可覆盖数量;成交后刷新持仓和盘口,再继续用新的最优买盘拆分。
- - 最多尝试 5 次,全程使用限价卖出,并带
reduceOnly,不会主动市价平仓。
- - 盘口不足或订单未成交时会停止后续拆单,并提示剩余张数。
+ - 本轮只锁买一:张数 = min(持仓, 买一深度),限价 = 当场买一。
+ - 买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。
+ - 手动平仓只验有效买一(非残档);目标位自动平额外需可回收≥2×权利金并持续约 2 分钟。
+ - 全程
reduceOnly 限价卖,不吃买二及以下、不走市价。
+
打开《期权开平仓与监控说明》
@@ -257,4 +258,4 @@
-
+
diff --git a/tests/test_options_close_gate_lib.py b/tests/test_options_close_gate_lib.py
index 4ebba33..1d28bdd 100644
--- a/tests/test_options_close_gate_lib.py
+++ b/tests/test_options_close_gate_lib.py
@@ -3,7 +3,12 @@ from __future__ import annotations
import unittest
-from lib.options.options_close_gate_lib import clear_close_gate, update_close_gate
+from lib.options.options_close_gate_lib import (
+ clear_close_gate,
+ is_close_gate_passed,
+ mark_close_gate_passed,
+ update_close_gate,
+)
class OptionsCloseGateTests(unittest.TestCase):
@@ -38,6 +43,22 @@ class OptionsCloseGateTests(unittest.TestCase):
self.assertFalse(g_again["ready"])
self.assertAlmostEqual(g_again["held_seconds"], 0.0)
+ def test_passed_latches_after_ready(self):
+ update_close_gate("ETH-Y", recycle_usdc=20.0, premium_paid=10.0, now=1000.0)
+ g_ready = update_close_gate("ETH-Y", recycle_usdc=21.0, premium_paid=10.0, now=1120.0)
+ self.assertTrue(g_ready["ready"])
+ self.assertTrue(g_ready["passed"])
+ self.assertTrue(is_close_gate_passed("ETH-Y"))
+ # 后续回收跌破 2×:计时重置,但 passed 仍保留供续批只验流动性
+ g_drop = update_close_gate("ETH-Y", recycle_usdc=5.0, premium_paid=10.0, now=1130.0)
+ self.assertFalse(g_drop["recycle_ok"])
+ self.assertTrue(g_drop["passed"])
+ self.assertFalse(g_drop["auto_close_blocked"])
+
+ def test_mark_passed_manual(self):
+ mark_close_gate_passed("ETH-Z")
+ self.assertTrue(is_close_gate_passed("ETH-Z"))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py
index 397eb54..077c16b 100644
--- a/tests/test_options_pricing.py
+++ b/tests/test_options_pricing.py
@@ -185,11 +185,13 @@ def test_format_quote_liquidity():
def test_estimate_close_by_bids_full_depth():
from lib.options.options_pricing_lib import estimate_close_by_bids
+ # 多档估算需显式 max_levels;默认只估买一
out = estimate_close_by_bids(
[{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}],
4,
ct_mult=0.01,
premium_paid=0.4,
+ max_levels=5,
)
assert out["covered_sheets"] == 4
assert out["uncovered_sheets"] == 0
@@ -199,6 +201,16 @@ def test_estimate_close_by_bids_full_depth():
assert out["estimated_pnl_ratio_pct"] == 22.0
assert [x["sheets"] for x in out["levels"]] == [2, 2]
+ bid1 = estimate_close_by_bids(
+ [{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}],
+ 4,
+ ct_mult=0.01,
+ premium_paid=0.4,
+ )
+ assert bid1["covered_sheets"] == 2
+ assert bid1["uncovered_sheets"] == 2
+ assert [x["sheets"] for x in bid1["levels"]] == [2]
+
def test_estimate_close_by_bids_partial_depth():
from lib.options.options_pricing_lib import estimate_close_by_bids