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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-15 21:49:06 +08:00
parent ab8b2a74e2
commit 1909eea654
17 changed files with 2136 additions and 2210 deletions
+1 -1
View File
@@ -176,7 +176,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 默认标的 | 如 ETH | | 默认标的 | 如 ETH |
| 最大到期天数 | 等常用策略参数 | | 最大到期天数 | 等常用策略参数 |
高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md). 高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md),[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)(线上 `/options/guide`).
--- ---
+16
View File
@@ -152,6 +152,20 @@
盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期). 盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期).
### 5.2.1 期权腿实盘平仓执行(与期权页共用)
对冲计划凡**必须物理平掉期权腿**时(如永期止损联动、期期平盈利腿),执行口径与独立期权模块一致:
| 规则 | 说明 |
|------|------|
| 禁市价 | 代码硬关闭,无市价兜底 |
| 只锁买一 | 本轮 `min(仓位, 买一深度)` × 买一限价;`reduceOnly` |
| 分批 | 买一不够则剩余下一轮再平再锁新买一 |
| 有效流动性 | 残档买一禁止按买盘平 |
| 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 |
完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`.
--- ---
### 5.3 企业微信推送(起止必发) ### 5.3 企业微信推送(起止必发)
@@ -630,6 +644,7 @@ API 草图:
| [期权对冲方案分析.md](./期权对冲方案分析.md) | 策略观念;本模块是其「计划化 + 自动执行」实现 | | [期权对冲方案分析.md](./期权对冲方案分析.md) | 策略观念;本模块是其「计划化 + 自动执行」实现 |
| [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) | P0 交付与口径校验 | | [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) | P0 交付与口径校验 |
| [期权方案.md](./期权方案.md) / [期权用法.md](./期权用法.md) | 期权 API、仅买方、限价规则必须遵守 | | [期权方案.md](./期权方案.md) / [期权用法.md](./期权用法.md) | 期权 API、仅买方、限价规则必须遵守 |
| [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) | 买一平仓、门控、监控与风险;线上 `/options/guide` |
| [position-sizing-mode.md](./position-sizing-mode.md) | 全仓公式与缓冲 0.98 | | [position-sizing-mode.md](./position-sizing-mode.md) | 全仓公式与缓冲 0.98 |
本模块上线后,可在《期权对冲方案分析》末尾增加「系统对冲计划」链接指向本文. 本模块上线后,可在《期权对冲方案分析》末尾增加「系统对冲计划」链接指向本文.
@@ -649,6 +664,7 @@ API 草图:
- [x] 永期张数:**ETH/BTC 10x 全仓 × 0.98**,先算盈亏再选期权 - [x] 永期张数:**ETH/BTC 10x 全仓 × 0.98**,先算盈亏再选期权
- [x] 行情自动;永期期权 **列表式**;期期 **T 型** - [x] 行情自动;永期期权 **列表式**;期期 **T 型**
- [x] 对冲开关在前端 **env 配置 →「对冲计划」** 维护(仅 OKX);计仓/杠杆/期权 API 复用已有分组 - [x] 对冲开关在前端 **env 配置 →「对冲计划」** 维护(仅 OKX);计仓/杠杆/期权 API 复用已有分组
- [x] 期权腿实盘平仓:**禁市价、只锁买一、分批;流动性/2×门控见开平仓说明**
--- ---
+87
View File
@@ -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. 监控逻辑
| 监控 | 行为 |
|------|------|
| 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** |
| 残档买一 | 买一 ≪ 标记/内在价值(默认 &lt; 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) — 永期/期期与期权腿
+7 -1
View File
@@ -92,9 +92,14 @@ OKX_OPTIONS_ITM_MAX_DIST_USD=30
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=cross 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`. 修改 `.env` 后须 `pm2 restart crypto_okx`.
## 6. 数据库 ## 6. 数据库
@@ -112,6 +117,7 @@ OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
| 方法 | 路径 | | 方法 | 路径 |
|------|------| |------|------|
| GET | `/options` | | GET | `/options` |
| GET | `/options/guide` | 开平仓与监控说明(独立页) |
| GET | `/api/options/balances` | | GET | `/api/options/balances` |
| GET | `/api/options/chain` | | GET | `/api/options/chain` |
| GET | `/api/options/quote` | | GET | `/api/options/quote` |
+10 -6
View File
@@ -58,15 +58,19 @@ OKX_OPTIONS_API_PASSPHRASE=...
## 4. 持仓与平仓 ## 4. 持仓与平仓
持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,浮盈,收益率,到期等. 持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,净盈亏,收益率,到期等.
**平仓(锁利/止损):** **买一平仓:**
1. 在持仓点击 **平仓** 1. 在持仓点击 **买一平仓**
2. 查看 **买一价** 与预估收回 2. 系统重读盘口并校验有效买一(非残档)
3. 确认 **限价卖出**(价格 = 买一) 3. 本轮只按买一深度限价卖出;买一不够则剩余下次再平
4. **市价平仓已禁用**(代码硬关闭)
> 默认不使用市价平仓.若 `.env` 开启 `OKX_OPTIONS_ALLOW_MARKET_CLOSE=true`,市价按钮会出现并带风险提示. 目标位自动平另需「可回收 ≥ 2×权利金并持续约 2 分钟」;细则见独立说明:
- 仓库文档:[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)
- 线上(登录后):`/options/guide`
## 5. 微信提醒 ## 5. 微信提醒
+18 -24
View File
@@ -490,12 +490,13 @@
function closeGateHint(preview) { function closeGateHint(preview) {
if (!preview) return ""; if (!preview) return "";
if (preview.bid_invalid) { if (preview.bid_invalid || preview.manual_close_blocked) {
return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓"; return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓";
} }
const gate = preview.close_gate || {}; const gate = preview.close_gate || {};
if (preview.close_gate_blocked || gate.ready === false) { // 2×门控仅约束目标位自动平;手动买一平仓不拦截,作状态提示
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 ""; return "";
} }
@@ -981,7 +982,7 @@
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' + '<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" + '<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<div class="pos-head-actions">' + '<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">多档平仓</button>' + '<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div></div>" + "</div></div>" +
'<div class="pos-meta">' + '<div class="pos-meta">' +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" + '<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
@@ -1304,30 +1305,22 @@
return; return;
} }
const preview = q.close_preview || {}; const preview = q.close_preview || {};
if (preview.bid_invalid) { if (preview.bid_invalid || preview.manual_close_blocked) {
alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止按买盘自动平仓。请到 OKX App 自行挂限价/市价。"); alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止买一平仓。");
return;
}
if (preview.close_gate_blocked || (preview.close_gate && preview.close_gate.ready === false)) {
alert(
preview.close_gate_msg ||
(preview.close_gate && preview.close_gate.msg) ||
"可回收需≥2×权利金,并持续满2分钟后才可平仓"
);
return; return;
} }
if (!preview.covered_sheets || preview.covered_sheets <= 0) { if (!preview.covered_sheets || preview.covered_sheets <= 0) {
alert("暂无可用买盘深度,请稍后 OKX App 平仓或等盘口恢复"); alert("暂无有效买一深度,请稍后重试或到 OKX App 挂限价");
return; return;
} }
const lv = (preview.levels && preview.levels[0]) || {};
const msg = [ const msg = [
"按最多5档买盘拆分限价卖出?", "按买一限价卖出本轮可平张数?",
"合约: " + inst, "合约: " + inst,
"锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张",
"预计收回: " + fmtClosePreviewText(preview), "预计收回: " + fmtClosePreviewText(preview),
preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "", preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "",
"", preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : ""
fmtPreviewLevels(preview),
preview.uncovered_sheets > 0 ? "\n注意: 当前买盘不足,预计仍剩 " + preview.uncovered_sheets + " 张未覆盖。" : ""
].filter(function (x) { return x !== ""; }).join("\n"); ].filter(function (x) { return x !== ""; }).join("\n");
if (!confirm(msg)) return; if (!confirm(msg)) return;
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
@@ -1335,13 +1328,14 @@
const r = await apiJson("/api/options/close", { const r = await apiJson("/api/options/close", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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) { 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.premium_received != null) okMsg += "\n预估收回: " + fmt(r.premium_received, 4) + " USDC";
if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张"; if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)";
if (r.stopped_reason) okMsg += "\n停止原因: " + r.stopped_reason; if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason;
alert(okMsg); alert(okMsg);
} else { } else {
alert(r.msg || "平仓失败"); alert(r.msg || "平仓失败");
+5 -5
View File
@@ -66,12 +66,12 @@
function closeGateHint(preview) { function closeGateHint(preview) {
if (!preview) return ""; if (!preview) return "";
if (preview.bid_invalid) { if (preview.bid_invalid || preview.manual_close_blocked) {
return preview.bid_invalid_reason || "当前买一无效,禁止按买盘自动平仓"; return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓";
} }
const gate = preview.close_gate || {}; const gate = preview.close_gate || {};
if (preview.close_gate_blocked || gate.ready === false) { if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟后才可平仓"; return "自动平仓: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
} }
return ""; return "";
} }
@@ -138,7 +138,7 @@
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
headActions = headActions =
'<div class="pos-head-actions">' + '<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">多档平仓</button>' + '<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div>"; "</div>";
} }
return ( return (
@@ -13,7 +13,9 @@
<div class="card hp-head-card"> <div class="card hp-head-card">
<div class="hp-head-row"> <div class="hp-head-row">
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span></h2> <h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
</h2>
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button> <button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div> </div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类"> <div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
+371
View File
@@ -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="目标位平仓",
)
+30 -5
View File
@@ -40,6 +40,26 @@ def clear_close_gate(inst_id: str | None = None) -> None:
_gates.clear() _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( def update_close_gate(
inst_id: str, inst_id: str,
*, *,
@@ -86,6 +106,8 @@ def update_close_gate(
ok_since = None ok_since = None
held = (ts - float(ok_since)) if ok_since is not None else 0.0 held = (ts - float(ok_since)) if ok_since is not None else 0.0
ready = bool(recycle_ok and held + 1e-9 >= hold) ready = bool(recycle_ok and held + 1e-9 >= hold)
prev_passed = bool(prev.get("passed"))
passed = prev_passed or ready
state = { state = {
"ok_since": ok_since, "ok_since": ok_since,
"recycle": recv, "recycle": recv,
@@ -94,6 +116,7 @@ def update_close_gate(
"updated": ts, "updated": ts,
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"passed": passed,
} }
_gates[inst] = state _gates[inst] = state
@@ -103,18 +126,20 @@ def update_close_gate(
elif recv is None: elif recv is None:
msg = "暂无有效买盘可回收金额" msg = "暂无有效买盘可回收金额"
elif not recycle_ok: 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: elif not ready:
msg = ( msg = (
f"可回收已达×{mult:g}({recv:.4f}/{need:.4f})," 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: else:
msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,允许按买平仓" msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,允许自动按买平仓"
auto_blocked = not (ready or passed)
return { return {
"ok": True, "ok": True,
"ready": ready, "ready": ready,
"passed": passed,
"recycle_ok": recycle_ok, "recycle_ok": recycle_ok,
"recycle_usdc": recv, "recycle_usdc": recv,
"premium_paid": prem, "premium_paid": prem,
@@ -125,8 +150,8 @@ def update_close_gate(
"remain_seconds": round(remain, 1) if remain is not None else None, "remain_seconds": round(remain, 1) if remain is not None else None,
"ok_since": ok_since, "ok_since": ok_since,
"msg": msg, "msg": msg,
"auto_close_blocked": not ready, "auto_close_blocked": auto_blocked,
"close_gate_blocked": not ready, "close_gate_blocked": auto_blocked,
} }
+11 -4
View File
@@ -5,7 +5,7 @@ from typing import Any
from lib.options.options_db import init_options_tables from lib.options.options_db import init_options_tables
from lib.options.options_history_lib import enrich_position_row_display 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 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("strike") or row.get("stk")),
_safe_float(row.get("idx_px") or row.get("idxPx")), _safe_float(row.get("idx_px") or row.get("idxPx")),
) )
# 与实盘一致:只按买一估算本轮可平
preview = estimate_close_by_bids( preview = estimate_close_by_bids(
row["bid_depth"], row["bid_depth"],
target_sheets, target_sheets,
@@ -48,23 +49,29 @@ def attach_close_preview(
premium_paid=paid, premium_paid=paid,
mark_px=mark_px, mark_px=mark_px,
intrinsic_px=intrinsic, intrinsic_px=intrinsic,
max_levels=1,
) )
# 残档时不累计 2×权利金门控;有效回收时刷新持续计时 # 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"): if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid) gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid)
preview["close_gate"] = gate preview["close_gate"] = gate
preview["close_gate_blocked"] = True preview["close_gate_blocked"] = True
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg") preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
preview["manual_close_blocked"] = True
preview["liquidity_ok"] = False
else: else:
gate = update_close_gate( gate = update_close_gate(
inst_id, inst_id,
recycle_usdc=_safe_float(preview.get("total_received")), recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid, 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"] = gate
preview["close_gate_blocked"] = not gate.get("ready") preview["close_gate_blocked"] = not passed
preview["close_gate_msg"] = gate.get("msg") 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 preview["auto_close_blocked"] = True
row["close_preview"] = preview row["close_preview"] = preview
return row return row
+7 -3
View File
@@ -166,12 +166,14 @@ def estimate_close_by_bids(
mark_px: float | None = None, mark_px: float | None = None,
intrinsic_px: float | None = None, intrinsic_px: float | None = None,
min_bid_ratio: float = BID_CLOSE_MIN_RATIO, min_bid_ratio: float = BID_CLOSE_MIN_RATIO,
max_levels: int = 1,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""按买一到买N逐档估算限价卖出可收回金额;残档买盘不参与估算与自动平仓.""" """按买估算限价卖出可收回金额;默认只估算买一(与实盘平仓一致);残档不参与."""
target = max(0, int(float(sheets or 0))) target = max(0, int(float(sheets or 0)))
remaining = target remaining = target
total_received = 0.0 total_received = 0.0
levels: list[dict[str, Any]] = [] levels: list[dict[str, Any]] = []
max_lv = max(1, int(max_levels or 1))
empty = { empty = {
"levels": [], "levels": [],
"covered_sheets": 0, "covered_sheets": 0,
@@ -183,6 +185,7 @@ def estimate_close_by_bids(
"bid_invalid": False, "bid_invalid": False,
"bid_invalid_reason": None, "bid_invalid_reason": None,
"auto_close_blocked": False, "auto_close_blocked": False,
"max_levels": max_lv,
} }
if target <= 0 or ct_mult <= 0: if target <= 0 or ct_mult <= 0:
return empty return empty
@@ -196,7 +199,7 @@ def estimate_close_by_bids(
out["auto_close_blocked"] = True out["auto_close_blocked"] = True
out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None
return out return out
for i, level in enumerate(usable, start=1): for i, level in enumerate(usable[:max_lv], start=1):
if remaining <= 0: if remaining <= 0:
break break
try: try:
@@ -223,7 +226,7 @@ def estimate_close_by_bids(
remaining -= take remaining -= take
covered = target - remaining covered = target - remaining
avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None
# 净盈亏 = 买盘可回收 全部权利金(与「可落袋」口径一致;买一不够会展开更多档) # 净盈亏 = 本轮买盘可回收 全部权利金(买一不够时剩余张数计入 uncovered)
estimated_pnl = None estimated_pnl = None
estimated_pnl_ratio_pct = None estimated_pnl_ratio_pct = None
if premium_paid is not None and covered > 0: if premium_paid is not None and covered > 0:
@@ -242,6 +245,7 @@ def estimate_close_by_bids(
"bid_invalid": False, "bid_invalid": False,
"bid_invalid_reason": None, "bid_invalid_reason": None,
"auto_close_blocked": False, "auto_close_blocked": False,
"max_levels": max_lv,
} }
+66 -320
View File
@@ -11,20 +11,13 @@ from jinja2 import ChoiceLoader, FileSystemLoader
from lib.options.options_db import init_options_tables from lib.options.options_db import init_options_tables
from lib.options.options_monitor_lib import options_monitor_loop 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 ( from lib.options.options_pricing_lib import (
calc_order_size, calc_order_size,
close_ref_prices,
ct_mult_from_meta, ct_mult_from_meta,
estimate_close_by_bids,
fetch_option_mark_px,
filter_bids_for_close,
is_stub_bid_px,
min_sz_from_meta, min_sz_from_meta,
premium_per_sheet, premium_per_sheet,
total_premium,
) )
from lib.exchange.okx_options_lib import _pos_side_from_position, _safe_float, td_mode_for_option_buy from lib.exchange.okx_options_lib import _safe_float, td_mode_for_option_buy
def _env_bool(key: str, default: bool = False) -> bool: def _env_bool(key: str, default: bool = False) -> bool:
@@ -112,7 +105,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.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), "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0),
"td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
"allow_market_close": _env_bool("OKX_OPTIONS_ALLOW_MARKET_CLOSE", False), # 市价平仓已硬关闭(忽略 env),仅买一限价
"allow_market_close": False,
"profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0),
"poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0),
"account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(),
@@ -298,6 +292,52 @@ def _sync_options_trades(
def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
lr = cfg["login_required"] 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(
"""
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>期权开平仓与监控说明</title>
<style>
:root { color-scheme: light dark; }
body { font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
max-width: 860px; margin: 24px auto; padding: 0 16px 48px; line-height: 1.65; }
h1,h2,h3 { line-height: 1.3; }
code, pre { font-family: ui-monospace, Consolas, monospace; }
pre { overflow: auto; padding: 12px; border-radius: 8px; background: rgba(127,127,127,.12); }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid rgba(127,127,127,.35); padding: 8px 10px; text-align: left; }
a { color: #0b6bcb; }
.top { margin-bottom: 16px; }
</style>
</head>
<body>
<p class="top"><a href="/options">← 返回期权</a> · <a href="/hedge_plan">对冲计划</a></p>
{{ body|safe }}
</body>
</html>
""",
body=body,
)
@app.route("/api/options/balances") @app.route("/api/options/balances")
@lr @lr
def api_options_balances(): def api_options_balances():
@@ -733,227 +773,31 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": err}) return jsonify({"ok": False, "msg": err})
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip() 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: if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"}) return jsonify({"ok": False, "msg": "缺少 inst_id"})
if data.get("market"):
return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"})
sheets = data.get("sheets") sheets = data.get("sheets")
q = cfg["quote_option_contract"](ex, inst_id) try:
bid = q.get("bid") sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None
if not use_market and not depth_split and (bid is None or bid <= 0): except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"}) return jsonify({"ok": False, "msg": "张数无效"})
raw_positions = cfg["fetch_option_positions"](ex) from lib.options.options_close_exec_lib import close_option_by_bid1
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")) # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓
if mark_px is None: result = close_option_by_bid1(
mark_px = fetch_option_mark_px(ex, inst_id) cfg,
opt_type = pos.get("optType") or q.get("opt_type") ex,
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, inst_id,
recycle_usdc=_safe_float(preview_gate.get("total_received")), sheets=sheets_i,
premium_paid=paid, require_recycle_gate=False,
) )
if not gate.get("ready"): if result.get("ok"):
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 from lib.exchange.okx_options_lib import invalidate_option_positions_cache
invalidate_option_positions_cache() invalidate_option_positions_cache()
_sync_options_trades(cfg, force=True) _sync_options_trades(cfg, force=True)
if result.get("fully_closed"):
try: try:
from lib.options.options_target_lib import cancel_target_monitor from lib.options.options_target_lib import cancel_target_monitor
@@ -965,106 +809,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn2.close() conn2.close()
except Exception: except Exception:
pass pass
return jsonify( _mark_balances_stale(cfg)
{ return jsonify(result)
"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"]) @app.route("/api/options/convert/quote", methods=["POST"])
@lr @lr
+9 -379
View File
@@ -6,15 +6,7 @@ import time
from typing import Any, Callable from typing import Any, Callable
from lib.options.options_db import init_options_tables 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, fetch_option_mark_px
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: def _safe_float(v: Any) -> float | None:
@@ -285,380 +277,18 @@ def close_option_by_bid_depth(
*, *,
sheets: int | None = None, sheets: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""目标触发后仅用买一/买盘限价卖出(最多5档);无买一则等待下次轮询,不用标记价.""" """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
from lib.exchange.okx_options_lib import ( from lib.options.options_close_exec_lib import close_option_by_bid1
_pos_side_from_position,
invalidate_option_positions_cache,
)
q = cfg["quote_option_contract"](ex, inst_id) return close_option_by_bid1(
if not q.get("ok"): cfg,
return {"ok": False, "msg": q.get("msg") or "报价失败"} ex,
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, inst_id,
recycle_usdc=_safe_float(preview0.get("total_received")), sheets=sheets,
premium_paid=premium_paid, require_recycle_gate=True,
signal_note="目标位平仓",
) )
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( def _notify_target_close(
+10 -9
View File
@@ -6,8 +6,8 @@
<div class="options-dual-grid"> <div class="options-dual-grid">
<div class="card options-order-card"> <div class="card options-order-card">
<h2>期权下单</h2> <h2>期权下单 <a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">开平仓与监控说明</a></h2>
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 <strong>~</strong>.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.</p> <p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 <strong>~</strong>.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.</p>
<div class="form-row options-chain-toolbar"> <div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button> <button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
@@ -137,15 +137,16 @@
<div id="opt-pos-cards"></div> <div id="opt-pos-cards"></div>
</div> </div>
<details class="opt-close-rule"> <details class="opt-close-rule">
<summary>多档平仓规则说明</summary> <summary>买一平仓规则说明</summary>
<div class="opt-close-rule-body"> <div class="opt-close-rule-body">
<p>系统平仓前重新读取最新买盘,不使用页面缓存</p> <p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用</p>
<ul> <ul>
<li>买一数量足够覆盖持仓时,只按买一价提交一笔限价卖单</li> <li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一</li>
<li>买一不够时,先卖买一可覆盖数量;成交后刷新持仓和盘口,再继续用新的最优买盘拆分</li> <li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」</li>
<li>最多尝试 5 次,全程使用限价卖出,并带 <code>reduceOnly</code>,不会主动市价平仓</li> <li>手动平仓只验有效买一(非残档);目标位自动平额外需可回收≥2×权利金并持续约 2 分钟</li>
<li>盘口不足或订单未成交时会停止后续拆单,并提示剩余张数</li> <li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价</li>
</ul> </ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div> </div>
</details> </details>
</div> </div>
@@ -257,4 +258,4 @@
</div> </div>
</div> </div>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=28"></script> <script src="/static/options_panel.js?v=29"></script>
+22 -1
View File
@@ -3,7 +3,12 @@ from __future__ import annotations
import unittest 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): class OptionsCloseGateTests(unittest.TestCase):
@@ -38,6 +43,22 @@ class OptionsCloseGateTests(unittest.TestCase):
self.assertFalse(g_again["ready"]) self.assertFalse(g_again["ready"])
self.assertAlmostEqual(g_again["held_seconds"], 0.0) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+12
View File
@@ -185,11 +185,13 @@ def test_format_quote_liquidity():
def test_estimate_close_by_bids_full_depth(): def test_estimate_close_by_bids_full_depth():
from lib.options.options_pricing_lib import estimate_close_by_bids from lib.options.options_pricing_lib import estimate_close_by_bids
# 多档估算需显式 max_levels;默认只估买一
out = estimate_close_by_bids( out = estimate_close_by_bids(
[{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}], [{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}],
4, 4,
ct_mult=0.01, ct_mult=0.01,
premium_paid=0.4, premium_paid=0.4,
max_levels=5,
) )
assert out["covered_sheets"] == 4 assert out["covered_sheets"] == 4
assert out["uncovered_sheets"] == 0 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 out["estimated_pnl_ratio_pct"] == 22.0
assert [x["sheets"] for x in out["levels"]] == [2, 2] 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(): def test_estimate_close_by_bids_partial_depth():
from lib.options.options_pricing_lib import estimate_close_by_bids from lib.options.options_pricing_lib import estimate_close_by_bids