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:
+1
-1
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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×门控见开平仓说明**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) — 永期/期期与期权腿
|
||||
+7
-1
@@ -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
@@ -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. 微信提醒
|
||||
|
||||
|
||||
@@ -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 || "平仓失败");
|
||||
|
||||
@@ -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="对冲计划分类">
|
||||
|
||||
@@ -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="目标位平仓",
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1083
-1337
File diff suppressed because it is too large
Load Diff
+443
-813
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user