From 1909eea6541322d644bb5a7a18145f6e7b9c7ec8 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 15 Jul 2026 21:49:06 +0800 Subject: [PATCH] Use bid1-only option closes with hard-disabled market exits. Manual close checks liquidity only; target auto still requires 2x recycle hold once, then reuses the shared bid1 executor. Add /options/guide doc and update hedge-plan refs. Co-authored-by: Cursor --- docs/env配置说明.md | 2 +- docs/对冲计划开发方案.md | 16 + docs/期权开平仓与监控说明.md | 87 + docs/期权方案.md | 8 +- docs/期权用法.md | 16 +- lib/common/static/options_panel.js | 42 +- lib/common/static/options_position_cards.js | 10 +- .../templates/hedge_plan_panel.html | 4 +- lib/options/options_close_exec_lib.py | 371 +++ lib/options/options_close_gate_lib.py | 35 +- lib/options/options_positions_lib.py | 15 +- lib/options/options_pricing_lib.py | 10 +- lib/options/options_register.py | 2420 ++++++++--------- lib/options/options_target_lib.py | 1256 +++------ lib/options/templates/options_panel.html | 19 +- tests/test_options_close_gate_lib.py | 23 +- tests/test_options_pricing.py | 12 + 17 files changed, 2136 insertions(+), 2210 deletions(-) create mode 100644 docs/期权开平仓与监控说明.md create mode 100644 lib/options/options_close_exec_lib.py diff --git a/docs/env配置说明.md b/docs/env配置说明.md index ada543c..65385a8 100644 --- a/docs/env配置说明.md +++ b/docs/env配置说明.md @@ -176,7 +176,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT | 默认标的 | 如 ETH | | 最大到期天数 | 等常用策略参数 | -高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md). +高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md),[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)(线上 `/options/guide`). --- diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md index 4cac6af..ac2b012 100644 --- a/docs/对冲计划开发方案.md +++ b/docs/对冲计划开发方案.md @@ -152,6 +152,20 @@ 盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期). +### 5.2.1 期权腿实盘平仓执行(与期权页共用) + +对冲计划凡**必须物理平掉期权腿**时(如永期止损联动、期期平盈利腿),执行口径与独立期权模块一致: + +| 规则 | 说明 | +|------|------| +| 禁市价 | 代码硬关闭,无市价兜底 | +| 只锁买一 | 本轮 `min(仓位, 买一深度)` × 买一限价;`reduceOnly` | +| 分批 | 买一不够则剩余下一轮再平再锁新买一 | +| 有效流动性 | 残档买一禁止按买盘平 | +| 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 | + +完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`. + --- ### 5.3 企业微信推送(起止必发) @@ -630,6 +644,7 @@ API 草图: | [期权对冲方案分析.md](./期权对冲方案分析.md) | 策略观念;本模块是其「计划化 + 自动执行」实现 | | [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) | P0 交付与口径校验 | | [期权方案.md](./期权方案.md) / [期权用法.md](./期权用法.md) | 期权 API、仅买方、限价规则必须遵守 | +| [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) | 买一平仓、门控、监控与风险;线上 `/options/guide` | | [position-sizing-mode.md](./position-sizing-mode.md) | 全仓公式与缓冲 0.98 | 本模块上线后,可在《期权对冲方案分析》末尾增加「系统对冲计划」链接指向本文. @@ -649,6 +664,7 @@ API 草图: - [x] 永期张数:**ETH/BTC 10x 全仓 × 0.98**,先算盈亏再选期权 - [x] 行情自动;永期期权 **列表式**;期期 **T 型** - [x] 对冲开关在前端 **env 配置 →「对冲计划」** 维护(仅 OKX);计仓/杠杆/期权 API 复用已有分组 +- [x] 期权腿实盘平仓:**禁市价、只锁买一、分批;流动性/2×门控见开平仓说明** --- diff --git a/docs/期权开平仓与监控说明.md b/docs/期权开平仓与监控说明.md new file mode 100644 index 0000000..c072f94 --- /dev/null +++ b/docs/期权开平仓与监控说明.md @@ -0,0 +1,87 @@ +# OKX 期权 — 开平仓与监控说明 + +> 独立页查看(登录后):`/options/guide` +> 对冲计划侧同步见 [对冲计划开发方案.md](./对冲计划开发方案.md) §期权腿开平仓. + +--- + +## 1. 开仓方式 + +| 项 | 规则 | +|----|------| +| 方向 | **仅买方**(Call / Put 限价买入) | +| 价格 | **卖一 ask** 限价;无卖一时可用标记估算展示,实下单仍以可挂限价为准 | +| 张数 | 1 张 = 0.01 ETH/BTC;可按预算打满或指定数量 | +| 资金 | 交易账户 **USDC**(或 USDG);不自动兑划 | +| 入口 | OKX 实例 **期权** 页列表 / T 型;对冲计划可带期权腿开仓 | + +开仓后写入 `options_trades`(open),并在持仓卡展示权利金、买盘深度、按买一可回收等. + +--- + +## 2. 平仓方式 + +### 2.1 统一规则(手动 / 目标自动共用执行核) + +1. **禁止市价平仓**(代码硬关闭,忽略 `OKX_OPTIONS_ALLOW_MARKET_CLOSE`). +2. **只锁买一**:本轮张数 = `min(持仓, 买一深度)`,限价 = 校验通过当刻的买一价. +3. **不吃买二及以下**;买一不够则只平本轮能吃掉的部分,**剩余仓位保留**,下次再平再锁新的买一. +4. 全程 `reduceOnly` 限价卖. + +示例:持仓 300、买一深度 200 → 本轮只平 200;剩 100 等下次「买一平仓」或目标位再次触发. + +### 2.2 手动「买一平仓」 + +- 入口:持仓卡按钮. +- **只校验有效流动性**(买一非残档、有深度). +- **不卡**「回收 ≥ 2×权利金」门控(用户主动平仓). + +### 2.3 目标位自动平仓 + +- 设置目标指数后由监控轮询;Call 指数 ≥ 目标 / Put 指数 ≤ 目标触发. +- 触发后走同一买一执行核. +- **额外门控**:买一可回收 ≥ **权利金 × 2**,且该状态连续保持默认 **120 秒**(env:`OKX_OPTIONS_CLOSE_RECYCLE_MULT` / `OKX_OPTIONS_CLOSE_HOLD_SECONDS`). +- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时. +- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂. + +--- + +## 3. 监控逻辑 + +| 监控 | 行为 | +|------|------| +| 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** | +| 残档买一 | 买一 ≪ 标记/内在价值(默认 < 30%) → 禁止按买盘平,UI 显示无效 | +| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | +| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 | +| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | + +--- + +## 4. 平仓校验(门控) + +| 门控 | 手动买一平 | 目标自动平 | 说明 | +|------|------------|------------|------| +| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 | +| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 | +| 锁定买一价 | ✅ | ✅ | 下单价 = 通过校验时的买一 | +| 市价兜底 | ❌ | ❌ | 永不市价 | + +--- + +## 5. 风险点 + +1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤单,限价可能挂而不成交. +2. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价. +3. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期. +4. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期. +5. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」. +6. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节. + +--- + +## 6. 相关文档 + +- [期权用法.md](./期权用法.md) — 资金兑划与页面操作 +- [期权方案.md](./期权方案.md) — env 与架构 +- [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿 diff --git a/docs/期权方案.md b/docs/期权方案.md index 7fe0369..f56c11f 100644 --- a/docs/期权方案.md +++ b/docs/期权方案.md @@ -92,9 +92,14 @@ OKX_OPTIONS_ITM_MAX_DIST_USD=30 OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_TD_MODE=cross -OKX_OPTIONS_ALLOW_MARKET_CLOSE=false +# 市价平仓已在代码中硬关闭,此变量无效,可删 +# OKX_OPTIONS_ALLOW_MARKET_CLOSE=false +OKX_OPTIONS_CLOSE_RECYCLE_MULT=2 +OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 ``` +平仓执行:**只锁买一限价**,说明见 [期权开平仓与监控说明.md](./期权开平仓与监控说明.md);线上 `/options/guide`. + 修改 `.env` 后须 `pm2 restart crypto_okx`. ## 6. 数据库 @@ -112,6 +117,7 @@ OKX_OPTIONS_ALLOW_MARKET_CLOSE=false | 方法 | 路径 | |------|------| | GET | `/options` | +| GET | `/options/guide` | 开平仓与监控说明(独立页) | | GET | `/api/options/balances` | | GET | `/api/options/chain` | | GET | `/api/options/quote` | diff --git a/docs/期权用法.md b/docs/期权用法.md index 220cb4c..d4721d9 100644 --- a/docs/期权用法.md +++ b/docs/期权用法.md @@ -58,15 +58,19 @@ OKX_OPTIONS_API_PASSPHRASE=... ## 4. 持仓与平仓 -持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,浮盈,收益率,到期等. +持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,净盈亏,收益率,到期等. -**平仓(锁利/止损):** +**买一平仓:** -1. 在持仓行点击 **平仓** -2. 查看 **买一价** 与预估收回 -3. 确认 **限价卖出**(价格 = 买一) +1. 在持仓卡点击 **买一平仓** +2. 系统重读盘口并校验有效买一(非残档) +3. 本轮只按买一深度限价卖出;买一不够则剩余下次再平 +4. **市价平仓已禁用**(代码硬关闭) -> 默认不使用市价平仓.若 `.env` 开启 `OKX_OPTIONS_ALLOW_MARKET_CLOSE=true`,市价按钮会出现并带风险提示. +目标位自动平另需「可回收 ≥ 2×权利金并持续约 2 分钟」;细则见独立说明: + +- 仓库文档:[期权开平仓与监控说明.md](./期权开平仓与监控说明.md) +- 线上(登录后):`/options/guide` ## 5. 微信提醒 diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index a1be281..1a1f77d 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -490,12 +490,13 @@ function closeGateHint(preview) { if (!preview) return ""; - if (preview.bid_invalid) { - return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓"; + if (preview.bid_invalid || preview.manual_close_blocked) { + return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓"; } const gate = preview.close_gate || {}; - if (preview.close_gate_blocked || gate.ready === false) { - return preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟后才可平仓"; + // 2×门控仅约束目标位自动平;手动买一平仓不拦截,作状态提示 + if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { + return "自动平仓: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); } return ""; } @@ -981,7 +982,7 @@ '
' + (p.inst_id || "") + '' + '' + optTypeLabel(p.opt_type) + "
" + '
' + - '' + + '' + "
" + '
' + '行权价: ' + fmt(p.strike, 0) + "" + @@ -1304,30 +1305,22 @@ return; } const preview = q.close_preview || {}; - if (preview.bid_invalid) { - alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止按买盘自动平仓。请到 OKX App 自行挂限价/市价。"); - return; - } - if (preview.close_gate_blocked || (preview.close_gate && preview.close_gate.ready === false)) { - alert( - preview.close_gate_msg || - (preview.close_gate && preview.close_gate.msg) || - "可回收需≥2×权利金,并持续满2分钟后才可平仓" - ); + if (preview.bid_invalid || preview.manual_close_blocked) { + alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止买一平仓。"); return; } if (!preview.covered_sheets || preview.covered_sheets <= 0) { - alert("暂无可用买盘深度,请稍后在 OKX App 平仓或等盘口恢复"); + alert("暂无有效买一深度,请稍后重试或到 OKX App 挂限价"); return; } + const lv = (preview.levels && preview.levels[0]) || {}; const msg = [ - "按最多5档买盘拆分限价卖出?", + "按买一限价卖出本轮可平张数?", "合约: " + inst, + "锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张", "预计收回: " + fmtClosePreviewText(preview), preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "", - "", - fmtPreviewLevels(preview), - preview.uncovered_sheets > 0 ? "\n注意: 当前买盘不足,预计仍剩 " + preview.uncovered_sheets + " 张未覆盖。" : "" + preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : "" ].filter(function (x) { return x !== ""; }).join("\n"); if (!confirm(msg)) return; if (btn) btn.disabled = true; @@ -1335,13 +1328,14 @@ const r = await apiJson("/api/options/close", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inst_id: inst, mode: "depth_split", sheets: sheets }), + body: JSON.stringify({ inst_id: inst, mode: "bid1", sheets: sheets }), }); if (r.ok) { - let okMsg = "平仓单已提交 " + (r.submitted_sheets || 0) + " 张"; + let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张"; + if (r.locked_bid_px != null) okMsg += "\n锁定买一: " + r.locked_bid_px; if (r.premium_received != null) okMsg += "\n预估收回: " + fmt(r.premium_received, 4) + " USDC"; - if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张"; - if (r.stopped_reason) okMsg += "\n停止原因: " + r.stopped_reason; + if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)"; + if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason; alert(okMsg); } else { alert(r.msg || "平仓失败"); diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index b5609b9..55c823e 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -66,12 +66,12 @@ function closeGateHint(preview) { if (!preview) return ""; - if (preview.bid_invalid) { - return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓"; + if (preview.bid_invalid || preview.manual_close_blocked) { + return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓"; } const gate = preview.close_gate || {}; - if (preview.close_gate_blocked || gate.ready === false) { - return preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟后才可平仓"; + if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { + return "自动平仓: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); } return ""; } @@ -138,7 +138,7 @@ const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; headActions = '
' + - '' + + '' + "
"; } return ( diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index bec5c75..5e53529 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -13,7 +13,9 @@
-

对冲计划 测算 · 下单

+

对冲计划 测算 · 下单 + 期权开平仓与监控说明 +

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 档,可展开全部.平仓仅买一限价,见说明.

@@ -137,15 +137,16 @@
- 多档平仓规则说明 + 买一平仓规则说明
-

系统平仓前会重新读取最新买盘,不使用页面缓存。

+

平仓前重新读盘口并校验有效流动性;市价平仓已禁用。

    -
  • 买一数量足够覆盖持仓时,只按买一价提交一笔限价卖单。
  • -
  • 买一不够时,先卖买一可覆盖数量;成交后刷新持仓和盘口,再继续用新的最优买盘拆分。
  • -
  • 最多尝试 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