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 |
| 最大到期天数 | 等常用策略参数 |
高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md).
高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md),[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)(线上 `/options/guide`).
---
+16
View File
@@ -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×门控见开平仓说明**
---
+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_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` |
+10 -6
View File
@@ -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. 微信提醒
+18 -24
View File
@@ -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 @@
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<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 class="pos-meta">' +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
@@ -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 || "平仓失败");
+5 -5
View File
@@ -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 =
'<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>";
}
return (
@@ -13,7 +13,9 @@
<div class="card hp-head-card">
<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>
</div>
<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()
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,
}
+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_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
+7 -3
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -6,8 +6,8 @@
<div class="options-dual-grid">
<div class="card options-order-card">
<h2>期权下单</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>
<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>
<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" data-uly="BTC">BTC</button>
@@ -137,15 +137,16 @@
<div id="opt-pos-cards"></div>
</div>
<details class="opt-close-rule">
<summary>多档平仓规则说明</summary>
<summary>买一平仓规则说明</summary>
<div class="opt-close-rule-body">
<p>系统平仓前重新读取最新买盘,不使用页面缓存</p>
<p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用</p>
<ul>
<li>买一数量足够覆盖持仓时,只按买一价提交一笔限价卖单</li>
<li>买一不够时,先卖买一可覆盖数量;成交后刷新持仓和盘口,再继续用新的最优买盘拆分</li>
<li>最多尝试 5 次,全程使用限价卖出,并带 <code>reduceOnly</code>,不会主动市价平仓</li>
<li>盘口不足或订单未成交时会停止后续拆单,并提示剩余张数</li>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」</li>
<li>手动平仓只验有效买一(非残档);目标位自动平额外需可回收≥2×权利金并持续约 2 分钟</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
</details>
</div>
@@ -257,4 +258,4 @@
</div>
</div>
<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
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()
+12
View File
@@ -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