6 Commits

Author SHA1 Message Date
dekun 6fad68f7b1 fix(options): speed up chain refresh with fast path and non-blocking UI
Skip full REST tickers when WS is warm, seed subscriptions off-request, and keep the old chain visible while refreshing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:06:16 +08:00
dekun 14a7adae1f feat(options): push chain asks/bids via OKX WS + SSE
Replace soft REST polling with OKX public tickers WS ingest and browser SSE patches so list quotes stay live while watching an expiry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:49:49 +08:00
dekun 24bb8532c4 fix(options): soft-poll chain quotes so list asks stay fresh
SSE only refreshed positions; chain asks were one-shot until manual reload, which could mislead open decisions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:21:30 +08:00
dekun c514a75026 fix(options): cache instruments and backoff on OKX 50011
期权链拉取遇限频时退避重试并回退短缓存,前端提示更友好。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:11:30 +08:00
dekun 5c3969674a feat(options): use premium profit RR instead of target index
单独期权与中控改为盈亏比×权利金触发买一平仓,默认2;不达标等到期。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:34:14 +08:00
dekun 3b56e15fb1 feat(hedge): replace OO breakout targets with premium profit RR
期期改用盈亏比×权利金止盈(默认2);不达标持有至到期。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:21:47 +08:00
60 changed files with 2298 additions and 2877 deletions
-4
View File
@@ -115,10 +115,6 @@ OKX_OPTIONS_ENABLED=false
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_BUDGET_BUFFER=0.95
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲 # 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」 # 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
OKX_TRADE_MODE=options OKX_TRADE_MODE=options
-11
View File
@@ -6875,17 +6875,6 @@ def render_main_page(page="trade", embed_mode=None):
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
options_compound_full_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_usdc=float(
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300"
),
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
options_chain_ask_liq_filter=os.getenv( options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
@@ -1,241 +0,0 @@
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
> 状态:**方案待实现**(按本文落地;改需求先改本文).
> 范围:**仅 `crypto_monitor_okx` 单笔期权**;对冲计划(永期/期期)**不接币本位**.
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md)
---
## 1. 背景与动机
当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平.
实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量.
币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大.
---
## 2. 目标(首版)
1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥).
2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量).
3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT.
4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**.
5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`.
6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**.
7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持.
---
## 3. 不做(首版外)
- 对冲计划(永期/期期)币本位腿或双模式混开
- 盘中按单笔切换本位(必须 env + 重启/无仓校验)
- 按权利金精确计算后再买现货(明确不做;见 §5)
- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转)
- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则)
- 多笔并行单笔期权仓(维持「一次一仓」)
- 中控代下币本位期权
---
## 4. 模式开关与互斥
### 4.1 env(草案)
| 变量 | 含义 | 默认 |
|------|------|------|
| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `usdc` |
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` |
| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` |
| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) |
| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` |
| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) |
| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) |
说明:
- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`.
- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`.
- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致).
### 4.2 切换门禁
| 条件 | 行为 |
|------|------|
| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc``coin` |
| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 |
| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC |
| 无仓且无半成品 | 允许改 env 并重启后生效 |
启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价.
---
## 5. 币本位资金桥与开平流水
### 5.1 开仓(先买满,再开满)
```
1. 读取交易账户 USDT 可用
2. 计算 budget_usdt(§4.1)
3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致)
4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权
- 受:最小张数、卖一深度、单笔一仓规则约束
- 不要求「币数量精确等于权利金」;允许开满后仍残留部分币
5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding
```
### 5.2 平仓(先平期权,再卖回 USDT)
```
1. 按现有纪律买一限价卖出期权(可分批深度)
2. 期权仓清零(或本轮目标完成)后:
现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT
3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利
```
### 5.3 失败回滚(必须)
| 失败点 | 处理 |
|--------|------|
| 现货买入失败 | 不开期权;报错 |
| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 |
| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 |
| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 |
---
## 6. 复利与「人工转走」
### 6.1 口径
- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算).
- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」.
- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`):
- **默认关闭** → 纯靠人工转走控规模.
- **开启** → `min(账户×0.95, N)`,防止单笔过大.
### 6.2 与永续「全仓」的关系
思想同类(吃可用 × 缓冲),但资产不同:
- 永续全仓:USDT 保证金 × 杠杆 → 合约名义
- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金
**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合.
### 6.3 一次一仓
复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品.
---
## 7. 产品与 UI
### 7.1 模式可见性
- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`.
- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态.
- USDC 模式保持现有 USDC 余额与预算展示.
### 7.2 开仓按钮文案(示例)
- 币本位:`买币并开仓(预算 ≈ xx USDT)`
- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT.
### 7.3 对冲
- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算.
- 不在此模式自动把对冲预算改成 USDT 桥.
### 7.4 复盘字段(建议)
单笔 round-trip 尽量可拆:
- 期权腿盈亏(币或折合 USDT)
- 桥兑换盈亏(买币成本 vs 卖币回收)
- 合计 USDT 变化(对复利最有意义)
首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**.
---
## 8. 技术要点
### 8.1 合约与报价
- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径.
- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设).
- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套.
### 8.2 模块建议
| 块 | 职责 |
|----|------|
| 模式读取 + 门禁 | env、有仓拒切、启动一致性 |
| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 |
| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 |
| 定价/张数 | 币本位分支 |
| UI/API | 预算预览、确认、半成品提示 |
现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同.
### 8.3 权限与账户
- API 需具备:交易账户现货市价、期权开平.
- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划).
### 8.4 测试
- 预算计算:复利开/关、上限开/关、余额边界.
- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功.
- 门禁:有仓切换拒绝;一次一仓.
- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC.
---
## 9. 验收标准
1. `usdc` 模式:单笔期权行为与现网一致.
2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警).
3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时).
4. 上限开关默认关;开启后预算不超过 N.
5. 有持仓或半成品时切换模式被拒绝.
6. 币本位下对冲不能误开币本位腿.
7. 开仓失败自动卖回 USDT,不留下无主现货.
---
## 10. 实现顺序建议
1. 模式 env + 有仓/半成品门禁 + UI 展示当前模式
2. 现货桥(买/卖/回滚/待卖回) + 单测
3. 币本位合约适配 + 卖一开/买一平接入编排
4. 复利预算预览与开仓确认
5. 上限开关
6. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔
---
## 11. 决策摘要(已拍板)
| 决策 | 结论 |
|------|------|
| 对冲 | 暂不接币本位 |
| 单笔模式 | env:`usdc``coin` |
| 有持仓切换 | **拒绝** |
| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) |
| 复利 | 交易账户 USDT × 0.95;人工转走控规模 |
| 单笔不超过 N U | **独立开关,默认关闭** |
| 动机 | 币本位流动性往往优于 USDC,利于成交 |
---
## 12. 风险与说明
- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显.
- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待.
- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准.
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
@@ -1,233 +0,0 @@
# 实盘下单 · 盘口深度预览 — 开发方案
> 状态:**方案待实现**(按本文落地;改需求先改本文).
> 范围:**三所实例**实盘下单监控(Binance / OKX / Gate);中控嵌入同一表单时一并带上.
> 相关:[manual-order-rr-preview.md](./manual-order-rr-preview.md) · [position-sizing-mode.md](./position-sizing-mode.md) · 期权侧已有「卖一开 / 买一平」深度硬约束(本方案**不照搬硬挡**,首版以预览为主).
---
## 1. 背景与问题
实盘下单表单目前只展示 **标的现价/标记价**,再按止损与计仓模式算出预估风险 / 预估 RR.
- **资金小**:名义仓位通常远小于盘口前几档,市价成交贴近买卖一,现价参考够用.
- **资金大**(尤其 `POSITION_SIZING_MODE=full_margin`):名义 = 可用保证金 × 缓冲 × 杠杆,容易到数十万 U. 市价单会沿对手盘穿档,入场均价偏离「现价」后,止损距离与有效盈亏比都会偏.
典型例子:
| 条件 | 含义 |
|------|------|
| 可用约 1 万 U,20 倍杠杆,全仓 | 计划名义约 **20 万 U** |
| **市价做空** | 立刻卖出 ≈ 20 万 U 名义 → 吃 **买单(bid)** |
| **市价做多** | 立刻买入 ≈ 20 万 U 名义 → 吃 **卖单(ask)** |
用户需要的不是整本订单簿娱乐墙,而是回答:
> 当前计划名义下,对手盘前几档**能不能接住**,接住后的**预估均价 / 滑点**大概多少?
---
## 2. 目标(首版)
在「实盘下单监控」开仓区增加 **计划名义 vs 对手盘深度** 的只读预览:
1. 按当前表单算出的 **计划名义(USDT)****方向**,取对应一侧盘口.
2. 从最优档往外累加,直到累计名义 ≥ 计划名义(或盘口耗尽).
3. 展示:吃到第几档、累计可吸收名义、预估成交均价(VWAP)、相对参考价的滑点(bps 或 %).
4. **不拦截下单**(首版);可选标黄提示,见 §6.
与现有「预估风险 / 预估盈利 / 预估盈亏比」并列,作为下单前参考,不替代服务端风控与交易所真实成交.
---
## 3. 不做(首版外)
- 完整 20/50 档盘口图、深度图动画、WebSocket 持续推送盘口(首版 REST 轮询即可)
- 按深度 **自动缩仓****禁止开仓**(期权硬约束那套;列为二期,见 §10)
- 限价挂单的「挂单价到盘口距离」专项(可后加;首版聚焦市价吃单路径)
- 平仓/止损单穿档预估(开仓侧先做;平仓可二期)
- 改开仓逻辑、改计仓公式、改交易所下单路径
- 中控独立深度页或跨所聚合盘口
---
## 4. 产品规则
### 4.1 对手盘方向
| 用户方向 | 市价开仓动作 | 累加侧 |
|----------|--------------|--------|
| 做多(long) | 买入 | **卖盘 asks**(卖一 → 卖 N) |
| 做空(short) | 卖出 | **买盘 bids**(买一 → 买 N) |
### 4.2 计划名义从哪来
与现有开仓计仓一致,优先复用服务端已有 sizing 口径(避免前后端各算一套):
| 计仓模式 | 计划名义 |
|----------|----------|
| `full_margin` | `notional_value` ≈ 可用 × 缓冲 × 杠杆(与 `compute_full_margin_sizing` 一致) |
| `risk`(以损定仓) | 由风险金额与止损距离反推的仓位名义(与现开仓 `add_order` 路径一致) |
表单未填齐止损/方向/币种、或无法取可用保证金时:深度预览显示「—」,不报错打断填写.
### 4.3 参考价与滑点
- **参考价**:优先与表单现价条同一口径(标记价/最新价,跟现有 `symbol_live_price` / `order_defaults` 一致).
- **预估均价(VWAP)**:按所吃各档 `价格 × 该档名义` 加权.
- **滑点**:
- 做多: `(vwap - ref) / ref`(越正越差)
- 做空: `(ref - vwap) / ref`(越正越差)
- 展示可用 **bps**(1 bps = 0.01%)或 `%`,UI 统一一种即可(建议 bps,大单更直观).
### 4.4 盘口档数
- 请求深度建议 **520 档**(实现时三所取各自 API 稳妥上限,默认 20).
- 累加只展示「覆盖计划名义所需」的档位摘要,不必把未吃到的远档全部渲染.
- 若累加后仍 `< 计划名义`:明确写 **深度不足 / 缺口约 X U**,不要伪装成已完全覆盖.
### 4.5 文案示例(空单 20 万 U)
```
对手盘(买):买一~买4 累计约 23.1 万 U · 预估均价 63480(相对现价约 5 bps)
```
深度不足时:
```
对手盘(买):前 20 档累计约 12.4 万 U · 缺口约 7.6 万 U · 预估均价按已有档估算(仅供参考)
```
---
## 5. 界面位置
放在实盘下单开仓区、现有预览条附近,避免抢主按钮视觉:
| 区域 | 建议 |
|------|------|
| 现价条旁或下方 | 一行摘要即可(§4.5) |
| `#order-plan-preview` | 可增一项「盘口深度」或独立 `#order-depth-preview` |
| 详细档位 | 首版可不展开;若展开,仅列出已累加到的那几档(价/量/累计名义) |
小资金且滑点低于阈值时,可用灰色弱提示「前 N 档已覆盖,滑点可忽略」,避免噪音.
---
## 6. 提示阈值(软提示,不挡单)
建议可配置(`.env`,有默认值),仅影响颜色/文案:
| 变量(草案) | 含义 | 默认建议 |
|------------|------|----------|
| `MANUAL_DEPTH_WARN_BPS` | 预估滑点 ≥ 此值标黄 | `5` |
| `MANUAL_DEPTH_ALERT_BPS` | 预估滑点 ≥ 此值标红/强调 | `15` |
| `MANUAL_DEPTH_SHORTFALL_WARN` | 累计名义 < 计划名义时强调 | 开 |
首版:**不**因此 `disabled` 开仓按钮;与期权「无卖一禁止开仓」区分开.
---
## 7. 技术设计
### 7.1 API(三所各暴露,或抽到 `lib/` 共用 handler)
建议新增(名称可微调):
`GET /api/order_depth_preview`
| 参数 | 说明 |
|------|------|
| `symbol` | 与开仓表单一致 |
| `direction` | `long` / `short` |
| `sl` / `sl_pct` / `fixed_rr` / `sltp_mode` 等 | 以损定仓算名义时需要;全仓模式可只传 symbol+direction |
| 或直接传 `notional_usdt` | 若前端已从其它 preview API 拿到名义,可减少重复计算(**二选一,实现时定一种主路径**) |
响应草案:
```json
{
"ok": true,
"side": "bid",
"ref_px": 63512.3,
"plan_notional_usdt": 200000,
"covered_notional_usdt": 231000,
"shortfall_usdt": 0,
"levels_used": 4,
"vwap": 63480.0,
"slippage_bps": 5.1,
"levels": [
{"px": 63510, "sz": "...", "notional_usdt": 50000, "cum_notional_usdt": 50000}
],
"msg": ""
}
```
失败(拉盘口失败、币种无效):`ok=false` + 简短 `msg`;前端显示「深度暂不可用」,不影响开仓。
### 7.2 交易所盘口
| 所 | 合约盘口 | 注意 |
|----|----------|------|
| Binance | USD-M 深度 | 数量单位换算成 USDT 名义 |
| OKX | swap books | 同左;与期权 `fetch_option_book_depth` **分开**,勿混用期权接口 |
| Gate | futures order book | 同左 |
公共逻辑建议落在 `lib/trade/`(例如 `manual_order_depth_preview_lib.py`):输入档位列表 + 计划名义 + 方向 → 输出 VWAP / 缺口 / levels_used.
各所只负责 **拉 book + 单位换算成 USDT 名义**.
### 7.3 前端
- 共享脚本(建议):`lib/common/static/manual_order_depth_preview.js`
-`manual_order_rr_preview.js` 同样在币种/方向/止损/模式变更时 debounce 刷新
- 轮询间隔建议 3~5s(仅表单可见且字段有效时);切页或无焦点可停
- 三所 `index` / 嵌入 fragment 引入同一脚本
### 7.4 测试
- 纯函数:给定假盘口 + 名义,断言 `levels_used` / `vwap` / `shortfall`
- 方向: long 只吃 ask, short 只吃 bid
- 深度不足与刚好覆盖边界
- 不要求联调真盘口也能合入(真盘口可手工验一次 BTC/山寨对比)
---
## 8. 验收标准
1. 全仓 + 已知杠杆下,预览「计划名义」与开仓实际计仓名义同量级(允许四舍五入误差).
2. 市价空只反映买盘累加;市价多只反映卖盘累加.
3. BTC 厚盘:小名义常显示「前 1~2 档已覆盖、滑点很低」.
4. 人为放大名义或选薄流动性标的:能看到多档累加或「深度不足」.
5. 拉盘口失败时不阻断开仓按钮.
6. 中控嵌入实盘下单同样可见(与实例页同源表单).
---
## 9. 实现顺序建议
1. `lib/trade` 累加/VWAP 纯函数 + 单测
2. 一所(建议 OKX 或当前主力所)拉 book + API + 前端一行预览
3. 抽换算差异,补 Binance / Gate
4. 接入软提示阈值与文案打磨
5. 文档验收记录补进本文或 `docs/更新文档.md`
---
## 10. 二期(明确不做进首版)
| 项 | 说明 |
|----|------|
| 深度不够自动缩名义 | 类似期权 `cap_by_ask_depth` |
| 滑点超阈值二次确认 / 禁止市价 | 产品确认后再做硬门禁 |
| 平仓与止损穿档预估 | 持仓卡或平仓按钮旁 |
| WS 盘口 | 降低 REST 压力、更即时 |
| 限价开仓:挂单价相对盘口位置 | 另一套提示 |
---
## 11. 决策摘要(已拍板)
- **要做**:按计划名义展示「覆盖该名义所需」的对手盘摘要 + 预估均价/滑点.
- **做空看买单,做多看卖单**.
- **首版只展示 + 软提示,不挡单**.
- **不为小资金做整屏盘口墙**;大名义时深度预览才有关键决策价值.
+7 -16
View File
@@ -47,13 +47,6 @@
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时. - 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂. - 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
### 2.4 翻倍出场(可选)
- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
- 可随时关闭;与目标位监控并行,谁先达标谁平.
- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
--- ---
## 3. 监控逻辑 ## 3. 监控逻辑
@@ -65,21 +58,19 @@
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 | | 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) | | 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | | 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 | | 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | | 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
--- ---
## 4. 平仓校验(门控) ## 4. 平仓校验(门控)
| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 | | 门控 | 手动买一平 | 目标自动平 | 说明 |
|------|------------|------------|----------|------| |------|------------|------------|------|
| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 | | 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 | | 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 |
| 回收 ≥ 权利金×(1+倍数) | ❌ | ❌ | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 | | 锁定买一价 | ✅ | ✅ | 下单价 = 通过校验时的买一 |
| 锁定买一价 | | | ✅ | 下单价 = 通过校验时的买一 | | 市价兜底 | | | 永不市价 |
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
--- ---
+44 -37
View File
@@ -1165,11 +1165,9 @@
fillExpSelect($("hp-oo-exp-select"), d); fillExpSelect($("hp-oo-exp-select"), d);
renderListStrikes(); renderListStrikes();
renderTStrikes(); renderTStrikes();
if (d.index_px) { // 期期盈亏比默认 2,不再用指数自动填上破/下破
// 盈亏比默认2,不随指数自动改写 if ($("hp-oo-rr") && !$("hp-oo-rr").value) {
if ($("hp-profit-rr") && !$("hp-profit-rr").value) { $("hp-oo-rr").value = "2";
$("hp-profit-rr").value = "2";
}
} }
} }
@@ -1578,7 +1576,7 @@
if ($("hp-contracts")) $("hp-contracts").value = ""; if ($("hp-contracts")) $("hp-contracts").value = "";
if ($("hp-tp")) $("hp-tp").value = ""; if ($("hp-tp")) $("hp-tp").value = "";
if ($("hp-sl")) $("hp-sl").value = ""; if ($("hp-sl")) $("hp-sl").value = "";
if ($("hp-profit-rr")) $("hp-profit-rr").value = "2"; if ($("hp-oo-rr")) $("hp-oo-rr").value = "2";
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
if ($("hp-oo-sheets-a")) { if ($("hp-oo-sheets-a")) {
@@ -1614,11 +1612,11 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值"); throw new Error("期期两腿须为平值或虚值,不可选实值");
} }
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0); const rr = numInput("hp-oo-rr", 2);
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)"); if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
body = { body = {
plan_type: "options_options", plan_type: "options_options",
profit_rr: rr, oo_profit_rr: rr,
index_px: indexPx() || 0, index_px: indexPx() || 0,
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
@@ -1711,28 +1709,37 @@
fmt(s.premium_paid) + fmt(s.premium_paid) +
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
} else { } else {
const rrTarget = s.profit_rr != null ? s.profit_rr : null; const rr = s.oo_profit_rr != null ? s.oo_profit_rr : s.rr_target;
const tgt = s.target_profit != null ? s.target_profit : s.at_target_total;
if (rr != null) {
summary.innerHTML =
"盈亏比 ×" +
fmt(rr, 2) +
" · 目标盈利 " +
fmtPnlHtml(tgt) +
" · 到期现价 " +
fmtPnlHtml(s.expiry_flat_total) +
" · 保费 " +
fmt(s.premium_paid) +
'<span class="muted">(达标全平;不达标等到期)</span>' +
(s.expiry_is_loss ? " · 到期现价情景为亏" : "");
} else {
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total;
const dnTot = s.at_target_down_total;
let rrLine = ""; let rrLine = "";
if (rrTarget != null) { if (s.rr_at_up != null || s.rr_at_down != null) {
rrLine =
" · 目标盈亏比 " +
fmt(rrTarget, 2) +
'<span class="muted">(盈利金额/总权利金)</span>';
} else if (s.rr_at_up != null || s.rr_at_down != null) {
rrLine = rrLine =
" · 盈亏比 上破 " + " · 盈亏比 上破 " +
fmtRr(s.rr_at_up) + fmtRr(s.rr_at_up) +
(s.at_target_down_total != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") + (dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") +
'<span class="muted">(亏=全额保费 ' + '<span class="muted">(亏=全额保费 ' +
fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) +
"</span>"; "</span>";
} }
const aTot = s.at_rr_a_full_total != null ? s.at_rr_a_full_total : s.at_target_up_total;
const bTot = s.at_rr_b_full_total != null ? s.at_rr_b_full_total : s.at_target_down_total;
summary.innerHTML = summary.innerHTML =
(rrTarget != null ? "腿A达标 " : "上破 ") + "上破 " +
fmtPnlHtml(aTot) + fmtPnlHtml(upTot) +
(bTot != null ? (rrTarget != null ? " · 腿B达标 " : " · 下破 ") + fmtPnlHtml(bTot) : "") + (dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") +
" · 到期现价 " + " · 到期现价 " +
fmtPnlHtml(s.expiry_flat_total) + fmtPnlHtml(s.expiry_flat_total) +
" · 保费 " + " · 保费 " +
@@ -1741,6 +1748,7 @@
(s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : "");
} }
} }
}
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ""; tbody.innerHTML = "";
(d.scenarios || []).forEach(function (sc) { (d.scenarios || []).forEach(function (sc) {
@@ -2055,7 +2063,7 @@
"hp-tp", "hp-tp",
"hp-sl", "hp-sl",
"hp-sheets", "hp-sheets",
"hp-profit-rr", "hp-oo-rr",
]); ]);
if ($("hp-preview-btn")) if ($("hp-preview-btn"))
$("hp-preview-btn").addEventListener("click", function () { $("hp-preview-btn").addEventListener("click", function () {
@@ -2168,8 +2176,8 @@
if (p.plan_type === "perp_options") { if (p.plan_type === "perp_options") {
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
} }
if (p.profit_rr != null && Number(p.profit_rr) > 0) { if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
return "盈亏比 " + fmt(p.profit_rr, 2); return "盈亏比 ×" + fmt(p.oo_profit_rr, 2) + "(达标全平)";
} }
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
} }
@@ -2330,9 +2338,10 @@
target_win_leg: "期期平盈利腿", target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿", target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿",
profit_rr_win_leg: "期期盈亏比达标·平盈利腿", oo_rr_target: "期期盈亏比达标",
oo_rest_closing: "期期残值平·清亏损腿中", oo_rr_closing: "期期盈亏比平仓中",
oo_rest_closed: "期期残值平·两腿已平", oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平",
orphaned_after_tp: "止盈后持有至到期", orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期", orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期", hold_to_expiry: "持有至到期",
@@ -2405,12 +2414,11 @@
"x · 张数 " + "x · 张数 " +
fmt(p.perp_size, 4) + fmt(p.perp_size, 4) +
"</div>"; "</div>";
} else { } else if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
if (p.profit_rr != null && Number(p.profit_rr) > 0) {
html += html +=
"<div><span class=\"muted\">盈亏比</span> " + "<div><span class=\"muted\">盈亏比</span> ×" +
fmt(p.profit_rr, 2) + fmt(p.oo_profit_rr, 2) +
" <span class=\"muted\">(盈利金额/总权利金)</span></div>"; "(浮盈达标全平;不达标等到期)</div>";
} else { } else {
html += html +=
"<div><span class=\"muted\">目标价</span> 上破 " + "<div><span class=\"muted\">目标价</span> 上破 " +
@@ -2419,7 +2427,6 @@
fmt(p.target_price_down || p.target_price) + fmt(p.target_price_down || p.target_price) +
"</div>"; "</div>";
} }
}
html += html +=
"<div><span class=\"muted\">权利金合计</span> " + "<div><span class=\"muted\">权利金合计</span> " +
fmt(p.premium_total, 4) + fmt(p.premium_total, 4) +
@@ -2634,12 +2641,12 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值"); throw new Error("期期两腿须为平值或虚值,不可选实值");
} }
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0); const rr = numInput("hp-oo-rr", 2);
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)"); if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
body = { body = {
plan_type: "options_options", plan_type: "options_options",
underlying: state.underlying, underlying: state.underlying,
profit_rr: rr, oo_profit_rr: rr,
index_px: indexPx() || 0, index_px: indexPx() || 0,
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
oo_sheets_mode: state.ooSheetsMode || "same_sheets", oo_sheets_mode: state.ooSheetsMode || "same_sheets",
+1 -41
View File
@@ -202,7 +202,6 @@
function renderEnvFieldRow(field) { function renderEnvFieldRow(field) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : ""); row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
row.dataset.envKey = field.key;
const label = document.createElement("label"); const label = document.createElement("label");
label.className = "env-field-label"; label.className = "env-field-label";
label.htmlFor = "env-f-" + field.key; label.htmlFor = "env-f-" + field.key;
@@ -295,10 +294,6 @@
input.dataset.envKey = field.key; input.dataset.envKey = field.key;
input.className = "env-field-input"; input.className = "env-field-input";
row.appendChild(input); row.appendChild(input);
if (field.hidden) {
row.hidden = true;
row.style.display = "none";
}
return row; return row;
} }
@@ -350,41 +345,9 @@
body.appendChild(panelsWrap); body.appendChild(panelsWrap);
body.dataset.envModeSectionIdx = String(modeSectionIdx); body.dataset.envModeSectionIdx = String(modeSectionIdx);
bindTradeModeAutoRefresh(body); bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
return body; return body;
} }
function envFieldRowByKey(body, key) {
if (!body || !key) return null;
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
if (byRow) return byRow;
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
return input ? input.closest(".env-field-row") : null;
}
function syncCompoundBudgetVisibility(body) {
if (!body) return;
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
if (!budgetRow) return;
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
budgetRow.hidden = compoundOn;
budgetRow.style.display = compoundOn ? "none" : "";
}
function bindCompoundBudgetVisibility(body) {
if (!body) return;
syncCompoundBudgetVisibility(body);
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
compoundSel.dataset.compoundBudgetBound = "1";
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
}
function bindTradeModeAutoRefresh(body) { function bindTradeModeAutoRefresh(body) {
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]'); const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return; if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
@@ -579,10 +542,7 @@
loadEnvConfig(false); loadEnvConfig(false);
const root = envConfigRoot(); const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body"); const body = root && root.querySelector("#env-config-body");
if (body) { if (body) bindTradeModeAutoRefresh(body);
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
}
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__); if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
} }
-24
View File
@@ -2494,11 +2494,6 @@ html[data-theme="light"] .journal-detail-img-thumb {
min-width: 0; min-width: 0;
} }
/* display:flex 会盖掉 UA [hidden];全仓复利开时隐藏单笔预算等依赖此规则 */
.env-field-row[hidden] {
display: none !important;
}
.env-field-row--restart .env-field-label { .env-field-row--restart .env-field-label {
color: #d4c4a0; color: #d4c4a0;
} }
@@ -4424,9 +4419,6 @@ html[data-theme="light"] .opt-pending-item {
.opt-size-mode-chip { .opt-size-mode-chip {
position: relative; position: relative;
} }
.opt-size-mode-chip[hidden] {
display: none !important;
}
.opt-size-mode-chip input[type="radio"] { .opt-size-mode-chip input[type="radio"] {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
@@ -4450,22 +4442,6 @@ html[data-theme="light"] .opt-pending-item {
min-height: 32px; min-height: 32px;
box-sizing: border-box; box-sizing: border-box;
} }
.options-estimate-row .opt-profit-exit-mult,
.options-page-wrap .opt-pos-profit-exit-mult {
width: 4.5rem;
min-width: 0;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-page-wrap .opt-profit-exit-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
white-space: nowrap;
}
.options-estimate-row .k { .options-estimate-row .k {
color: #8892b0; color: #8892b0;
} }
File diff suppressed because it is too large Load Diff
+23 -19
View File
@@ -219,38 +219,42 @@
const hint = closeGateHint(closePreview); const hint = closeGateHint(closePreview);
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : ""; return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
})() + })() +
(p.target_index != null (p.profit_rr != null || p.target_index != null
? (function () { ? (function () {
const eth = p.eth_amount != null ? Number(p.eth_amount) const hedgeTarget = p.hedge_plan_target || null;
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null); const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const strike = Number(p.strike); const rr =
const tgt = Number(p.target_index); managed && hedgeTarget.oo_profit_rr != null
? Number(hedgeTarget.oo_profit_rr)
: p.profit_rr != null
? Number(p.profit_rr)
: null;
const prem = Number(p.premium_paid); const prem = Number(p.premium_paid);
let profit = null; let profit = null;
let value = null; let need = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { if (rr != null && Number.isFinite(rr) && rr > 0 && Number.isFinite(prem) && prem > 0) {
const o = String(p.opt_type || "").toUpperCase(); profit = Math.round(prem * rr * 100) / 100;
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; need = Math.round((prem + profit) * 100) / 100;
if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
}
} }
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const profitSpan = hidePnl const profitSpan = hidePnl
? "" ? ""
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>"; : '<span class="pos-value' + profitCls + '">目标盈利 ' + profitTxt + "</span>";
const ruleTxt =
rr != null && Number.isFinite(rr) && rr > 0
? "盈亏比 ×" + fmt(rr, 2)
: p.target_index != null
? "目标 " + fmt(p.target_index, 1)
: "委托中";
return ( return (
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' + '<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" + '<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" + '<span class="pos-value">' + ruleTxt + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" + (need != null ? '<span class="pos-value">需回收 ' + fmtUsdc(need) + " USDC</span>" : "") +
profitSpan + profitSpan +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + (managed ? "进行中 · 由对冲计划监控" : "监控中 · 买一浮盈达盈亏比后全平") +
"</span></div>" "</span></div>"
); );
})() })()
+4 -3
View File
@@ -76,9 +76,10 @@
target_win_leg: "期期平盈利腿", target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿", target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿",
profit_rr_win_leg: "期期盈亏比达标·平盈利腿", oo_rr_target: "期期盈亏比达标",
oo_rest_closing: "期期残值平·清亏损腿中", oo_rr_closing: "期期盈亏比平仓中",
oo_rest_closed: "期期残值平·两腿已平", oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平",
orphaned_after_tp: "止盈后持有至到期", orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期", orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期", hold_to_expiry: "持有至到期",
-5
View File
@@ -95,11 +95,6 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS", "OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER",
"OKX_TRADE_MODE", "OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS", "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_LIVE_ORDER",
+2 -42
View File
@@ -143,27 +143,8 @@ _OPTIONS_SECTION: dict[str, Any] = {
"fields": [ "fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
( ("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
"OKX_OPTIONS_TRADE_BUDGET_USDC", ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
"单笔预算(USDC)",
"仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
),
( (
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)", "期权持仓上限(笔)",
@@ -472,7 +453,6 @@ def build_env_ui_payload(
_build_field(key, label, note, schema, values) _build_field(key, label, note, schema, values)
for key, label, note in sec["fields"] for key, label, note in sec["fields"]
] ]
fields = _mark_compound_budget_hidden(fields)
groups.append({ groups.append({
"title": sec["title"], "title": sec["title"],
"fields": fields, "fields": fields,
@@ -481,26 +461,6 @@ def build_env_ui_payload(
return groups return groups
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示)."""
compound_on = True
for f in fields:
if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true"))
break
if not compound_on:
return fields
out: list[dict[str, Any]] = []
for f in fields:
if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC":
item = dict(f)
item["hidden"] = True
out.append(item)
else:
out.append(f)
return out
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]: def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
allowed = ui_allowed_keys(exchange_key) allowed = ui_allowed_keys(exchange_key)
return {k: v for k, v in (updates or {}).items() if k in allowed} return {k: v for k, v in (updates or {}).items() if k in allowed}
+123 -84
View File
@@ -19,11 +19,34 @@ from lib.options.options_pricing_lib import (
) )
_OKX_OPTION_ERR_ZH: dict[str, str] = { _OKX_OPTION_ERR_ZH: dict[str, str] = {
"51008": "可用余额或保证金不足(期权买入请确认交易账户 USDC 足够)", "51008": "资金账户 USDT 可用余额不足",
"51018": "期权账户不能持有净空头头寸", "51018": "期权账户不能持有净空头头寸",
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
} }
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
# 期权合约列表变化慢;短缓存+限频退避,避免 50011 拖垮期权链
_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
_INSTRUMENTS_CACHE_LOCK = threading.Lock()
_INSTRUMENTS_CACHE_TTL_SEC = 90.0
_INSTRUMENTS_STALE_SEC = 600.0
_TICKERS_CACHE: dict[str, dict[str, Any]] = {}
_TICKERS_CACHE_LOCK = threading.Lock()
_TICKERS_CACHE_TTL_SEC = 8.0
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
with _INSTRUMENTS_CACHE_LOCK:
if inst_family:
_INSTRUMENTS_CACHE.pop(str(inst_family), None)
else:
_INSTRUMENTS_CACHE.clear()
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
row: dict[str, Any] | None = None row: dict[str, Any] | None = None
@@ -44,18 +67,10 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
pass pass
if row: if row:
code = str(row.get("sCode") or "") code = str(row.get("sCode") or "")
msg = str(row.get("sMsg") or "").strip()
low = msg.lower()
if code == "51008":
# 勿写死「资金账户 USDT」:期权开仓常因交易户 USDC 不足
if "usdc" in low:
return "交易账户 USDC 可用余额不足"
if "usdt" in low:
return "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)"
return _OKX_OPTION_ERR_ZH["51008"]
zh = _OKX_OPTION_ERR_ZH.get(code) zh = _OKX_OPTION_ERR_ZH.get(code)
if zh: if zh:
return zh return zh
msg = str(row.get("sMsg") or "").strip()
if msg: if msg:
return msg return msg
if exc is not None: if exc is not None:
@@ -66,28 +81,6 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
return "下单失败" return "下单失败"
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活
_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock()
_OPTION_INSTRUMENTS_CACHE_TTL = 90.0
_OPTION_INSTRUMENTS_STALE_MAX = 600.0
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
with _OPTION_INSTRUMENTS_CACHE_LOCK:
if inst_family:
_OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None)
else:
_OPTION_INSTRUMENTS_CACHE.clear()
def td_mode_for_option_buy(configured: str | None = None) -> str: def td_mode_for_option_buy(configured: str | None = None) -> str:
"""OKX 买入期权(多头)必须使用逐仓.""" """OKX 买入期权(多头)必须使用逐仓."""
mode = (configured or "isolated").strip().lower() mode = (configured or "isolated").strip().lower()
@@ -430,31 +423,25 @@ def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] |
family = inst_family_from_inst_id(inst_id) family = inst_family_from_inst_id(inst_id)
if not family: if not family:
return None return None
# 优先从全族缓存取,避免每选一腿再打 instruments
try:
cached_rows = fetch_option_instruments(ex, family, allow_stale=True)
for r in cached_rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r
except Exception:
pass
last_err: BaseException | None = None last_err: BaseException | None = None
for attempt in range(2): for attempt in range(3):
try: try:
rows = ex.public_get_public_instruments( rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family, "instId": inst_id} {"instType": "OPTION", "instFamily": family, "instId": inst_id}
).get("data") or [] ).get("data") or []
if rows and isinstance(rows[0], dict): if rows and isinstance(rows[0], dict):
return rows[0] return rows[0]
rows = fetch_option_instruments(ex, family, allow_stale=True) rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family}
).get("data") or []
for r in rows: for r in rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id: if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r return r
return None return None
except Exception as e: except Exception as e:
last_err = e last_err = e
if _is_okx_rate_limit(e) and attempt < 1: if _is_okx_rate_limit(e) and attempt < 2:
time.sleep(1.2) time.sleep(0.45 * (attempt + 1))
continue continue
break break
if last_err is not None and _is_okx_rate_limit(last_err): if last_err is not None and _is_okx_rate_limit(last_err):
@@ -676,53 +663,103 @@ def fetch_option_instruments(
inst_family: str, inst_family: str,
*, *,
force: bool = False, force: bool = False,
allow_stale: bool = True,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""拉取 OPTION instruments;进程内缓存,50011 时回退旧列表.""" """拉取 live 期权合约列表;短 TTL 缓存,50011 退避重试并可回退过期缓存."""
family = str(inst_family or "").strip() family = (inst_family or "").strip()
if not family: if not family:
return [] return []
now = time.time() now = time.time()
with _OPTION_INSTRUMENTS_CACHE_LOCK: with _INSTRUMENTS_CACHE_LOCK:
entry = _OPTION_INSTRUMENTS_CACHE.get(family) cached = _INSTRUMENTS_CACHE.get(family)
if ( if (
not force not force
and entry is not None and cached
and entry.get("rows") is not None and now - float(cached.get("updated_at") or 0) < _INSTRUMENTS_CACHE_TTL_SEC
and now - float(entry.get("updated_at") or 0) < _OPTION_INSTRUMENTS_CACHE_TTL and isinstance(cached.get("rows"), list)
and cached["rows"]
): ):
return list(entry["rows"]) return list(cached["rows"])
last_err: BaseException | None = None
rows: list[dict[str, Any]] = []
for attempt in range(4):
try: try:
rows = ex.public_get_public_instruments( raw = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family} {"instType": "OPTION", "instFamily": family}
).get("data") or [] ).get("data") or []
live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] rows = [r for r in raw if isinstance(r, dict) and r.get("state") == "live"]
with _OPTION_INSTRUMENTS_CACHE_LOCK: last_err = None
_OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live} break
return list(live)
except Exception as e: except Exception as e:
if allow_stale: last_err = e
with _OPTION_INSTRUMENTS_CACHE_LOCK: if _is_okx_rate_limit(e) and attempt < 3:
entry = _OPTION_INSTRUMENTS_CACHE.get(family) time.sleep(0.8 * (2**attempt))
if entry is not None and entry.get("rows") is not None: continue
age = now - float(entry.get("updated_at") or 0) break
if age <= _OPTION_INSTRUMENTS_STALE_MAX:
return list(entry["rows"]) if rows:
raise with _INSTRUMENTS_CACHE_LOCK:
_INSTRUMENTS_CACHE[family] = {"updated_at": time.time(), "rows": list(rows)}
return rows
# 限频/短暂失败:优先用未过期太久的缓存,避免整页「拉取失败」
if cached and isinstance(cached.get("rows"), list) and cached["rows"]:
age = now - float(cached.get("updated_at") or 0)
if age < _INSTRUMENTS_STALE_SEC and (
last_err is None or _is_okx_rate_limit(last_err) or not rows
):
return list(cached["rows"])
if last_err is not None:
raise last_err
return []
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: def fetch_option_tickers(
ex: ccxt.okx,
inst_family: str,
*,
force: bool = False,
) -> dict[str, dict[str, Any]]:
family = (inst_family or "").strip()
if not family:
return {}
now = time.time()
with _TICKERS_CACHE_LOCK:
cached = _TICKERS_CACHE.get(family)
if (
not force
and cached
and now - float(cached.get("updated_at") or 0) < _TICKERS_CACHE_TTL_SEC
and isinstance(cached.get("rows"), dict)
and cached["rows"]
):
return dict(cached["rows"])
out: dict[str, dict[str, Any]] = {} out: dict[str, dict[str, Any]] = {}
last_err: BaseException | None = None
for attempt in range(3):
try: try:
rows = ex.public_get_market_tickers( rows = ex.public_get_market_tickers(
{"instType": "OPTION", "instFamily": inst_family} {"instType": "OPTION", "instFamily": family}
).get("data") or [] ).get("data") or []
for r in rows: for r in rows:
if isinstance(r, dict) and r.get("instId"): if isinstance(r, dict) and r.get("instId"):
out[str(r["instId"])] = r out[str(r["instId"])] = r
except Exception: if out:
pass with _TICKERS_CACHE_LOCK:
_TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)}
return out
except Exception as e:
last_err = e
if _is_okx_rate_limit(e) and attempt < 2:
time.sleep(0.6 * (attempt + 1))
continue
break
if cached and isinstance(cached.get("rows"), dict) and cached["rows"]:
return dict(cached["rows"])
if last_err is not None and _is_okx_rate_limit(last_err):
return out
return out return out
@@ -734,6 +771,9 @@ def build_option_chain(
itm_only: bool = True, itm_only: bool = True,
itm_max_dist_usd: float = 30.0, itm_max_dist_usd: float = 30.0,
index_px: float | None = None, index_px: float | None = None,
tickers_override: dict[str, dict[str, Any]] | None = None,
fetch_tickers: bool = True,
force_tickers: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
u = (underlying or "ETH").upper() u = (underlying or "ETH").upper()
family = f"{u}-USD_UM" family = f"{u}-USD_UM"
@@ -743,27 +783,24 @@ def build_option_chain(
max_ms = now_ms + max_dte_days * 86400 * 1000 max_ms = now_ms + max_dte_days * 86400 * 1000
instruments_err = "" instruments_err = ""
instruments: list[dict[str, Any]] = [] instruments: list[dict[str, Any]] = []
rate_limited = False
try: try:
instruments = fetch_option_instruments(ex, family) instruments = fetch_option_instruments(ex, family)
if not instruments:
# 空列表可能是瞬时空;短退避后强制再拉一次(非 50011)
time.sleep(0.5)
instruments = fetch_option_instruments(ex, family, force=True)
if not instruments: if not instruments:
instruments_err = "期权合约列表为空" instruments_err = "期权合约列表为空"
except Exception as e: except Exception as e:
instruments = [] instruments = []
instruments_err = str(e) or e.__class__.__name__ instruments_err = str(e) or e.__class__.__name__
# 限频:再等一下用 stale/缓存,不要连打 rate_limited = _is_okx_rate_limit(e)
if _is_okx_rate_limit(e): if rate_limited:
time.sleep(1.5) instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试"
try: tickers: dict[str, dict[str, Any]] = {}
instruments = fetch_option_instruments(ex, family, allow_stale=True) if fetch_tickers:
if instruments: tickers = fetch_option_tickers(ex, family, force=force_tickers)
instruments_err = "" if tickers_override:
except Exception as e2: for iid, row in tickers_override.items():
instruments_err = str(e2) or e2.__class__.__name__ if isinstance(row, dict) and iid:
tickers = fetch_option_tickers(ex, family) tickers[str(iid)] = {**(tickers.get(str(iid)) or {}), **row}
expiries: dict[str, list[dict[str, Any]]] = {} expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0 skipped_no_index = 0
for meta in instruments: for meta in instruments:
@@ -841,6 +878,8 @@ def build_option_chain(
"expiries": exp_list, "expiries": exp_list,
"instruments_count": len(instruments), "instruments_count": len(instruments),
} }
if rate_limited:
out["rate_limited"] = True
if not exp_list: if not exp_list:
if instruments_err: if instruments_err:
out["chain_error"] = f"拉取期权合约失败: {instruments_err}" out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
+199
View File
@@ -0,0 +1,199 @@
"""OKX 公共 WebSocket(同步线程):订阅 tickers / index-tickers,自动重连."""
from __future__ import annotations
import json
import logging
import threading
import time
from collections.abc import Callable
from typing import Any
logger = logging.getLogger(__name__)
OKX_PUBLIC_WS_URL = "wss://ws.okx.com:8443/ws/v5/public"
_SUBSCRIBE_CHUNK = 40
_APP_PING_SEC = 20.0
class OkxPublicWs:
"""单连接公共 WS;set_subscriptions 全量对齐目标频道."""
def __init__(
self,
*,
on_data: Callable[[dict[str, Any]], None],
url: str = OKX_PUBLIC_WS_URL,
name: str = "okx-public-ws",
) -> None:
self._on_data = on_data
self._url = url
self._name = name
self._lock = threading.RLock()
self._desired: dict[str, dict[str, str]] = {}
self._active: set[str] = set()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._ws: Any = None
self._connected = False
self._last_msg_at = 0.0
@property
def connected(self) -> bool:
return self._connected
@property
def last_msg_at(self) -> float:
return self._last_msg_at
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run_loop, name=self._name, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
ws = self._ws
if ws is not None:
try:
ws.close()
except Exception:
pass
if self._thread and self._thread.is_alive():
self._thread.join(timeout=3.0)
def set_subscriptions(self, args: list[dict[str, str]]) -> None:
desired: dict[str, dict[str, str]] = {}
for raw in args:
if not isinstance(raw, dict):
continue
channel = str(raw.get("channel") or "").strip()
inst_id = str(raw.get("instId") or "").strip()
if not channel or not inst_id:
continue
key = f"{channel}:{inst_id}"
desired[key] = {"channel": channel, "instId": inst_id}
with self._lock:
self._desired = desired
ws = self._ws
connected = self._connected
active = set(self._active)
if connected and ws is not None:
self._sync_subs(ws, active, desired)
def _sync_subs(
self,
ws: Any,
active: set[str],
desired: dict[str, dict[str, str]],
) -> None:
unsub_args: list[dict[str, str]] = []
for key in active - set(desired.keys()):
channel, _, inst_id = key.partition(":")
if channel and inst_id:
unsub_args.append({"channel": channel, "instId": inst_id})
sub_args = [desired[k] for k in (set(desired.keys()) - active)]
if unsub_args:
self._send_op(ws, "unsubscribe", unsub_args)
if sub_args:
self._send_op(ws, "subscribe", sub_args)
with self._lock:
self._active = set(desired.keys())
def _send_op(self, ws: Any, op: str, args: list[dict[str, str]]) -> None:
for i in range(0, len(args), _SUBSCRIBE_CHUNK):
chunk = args[i : i + _SUBSCRIBE_CHUNK]
try:
ws.send(json.dumps({"op": op, "args": chunk}, ensure_ascii=False))
except Exception as e:
logger.warning("%s %s failed: %s", self._name, op, e)
return
if i + _SUBSCRIBE_CHUNK < len(args):
time.sleep(0.08)
def _run_loop(self) -> None:
try:
import websocket
except ImportError:
logger.error("%s: websocket-client not installed", self._name)
return
backoff = 1.0
while not self._stop.is_set():
opened = False
try:
self._connected = False
with self._lock:
self._active.clear()
def on_open(ws: Any) -> None:
nonlocal opened
opened = True
self._connected = True
self._last_msg_at = time.time()
with self._lock:
desired = dict(self._desired)
self._sync_subs(ws, set(), desired)
def on_message(_ws: Any, message: str) -> None:
self._last_msg_at = time.time()
if message == "pong":
return
try:
payload = json.loads(message)
except Exception:
return
if not isinstance(payload, dict):
return
if payload.get("event") in ("subscribe", "unsubscribe", "error"):
if payload.get("event") == "error":
logger.warning("%s event error: %s", self._name, payload)
return
if payload.get("arg") and payload.get("data") is not None:
try:
self._on_data(payload)
except Exception:
logger.exception("%s on_data failed", self._name)
def on_error(_ws: Any, error: Any) -> None:
logger.warning("%s error: %s", self._name, error)
def on_close(_ws: Any, *_args: Any) -> None:
self._connected = False
self._ws = websocket.WebSocketApp(
self._url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
on_close=on_close,
)
ping_stop = threading.Event()
def ping_loop() -> None:
while not self._stop.is_set() and not ping_stop.is_set():
ws = self._ws
if ws is not None and self._connected:
try:
ws.send("ping")
except Exception:
pass
if ping_stop.wait(_APP_PING_SEC):
break
ping_thread = threading.Thread(
target=ping_loop, name=f"{self._name}-ping", daemon=True
)
ping_thread.start()
self._ws.run_forever(ping_interval=0)
ping_stop.set()
except Exception as e:
logger.warning("%s run failed: %s", self._name, e)
finally:
self._connected = False
self._ws = None
if self._stop.is_set():
break
time.sleep(backoff)
backoff = 1.0 if opened else min(30.0, backoff * 1.7)
+35 -131
View File
@@ -58,42 +58,6 @@ def option_expiry_pnl(
return value - float(premium_paid) return value - float(premium_paid)
def spot_from_expiry_intrinsic_profit(
*,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
profit: float,
) -> float | None:
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
到期价值=实值×张数×乘数;盈亏=价值权利金 实值/=(profit+权利金)/(张数×乘数).
Call: spot=K+实值/; Put: spot=K实值/.
"""
try:
k = float(strike)
n = float(sheets or 0)
ct = float(ct_mult or 0.01)
prem = float(premium_paid or 0)
pnl = float(profit)
except (TypeError, ValueError):
return None
denom = n * ct
if denom <= 0:
return None
need = (pnl + prem) / denom
if need < 0:
need = 0.0
o = (opt_type or "").strip().upper()
if o in ("C", "CALL"):
return round(k + need, 2)
if o in ("P", "PUT"):
return round(k - need, 2)
return None
def suggest_contracts_from_notional( def suggest_contracts_from_notional(
*, *,
notional: float, notional: float,
@@ -480,18 +444,18 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
def build_options_options_preview( def build_options_options_preview(
*, *,
profit_rr: float | None = None,
target_price: float | None = None, target_price: float | None = None,
target_price_up: float | None = None, target_price_up: float | None = None,
target_price_down: float | None = None, target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float, index_px: float,
leg_a: dict[str, Any], leg_a: dict[str, Any],
leg_b: dict[str, Any], leg_b: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗. """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价. profit_rr=2 表示目标盈利=2×权利金;中途不达标则等到期.
残值按亏损腿本合约权利金的 20% . 仍接受旧上破/下破参数仅作兼容测算.
""" """
def _leg_pnl(leg: dict[str, Any], spot: float) -> float: def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
@@ -504,124 +468,70 @@ def build_options_options_preview(
premium_paid=float(leg.get("premium_paid") or 0), premium_paid=float(leg.get("premium_paid") or 0),
) )
prem_a = float(leg_a.get("premium_paid") or 0) prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
prem_b = float(leg_b.get("premium_paid") or 0)
prem = prem_a + prem_b
rr = float(profit_rr) if profit_rr is not None else None
# 新:盈亏比情景(不依赖指数上下破价)
if rr is not None and rr > 0:
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
win_profit = rr * prem
a_at_a = win_profit
b_at_a_full = -prem_b
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
b_at_b = win_profit
a_at_b_full = -prem_a
a_at_b_res = -prem_a * 0.8
spot_a = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_a.get("opt_type") or ""),
strike=float(leg_a["strike"]),
sheets=float(leg_a.get("sheets") or 0),
ct_mult=float(leg_a.get("ct_mult") or 0.01),
premium_paid=prem_a,
profit=win_profit,
)
spot_b = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_b.get("opt_type") or ""),
strike=float(leg_b["strike"]),
sheets=float(leg_b.get("sheets") or 0),
ct_mult=float(leg_b.get("ct_mult") or 0.01),
premium_paid=prem_b,
profit=win_profit,
)
a_flat = _leg_pnl(leg_a, index_px) a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px) b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat flat_total = a_flat + b_flat
rr = None
if profit_rr not in (None, ""):
try:
rr = float(profit_rr)
except (TypeError, ValueError):
rr = None
if rr is not None and rr > 0:
target_pnl = rr * prem
return { return {
"plan_type": "options_options", "plan_type": "options_options",
"premium_paid": round(prem, 6), "premium_paid": round(prem, 6),
"profit_rr": rr, "oo_profit_rr": round(rr, 4),
"target_price": None, "target_profit": round(target_pnl, 4),
"target_price_up": None,
"target_price_down": None,
"winner_at_up": "a",
"winner_at_down": "b",
"winner_at_target": "a",
"scenarios": [ "scenarios": [
{ {
"id": "rr_leg_a_full", "id": "rr_target",
"label": f"腿A达盈亏比{rr:g}(亏腿全损)", "label": f"盈亏比×{rr:g}",
"spot": spot_a, "spot": None,
"leg_a_pnl": round(a_at_a, 4), "leg_a_pnl": None,
"leg_b_pnl": round(b_at_a_full, 4), "leg_b_pnl": None,
"total": round(a_at_a + b_at_a_full, 4), "total": round(target_pnl, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏", "note": f"两腿合计浮盈≥{rr:g}×权利金({round(prem, 4)})时全平;不达标等到期",
},
{
"id": "rr_leg_b_full",
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
"spot": spot_b,
"leg_a_pnl": round(a_at_b_full, 4),
"leg_b_pnl": round(b_at_b, 4),
"total": round(a_at_b_full + b_at_b, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_a_residual",
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_res, 4),
"total": round(a_at_a + b_at_a_res, 4),
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
}, },
{ {
"id": "expiry_flat", "id": "expiry_flat",
"label": "到期·现价", "label": "到期·现价(未达标)",
"spot": index_px, "spot": index_px,
"leg_a_pnl": round(a_flat, 4), "leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4), "leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4), "total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", "note": "中途未达盈亏比则持有至到期结算",
}, },
{ {
"id": "max_premium_loss", "id": "max_premium_loss",
"label": "最大保费损耗", "label": "最大保费损耗",
"spot": None, "spot": None,
"leg_a_pnl": round(-prem_a, 4), "leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
"leg_b_pnl": round(-prem_b, 4), "leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
"total": round(-prem, 4), "total": round(-prem, 4),
"note": "双腿权利金全部损失", "note": "双腿权利金全部损失",
}, },
], ],
"summary": { "summary": {
"profit_rr": rr, "oo_profit_rr": round(rr, 4),
"spot_at_rr_a": spot_a, "target_profit": round(target_pnl, 4),
"spot_at_rr_b": spot_b, "at_target_total": round(target_pnl, 4),
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
"at_target_total": round(a_at_a + b_at_a_full, 4),
"expiry_flat_total": round(flat_total, 4), "expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6), "premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0, "expiry_is_loss": flat_total <= 0,
"rr_risk_premium": round(prem, 6), "rr_risk_premium": round(prem, 6),
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None, "rr_target": round(rr, 4),
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
}, },
} }
# 兼容旧单目标:若未传上下目标则用 target_price 填两边 # 兼容旧上破/下破测算
up = target_price_up if target_price_up is not None else target_price up = target_price_up if target_price_up is not None else target_price
down = target_price_down if target_price_down is not None else target_price down = target_price_down if target_price_down is not None else target_price
if up is None or down is None: if up is None or down is None:
raise ValueError("缺少盈亏比或上破/下破目标价") raise ValueError("请填写盈亏比(相对权利金,默认2)")
up_f = float(up) up_f = float(up)
down_f = float(down) down_f = float(down)
@@ -635,15 +545,10 @@ def build_options_options_preview(
at_dn = a_dn + b_dn at_dn = a_dn + b_dn
win_dn = "a" if a_dn >= b_dn else "b" win_dn = "a" if a_dn >= b_dn else "b"
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
expiry_loss = flat_total if flat_total <= 0 else flat_total
return { return {
"plan_type": "options_options", "plan_type": "options_options",
"premium_paid": round(prem, 6), "premium_paid": round(prem, 6),
"target_price": up_f, # 兼容旧字段,取上破 "target_price": up_f,
"target_price_up": up_f, "target_price_up": up_f,
"target_price_down": down_f, "target_price_down": down_f,
"winner_at_up": win_up, "winner_at_up": win_up,
@@ -681,8 +586,8 @@ def build_options_options_preview(
"id": "max_premium_loss", "id": "max_premium_loss",
"label": "最大保费损耗", "label": "最大保费损耗",
"spot": None, "spot": None,
"leg_a_pnl": round(-prem_a, 4), "leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
"leg_b_pnl": round(-prem_b, 4), "leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
"total": round(-prem, 4), "total": round(-prem, 4),
"note": "双腿权利金全部损失", "note": "双腿权利金全部损失",
}, },
@@ -691,10 +596,9 @@ def build_options_options_preview(
"at_target_up_total": round(at_up, 4), "at_target_up_total": round(at_up, 4),
"at_target_down_total": round(at_dn, 4), "at_target_down_total": round(at_dn, 4),
"at_target_total": round(at_up, 4), "at_target_total": round(at_up, 4),
"expiry_flat_total": round(expiry_loss, 4), "expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6), "premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0, "expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6), "rr_risk_premium": round(prem, 6),
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None, "rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None, "rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
+23 -12
View File
@@ -72,9 +72,9 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
) )
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL") _ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL") _ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价 # 期期:目标盈亏比=目标盈利/权利金(如 2=盈利 2 倍权利金);不达标则等到期
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL") _ensure_column(conn, "hedge_plans", "oo_profit_rr", "REAL")
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期 # close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
# 永期「以期权为主」 # 永期「以期权为主」
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER") _ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
@@ -266,7 +266,7 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。 """返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
这些目标由 hedge_plan_monitor_lib 执行绝不能写入 options_target_monitors 这些目标由 hedge_plan_monitor_lib 执行绝不能写入 options_target_monitors
否则两套监控会同时尝试平掉同一条期权腿 否则两套监控会同时尝试平掉同一条期权腿
@@ -274,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
p.profit_rr, l.inst_id, l.opt_type p.oo_profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options' WHERE p.plan_type = 'options_options'
@@ -289,25 +289,36 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
for raw in rows: for raw in rows:
row = dict(raw) row = dict(raw)
inst_id = str(row.get("inst_id") or "") inst_id = str(row.get("inst_id") or "")
opt_type = str(row.get("opt_type") or "").upper()
if not inst_id or inst_id in out: if not inst_id or inst_id in out:
continue continue
profit_rr = _sf(row.get("profit_rr")) opt_type = str(row.get("opt_type") or "").upper()
if profit_rr is not None and profit_rr > 0: rr = _sf(row.get("oo_profit_rr"))
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target)
# 盈亏比模式无指数目标价;旧上破/下破计划仍透出 target_index 只读展示
if rr is not None and rr > 0:
out[inst_id] = { out[inst_id] = {
"plan_id": int(row["plan_id"]), "plan_id": int(row["plan_id"]),
"inst_id": inst_id, "inst_id": inst_id,
"underlying": row.get("underlying"), "underlying": row.get("underlying"),
"opt_type": opt_type, "opt_type": opt_type,
"profit_rr": profit_rr,
"target_index": None, "target_index": None,
"exit_mode": "profit_rr", "oo_profit_rr": rr,
"plan_type": "options_options",
"managed_by": "hedge_plan", "managed_by": "hedge_plan",
} }
continue continue
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target)
if target_f is None or target_f <= 0: if target_f is None or target_f <= 0:
# 无目标价也标记托管,避免期权页误拆组
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"target_index": None,
"plan_type": "options_options",
"managed_by": "hedge_plan",
}
continue continue
out[inst_id] = { out[inst_id] = {
"plan_id": int(row["plan_id"]), "plan_id": int(row["plan_id"]),
+135 -203
View File
@@ -173,11 +173,11 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str: def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
"""盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平). """盈利腿平后另一腿:close_all(平) / hold_expiry(到期平).
- 方案C关闭 强制到期平 - 方案C关闭 强制到期平
- 计划未写 oo_close_mode(旧单) 到期平,避免误清残腿 - 计划未写 oo_close_mode(旧单) 到期平,避免误清残腿
- 新开仓默认写入 close_all(残值平:权利金初始20%且有买一) - 新开仓默认写入 close_all
""" """
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True): if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
return "hold_expiry" return "hold_expiry"
@@ -190,12 +190,6 @@ def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
return "close_all" return "close_all"
# 期期亏损腿残值平:当前买一回收 ≤ 本合约初始权利金 × 该比例
OO_LOSS_LEG_RESIDUAL_RATIO = 0.20
# 期期默认盈亏比:盈利金额 / 总权利金
OO_DEFAULT_PROFIT_RR = 2.0
def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]: def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
out = [] out = []
for x in legs: for x in legs:
@@ -206,63 +200,6 @@ def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) ->
return out return out
def _oo_quote_bid(cfg: dict[str, Any], inst_id: str) -> tuple[Optional[float], Optional[float]]:
quote_fn = cfg.get("quote_option_contract")
ex_opt = cfg.get("exchange_options")
if not callable(quote_fn) or ex_opt is None or not inst_id:
return None, None
try:
q = quote_fn(ex_opt, inst_id)
if not q.get("ok"):
return None, None
return _sf(q.get("bid")), _sf(q.get("bid_sz"))
except Exception:
return None, None
def _oo_leg_mark_value(leg: dict[str, Any], bid: Optional[float]) -> Optional[float]:
"""买一可回收金额(USDC)= bid × 张数 × ct_mult."""
b = _sf(bid)
if b is None or b < 0:
return None
sheets = float(leg.get("size") or 1)
ct = float(leg.get("ct_mult") or 0.01)
return float(b) * sheets * ct
def _oo_plan_premium_total(plan: dict[str, Any], legs: list[dict[str, Any]]) -> float:
"""双腿总权利金:优先计划字段,否则对期权腿 premium 求和."""
total = _sf(plan.get("premium_total"))
if total is not None and total > 0:
return float(total)
s = 0.0
for leg in legs:
if not str(leg.get("leg_role") or "").startswith("option"):
continue
s += float(leg.get("premium") or 0)
return s
def _oo_leg_profit_rr(
leg: dict[str, Any], bid: Optional[float], *, total_premium: float
) -> Optional[float]:
"""盈亏比 = 该腿盈利金额 / 总权利金;盈利金额 = 买一回收 − 本腿权利金."""
if total_premium <= 0:
return None
leg_prem = float(leg.get("premium") or 0)
value = _oo_leg_mark_value(leg, bid)
if value is None:
return None
return (value - leg_prem) / total_premium
def _oo_resolve_profit_rr(plan: dict[str, Any]) -> Optional[float]:
rr = _sf(plan.get("profit_rr"))
if rr is not None and rr > 0:
return rr
return None
def _finalize_oo_all_closed( def _finalize_oo_all_closed(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -1048,12 +985,7 @@ def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Opti
def _tick_oo_close_rest( def _tick_oo_close_rest(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""盈利腿已平后:残值平模式清亏损腿. """盈利腿已平后:平模式清残腿(无2×门控,买一失败则下轮重试)."""
条件:买一回收 本合约初始权利金×20%,且买一有流动性;失败或未达条件则下轮重试.
"""
from lib.hedge_plan.hedge_plan_option_primary_lib import option_bid_liquidity_ok
if resolve_oo_rest_close_mode(plan) != "close_all": if resolve_oo_rest_close_mode(plan) != "close_all":
return None return None
open_legs = _oo_option_legs(legs, statuses=("open",)) open_legs = _oo_option_legs(legs, statuses=("open",))
@@ -1066,8 +998,9 @@ def _tick_oo_close_rest(
"target_win_leg", "target_win_leg",
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing", "oo_rest_closing",
"oo_rr_closing",
"oo_rr_target",
"", "",
) )
if reason0 not in allowed_reasons and not ( if reason0 not in allowed_reasons and not (
@@ -1077,45 +1010,23 @@ def _tick_oo_close_rest(
idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
acted = False acted = False
waiting = False
for leg in list(open_legs): for leg in list(open_legs):
inst_id = str(leg.get("inst_id") or "") close_r = _sell_option(
sheets = float(leg.get("size") or 1) cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
premium = float(leg.get("premium") or 0) )
bid, bid_sz = _oo_quote_bid(cfg, inst_id)
value = _oo_leg_mark_value(leg, bid)
# 残值门槛:相对本合约初始权利金,买一回收须 ≤ 20%
if premium > 0:
if value is None:
waiting = True
continue
if value > premium * OO_LOSS_LEG_RESIDUAL_RATIO + 1e-12:
waiting = True
continue
liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets)
if not liq_ok:
waiting = True
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {
"plan_id": plan["id"],
"msg": "残值平等待买一流动性",
"detail": liq_msg,
"waiting": True,
}
close_r = _sell_option(cfg, inst_id=inst_id, sheets=sheets)
if not close_r.get("ok"): if not close_r.get("ok"):
notify_hedge( notify_hedge(
cfg, cfg,
build_hedge_alert_message( build_hedge_alert_message(
title="期期残值平·亏损腿平仓失败(将重试)", title="期期全平·残腿平仓失败(将重试)",
plan_id=plan.get("id"), plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r), detail=str(close_r.get("msg") or close_r),
), ),
) )
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True} return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
bid_fill = _sf(close_r.get("bid")) or bid bid = _sf(close_r.get("bid"))
est = _estimate_leg_close_pnl(leg, idx, bid_fill) est = _estimate_leg_close_pnl(leg, idx, bid)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
@@ -1126,9 +1037,6 @@ def _tick_oo_close_rest(
acted = True acted = True
if not acted: if not acted:
if waiting:
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残值平等待本合约权利金≤20%", "waiting": True}
return None return None
legs2 = get_plan_legs(conn, int(plan["id"])) legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry")) still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
@@ -1140,126 +1048,116 @@ def _tick_oo_close_rest(
) )
def _after_oo_winner_closed( def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期期止盈:优先盈亏比(浮盈≥rr×权利金则两腿全平);否则兼容旧上破/下破."""
rr = _sf(plan.get("oo_profit_rr"))
if rr is not None and rr > 0:
return _tick_oo_rr_target(cfg, conn, plan, legs, rr=float(rr))
return _tick_oo_price_target(cfg, conn, plan, legs)
def _tick_oo_rr_target(
cfg: dict[str, Any], cfg: dict[str, Any],
conn: Any, conn: Any,
plan: dict[str, Any], plan: dict[str, Any],
open_legs: list[dict[str, Any]], legs: list[dict[str, Any]],
best: dict[str, Any],
*, *,
reason: str, rr: float,
extra: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""盈利腿已平后:残值平同轮尝试 / 到期平标记 hold_to_expiry."""
rest_mode = resolve_oo_rest_close_mode(plan)
update_plan(conn, int(plan["id"]), close_reason=reason)
mid = dict(plan)
mid["close_reason"] = reason
mid["status"] = "active"
mid["oo_close_mode"] = rest_mode
notify_plan_end(cfg, conn, mid)
out: dict[str, Any] = {
"plan_id": plan["id"],
"close_reason": reason,
"closed_leg": best.get("id"),
"oo_close_mode": rest_mode,
}
if extra:
out.update(extra)
if rest_mode == "close_all":
legs2 = get_plan_legs(conn, int(plan["id"]))
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
if rest:
out["rest"] = rest
return out
for leg in open_legs:
if int(leg.get("id") or 0) == int(best.get("id") or 0):
continue
conn.execute(
"UPDATE hedge_plan_legs SET status=? WHERE id=?",
("hold_to_expiry", leg["id"]),
)
return out
def _tick_oo_profit_rr(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, rr_target: float
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""期期:任一开仓腿盈亏比(该腿盈利金额/总权利金)达目标 → 平盈利腿.""" """浮盈(买一回收−权利金)≥盈亏比×总权利金 → 两腿全平;不达标则等到期."""
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None
open_legs = _oo_option_legs(legs, statuses=("open",)) open_legs = _oo_option_legs(legs, statuses=("open",))
if len(open_legs) < 2: if len(open_legs) < 1:
return None return None
total_prem = _oo_plan_premium_total(plan, legs) premium = float(plan.get("premium_total") or 0)
if total_prem <= 0: if premium <= 0:
premium = sum(float(x.get("premium") or 0) for x in open_legs)
if premium <= 0:
return None return None
need = float(rr) * premium
ranked: list[tuple[float, float, dict[str, Any]]] = [] quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return None
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
total_pnl = 0.0
missing_bid = 0
for leg in open_legs: for leg in open_legs:
bid, _bid_sz = _oo_quote_bid(cfg, str(leg.get("inst_id") or "")) inst = str(leg.get("inst_id") or "")
rr = _oo_leg_profit_rr(leg, bid, total_premium=total_prem) bid = None
if rr is None: try:
continue q = quote_fn(ex, inst) if inst else {}
value = _oo_leg_mark_value(leg, bid) or 0.0 if isinstance(q, dict) and q.get("ok"):
premium = float(leg.get("premium") or 0) bid = _sf(q.get("bid"))
pnl = value - premium except Exception:
ranked.append((rr, pnl, leg)) bid = None
if not ranked: if bid is None or float(bid) <= 0:
missing_bid += 1
# 无买一时用内在价值兜底,避免短暂无盘口卡住;两腿都无买一则本轮跳过
total_pnl += _estimate_leg_close_pnl(leg, idx, None)
else:
total_pnl += _estimate_leg_close_pnl(leg, idx, float(bid))
if missing_bid >= len(open_legs):
return None return None
ranked.sort(key=lambda x: x[0], reverse=True) if total_pnl + 1e-9 < need:
best_rr, best_pnl, best = ranked[0]
if best_rr + 1e-12 < float(rr_target) or best_pnl <= 0:
return None return None
acted = False
for leg in list(open_legs):
close_r = _sell_option( close_r = _sell_option(
cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1) cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
) )
if not close_r.get("ok"): if not close_r.get("ok"):
notify_hedge( notify_hedge(
cfg, cfg,
build_hedge_alert_message( build_hedge_alert_message(
title="期期平盈利腿失败", title="期期盈亏比达标·平仓失败(将重试)",
plan_id=plan.get("id"), plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r), detail=(
f"目标 {rr:g}×权利金={need:.4f};估算浮盈 {total_pnl:.4f}; "
f"{close_r.get('msg') or close_r}"
),
), ),
) )
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
return {
reason = "profit_rr_win_leg" "plan_id": plan["id"],
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl)) "msg": "盈亏比达标但平仓失败",
"close": close_r,
"retry": True,
"rr": rr,
"need": need,
"mtm": total_pnl,
}
bid = _sf(close_r.get("bid"))
est = _estimate_leg_close_pnl(leg, idx, bid)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), closed_pnl, best["id"]), ("closed", "oo_rr_target", _now(), round(pnl, 4), leg["id"]),
)
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
return _after_oo_winner_closed(
cfg,
conn,
plan,
open_legs,
best,
reason=reason,
extra={
"profit_rr": best_rr,
"rr_target": float(rr_target),
"total_premium": total_prem,
"index": idx,
},
) )
acted = True
if not acted:
return None
legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
if still_open:
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
return {
"plan_id": plan["id"],
"msg": "盈亏比达标·部分已平,继续重试",
"remaining": len(still_open),
"rr": rr,
}
return _finalize_oo_all_closed(cfg, conn, plan, legs2, reason="oo_rr_target")
def _tick_oo_target( def _tick_oo_price_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""期期:优先按盈亏比平盈利腿;旧单无 profit_rr 时回退上/下破目标价.""" """旧逻辑:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
rr_target = _oo_resolve_profit_rr(plan)
if rr_target is not None:
return _tick_oo_profit_rr(cfg, conn, plan, legs, rr_target=rr_target)
idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None: if idx is None:
return None return None
@@ -1275,8 +1173,10 @@ def _tick_oo_target(
return None return None
hit_side: Optional[str] = None hit_side: Optional[str] = None
# 上破:现价接近或超过上破目标
if up is not None and idx >= up * 0.998: if up is not None and idx >= up * 0.998:
hit_side = "up" hit_side = "up"
# 下破:现价接近或低于下破目标
elif down is not None and idx <= down * 1.002: elif down is not None and idx <= down * 1.002:
hit_side = "down" hit_side = "down"
if not hit_side: if not hit_side:
@@ -1310,20 +1210,52 @@ def _tick_oo_target(
) )
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg" reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg"
# 选腿用内在估算;落库优先交易所已实现盈亏
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl)) closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), closed_pnl, best["id"]), ("closed", reason, _now(), closed_pnl, best["id"]),
) )
return _after_oo_winner_closed( rest_mode = resolve_oo_rest_close_mode(plan)
cfg, update_plan(conn, int(plan["id"]), close_reason=reason)
conn, mid = dict(plan)
plan, mid["close_reason"] = reason
open_legs, mid["status"] = "active"
best, mid["oo_close_mode"] = rest_mode
reason=reason, notify_plan_end(cfg, conn, mid)
extra={"hit_side": hit_side, "index": idx},
# 全平:同轮尝试清残腿;失败则下轮 _tick_oo_close_rest 重试
if rest_mode == "close_all":
legs2 = get_plan_legs(conn, int(plan["id"]))
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
out = {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
if rest:
out["rest"] = rest
return out
# 到期平:显式标记残腿 hold_to_expiry
for leg in open_legs:
if int(leg.get("id") or 0) == int(best.get("id") or 0):
continue
conn.execute(
"UPDATE hedge_plan_legs SET status=? WHERE id=?",
("hold_to_expiry", leg["id"]),
) )
return {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
def _tick_oo_expiry( def _tick_oo_expiry(
+26 -26
View File
@@ -46,11 +46,11 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
] ]
) )
else: else:
rr = plan.get("profit_rr") rr = plan.get("oo_profit_rr")
if rr not in (None, ""): if rr not in (None, ""):
lines.extend( lines.extend(
[ [
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)", f"🎯 盈亏比:{_fmt(rr)}×权利金(达标全平;不达标等到期)",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
] ]
) )
@@ -90,9 +90,10 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
"target_win_leg": "期期已平盈利腿(中间态)", "target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿", "target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿",
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿", "oo_rr_target": "期期盈亏比达标·两腿已平",
"oo_rest_closing": "期期残值平·清亏损腿", "oo_rr_closing": "期期盈亏比达标·平仓",
"oo_rest_closed": "期期残值平·两腿已平", "oo_rest_closing": "期期全平·清残腿中",
"oo_rest_closed": "期期全平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损", "oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利", "oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口", "expiry": "到期收口",
@@ -162,37 +163,36 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_win_leg", "target_win_leg",
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing", "oo_rest_closing",
"oo_rr_closing",
) and (plan.get("status") or "") != "closed": ) and (plan.get("status") or "") != "closed":
cr = str(plan.get("close_reason") or "") if "oo_rr" in str(plan.get("close_reason") or ""):
if "profit_rr" in cr: notify_hedge(
side = "盈亏比达标" cfg,
elif "up" in cr: build_hedge_alert_message(
side = "上破" title="期期盈亏比达标·平仓进行中",
elif "down" in cr: plan_id=plan.get("id"),
side = "下破" detail=f"盈亏比 {_fmt(plan.get('oo_profit_rr'))}×权利金",
else: ),
side = "目标" )
return True
side = "上破" if "up" in str(plan.get("close_reason")) else (
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
)
mode = (plan.get("oo_close_mode") or "").strip().lower() mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平", "残值平"): if mode in ("close_all", "全平"):
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)" rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)"
else: else:
rest_txt = "另一腿到期平(持有至到期结算)" rest_txt = "另一腿到期平(持有至到期结算)"
rr = plan.get("profit_rr")
if rr not in (None, ""):
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
else:
detail = (
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
)
notify_hedge( notify_hedge(
cfg, cfg,
build_hedge_alert_message( build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}", title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"), plan_id=plan.get("id"),
detail=detail, detail=(
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
),
), ),
) )
return True return True
+4 -3
View File
@@ -1146,6 +1146,8 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
b = body.get("leg_b") or {} b = body.get("leg_b") or {}
if not a.get("inst_id") or not b.get("inst_id"): if not a.get("inst_id") or not b.get("inst_id"):
return "请选用两条期权腿" return "请选用两条期权腿"
rr_raw = body.get("oo_profit_rr")
if rr_raw in (None, ""):
rr_raw = body.get("profit_rr") rr_raw = body.get("profit_rr")
if rr_raw not in (None, ""): if rr_raw not in (None, ""):
try: try:
@@ -1153,9 +1155,8 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
except (TypeError, ValueError): except (TypeError, ValueError):
return "盈亏比无效" return "盈亏比无效"
if rr <= 0: if rr <= 0:
return "盈亏比须大于0" return "盈亏比须大于 0"
else: else:
# 兼容旧上/下破
up = body.get("target_price_up") up = body.get("target_price_up")
down = body.get("target_price_down") down = body.get("target_price_down")
legacy = body.get("target_price") legacy = body.get("target_price")
@@ -1164,7 +1165,7 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
if down in (None, "") and legacy not in (None, ""): if down in (None, "") and legacy not in (None, ""):
down = legacy down = legacy
if up in (None, "") or down in (None, ""): if up in (None, "") or down in (None, ""):
return "请填写盈亏比" return "请填写盈亏比(相对权利金,默认2)"
try: try:
if float(up) <= float(down): if float(up) <= float(down):
return "上破目标价必须大于下破目标价" return "上破目标价必须大于下破目标价"
+52 -47
View File
@@ -537,36 +537,25 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
float(b.get("premium") or 0) if b_ok else 0.0 float(b.get("premium") or 0) if b_ok else 0.0
) )
rr_raw = body.get("oo_profit_rr")
if rr_raw in (None, ""):
rr_raw = body.get("profit_rr") rr_raw = body.get("profit_rr")
try: try:
profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0 oo_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
except (TypeError, ValueError): except (TypeError, ValueError):
profit_rr = 2.0 oo_rr = 2.0
if profit_rr <= 0: if oo_rr <= 0:
profit_rr = 2.0 oo_rr = 2.0
# 旧字段兼容:不再要求上/下破;有传则原样落库
def _opt_float(key: str, *alts: str) -> float | None:
for k in (key, *alts):
v = body.get(k)
if v not in (None, ""):
try:
return float(v)
except (TypeError, ValueError):
continue
return None
up_f = _opt_float("target_price_up", "target_price")
down_f = _opt_float("target_price_down", "target_price")
plan_id = insert_plan( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "options_options", "plan_type": "options_options",
"status": "partial" if is_partial else "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": up_f, "target_price": None,
"target_price_up": up_f, "target_price_up": None,
"target_price_down": down_f, "target_price_down": None,
"profit_rr": profit_rr, "oo_profit_rr": oo_rr,
"sizing_mode_at_open": load_position_sizing_mode(), "sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium, "premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
@@ -1247,32 +1236,24 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
rr_raw = body.get("oo_profit_rr")
if rr_raw in (None, ""):
rr_raw = body.get("profit_rr") rr_raw = body.get("profit_rr")
profit_rr = None rr = None
if rr_raw not in (None, ""): if rr_raw not in (None, ""):
profit_rr = float(rr_raw) try:
if profit_rr <= 0: rr = float(rr_raw)
raise ValueError("盈亏比须大于0") except (TypeError, ValueError) as e:
up = body.get("target_price_up") raise ValueError("盈亏比无效") from e
down = body.get("target_price_down") if rr <= 0:
legacy = body.get("target_price") raise ValueError("盈亏比须大于 0")
if up in (None, "") and legacy not in (None, ""):
up = legacy
if down in (None, "") and legacy not in (None, ""):
down = legacy
if profit_rr is None and (up in (None, "") or down in (None, "")):
raise ValueError("请填写盈亏比")
up_f = float(up) if up not in (None, "") else None
down_f = float(down) if down not in (None, "") else None
if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f:
raise ValueError("上破目标价必须大于下破目标价")
index_px = body.get("index_px") index_px = body.get("index_px")
if index_px in (None, ""): try:
if up_f is not None and down_f is not None: index_px_f = float(index_px) if index_px not in (None, "") else 0.0
index_px = (up_f + down_f) / 2 except (TypeError, ValueError):
else: index_px_f = 0.0
raise ValueError("缺少指数价格")
index_px = float(index_px)
leg_a = body.get("leg_a") or {} leg_a = body.get("leg_a") or {}
leg_b = body.get("leg_b") or {} leg_b = body.get("leg_b") or {}
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
@@ -1286,14 +1267,38 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
) )
if leg.get("premium_paid") is None: if leg.get("premium_paid") is None:
raise ValueError(f"缺少 {name} 权利金") raise ValueError(f"缺少 {name} 权利金")
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px_f or None)
if money_err: if money_err:
raise ValueError(money_err) raise ValueError(money_err)
if rr is not None:
return build_options_options_preview(
profit_rr=rr,
index_px=index_px_f,
leg_a=leg_a,
leg_b=leg_b,
)
# 兼容旧上破/下破
up = body.get("target_price_up")
down = body.get("target_price_down")
legacy = body.get("target_price")
if up in (None, "") and legacy not in (None, ""):
up = legacy
if down in (None, "") and legacy not in (None, ""):
down = legacy
if up in (None, "") or down in (None, ""):
raise ValueError("请填写盈亏比(相对权利金,默认2)")
up_f = float(up)
down_f = float(down)
if up_f <= down_f:
raise ValueError("上破目标价必须大于下破目标价")
if index_px_f <= 0:
index_px_f = (up_f + down_f) / 2
return build_options_options_preview( return build_options_options_preview(
profit_rr=profit_rr,
target_price_up=up_f, target_price_up=up_f,
target_price_down=down_f, target_price_down=down_f,
index_px=index_px, index_px=index_px_f,
leg_a=leg_a, leg_a=leg_a,
leg_b=leg_b, leg_b=leg_b,
) )
@@ -213,7 +213,7 @@
<div class="tip-collapse-body rule-tip"> <div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p> <p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p> <p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期</p> <p><strong>板块</strong>:左填<strong>盈亏比</strong>(相对权利金,默认 2=盈利 2 倍权利金)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。中途浮盈达盈亏比→两腿全平;不达标→等到期。「全平/到期平」仅兼容旧上破下破计划残腿处理</p>
</div> </div>
</details> </details>
<div class="form-row hp-uly-row"> <div class="form-row hp-uly-row">
@@ -221,7 +221,7 @@
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div> </div>
<div class="form-row hp-target-row hp-oo-target-row"> <div class="form-row hp-target-row hp-oo-target-row">
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label> <label title="目标盈利 = 盈亏比 × 两腿权利金合计;例 2=赚满 2 倍权利金后全平">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-oo-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span> <span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div> </div>
<div class="hp-oo-controls"> <div class="hp-oo-controls">
@@ -236,7 +236,7 @@
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row"> <div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span> <span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式"> <div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>残值</button> <button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后立刻买一清另一腿(无2×,失败重试)"><span class="hp-oo-check" aria-hidden="true"></span></button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button> <button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div> </div>
</div> </div>
@@ -405,4 +405,4 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/static/hedge_plan.js?v=46"></script> <script src="/static/hedge_plan.js?v=47"></script>
+14 -52
View File
@@ -121,43 +121,33 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
return default return default
def _format_profit_exit_mult(mult: Any) -> str:
try:
n = float(mult)
except (TypeError, ValueError):
return "1倍"
if n <= 0:
return "1倍"
if abs(n - round(n)) < 1e-9:
return f"{int(round(n))}"
return f"{n:g}"
def _format_options_target(p: dict[str, Any]) -> str: def _format_options_target(p: dict[str, Any]) -> str:
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
if hedge: if hedge:
rr = _safe_float(hedge.get("profit_rr")) rr = _safe_float(hedge.get("oo_profit_rr") or hedge.get("profit_rr"))
pid = hedge.get("plan_id") pid = hedge.get("plan_id")
if rr is not None and rr > 0: if rr is not None and rr > 0:
return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}" return f"对冲#{pid} 盈亏比×{rr:g}" if pid is not None else f"盈亏比×{rr:g}"
ot = str(hedge.get("opt_type") or opt_type).upper() ot = str(hedge.get("opt_type") or p.get("opt_type") or p.get("optType") or "").upper()
side = "Put ≤" if ot == "P" else "Call ≥" side = "Put ≤" if ot == "P" else "Call ≥"
tgt = _safe_float(hedge.get("target_index")) tgt = _safe_float(hedge.get("target_index"))
if tgt is not None: if tgt is not None:
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
parts: list[str] = [] mon = p.get("target_monitor") if isinstance(p.get("target_monitor"), dict) else None
rr = _safe_float(p.get("profit_rr"))
if rr is None and mon:
rr = _safe_float(mon.get("profit_rr"))
if rr is not None and rr > 0:
return f"盈亏比×{rr:g}"
tgt = _safe_float(p.get("target_index")) tgt = _safe_float(p.get("target_index"))
if tgt is None and mon:
tgt = _safe_float(mon.get("target_index"))
if tgt is not None and tgt > 0: if tgt is not None and tgt > 0:
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
side = "Put ≤" if opt_type == "P" else "Call ≥" side = "Put ≤" if opt_type == "P" else "Call ≥"
parts.append(f"{side} {tgt:g}") return f"{side} {tgt:g}"
if p.get("profit_exit_enabled"):
parts.append(_format_profit_exit_mult(p.get("profit_exit_mult")))
if parts:
return " · ".join(parts)
return "" return ""
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
@@ -370,39 +360,11 @@ def collect_options_items(
raw = fetch_options_positions() or [] raw = fetch_options_positions() or []
except Exception: except Exception:
return [] return []
pe_map: dict[str, dict[str, Any]] = {}
tgt_map: dict[str, dict[str, Any]] = {}
hedge_map: dict[str, dict[str, Any]] = {}
if conn is not None:
try:
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import targets_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
pe_map = profit_exit_by_inst(conn)
tgt_map = targets_by_inst(conn)
hedge_map = active_options_targets_by_inst(conn)
except Exception:
pe_map, tgt_map, hedge_map = {}, {}, {}
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for p in raw: for p in raw:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
row = dict(p) out.append(_format_options_item(p, conn=conn))
inst = str(row.get("inst_id") or row.get("instId") or "").strip()
mon = tgt_map.get(inst)
if mon:
row["target_index"] = mon.get("target_index")
pe = pe_map.get(inst)
if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult")
hedge = hedge_map.get(inst)
if hedge:
row["hedge_plan_target"] = hedge
if not mon:
row["target_index"] = hedge.get("target_index")
out.append(_format_options_item(row, conn=conn))
return out return out
+2 -2
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=13"> <link rel="stylesheet" href="/static/instance_page.css?v=13">
<link rel="stylesheet" href="/static/instance_theme.css?v=117"> <link rel="stylesheet" href="/static/instance_theme.css?v=114">
<script src="/static/account_risk_badge.js?v=4"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<script src="/static/open_submit_gate.js?v=1"></script> <script src="/static/open_submit_gate.js?v=1"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
@@ -170,7 +170,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script> <script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </script>
<script src="/static/instance_settings_prefs.js?v=21"></script> <script src="/static/instance_settings_prefs.js?v=19"></script>
<script src="/static/instance_live.js?v=6"></script> <script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=31"></script> <script src="/static/instance_embed.js?v=31"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script> <script src="/static/instance_mobile_nav.js?v=2"></script>
+1 -1
View File
@@ -37,7 +37,7 @@
{% endif %} {% endif %}
<div class="env-form-grid"> <div class="env-form-grid">
{% for field in group.fields %} {% for field in group.fields %}
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}" data-env-key="{{ field.key }}"{% if field.hidden %} hidden style="display:none"{% endif %}> <div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}">
<label class="env-field-label" for="env-f-{{ field.key }}"> <label class="env-field-label" for="env-f-{{ field.key }}">
{{ field.label or field.key }} {{ field.label or field.key }}
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %} {% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
+2 -2
View File
@@ -19,7 +19,7 @@
<link rel="manifest" href="/static/icons/manifest.webmanifest"> <link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=13"> <link rel="stylesheet" href="/static/instance_page.css?v=13">
<link rel="stylesheet" href="/static/instance_theme.css?v=117"> <link rel="stylesheet" href="/static/instance_theme.css?v=114">
</head> </head>
<body <body
@@ -2045,6 +2045,6 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
{% endif %} {% endif %}
</script> </script>
<script src="/static/instance_settings_prefs.js?v=21"></script> <script src="/static/instance_settings_prefs.js?v=19"></script>
</body> </body>
</html> </html>
+3
View File
@@ -72,11 +72,14 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict
mon = tgt_map.get(str(row.get("inst_id") or "")) mon = tgt_map.get(str(row.get("inst_id") or ""))
if mon: if mon:
row["target_index"] = mon.get("target_index") row["target_index"] = mon.get("target_index")
row["profit_rr"] = mon.get("profit_rr")
row["target_monitor_id"] = mon.get("id") row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon row["target_monitor"] = mon
hedge_target = hedge_target_map.get(str(row.get("inst_id") or "")) hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
if hedge_target: if hedge_target:
row["hedge_plan_target"] = hedge_target row["hedge_plan_target"] = hedge_target
if hedge_target.get("oo_profit_rr") is not None and row.get("profit_rr") is None:
row["profit_rr"] = hedge_target.get("oo_profit_rr")
if not mon: if not mon:
row["target_index"] = hedge_target.get("target_index") row["target_index"] = hedge_target.get("target_index")
rows.append(row) rows.append(row)
-3
View File
@@ -98,9 +98,6 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
for ddl in ( for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
): ):
try: try:
conn.execute(ddl) conn.execute(ddl)
+2 -19
View File
@@ -28,42 +28,25 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn) target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn) hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values()) target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
{
"inst_id": pe.get("inst_id"),
"exit_mode": "profit_exit",
"profit_exit_mult": pe.get("profit_exit_mult"),
"profit_exit_enabled": True,
}
)
for p in positions: for p in positions:
mon = tgt_map.get(str(p.get("inst_id") or "")) mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon: if mon:
p["target_index"] = mon.get("target_index") p["target_index"] = mon.get("target_index")
p["profit_rr"] = mon.get("profit_rr")
p["target_monitor_id"] = mon.get("id") p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon p["target_monitor"] = mon
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
if pe:
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target: if hedge_target:
p["hedge_plan_target"] = hedge_target p["hedge_plan_target"] = hedge_target
if not mon: if not mon:
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
p["target_index"] = hedge_target.get("target_index") p["target_index"] = hedge_target.get("target_index")
p["profit_rr"] = hedge_target.get("oo_profit_rr")
try: try:
from lib.instance.instance_dashboard_lib import ( from lib.instance.instance_dashboard_lib import (
_format_options_target, _format_options_target,
+1 -17
View File
@@ -428,8 +428,6 @@ def options_monitor_loop(
profit_ratio: float, profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None, sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None, target_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None, stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
stop_event: Any = None, stop_event: Any = None,
) -> None: ) -> None:
@@ -457,25 +455,11 @@ def options_monitor_loop(
conn, conn,
positions, positions,
close_fn=target_close_fn, close_fn=target_close_fn,
bid_fn=ticker_bid_fn,
send_wechat=send_wechat, send_wechat=send_wechat,
account_label=account_label, account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label}, cfg={"send_wechat": send_wechat, "account_label": account_label},
) )
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
run_options_profit_exits(
conn,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
if sync_trades_fn is not None: if sync_trades_fn is not None:
sync_trades_fn(conn) sync_trades_fn(conn)
conn.commit() conn.commit()
+19 -2
View File
@@ -55,6 +55,7 @@ def build_options_open_message(
premium_paid: Any = None, premium_paid: Any = None,
open_quote: Any = None, open_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
signal_note: str = "", signal_note: str = "",
trade_id: Any = None, trade_id: Any = None,
) -> str: ) -> str:
@@ -73,7 +74,12 @@ def build_options_open_message(
f"权利金:{_fmt(premium_paid)} USDC", f"权利金:{_fmt(premium_paid)} USDC",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if profit_rr is not None and str(profit_rr).strip() != "":
try:
lines.append(f"盈亏比:×{float(profit_rr):g}(达标全平;不达标等到期)")
except (TypeError, ValueError):
lines.append(f"盈亏比:{profit_rr}")
elif target_index is not None and str(target_index).strip() != "":
try: try:
lines.append(f"目标指数:{float(target_index):g}") lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -96,6 +102,7 @@ def build_options_close_message(
realized_pnl: Any = None, realized_pnl: Any = None,
close_quote: Any = None, close_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
trade_id: Any = None, trade_id: Any = None,
) -> str: ) -> str:
@@ -116,7 +123,12 @@ def build_options_close_message(
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if profit_rr is not None and str(profit_rr).strip() != "":
try:
lines.append(f"盈亏比:×{float(profit_rr):g}")
except (TypeError, ValueError):
lines.append(f"盈亏比:{profit_rr}")
elif target_index is not None and str(target_index).strip() != "":
try: try:
lines.append(f"目标指数:{float(target_index):g}") lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -141,6 +153,7 @@ def notify_options_open(
premium_paid: Any = None, premium_paid: Any = None,
open_quote: Any = None, open_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
signal_note: str = "", signal_note: str = "",
) -> bool: ) -> bool:
ensure_options_notify_columns(conn) if conn is not None else None ensure_options_notify_columns(conn) if conn is not None else None
@@ -160,6 +173,7 @@ def notify_options_open(
premium_paid=premium_paid, premium_paid=premium_paid,
open_quote=open_quote, open_quote=open_quote,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
signal_note=signal_note, signal_note=signal_note,
trade_id=trade_id, trade_id=trade_id,
) )
@@ -196,6 +210,7 @@ def notify_options_close(
realized_pnl: Any = None, realized_pnl: Any = None,
close_quote: Any = None, close_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
force: bool = False, force: bool = False,
) -> bool: ) -> bool:
@@ -257,6 +272,7 @@ def notify_options_close(
realized_pnl=total_pnl, realized_pnl=total_pnl,
close_quote=close_quote if close_quote is not None else head.get("close_quote"), close_quote=close_quote if close_quote is not None else head.get("close_quote"),
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=head.get("id") if len(rows) == 1 else None, trade_id=head.get("id") if len(rows) == 1 else None,
) )
@@ -286,6 +302,7 @@ def notify_options_close(
realized_pnl=realized_pnl, realized_pnl=realized_pnl,
close_quote=close_quote, close_quote=close_quote,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=trade_id, trade_id=trade_id,
) )
-23
View File
@@ -119,26 +119,3 @@ def option_position_limit_block_msg(
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓" f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
) )
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开" return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
def compound_full_single_position_block_msg(
ex: Any,
*,
fetch_positions=None,
) -> Optional[str]:
"""全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔)."""
fetch = fetch_positions
if fetch is None:
from lib.exchange.okx_options_lib import fetch_option_positions
fetch = fetch_option_positions
try:
rows = fetch(ex)
except Exception:
rows = None
if rows is None:
return "无法获取期权持仓,全仓复利模式暂不可开仓"
active = count_live_option_positions(rows)
if active >= 1:
return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓"
return None
-19
View File
@@ -264,25 +264,6 @@ def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> f
return min(float(trading_usdc), float(trade_budget_usdc)) return min(float(trading_usdc), float(trade_budget_usdc))
def resolve_compound_full_usdc(
trading_usdc: float,
*,
cap_enabled: bool = False,
cap_usdc: float | None = None,
) -> float:
"""全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶."""
bal = max(0.0, float(trading_usdc or 0))
if not cap_enabled:
return bal
try:
cap = float(cap_usdc) if cap_usdc is not None else 0.0
except (TypeError, ValueError):
cap = 0.0
if cap <= 0:
return bal
return min(bal, cap)
def calc_order_size( def calc_order_size(
*, *,
quote_per_unit: float, quote_per_unit: float,
-377
View File
@@ -1,377 +0,0 @@
"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
1 = 盈利金额等于初始权利金 买一可回收 权利金 × (1 + 倍数).
目标位并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立.
"""
from __future__ import annotations
import sqlite3
from typing import Any, Callable
from lib.options.options_db import init_options_tables, sum_open_premium_paid
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 ensure_profit_exit_columns(conn: sqlite3.Connection) -> None:
init_options_tables(conn)
for ddl in (
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
):
try:
conn.execute(ddl)
except Exception:
pass
def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float:
try:
mult = float(raw)
except (TypeError, ValueError):
mult = float(default)
if mult <= 0:
mult = float(default)
return round(mult, 4)
def profit_exit_hit(
*,
premium_paid: float,
recycle_usdc: float,
mult: float,
) -> bool:
"""1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult)."""
prem = float(premium_paid or 0)
recv = float(recycle_usdc or 0)
m = float(mult or 0)
if prem <= 0 or m <= 0 or recv <= 0:
return False
return recv + 1e-9 >= prem * (1.0 + m)
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
prem = float(premium_paid or 0)
m = float(mult or 0)
if prem <= 0 or m <= 0:
return None
return round(prem * (1.0 + m), 4)
def set_profit_exit(
conn: sqlite3.Connection,
*,
inst_id: str,
enabled: bool,
mult: float | None = None,
) -> dict[str, Any]:
ensure_profit_exit_columns(conn)
inst = (inst_id or "").strip()
if not inst:
return {"ok": False, "msg": "缺少 inst_id"}
m = normalize_profit_exit_mult(mult if mult is not None else 1.0)
rows = conn.execute(
"""
SELECT id FROM options_trades
WHERE inst_id = ? AND status = 'open'
""",
(inst,),
).fetchall()
if not rows:
return {"ok": False, "msg": "未找到该合约的本地开仓记录"}
if enabled:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 1,
profit_exit_mult = ?,
profit_exit_state = 'active'
WHERE inst_id = ? AND status = 'open'
""",
(m, inst),
)
else:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0,
profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst,),
)
return {
"ok": True,
"inst_id": inst,
"profit_exit_enabled": bool(enabled),
"profit_exit_mult": m if enabled else None,
"updated": len(rows),
}
def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""进行中(active/closing)的翻倍出场,按合约取最新一条规则."""
ensure_profit_exit_columns(conn)
rows = conn.execute(
"""
SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state
FROM options_trades
WHERE status = 'open'
AND (
CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1
OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing')
)
ORDER BY id DESC
"""
).fetchall()
out: dict[str, dict[str, Any]] = {}
for r in rows:
inst = str(r["inst_id"] or "").strip()
if not inst or inst in out:
continue
enabled = int(r["profit_exit_enabled"] or 0) == 1
state = str(r["profit_exit_state"] or "idle")
if not enabled and state not in ("active", "closing"):
continue
mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0)
out[inst] = {
"inst_id": inst,
"profit_exit_enabled": enabled or state in ("active", "closing"),
"profit_exit_mult": mult,
"profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"),
"required_recycle": None,
}
for inst, info in out.items():
prem = sum_open_premium_paid(conn, inst)
if prem is not None:
info["premium_paid"] = prem
info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"]))
return out
def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_state = ?
WHERE inst_id = ? AND status = 'open'
""",
(state, inst_id),
)
def _commit(conn: sqlite3.Connection) -> None:
try:
conn.commit()
except Exception:
pass
def _result_fully_done(result: dict[str, Any]) -> bool:
if result.get("already_flat"):
return True
if result.get("fully_closed"):
return True
remaining = result.get("remaining_sheets")
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
return True
return False
def close_option_by_bid_profit_exit(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
from lib.options.options_close_exec_lib import close_option_by_bid1
return close_option_by_bid1(
cfg,
ex,
inst_id,
sheets=sheets,
require_recycle_gate=False,
signal_note="翻倍出场",
)
def _estimate_recycle(
cfg: dict[str, Any],
ex: Any,
pos: dict[str, Any],
premium_paid: float | None,
) -> float | None:
from lib.options.options_positions_lib import attach_close_preview
row = dict(pos)
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"):
return None
return _safe_float(preview.get("total_received"))
def _notify_profit_exit_close(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
inst_id: str,
mult: float,
premium_paid: float | None,
recycle: float | None,
result: dict[str, Any],
conn: Any = None,
) -> None:
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
notify_options_close(
cfg,
conn,
inst_id=inst_id,
reason=f"翻倍出场({mult:g}倍)",
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
)
return
except Exception:
pass
if not send_wechat:
return
try:
send_wechat(
"\n".join(
[
"【OKX期权·翻倍出场】",
f"账户:{account_label}",
f"合约:{inst_id}",
f"倍数:{mult:g}(1倍=盈利=权利金)",
f"权利金:{premium_paid if premium_paid is not None else ''}",
f"可回收:{recycle if recycle is not None else ''}",
f"提交张数:{result.get('submitted_sheets') or ''}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
)
except Exception:
pass
def run_options_profit_exits(
conn: sqlite3.Connection,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None,
ex: Any = None,
) -> int:
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
hedge_managed: set[str] = set()
try:
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
return 0
rules = profit_exit_by_inst(conn)
triggered = 0
for inst_id, info in list(rules.items()):
if not inst_id:
continue
if inst_id in hedge_managed:
_mark_state(conn, inst_id, "idle")
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
_commit(conn)
continue
pos = pos_by_inst.get(inst_id)
if not pos:
# 持仓已平:收尾
_mark_state(conn, inst_id, "done")
_commit(conn)
continue
state = str(info.get("profit_exit_state") or "active")
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
prem = sum_open_premium_paid(conn, inst_id)
if prem is None or prem <= 0:
continue
if state == "closing":
result = close_fn(inst_id)
if result.get("already_flat") or _result_fully_done(result):
_mark_state(conn, inst_id, "done")
_commit(conn)
else:
_mark_state(conn, inst_id, "closing")
_commit(conn)
continue
if not info.get("profit_exit_enabled"):
continue
if recycle_fn is not None:
recycle = recycle_fn(pos, prem)
elif cfg is not None and ex is not None:
recycle = _estimate_recycle(cfg, ex, pos, prem)
else:
continue
if recycle is None:
continue
if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
continue
result = close_fn(inst_id)
if result.get("already_flat"):
_mark_state(conn, inst_id, "done")
_commit(conn)
continue
if not result.get("ok"):
_mark_state(conn, inst_id, "active")
_commit(conn)
continue
done = _result_fully_done(result)
_mark_state(conn, inst_id, "done" if done else "closing")
_commit(conn)
triggered += 1
_notify_profit_exit_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
mult=mult,
premium_paid=prem,
recycle=recycle,
result=result,
conn=conn,
)
return triggered
+509
View File
@@ -0,0 +1,509 @@
"""期权链实时报价:OKX 公共 WS tickers → 内存缓存 → SSE 推前端."""
from __future__ import annotations
import json
import logging
import os
import queue
import threading
import time
from collections.abc import Iterator
from typing import Any, Callable
from lib.exchange.okx_public_ws_lib import OkxPublicWs
from lib.options.options_pricing_lib import (
expiry_breakeven_from_ask,
idx_distance_to_be,
)
logger = logging.getLogger(__name__)
OPTIONS_QUOTE_SSE_HEARTBEAT_SEC = float(os.getenv("OKX_OPTIONS_QUOTE_SSE_HEARTBEAT_SEC", "20"))
OPTIONS_QUOTE_FLUSH_MS = float(os.getenv("OKX_OPTIONS_QUOTE_FLUSH_MS", "120"))
# OKX 单连接约 240 频道;当前到期日合约 + 指数通常够用
OPTIONS_QUOTE_MAX_INST = int(os.getenv("OKX_OPTIONS_QUOTE_MAX_INST", "220"))
def _safe_float(v: Any) -> float | None:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
class OptionsQuoteLive:
def __init__(self) -> None:
self._lock = threading.RLock()
self._watchers: dict[str, dict[str, Any]] = {}
self._meta: dict[str, dict[str, Any]] = {}
self._tickers: dict[str, dict[str, Any]] = {}
self._index_by_uly: dict[str, float] = {}
self._index_insts: set[str] = set()
self._dirty_inst: set[str] = set()
self._dirty_index: set[str] = set()
self._version = 0
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._flush_thread: threading.Thread | None = None
ws_url = (os.getenv("OKX_PUBLIC_WS_URL") or "").strip() or None
self._ws = OkxPublicWs(
on_data=self._on_ws_data,
name="okx-options-quote-ws",
**({"url": ws_url} if ws_url else {}),
)
self._started = False
def start(self) -> None:
if self._started:
return
self._started = True
self._stop.clear()
self._ws.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, name="options-quote-flush", daemon=True
)
self._flush_thread.start()
def stop(self) -> None:
self._stop.set()
self._ws.stop()
self._broadcast(close=True)
self._started = False
def status(self) -> dict[str, Any]:
with self._lock:
uly = ""
exp = ""
index_inst = ""
if self._watchers:
last = next(reversed(list(self._watchers.values())))
uly = str(last.get("underlying") or "")
exp = str(last.get("exp_time") or "")
index_inst = str(last.get("index_inst") or "")
return {
"ok": True,
"started": self._started,
"ws_ok": self._ws.connected,
"underlying": uly,
"index_inst": index_inst,
"index_px": self._index_by_uly.get(uly),
"watch_exp": exp,
"watch_count": len(self._meta),
"watcher_count": len(self._watchers),
"version": self._version,
"last_msg_at": self._ws.last_msg_at,
}
def watch(
self,
*,
underlying: str,
exp_time: str | int | None,
contracts: list[dict[str, Any]],
index_inst_id: str | None = None,
watcher_id: str | None = None,
) -> dict[str, Any]:
u = (underlying or "ETH").upper()
index_id = (index_inst_id or f"{u}-USD").strip()
wid = (watcher_id or "default").strip() or "default"
meta: dict[str, dict[str, Any]] = {}
for c in contracts or []:
if not isinstance(c, dict):
continue
inst_id = str(c.get("inst_id") or c.get("instId") or "").strip()
if not inst_id:
continue
meta[inst_id] = {
"inst_id": inst_id,
"opt_type": str(c.get("opt_type") or c.get("optType") or "").upper(),
"strike": _safe_float(c.get("strike")),
"tick_sz": c.get("tick_sz") or c.get("tickSz"),
"underlying": u,
}
if len(meta) >= max(1, OPTIONS_QUOTE_MAX_INST):
break
with self._lock:
self._watchers[wid] = {
"underlying": u,
"exp_time": str(exp_time or ""),
"index_inst": index_id,
"meta": meta,
}
self._rebuild_subscriptions_locked()
if not self._started:
self.start()
return self.status()
def _rebuild_subscriptions_locked(self) -> None:
merged: dict[str, dict[str, Any]] = {}
index_insts: set[str] = set()
for w in self._watchers.values():
index_insts.add(str(w.get("index_inst") or ""))
for inst_id, m in (w.get("meta") or {}).items():
if inst_id not in merged:
merged[inst_id] = dict(m)
if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
break
if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
break
index_insts = {x for x in index_insts if x}
self._meta = merged
self._index_insts = index_insts
keep = set(merged.keys())
for k in list(self._tickers.keys()):
if k not in keep:
self._tickers.pop(k, None)
args = [{"channel": "tickers", "instId": iid} for iid in merged]
for iid in sorted(index_insts):
args.append({"channel": "index-tickers", "instId": iid})
# 订阅可能分片 sleep,不能堵 Flask 请求线程
threading.Thread(
target=self._ws.set_subscriptions,
args=(args,),
name="okx-options-quote-sub",
daemon=True,
).start()
def as_okx_tickers(self, underlying: str | None = None) -> dict[str, dict[str, Any]]:
"""转成 build_option_chain 可用的 OKX ticker 字段."""
u = (underlying or "").upper()
out: dict[str, dict[str, Any]] = {}
with self._lock:
for inst_id, q in self._tickers.items():
if u and str(q.get("underlying") or "").upper() not in ("", u):
continue
row: dict[str, Any] = {"instId": inst_id}
if q.get("ask") is not None and not q.get("ask_estimated"):
row["askPx"] = q.get("ask")
row["askSz"] = q.get("ask_sz")
if q.get("bid") is not None:
row["bidPx"] = q.get("bid")
row["bidSz"] = q.get("bid_sz")
if q.get("mark_px") is not None:
row["markPx"] = q.get("mark_px")
out[inst_id] = row
return out
def index_px_for(self, underlying: str) -> float | None:
u = (underlying or "").upper()
with self._lock:
return self._index_by_uly.get(u)
def is_ws_fresh(self, *, max_age_sec: float = 15.0) -> bool:
if not self._ws.connected:
return False
last = float(self._ws.last_msg_at or 0)
return last > 0 and (time.time() - last) <= max_age_sec
def schedule_seed_from_chain(
self,
chain: dict[str, Any],
*,
exp_time: str | int | None = None,
watcher_id: str | None = None,
) -> None:
threading.Thread(
target=self.seed_from_chain,
kwargs={"chain": chain, "exp_time": exp_time, "watcher_id": watcher_id},
name="options-quote-seed",
daemon=True,
).start()
def seed_from_chain(
self,
chain: dict[str, Any],
*,
exp_time: str | int | None = None,
watcher_id: str | None = None,
) -> None:
"""REST 拉链后预填报价,并默认监视指定/最近到期."""
if not isinstance(chain, dict):
return
u = str(chain.get("underlying") or "ETH").upper()
index_px = _safe_float(chain.get("index_px"))
expiries = chain.get("expiries") or []
target = None
if exp_time is not None and str(exp_time):
for e in expiries:
if str(e.get("exp_time")) == str(exp_time):
target = e
break
if target is None and expiries:
target = expiries[0]
contracts = list((target or {}).get("contracts") or [])
if index_px is not None:
with self._lock:
self._index_by_uly[u] = index_px
self._dirty_index.add(u)
for c in contracts:
inst_id = str(c.get("inst_id") or "").strip()
if not inst_id:
continue
patch = {
"inst_id": inst_id,
"ask": c.get("ask"),
"bid": c.get("bid"),
"ask_sz": c.get("ask_sz"),
"bid_sz": c.get("bid_sz"),
"mark_px": c.get("mark_px"),
"ask_estimated": bool(c.get("ask_estimated")),
"expiry_be_px": c.get("expiry_be_px"),
"dist_expiry_be": c.get("dist_expiry_be"),
"underlying": u,
}
with self._lock:
self._tickers[inst_id] = patch
self._dirty_inst.add(inst_id)
self.watch(
underlying=u,
exp_time=(target or {}).get("exp_time"),
contracts=contracts,
index_inst_id=f"{u}-USD",
watcher_id=watcher_id or f"seed:{u}",
)
def _on_ws_data(self, payload: dict[str, Any]) -> None:
arg = payload.get("arg") or {}
channel = str(arg.get("channel") or "")
rows = payload.get("data") or []
if not isinstance(rows, list) or not rows:
return
if channel == "index-tickers":
row = rows[0] if isinstance(rows[0], dict) else {}
px = _safe_float(row.get("idxPx"))
inst = str(row.get("instId") or arg.get("instId") or "")
uly = inst.split("-")[0].upper() if inst else ""
if px is None or not uly:
return
with self._lock:
if self._index_by_uly.get(uly) == px:
return
self._index_by_uly[uly] = px
self._dirty_index.add(uly)
return
if channel != "tickers":
return
for row in rows:
if not isinstance(row, dict):
continue
inst_id = str(row.get("instId") or arg.get("instId") or "").strip()
if not inst_id:
continue
patch = self._ticker_to_patch(inst_id, row)
with self._lock:
prev = self._tickers.get(inst_id) or {}
if (
prev.get("ask") == patch.get("ask")
and prev.get("bid") == patch.get("bid")
and prev.get("ask_sz") == patch.get("ask_sz")
and prev.get("bid_sz") == patch.get("bid_sz")
and prev.get("mark_px") == patch.get("mark_px")
):
continue
self._tickers[inst_id] = patch
self._dirty_inst.add(inst_id)
def _ticker_to_patch(self, inst_id: str, row: dict[str, Any]) -> dict[str, Any]:
ask = _safe_float(row.get("askPx"))
bid = _safe_float(row.get("bidPx"))
ask_sz = _safe_float(row.get("askSz"))
bid_sz = _safe_float(row.get("bidSz"))
mark = _safe_float(row.get("markPx"))
ask_estimated = False
with self._lock:
meta = dict(self._meta.get(inst_id) or {})
uly = str(meta.get("underlying") or inst_id.split("-")[0] or "").upper()
index_px = self._index_by_uly.get(uly)
if ask is None and mark is not None and mark > 0:
ask = mark
ask_estimated = True
ask_sz = None
if bid is None and mark is not None and mark > 0:
bid = mark
be = expiry_breakeven_from_ask(
opt_type=str(meta.get("opt_type") or ""),
strike=meta.get("strike"),
ask_px=None if ask_estimated else ask,
mark_px=mark,
)
dist = idx_distance_to_be(index_px, be)
return {
"inst_id": inst_id,
"underlying": uly,
"ask": ask,
"bid": bid,
"ask_sz": ask_sz,
"bid_sz": bid_sz,
"mark_px": mark,
"ask_estimated": ask_estimated,
"expiry_be_px": be,
"dist_expiry_be": dist,
}
def _flush_loop(self) -> None:
interval = max(0.05, OPTIONS_QUOTE_FLUSH_MS / 1000.0)
while not self._stop.is_set():
if self._stop.wait(interval):
break
event = self._build_flush_event()
if event is None:
continue
self._broadcast(event)
def _build_flush_event(self) -> str | None:
with self._lock:
if not self._dirty_inst and not self._dirty_index:
return None
dirty_uly = set(self._dirty_index)
self._dirty_index.clear()
quotes: list[dict[str, Any]] = []
for inst_id in list(self._dirty_inst):
q = self._tickers.get(inst_id)
if q:
quotes.append(dict(q))
self._dirty_inst.clear()
for uly in dirty_uly:
index_px = self._index_by_uly.get(uly)
if index_px is None:
continue
for inst_id, q in list(self._tickers.items()):
if str(q.get("underlying") or "").upper() != uly:
continue
be = q.get("expiry_be_px")
dist = idx_distance_to_be(index_px, be if be is not None else None)
if q.get("dist_expiry_be") != dist:
q2 = dict(q)
q2["dist_expiry_be"] = dist
self._tickers[inst_id] = q2
quotes.append(q2)
self._version += 1
# 多标的时 index_px 取「最近一次 watch」的标的,前端仍以 payload.underlying 过滤
uly = ""
exp = ""
if self._watchers:
last = next(reversed(list(self._watchers.values())))
uly = str(last.get("underlying") or "")
exp = str(last.get("exp_time") or "")
# 若本批只有单一 underlying 的 quotes/index,优先用它
quote_ulys = {str(q.get("underlying") or "").upper() for q in quotes if q.get("underlying")}
if len(dirty_uly) == 1:
uly = next(iter(dirty_uly))
elif len(quote_ulys) == 1:
uly = next(iter(quote_ulys))
payload = {
"ok": True,
"live": True,
"ws_ok": self._ws.connected,
"version": self._version,
"underlying": uly,
"watch_exp": exp,
"index_px": self._index_by_uly.get(uly),
"indexes": dict(self._index_by_uly),
"quotes": quotes,
"ts": int(time.time() * 1000),
}
return json.dumps(payload, ensure_ascii=False)
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def _subscribe(self) -> queue.Queue[str | None]:
q: queue.Queue[str | None] = queue.Queue(maxsize=64)
with self._lock:
self._subscribers.append(q)
return q
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q = self._subscribe()
try:
yield self._format_event(
{
"ok": True,
"reason": "connect",
**self.status(),
"quotes": [],
"ts": int(time.time() * 1000),
}
)
while True:
try:
raw = q.get(timeout=OPTIONS_QUOTE_SSE_HEARTBEAT_SEC)
except queue.Empty:
yield ": heartbeat\n\n"
continue
if raw is None:
break
yield f"event: quotes\ndata: {raw}\n\n"
finally:
self._unsubscribe(q)
@staticmethod
def _format_event(data: dict[str, Any]) -> str:
return "event: quotes\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
options_quote_live = OptionsQuoteLive()
def start_options_quote_live() -> OptionsQuoteLive:
options_quote_live.start()
return options_quote_live
def register_options_quote_live_routes(app: Any, login_required: Callable) -> None:
from flask import Response, jsonify, request, stream_with_context
start_options_quote_live()
@app.route("/api/options/quotes/stream")
@login_required
def api_options_quotes_stream():
return Response(
stream_with_context(options_quote_live.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/options/quotes/watch", methods=["POST"])
@login_required
def api_options_quotes_watch():
data = request.get_json(silent=True) or {}
contracts = data.get("contracts") or []
if not contracts and data.get("inst_ids"):
contracts = [{"inst_id": x} for x in (data.get("inst_ids") or [])]
st = options_quote_live.watch(
underlying=str(data.get("underlying") or "ETH"),
exp_time=data.get("exp_time"),
contracts=contracts,
index_inst_id=data.get("index_inst_id"),
watcher_id=str(data.get("watcher_id") or "default"),
)
return jsonify({"ok": True, **st})
@app.route("/api/options/quotes/status")
@login_required
def api_options_quotes_status():
return jsonify(options_quote_live.status())
+112 -299
View File
@@ -61,6 +61,14 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
register_options_routes(app, cfg) register_options_routes(app, cfg)
_register_options_hub_bridge(app, cfg) _register_options_hub_bridge(app, cfg)
if enabled: if enabled:
try:
from lib.options.options_quote_live_lib import register_options_quote_live_routes
register_options_quote_live_routes(app, cfg["login_required"])
except Exception as e:
import logging
logging.getLogger(__name__).exception("options quote live init failed: %s", e)
_start_monitor_thread(app, cfg) _start_monitor_thread(app, cfg)
@@ -103,9 +111,6 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"render_main_page": app_module.render_main_page, "render_main_page": app_module.render_main_page,
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
"budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
"compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True),
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
@@ -177,69 +182,6 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
return resolve_budget_full_usdc(trading, float(cap)), "" return resolve_budget_full_usdc(trading, float(cap)), ""
def _compound_full_enabled() -> bool:
return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True)
def _budget_full_blocked_by_compound_msg() -> str | None:
if _compound_full_enabled():
return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式"
return None
def _size_mode_budget_cap(
cfg: dict[str, Any], mode: str, budget_cap: float | None
) -> float | None:
"""全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制)."""
if mode in ("budget_full", "compound_full"):
return budget_cap
if mode in ("sheets", "eth_amount"):
if _compound_full_enabled():
return None
return budget_cap
return None
def _normalize_size_mode(mode: str) -> tuple[str, str | None]:
"""全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓."""
m = (mode or "sheets").strip() or "sheets"
if m == "compound_full" and not _compound_full_enabled():
return "sheets", "全仓复利已关闭,已改用指定张数"
if m == "budget_full" and _compound_full_enabled():
return "compound_full", None
return m, None
def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
"""全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer."""
if not _compound_full_enabled():
return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)"
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
from lib.options.options_pricing_lib import resolve_compound_full_usdc
raw = fetch_options_trading_usdc(ex)
if raw is None or float(raw) <= 0:
return None, "交易账户 USDC 可用余额不足"
trading = float(raw)
# 额度热更读 env(与模板启动值无关)
cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False)
cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0)
if cap_on and cap_v <= 0:
return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)"
return (
resolve_compound_full_usdc(
trading,
cap_enabled=cap_on,
cap_usdc=cap_v,
),
"",
)
def _is_budget_mode(mode: str) -> bool:
return mode in ("budget_full", "compound_full")
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
@@ -421,16 +363,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": err}) return jsonify({"ok": False, "msg": err})
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
bal = cfg["fetch_options_balances"](ex, force=force, scope="main") bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
return jsonify( return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]})
{
"ok": True,
**bal,
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
"compound_full_enabled": _compound_full_enabled(),
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
}
)
@app.route("/api/options/chain") @app.route("/api/options/chain")
@lr @lr
@@ -441,6 +374,24 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
u = (request.args.get("underlying") or cfg["default_underly"]).upper() u = (request.args.get("underlying") or cfg["default_underly"]).upper()
# 热更新:链展示天数每次读 env,保存后刷新链即可 # 热更新:链展示天数每次读 env,保存后刷新链即可
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
fast = (request.args.get("fast") or "").strip().lower() in ("1", "true", "yes")
force_tickers = (request.args.get("force_tickers") or "").strip().lower() in ("1", "true", "yes")
watch_exp = (request.args.get("exp_time") or "").strip() or None
live_index = None
live_tickers = None
ws_fresh = False
try:
from lib.options.options_quote_live_lib import options_quote_live
ws_fresh = options_quote_live.is_ws_fresh()
live_index = options_quote_live.index_px_for(u)
live_tickers = options_quote_live.as_okx_tickers(u) or None
except Exception:
pass
# fast: WS 已热则跳过整家族 REST tickers(最慢的一步),用 WS 缓存覆盖
fetch_tickers = True
if fast and ws_fresh and not force_tickers:
fetch_tickers = False
try: try:
chain = cfg["build_option_chain"]( chain = cfg["build_option_chain"](
ex, ex,
@@ -448,6 +399,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
max_dte_days=chain_max_dte, max_dte_days=chain_max_dte,
itm_only=False, itm_only=False,
itm_max_dist_usd=cfg["itm_max_dist"], itm_max_dist_usd=cfg["itm_max_dist"],
index_px=live_index,
tickers_override=live_tickers,
fetch_tickers=fetch_tickers,
force_tickers=force_tickers,
) )
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
@@ -456,6 +411,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:每次读 env,保存配置后刷新链即可生效 # 热更新:每次读 env,保存配置后刷新链即可生效
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if expiries:
try:
from lib.options.options_quote_live_lib import options_quote_live
options_quote_live.schedule_seed_from_chain(chain, exp_time=watch_exp)
except Exception:
pass
if not expiries: if not expiries:
return jsonify( return jsonify(
{ {
@@ -466,6 +428,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter, "ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer, "budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"], "trade_budget": cfg["trade_budget"],
"chain_fast": fast,
"ws_fresh": ws_fresh,
} }
) )
return jsonify( return jsonify(
@@ -476,6 +440,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter, "ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer, "budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"], "trade_budget": cfg["trade_budget"],
"quote_live": True,
"chain_fast": fast,
"ws_fresh": ws_fresh,
"tickers_fetched": fetch_tickers,
} }
) )
@@ -494,7 +462,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ask = q.get("ask") ask = q.get("ask")
ct_mult = q.get("ct_mult") or 0.01 ct_mult = q.get("ct_mult") or 0.01
min_sz = q.get("min_sz") or 1 min_sz = q.get("min_sz") or 1
mode = (request.args.get("mode") or "sheets").strip() mode = (request.args.get("mode") or "budget_full").strip()
sheet_count = None sheet_count = None
try: try:
if request.args.get("sheets"): if request.args.get("sheets"):
@@ -505,45 +473,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
paid = _open_premium_paid(cfg, inst_id) paid = _open_premium_paid(cfg, inst_id)
target = sheet_count if sheet_count is not None else 0 target = sheet_count if sheet_count is not None else 0
return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid)) return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
mode, mode_note = _normalize_size_mode(mode)
budget = cfg["trade_budget"] budget = cfg["trade_budget"]
budget_cap = cfg["trade_budget"] budget_cap = cfg["trade_budget"]
available_usdc = None available_usdc = None
if mode == "budget_full": if mode == "budget_full":
blocked = _budget_full_blocked_by_compound_msg()
if blocked:
return jsonify(
{
"ok": False,
"msg": blocked,
"compound_full_enabled": _compound_full_enabled(),
}
)
budget, budget_err = _budget_full_usdc(cfg, ex) budget, budget_err = _budget_full_usdc(cfg, ex)
if budget is None: if budget is None:
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()}) return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget budget_cap = budget
from lib.exchange.okx_options_lib import fetch_options_trading_usdc from lib.exchange.okx_options_lib import fetch_options_trading_usdc
available_usdc = fetch_options_trading_usdc(ex) available_usdc = fetch_options_trading_usdc(ex)
elif mode == "compound_full":
if not _compound_full_enabled():
return jsonify(
{
"ok": False,
"msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)",
"compound_full_enabled": False,
}
)
budget, budget_err = _compound_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True})
budget_cap = budget
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
available_usdc = fetch_options_trading_usdc(ex)
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
budget_cap = None
eth_amount = None eth_amount = None
try: try:
if request.args.get("eth_amount"): if request.args.get("eth_amount"):
@@ -574,7 +514,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
}, },
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
} }
) )
except Exception as e: except Exception as e:
@@ -611,7 +550,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
}, },
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
} }
) )
except Exception as e: except Exception as e:
@@ -636,39 +574,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
}, },
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if mode == "compound_full":
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": compound_block,
"quote_per_unit": ask,
"premium_per_sheet": None,
"sizing": {
"ok": False,
"msg": compound_block,
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
"available_usdc": available_usdc,
"budget_full_usdc": None,
"compound_full_usdc": budget,
} }
) )
from lib.options.options_position_limit_lib import option_position_limit_block_msg
pos_limit_msg = option_position_limit_block_msg( pos_limit_msg = option_position_limit_block_msg(
ex, ex,
@@ -693,20 +601,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
}, },
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
} }
) )
sizing = calc_order_size( sizing = calc_order_size(
quote_per_unit=float(ask), quote_per_unit=float(ask),
ct_mult=float(ct_mult), ct_mult=float(ct_mult),
min_sz=int(min_sz), min_sz=int(min_sz),
budget_usdc=budget if _is_budget_mode(mode) else None, budget_usdc=budget if mode == "budget_full" else None,
budget_buffer=cfg["budget_buffer"], budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount if mode == "eth_amount" else None, eth_amount=eth_amount if mode == "eth_amount" else None,
sheets=sheet_count if mode == "sheets" else None, sheets=sheet_count if mode == "sheets" else None,
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
else None,
) )
if sizing.get("ok"): if sizing.get("ok"):
capped, cap_msg = cap_option_buy_sheets_to_ask_depth( capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
@@ -728,9 +633,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ct_mult=float(ct_mult), ct_mult=float(ct_mult),
min_sz=int(min_sz), min_sz=int(min_sz),
sheets=capped, sheets=capped,
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
else None,
) )
if sizing.get("ok"): if sizing.get("ok"):
sizing["ask_depth_capped"] = True sizing["ask_depth_capped"] = True
@@ -752,10 +655,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"sizing": sizing, "sizing": sizing,
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
"mode": mode,
"mode_note": mode_note,
"compound_full_enabled": _compound_full_enabled(),
} }
) )
@@ -787,13 +686,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"})
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()
mode = (data.get("mode") or "sheets").strip() mode = (data.get("mode") or "budget_full").strip()
mode, mode_note = _normalize_size_mode(mode)
signal_note = (data.get("signal_note") or "").strip() signal_note = (data.get("signal_note") or "").strip()
if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full":
# 前端残留全仓复利选中时,已自动改指定张数;继续开仓
pass
target_index = None target_index = None
profit_rr = None
raw_rr = data.get("profit_rr")
if raw_rr is None or str(raw_rr).strip() == "":
raw_rr = data.get("oo_profit_rr")
if raw_rr is not None and str(raw_rr).strip() != "":
try:
profit_rr = float(raw_rr)
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "盈亏比无效"})
if profit_rr <= 0:
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
raw_target = data.get("target_index") raw_target = data.get("target_index")
if raw_target is not None and str(raw_target).strip() != "": if raw_target is not None and str(raw_target).strip() != "":
try: try:
@@ -802,12 +708,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0: if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
profit_exit_enabled = bool(data.get("profit_exit_enabled")) # 未显式传目标时默认盈亏比 2
profit_exit_mult = 1.0 if profit_rr is None and target_index is None:
if profit_exit_enabled: profit_rr = 2.0
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
if not inst_id: if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"}) return jsonify({"ok": False, "msg": "缺少 inst_id"})
q = cfg["quote_option_contract"](ex, inst_id) q = cfg["quote_option_contract"](ex, inst_id)
@@ -826,17 +729,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ref_ask": q.get("ref_ask"), "ref_ask": q.get("ref_ask"),
} }
) )
from lib.options.options_position_limit_lib import ( from lib.options.options_position_limit_lib import option_position_limit_block_msg
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if mode == "compound_full":
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return jsonify({"ok": False, "msg": compound_block, "can_open": False})
pos_limit_msg = option_position_limit_block_msg( pos_limit_msg = option_position_limit_block_msg(
ex, ex,
@@ -858,49 +751,23 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
try: try:
sheet_count = int(data.get("sheets")) sheet_count = int(data.get("sheets"))
except (TypeError, ValueError): except (TypeError, ValueError):
sheet_count = None
if sheet_count is None or int(sheet_count) < 1:
# 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1
if (data.get("mode") or "").strip() == "compound_full":
sheet_count = 1
else:
return jsonify({"ok": False, "msg": "张数无效"}) return jsonify({"ok": False, "msg": "张数无效"})
budget = cfg["trade_budget"] budget = cfg["trade_budget"]
budget_cap = cfg["trade_budget"] budget_cap = cfg["trade_budget"]
if mode == "budget_full": if mode == "budget_full":
blocked = _budget_full_blocked_by_compound_msg()
if blocked:
return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()})
budget, budget_err = _budget_full_usdc(cfg, ex) budget, budget_err = _budget_full_usdc(cfg, ex)
if budget is None: if budget is None:
return jsonify({"ok": False, "msg": budget_err}) return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget budget_cap = budget
elif mode == "compound_full":
if not _compound_full_enabled():
return jsonify(
{
"ok": False,
"msg": "全仓复利未开启,请改用指定张数或先开启全仓复利",
"compound_full_enabled": False,
}
)
budget, budget_err = _compound_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
budget_cap = None
sizing = calc_order_size( sizing = calc_order_size(
quote_per_unit=float(ask), quote_per_unit=float(ask),
ct_mult=ct_mult, ct_mult=ct_mult,
min_sz=min_sz, min_sz=min_sz,
budget_usdc=budget if _is_budget_mode(mode) else None, budget_usdc=budget if mode == "budget_full" else None,
budget_buffer=cfg["budget_buffer"], budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount, eth_amount=eth_amount,
sheets=sheet_count, sheets=sheet_count,
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
else None,
) )
if not sizing.get("ok"): if not sizing.get("ok"):
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
@@ -983,9 +850,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
open_opt_type = None open_opt_type = None
try: try:
init_options_tables(conn) init_options_tables(conn)
from lib.options.options_profit_exit_lib import ensure_profit_exit_columns
ensure_profit_exit_columns(conn)
meta = q.get("meta") or {} meta = q.get("meta") or {}
u = str(meta.get("uly") or inst_id).split("-")[0] u = str(meta.get("uly") or inst_id).split("-")[0]
opt_type = meta.get("optType") opt_type = meta.get("optType")
@@ -995,9 +859,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
""" """
INSERT INTO options_trades INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id, open_quote, premium_paid, status, signal_note, exchange_ord_id)
profit_exit_enabled, profit_exit_mult, profit_exit_state) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
""", """,
( (
inst_id, inst_id,
@@ -1011,26 +874,22 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
sizing["total_premium"], sizing["total_premium"],
signal_note, signal_note,
ord_id, ord_id,
1 if profit_exit_enabled else 0,
profit_exit_mult if profit_exit_enabled else 1.0,
"active" if profit_exit_enabled else "idle",
), ),
) )
trade_id = int(cur.lastrowid) trade_id = int(cur.lastrowid)
if target_index is not None: if profit_rr is not None or target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor from lib.options.options_target_lib import upsert_target_monitor
target_mon = upsert_target_monitor( target_mon = upsert_target_monitor(
conn, conn,
inst_id=inst_id, inst_id=inst_id,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
underlying=u, underlying=u,
opt_type=str(opt_type) if opt_type else None, opt_type=str(opt_type) if opt_type else None,
trade_id=trade_id, trade_id=trade_id,
sheets=sheets, sheets=sheets,
) )
if profit_exit_enabled:
pass # 列已由 init_options_tables / ensure 迁移
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
@@ -1053,6 +912,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
premium_paid=sizing.get("total_premium"), premium_paid=sizing.get("total_premium"),
open_quote=fill_px, open_quote=fill_px,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
signal_note=signal_note, signal_note=signal_note,
) )
finally: finally:
@@ -1149,11 +1009,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.options.options_target_lib import targets_by_inst from lib.options.options_target_lib import targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn) hedge_target_map = active_options_targets_by_inst(conn)
rows = [] rows = []
for p in raw: for p in raw:
@@ -1170,17 +1028,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
mon = tgt_map.get(inst) mon = tgt_map.get(inst)
if mon: if mon:
row["target_index"] = mon.get("target_index") row["target_index"] = mon.get("target_index")
row["profit_rr"] = mon.get("profit_rr")
row["target_monitor_id"] = mon.get("id") row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon row["target_monitor"] = mon
pe = profit_exit_map.get(inst)
if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult")
row["profit_exit_state"] = pe.get("profit_exit_state")
row["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(inst) hedge_target = hedge_target_map.get(inst)
if hedge_target: if hedge_target:
row["hedge_plan_target"] = hedge_target row["hedge_plan_target"] = hedge_target
if hedge_target.get("oo_profit_rr") is not None:
row.setdefault("profit_rr", hedge_target.get("oo_profit_rr"))
try: try:
from lib.instance.instance_dashboard_lib import _resolve_options_source from lib.instance.instance_dashboard_lib import _resolve_options_source
@@ -1238,12 +1093,28 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn_h.close() conn_h.close()
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
profit_rr = None
target_index = None
raw_rr = data.get("profit_rr")
if raw_rr is None or str(raw_rr).strip() == "":
raw_rr = data.get("oo_profit_rr")
if raw_rr is not None and str(raw_rr).strip() != "":
try: try:
target_index = float(data.get("target_index")) profit_rr = float(raw_rr)
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "盈亏比无效"})
if profit_rr <= 0:
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
raw_tgt = data.get("target_index")
if raw_tgt is not None and str(raw_tgt).strip() != "":
try:
target_index = float(raw_tgt)
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0: if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
if profit_rr is None and target_index is None:
profit_rr = 2.0
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None: if raw is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"}) return jsonify({"ok": False, "msg": "获取期权持仓失败"})
@@ -1272,6 +1143,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn, conn,
inst_id=inst_id, inst_id=inst_id,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
underlying=str(underlying) if underlying else None, underlying=str(underlying) if underlying else None,
opt_type=str(opt_type) if opt_type else None, opt_type=str(opt_type) if opt_type else None,
trade_id=trade_id, trade_id=trade_id,
@@ -1304,62 +1176,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
finally: finally:
conn.close() conn.close()
@app.route("/api/options/profit-exit", methods=["POST"])
@lr
def api_options_profit_exit_set():
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip()
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"})
try:
from lib.hedge_plan.hedge_plan_db import (
active_hedge_option_inst_ids,
init_hedge_plan_tables,
)
conn_h = cfg["get_db"]()
try:
init_hedge_plan_tables(conn_h)
if inst_id in active_hedge_option_inst_ids(conn_h):
return jsonify(
{
"ok": False,
"msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场",
}
)
finally:
conn_h.close()
except Exception as e:
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
enabled_raw = data.get("enabled")
if enabled_raw is None:
enabled_raw = data.get("profit_exit_enabled")
enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in (
"0",
"false",
"off",
"no",
)
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit
mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0)
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
if not _find_position(raw, inst_id):
return jsonify({"ok": False, "msg": "未找到持仓"})
conn = cfg["get_db"]()
try:
out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult)
if out.get("ok"):
conn.commit()
return jsonify(out)
finally:
conn.close()
@app.route("/api/options/close", methods=["POST"]) @app.route("/api/options/close", methods=["POST"])
@lr @lr
def api_options_close(): def api_options_close():
@@ -1674,7 +1490,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None: if raw is None:
return [] return []
return [cfg["format_position_row"](p) for p in raw] rows = [cfg["format_position_row"](p) for p in raw]
try:
from lib.options.options_db import sum_open_premium_paid
conn = cfg["get_db"]()
try:
for row in rows:
inst = str(row.get("inst_id") or "")
if not inst:
continue
paid = sum_open_premium_paid(conn, inst)
if paid is not None:
row["premium_paid"] = paid
finally:
conn.close()
except Exception:
pass
return rows
def _sync(conn): def _sync(conn):
from lib.exchange.okx_options_lib import fetch_option_position_history from lib.exchange.okx_options_lib import fetch_option_position_history
@@ -1713,24 +1546,6 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
pass pass
return result return result
def _profit_exit_close(inst_id: str) -> dict[str, Any]:
from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit
ex = cfg.get("exchange_options")
if ex is None:
return {"ok": False, "msg": "期权 exchange 未就绪"}
result = close_option_by_bid_profit_exit(cfg, ex, inst_id)
if result.get("ok"):
try:
_sync_options_trades(cfg, force=True)
except Exception:
pass
try:
_mark_balances_stale(cfg)
except Exception:
pass
return result
def _stale_pending() -> dict[str, Any]: def _stale_pending() -> dict[str, Any]:
from lib.exchange.okx_options_lib import invalidate_option_positions_cache from lib.exchange.okx_options_lib import invalidate_option_positions_cache
from lib.options.options_pending_lib import cancel_stale_close_pending_orders from lib.options.options_pending_lib import cancel_stale_close_pending_orders
@@ -1781,8 +1596,6 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
"profit_ratio": cfg["profit_ratio"], "profit_ratio": cfg["profit_ratio"],
"sync_trades_fn": _sync, "sync_trades_fn": _sync,
"target_close_fn": _target_close, "target_close_fn": _target_close,
"profit_exit_close_fn": _profit_exit_close,
"profit_exit_cfg": cfg,
"stale_pending_fn": _stale_pending, "stale_pending_fn": _stale_pending,
}, },
daemon=True, daemon=True,
-1
View File
@@ -129,7 +129,6 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
_ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0") _ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL") _ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
_ensure_column(conn, "options_review_trades", "target_price_down", "REAL") _ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
_ensure_column(conn, "options_review_trades", "profit_rr", "REAL")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
-1
View File
@@ -450,7 +450,6 @@ def upsert_hedge_plan_row(
"target_price": _safe_float(plan.get("target_price")), "target_price": _safe_float(plan.get("target_price")),
"target_price_up": _safe_float(plan.get("target_price_up")), "target_price_up": _safe_float(plan.get("target_price_up")),
"target_price_down": _safe_float(plan.get("target_price_down")), "target_price_down": _safe_float(plan.get("target_price_down")),
"profit_rr": _safe_float(plan.get("profit_rr")),
"legs_json": _legs_json_from_plan(legs), "legs_json": _legs_json_from_plan(legs),
} }
existing = conn.execute( existing = conn.execute(
+176 -52
View File
@@ -1,11 +1,14 @@
"""期权目标委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" """期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算).
兼容旧目标指数委托: profit_rr 时仍按指数到位触发.
"""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
import time 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, sum_open_premium_paid
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
@@ -18,6 +21,18 @@ def _safe_float(v: Any) -> float | None:
return None return None
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
names: set[str] = set()
for r in rows:
try:
names.add(str(r["name"]))
except (TypeError, KeyError, IndexError):
names.add(str(r[1]))
if col not in names:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | 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.exchange.okx_options_lib import option_fields_from_inst_id
@@ -63,21 +78,44 @@ def ensure_target_tables(conn: sqlite3.Connection) -> None:
ON options_target_monitors(status) ON options_target_monitors(status)
""" """
) )
# 盈亏比=目标盈利/权利金;如 2=盈利 2 倍权利金.有值时优先生效,target_index 可置 0
_ensure_column(conn, "options_target_monitors", "profit_rr", "REAL")
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" """旧逻辑:Call 指数≥目标;Put 指数≤目标."""
ot = (opt_type or "").strip().upper() ot = (opt_type or "").strip().upper()
if ot == "P": if ot == "P":
return index_px <= target_index return index_px <= target_index
return index_px >= target_index return index_px >= target_index
def profit_rr_hit(
*,
premium: float,
bid: float | None,
sheets: float,
ct_mult: float,
profit_rr: float,
) -> bool:
"""买一回收 − 权利金 ≥ 盈亏比 × 权利金."""
if premium <= 0 or profit_rr <= 0:
return False
if bid is None or float(bid) <= 0:
return False
if sheets <= 0 or ct_mult <= 0:
return False
recycle = float(bid) * float(sheets) * float(ct_mult)
pnl = recycle - float(premium)
return pnl + 1e-9 >= float(profit_rr) * float(premium)
def upsert_target_monitor( def upsert_target_monitor(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
inst_id: str, inst_id: str,
target_index: float, target_index: float | None = None,
profit_rr: float | None = None,
underlying: str | None = None, underlying: str | None = None,
opt_type: str | None = None, opt_type: str | None = None,
trade_id: int | None = None, trade_id: int | None = None,
@@ -87,9 +125,18 @@ def upsert_target_monitor(
inst_id = (inst_id or "").strip() inst_id = (inst_id or "").strip()
if not inst_id: if not inst_id:
return {"ok": False, "msg": "缺少 inst_id"} return {"ok": False, "msg": "缺少 inst_id"}
if target_index is None or float(target_index) <= 0:
return {"ok": False, "msg": "目标位无效"} rr = _safe_float(profit_rr)
target_index = float(target_index) tgt = _safe_float(target_index)
if rr is not None and rr > 0:
tgt_store = float(tgt) if tgt is not None and tgt > 0 else 0.0
rr_store = float(rr)
elif tgt is not None and tgt > 0:
tgt_store = float(tgt)
rr_store = None
else:
return {"ok": False, "msg": "请填写盈亏比(相对权利金,默认2)"}
row = conn.execute( row = conn.execute(
""" """
SELECT id FROM options_target_monitors SELECT id FROM options_target_monitors
@@ -104,6 +151,7 @@ def upsert_target_monitor(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET target_index = ?, SET target_index = ?,
profit_rr = ?,
underlying = COALESCE(?, underlying), underlying = COALESCE(?, underlying),
opt_type = COALESCE(?, opt_type), opt_type = COALESCE(?, opt_type),
trade_id = COALESCE(?, trade_id), trade_id = COALESCE(?, trade_id),
@@ -115,14 +163,13 @@ def upsert_target_monitor(
triggered_at = NULL triggered_at = NULL
WHERE id = ? WHERE id = ?
""", """,
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), (tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])),
) )
mon_id = int(row["id"]) mon_id = int(row["id"])
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
conn.execute( conn.execute(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET status = 'cancelled', message = '被新目标覆盖' SET status = 'cancelled', message = '被新目标委托覆盖'
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
""", """,
(inst_id, mon_id), (inst_id, mon_id),
@@ -131,13 +178,21 @@ def upsert_target_monitor(
cur = conn.execute( cur = conn.execute(
""" """
INSERT INTO options_target_monitors INSERT INTO options_target_monitors
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status) (inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status)
VALUES (?, ?, ?, ?, ?, ?, 'active') VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
""", """,
(inst_id, underlying, opt_type, target_index, trade_id, sheets), (inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets),
) )
mon_id = int(cur.lastrowid) mon_id = int(cur.lastrowid)
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} out: dict[str, Any] = {
"ok": True,
"id": mon_id,
"inst_id": inst_id,
"target_index": tgt_store if tgt_store > 0 else None,
}
if rr_store is not None:
out["profit_rr"] = rr_store
return out
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
@@ -166,12 +221,19 @@ def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = Non
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
tgt = _safe_float(r["target_index"])
rr = None
try:
rr = _safe_float(r["profit_rr"])
except (KeyError, IndexError):
rr = None
return { return {
"id": int(r["id"]), "id": int(r["id"]),
"inst_id": r["inst_id"], "inst_id": r["inst_id"],
"underlying": r["underlying"], "underlying": r["underlying"],
"opt_type": r["opt_type"], "opt_type": r["opt_type"],
"target_index": _safe_float(r["target_index"]), "target_index": tgt if tgt is not None and tgt > 0 else None,
"profit_rr": rr if rr is not None and rr > 0 else None,
"trade_id": r["trade_id"], "trade_id": r["trade_id"],
"sheets": r["sheets"], "sheets": r["sheets"],
"status": r["status"], "status": r["status"],
@@ -180,16 +242,16 @@ def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
} }
_TARGET_SELECT = (
"SELECT id, inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, "
"status, message, created_at FROM options_target_monitors"
)
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
ensure_target_tables(conn) ensure_target_tables(conn)
rows = conn.execute( rows = conn.execute(
""" f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC"
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at
FROM options_target_monitors
WHERE status = 'active'
ORDER BY id DESC
"""
).fetchall() ).fetchall()
return [_row_to_target(r) for r in rows] return [_row_to_target(r) for r in rows]
@@ -198,13 +260,7 @@ def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""已挂出平仓单、等待成交的目标(不再重复推送微信).""" """已挂出平仓单、等待成交的目标(不再重复推送微信)."""
ensure_target_tables(conn) ensure_target_tables(conn)
rows = conn.execute( rows = conn.execute(
""" f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC"
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at
FROM options_target_monitors
WHERE status = 'closing'
ORDER BY id DESC
"""
).fetchall() ).fetchall()
return [_row_to_target(r) for r in rows] return [_row_to_target(r) for r in rows]
@@ -286,23 +342,23 @@ def close_option_by_bid_depth(
inst_id, inst_id,
sheets=sheets, sheets=sheets,
require_recycle_gate=True, require_recycle_gate=True,
signal_note="目标位平仓", signal_note="盈亏比平仓",
) )
def _notify_target_close( def _notify_target_close(
cfg: dict[str, Any] | None, cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None, send_wechat: Callable[[str], None] | None,
*, *,
account_label: str, account_label: str,
inst_id: str, inst_id: str,
target: float, target: float | None,
idx: float, profit_rr: float | None,
idx: float | None,
result: dict[str, Any], result: dict[str, Any],
conn: Any = None, conn: Any = None,
) -> None: ) -> None:
"""目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" """目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
if result.get("fully_closed") or result.get("already_flat"): if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None: if cfg is not None:
try: try:
@@ -312,12 +368,13 @@ def _notify_target_close(
cfg, cfg,
conn, conn,
inst_id=inst_id, inst_id=inst_id,
reason="目标位平仓", reason="盈亏比平仓" if profit_rr else "目标位平仓",
sheets=result.get("submitted_sheets"), sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"), premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"), close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target, target_index=target,
trigger_idx=idx, trigger_idx=idx,
profit_rr=profit_rr,
) )
return return
except Exception: except Exception:
@@ -325,14 +382,20 @@ def _notify_target_close(
if not send_wechat: if not send_wechat:
return return
try: try:
if profit_rr is not None and profit_rr > 0:
rule = f"盈亏比×{profit_rr:g}"
elif target is not None:
rule = f"目标指数:{target:g}"
else:
rule = "目标委托"
send_wechat( send_wechat(
"\n".join( "\n".join(
[ [
"【OKX期权·目标位平仓】", "【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】",
f"账户:{account_label}", f"账户:{account_label}",
f"合约:{inst_id}", f"合约:{inst_id}",
f"目标指数:{target:g}", rule,
f"触发指数:{idx:g}", f"触发指数:{idx:g}" if idx is not None else "触发指数:—",
f"提交张数:{result.get('submitted_sheets') or ''}", f"提交张数:{result.get('submitted_sheets') or ''}",
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else ''} USDC", f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else ''} USDC",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
@@ -354,18 +417,77 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
return False return False
def _monitor_should_close(
conn: sqlite3.Connection,
mon: dict[str, Any],
pos: dict[str, Any],
*,
bid_fn: Callable[[str], float | None] | None,
index_fn: Callable[[dict[str, Any]], float | None] | None,
) -> tuple[bool, float | None]:
"""返回 (是否触发, 当前指数)."""
inst_id = str(mon.get("inst_id") or "")
rr = _safe_float(mon.get("profit_rr"))
if index_fn is not None:
idx = index_fn(pos)
else:
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
if rr is not None and rr > 0:
premium = sum_open_premium_paid(conn, inst_id)
if premium is None or premium <= 0:
premium = _safe_float(pos.get("premium_paid"))
sheets = _safe_float(mon.get("sheets"))
if sheets is None or sheets <= 0:
sheets = _safe_float(pos.get("pos") or pos.get("avail_pos") or pos.get("availPos"))
ct = _safe_float(pos.get("ct_mult") or pos.get("ctMult")) or 0.01
bid = None
if bid_fn is not None:
try:
bid = bid_fn(inst_id)
except Exception:
bid = None
if bid is None:
bid = _safe_float(pos.get("bid_px") or pos.get("bidPx") or pos.get("bid"))
preview = pos.get("close_preview") if isinstance(pos.get("close_preview"), dict) else {}
if bid is None:
bid = _safe_float(preview.get("bid") or preview.get("best_bid"))
if premium is None or sheets is None:
return False, idx
return (
profit_rr_hit(
premium=float(premium),
bid=bid,
sheets=float(sheets),
ct_mult=float(ct),
profit_rr=float(rr),
),
idx,
)
target = _safe_float(mon.get("target_index"))
if target is None or target <= 0 or idx is None:
return False, idx
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
return (
target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target),
idx,
)
def run_options_target_closes( def run_options_target_closes(
conn: sqlite3.Connection, conn: sqlite3.Connection,
positions: list[dict[str, Any]], positions: list[dict[str, Any]],
*, *,
close_fn: Callable[[str], dict[str, Any]], close_fn: Callable[[str], dict[str, Any]],
index_fn: Callable[[dict[str, Any]], float | None] | None = None, index_fn: Callable[[dict[str, Any]], float | None] | None = None,
bid_fn: Callable[[str], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None, send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权", account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None, cfg: dict[str, Any] | None = None,
) -> int: ) -> int:
""" """
扫描 active 目标委托;指数到位后限价平仓. 扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓.
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
未完全成交进入 closing,仅重试平仓不再推送. 未完全成交进入 closing,仅重试平仓不再推送.
返回本次新触发(并推送)的条数. 返回本次新触发(并推送)的条数.
@@ -412,7 +534,7 @@ def run_options_target_closes(
status="triggered", status="triggered",
trigger_idx=idx, trigger_idx=idx,
close_ord_id=result.get("close_ord_id"), close_ord_id=result.get("close_ord_id"),
message="目标位限价平仓完成", message="盈亏比限价平仓完成",
) )
_commit_monitor(conn) _commit_monitor(conn)
continue continue
@@ -429,8 +551,7 @@ def run_options_target_closes(
triggered = 0 triggered = 0
for mon in list_active_targets(conn): for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "") inst_id = str(mon.get("inst_id") or "")
target = _safe_float(mon.get("target_index")) if not inst_id:
if not inst_id or target is None:
continue continue
if inst_id in hedge_managed: if inst_id in hedge_managed:
mark_monitor( mark_monitor(
@@ -444,17 +565,15 @@ def run_options_target_closes(
pos = pos_by_inst.get(inst_id) pos = pos_by_inst.get(inst_id)
if not pos: if not pos:
continue continue
if index_fn is not None: should, idx = _monitor_should_close(
idx = index_fn(pos) conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn
else: )
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) if not should:
if idx is None:
continue
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue continue
result = close_fn(inst_id) result = close_fn(inst_id)
rr = _safe_float(mon.get("profit_rr"))
target = _safe_float(mon.get("target_index"))
if result.get("already_flat"): if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn) _commit_monitor(conn)
@@ -472,15 +591,19 @@ def run_options_target_closes(
done = _result_fully_done(result) done = _result_fully_done(result)
status = "triggered" if done else "closing" status = "triggered" if done else "closing"
hit_msg = (
"盈亏比达标限价平仓"
if (rr is not None and rr > 0)
else "目标位触发限价平仓"
)
mark_monitor( mark_monitor(
conn, conn,
int(mon["id"]), int(mon["id"]),
status=status, status=status,
trigger_idx=idx, trigger_idx=idx,
close_ord_id=result.get("close_ord_id"), close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", message=hit_msg if done else "已挂买一限价,等待成交",
) )
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
_commit_monitor(conn) _commit_monitor(conn)
triggered += 1 triggered += 1
_notify_target_close( _notify_target_close(
@@ -489,6 +612,7 @@ def run_options_target_closes(
account_label=account_label, account_label=account_label,
inst_id=inst_id, inst_id=inst_id,
target=target, target=target,
profit_rr=rr,
idx=idx, idx=idx,
result=result, result=result,
conn=conn, conn=conn,
+10 -38
View File
@@ -2,11 +2,7 @@
data-default-underly="{{ options_default_underly | default('ETH') }}" data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}" data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}" data-trade-budget="{{ options_trade_budget | default(10) }}"
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}"
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}"> data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
{% if not options_enabled %} {% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code><code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div> <div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code><code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
{% endif %} {% endif %}
@@ -27,8 +23,6 @@
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li> <li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li> <li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li> <li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
<li>平仓仅买一限价,详见说明文档。</li> <li>平仓仅买一限价,详见说明文档。</li>
</ul> </ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p> <p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
@@ -113,46 +107,28 @@
</div> </div>
<div class="options-estimate-row"> <div class="options-estimate-row">
<div class="opt-est-main"> <div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label> <label class="btn-secondary opt-order-chip" for="opt-profit-rr" title="目标盈利=盈亏比×权利金;例2=赚满2倍权利金后全平">盈亏比</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值" <input type="number" id="opt-profit-rr" class="opt-target-idx" step="0.1" min="0.1" value="2" placeholder="默认2"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other"> autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span class="k">预计价值</span> <span class="k">目标盈利</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span> <span id="opt-est-profit" class="v"></span>
<span class="k">盈亏比</span> <span class="k">需回收</span>
<span id="opt-est-rr" class="v" title="利金额÷本合约权利金"></span> <span id="opt-est-value" class="v" title="利金+目标盈利"></span>
</div> </div>
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span> <span class="muted opt-est-note">按买一浮盈达盈亏比×权利金后限价平;不达标等到期;无止损</span>
</div>
<div class="options-estimate-row opt-profit-exit-row">
<div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
<input type="checkbox" id="opt-profit-exit-enabled">
<span>翻倍出场</span>
</label>
<label class="k" for="opt-profit-exit-mult">倍数</label>
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
</div> </div>
<div class="form-row options-order-mode-row"> <div class="form-row options-order-mode-row">
<div class="opt-size-mode-bar"> <div class="opt-size-mode-bar">
<label class="btn-secondary opt-order-chip opt-size-mode-chip"> <label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}> <input type="radio" name="opt-size-mode" value="sheets" checked>
<span>指定张数</span> <span>指定张数</span>
</label> </label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数" <input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other"> autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}> <label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}> <input type="radio" name="opt-size-mode" value="budget_full">
<span>按可用余额打满</span> <span>按可用余额打满</span>
</label> </label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}>
<input type="radio" name="opt-size-mode" value="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
<span>全仓复利</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip"> <label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth"> <input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<span>指定币数量</span> <span>指定币数量</span>
@@ -163,9 +139,6 @@
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4"> <p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。 余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
</p> </p>
<p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
</p>
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)" <input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly> data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
@@ -210,7 +183,6 @@
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li> <li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li> <li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li> <li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li> <li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
</ul> </ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p> <p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
@@ -350,4 +322,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=64"></script> <script src="/static/options_panel.js?v=59"></script>
@@ -416,4 +416,4 @@
</section> </section>
</div> </div>
<script src="/static/options_review.js?v=23"></script> <script src="/static/options_review.js?v=24"></script>
@@ -4,13 +4,10 @@
{% endif %} {% endif %}
{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%} {% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%}
{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %} {% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %}
{% set wl = trade_policy.symbol_whitelist %} <select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %} class="trade-policy-symbol-select">
{% set sole_sym = wl[0] if (wl|length) == 1 else '' %} <option value="">选择币种</option>
{% set effective = value if value else sole_sym %} {% for sym in trade_policy.symbol_whitelist %}
<select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %} class="trade-policy-symbol-select"{% if sole_sym %} data-sole-symbol="{{ sole_sym }}"{% endif %}> <option value="{{ sym }}" {% if value and ((value|upper) == sym or (value|upper).startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option>
{% if not sole_sym %}<option value="">选择币种</option>{% endif %}
{% for sym in wl %}
<option value="{{ sym }}" {% if effective and ((effective|upper) == sym or (effective|upper).startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option>
{% endfor %} {% endfor %}
</select> </select>
{% else %} {% else %}
+4 -9
View File
@@ -17,20 +17,15 @@ def trade_policy_template_context(policy: TradePolicy) -> dict:
def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str: def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str:
d = (raw_default or "").strip() d = (raw_default or "BTC/USDT").strip() or "BTC/USDT"
if policy.symbol_restrict_enabled and policy.symbol_whitelist: if policy.symbol_restrict_enabled and policy.symbol_whitelist:
# 白名单仅一币时直接用 env 币种,表单下拉同步默认选中
if len(policy.symbol_whitelist) == 1:
return f"{policy.symbol_whitelist[0]}/USDT"
from lib.trade.trade_policy_lib import symbol_base_coin from lib.trade.trade_policy_lib import symbol_base_coin
base = symbol_base_coin(d or "BTC/USDT") base = symbol_base_coin(d)
if base not in policy.symbol_whitelist: if base not in policy.symbol_whitelist:
return f"{policy.symbol_whitelist[0]}/USDT" return f"{policy.symbol_whitelist[0]}/USDT"
if d: return d
return d if "/" in d else f"{base}/USDT"
return f"{policy.symbol_whitelist[0]}/USDT"
return d or "BTC/USDT"
def check_symbol_policy( def check_symbol_policy(
policy: TradePolicy, policy: TradePolicy,
+2 -3
View File
@@ -23,9 +23,8 @@ HUB_DISABLED_IDS=
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100) # true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100)
HUB_TRUST_LAN=true HUB_TRUST_LAN=true
# 默认 true(代码默认允许公网/反代访问中控,靠 HUB_PASSWORD 保护) # 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"})
# 仅本机调试可关: HUB_ALLOW_PUBLIC=false # HUB_ALLOW_PUBLIC=true
HUB_ALLOW_PUBLIC=true
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改) # 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
HUB_USERNAME=admin HUB_USERNAME=admin
+3 -3
View File
@@ -187,9 +187,9 @@ HUB_PORT = int(os.getenv("HUB_PORT", "5100"))
HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip() HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower() _trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off") HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
# 默认 true:云端域名/反代可访问;仅靠 HUB_PASSWORD 保护.本地若要强制仅本机,设 HUB_ALLOW_PUBLIC=false _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
_allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC", "true") or "").strip().lower() # 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
HUB_ALLOW_PUBLIC = _allow_pub_raw not in ("0", "false", "no", "off") HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
DIR = Path(__file__).resolve().parent DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260607-hub-archive" HUB_BUILD = "20260607-hub-archive"
_archive_sync_stop: asyncio.Event | None = None _archive_sync_stop: asyncio.Event | None = None
+17 -37
View File
@@ -3928,54 +3928,34 @@
); );
} }
function formatProfitExitMultLabel(mult) { function renderOptionsTargetCell(target) {
const n = Number(mult); if (!target) return "<td>—</td>";
if (!Number.isFinite(n) || n <= 0) return "1倍"; const rr =
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + "倍"; target.profit_rr != null
return fmt(n, 2) + "倍"; ? Number(target.profit_rr)
: target.oo_profit_rr != null
? Number(target.oo_profit_rr)
: null;
if (rr != null && Number.isFinite(rr) && rr > 0) {
const txt = `盈亏比×${fmt(rr, 2)}`;
if (target.managed_by === "hedge_plan") {
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(txt)}</td>`;
} }
return `<td class="hub-opt-target-cell is-on" title="盈亏比监控">${esc(txt)}</td>`;
function renderOptionsTargetCell(target, pos) {
if (target && target.managed_by === "hedge_plan") {
const rr = target.profit_rr != null ? Number(target.profit_rr) : null;
if (rr != null && rr > 0) {
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} 盈亏比 ${esc(fmt(rr, 2))}</td>`;
} }
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥"; const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
const px = target.target_index != null ? fmt(target.target_index, 1) : "—"; const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
if (target.managed_by === "hedge_plan") {
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(side)} ${esc(px)}</td>`; return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(side)} ${esc(px)}</td>`;
} }
const parts = []; return `<td class="hub-opt-target-cell is-on" title="目标监控">${esc(side)} ${esc(px)}</td>`;
const hasIndex =
target &&
target.exit_mode !== "profit_exit" &&
target.target_index != null &&
Number(target.target_index) > 0;
if (hasIndex) {
const side = String(target.opt_type || (pos && pos.opt_type) || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
parts.push(side + " " + fmt(target.target_index, 1));
}
const peOn =
!!(pos && pos.profit_exit_enabled) ||
!!(target && (target.exit_mode === "profit_exit" || target.profit_exit_enabled));
if (peOn) {
const mult =
pos && pos.profit_exit_mult != null
? pos.profit_exit_mult
: target && target.profit_exit_mult != null
? target.profit_exit_mult
: 1;
parts.push(formatProfitExitMultLabel(mult));
}
if (!parts.length) return "<td>—</td>";
return `<td class="hub-opt-target-cell is-on" title="目标监控">${esc(parts.join(" · "))}</td>`;
} }
function renderOptionsPositionsTable(pos, targets) { function renderOptionsPositionsTable(pos, targets) {
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>'; if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
const showPnl = showAccountPnlPref(); const showPnl = showAccountPnlPref();
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>'; let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th>"; html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>盈亏比</th>";
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>"; if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
html += "</tr></thead><tbody>"; html += "</tr></thead><tbody>";
pos.forEach((p) => { pos.forEach((p) => {
@@ -3997,7 +3977,7 @@
<td>${esc(optType)}</td> <td>${esc(optType)}</td>
<td>${esc(p.pos)}</td> <td>${esc(p.pos)}</td>
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td> <td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
${renderOptionsTargetCell(target, p)}`; ${renderOptionsTargetCell(target)}`;
if (showPnl) { if (showPnl) {
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td> html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`; <td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
+3 -3
View File
@@ -115,7 +115,7 @@
<span class="plan-radio-row" id="plan-create-direction"></span> <span class="plan-radio-row" id="plan-create-direction"></span>
</label> </label>
<label class="plan-field"> <label class="plan-field">
<span>目标位</span> <span>盈亏比</span>
<input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /> <input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label> </label>
<label class="plan-field"> <label class="plan-field">
@@ -1765,8 +1765,8 @@
<script src="/assets/ai_review_render.js?v=3"></script> <script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script> <script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/options_expiry_countdown.js?v=1"></script> <script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=4"></script> <script src="/assets/options_position_cards.js?v=5"></script>
<script src="/assets/backup.js?v=1"></script> <script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260812-profit-exit"></script> <script src="/assets/app.js?v=20260811-opt-rr"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -146,7 +146,7 @@
return; return;
} }
if (r.status === 403) { if (r.status === 403) {
showErr("访问被拒绝(403):请确认 HUB_ALLOW_PUBLIC 未设为 false,并检查反代/登录配置"); showErr("访问被拒绝(403):云端 hub 需设置 HUB_ALLOW_PUBLIC=true");
} else { } else {
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")"); showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")");
} }
+1
View File
@@ -4,6 +4,7 @@
flask>=3.0,<4 flask>=3.0,<4
requests>=2.31,<3 requests>=2.31,<3
ccxt>=4.2,<5 ccxt>=4.2,<5
websocket-client>=1.6,<2
werkzeug>=3.0,<4 werkzeug>=3.0,<4
PySocks>=1.7,<2 PySocks>=1.7,<2
Pillow>=10.0,<12 Pillow>=10.0,<12
+21 -10
View File
@@ -109,16 +109,12 @@ class TestHedgePlanCalc(unittest.TestCase):
) )
self.assertEqual(p["summary"]["premium_paid"], 10) self.assertEqual(p["summary"]["premium_paid"], 10)
self.assertTrue(p["summary"]["expiry_is_loss"]) self.assertTrue(p["summary"]["expiry_is_loss"])
self.assertEqual(p["summary"]["profit_rr"], 2) self.assertEqual(p["summary"]["rr_risk_premium"], 10)
self.assertEqual(p["summary"]["at_rr_a_full_total"], 15) # 盈利=2*10, 亏腿-5 self.assertEqual(p["summary"]["oo_profit_rr"], 2)
self.assertEqual(len(p["scenarios"]), 5) self.assertAlmostEqual(p["summary"]["target_profit"], 20.0, places=4)
self.assertEqual(p["scenarios"][0]["id"], "rr_leg_a_full") self.assertEqual(len(p["scenarios"]), 3)
self.assertEqual(p["scenarios"][1]["id"], "rr_leg_b_full") self.assertEqual(p["scenarios"][0]["id"], "rr_target")
# 到期实值反推:Call 盈利20 → 价值25 → 每币2500 → spot=3300+2500 self.assertEqual(p["scenarios"][1]["id"], "expiry_flat")
self.assertEqual(p["scenarios"][0]["spot"], 5800.0)
# Put 盈利20 → spot=3100-2500
self.assertEqual(p["scenarios"][1]["spot"], 600.0)
self.assertEqual(p["scenarios"][2]["spot"], 5800.0) # 残值情景同腿A反推
def test_oo_legacy_single_target_still_works(self): def test_oo_legacy_single_target_still_works(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
@@ -127,6 +123,21 @@ class TestHedgePlanCalc(unittest.TestCase):
self.assertEqual(p["target_price_up"], 3500) self.assertEqual(p["target_price_up"], 3500)
self.assertEqual(p["target_price_down"], 3500) self.assertEqual(p["target_price_down"], 3500)
def test_oo_legacy_up_down_rr_fields(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
p = build_options_options_preview(
target_price_up=3500,
target_price_down=3000,
index_px=3200,
leg_a=a,
leg_b=b,
)
self.assertIsNotNone(p["summary"]["rr_at_up"])
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4)
self.assertEqual(p["scenarios"][0]["id"], "target_up")
self.assertEqual(p["scenarios"][1]["id"], "target_down")
def test_perp_short_pnl(self): def test_perp_short_pnl(self):
self.assertEqual( self.assertEqual(
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1), perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
+4 -4
View File
@@ -155,7 +155,7 @@ class TestHedgeHistoryStats(unittest.TestCase):
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800) self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan") self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
def test_active_options_targets_profit_rr(self): def test_active_options_targets_rr_mode_marks_managed(self):
conn = _mem() conn = _mem()
pid = insert_plan( pid = insert_plan(
conn, conn,
@@ -163,7 +163,7 @@ class TestHedgeHistoryStats(unittest.TestCase):
"plan_type": "options_options", "plan_type": "options_options",
"status": "active", "status": "active",
"underlying": "ETH", "underlying": "ETH",
"profit_rr": 2, "oo_profit_rr": 2,
}, },
) )
insert_leg( insert_leg(
@@ -177,9 +177,9 @@ class TestHedgeHistoryStats(unittest.TestCase):
}, },
) )
targets = active_options_targets_by_inst(conn) targets = active_options_targets_by_inst(conn)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["profit_rr"], 2) self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["exit_mode"], "profit_rr")
self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"]) self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"])
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["oo_profit_rr"], 2.0)
if __name__ == "__main__": if __name__ == "__main__":
+2 -1
View File
@@ -104,7 +104,8 @@ class TestHedgeMoneyness(unittest.TestCase):
err = validate_start_body( err = validate_start_body(
"options_options", "options_options",
{ {
"profit_rr": 2, "target_price_up": 1900,
"target_price_down": 1700,
"index_px": 1800, "index_px": 1800,
"leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700}, "leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700},
"leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900}, "leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900},
+3 -1
View File
@@ -169,7 +169,9 @@ class TestHedgePlanOrderPath(unittest.TestCase):
"budget_buffer": 0.95, "budget_buffer": 0.95,
} }
body = { body = {
"profit_rr": 2, "target_price": 1900,
"target_price_up": 1950,
"target_price_down": 1750,
"oo_sheets_mode": "same_sheets", "oo_sheets_mode": "same_sheets",
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, "leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
@@ -0,0 +1,55 @@
"""期权合约列表缓存与限频退避."""
from __future__ import annotations
import time
import unittest
from unittest.mock import MagicMock, patch
from lib.exchange import okx_options_lib as m
class FetchOptionInstrumentsCacheTests(unittest.TestCase):
def setUp(self):
m.invalidate_option_instruments_cache()
def tearDown(self):
m.invalidate_option_instruments_cache()
def test_cache_hit_skips_second_api_call(self):
ex = MagicMock()
ex.public_get_public_instruments.return_value = {
"data": [
{
"instId": "ETH-USD_UM-260812-2000-C",
"state": "live",
"expTime": "9999999999999",
}
]
}
a = m.fetch_option_instruments(ex, "ETH-USD_UM")
b = m.fetch_option_instruments(ex, "ETH-USD_UM")
self.assertEqual(len(a), 1)
self.assertEqual(len(b), 1)
self.assertEqual(ex.public_get_public_instruments.call_count, 1)
@patch("lib.exchange.okx_options_lib.time.sleep", return_value=None)
def test_rate_limit_falls_back_to_stale_cache(self, _sleep):
ex = MagicMock()
ex.public_get_public_instruments.return_value = {
"data": [{"instId": "ETH-USD_UM-260812-2000-C", "state": "live"}]
}
first = m.fetch_option_instruments(ex, "ETH-USD_UM")
self.assertEqual(len(first), 1)
# 过期 TTL,但仍在 stale 窗口
with m._INSTRUMENTS_CACHE_LOCK:
m._INSTRUMENTS_CACHE["ETH-USD_UM"]["updated_at"] = time.time() - 120
ex.public_get_public_instruments.side_effect = Exception(
'okx {"msg":"Too Many Requests","code":"50011"}'
)
second = m.fetch_option_instruments(ex, "ETH-USD_UM")
self.assertEqual(len(second), 1)
self.assertEqual(second[0]["instId"], "ETH-USD_UM-260812-2000-C")
if __name__ == "__main__":
unittest.main()
+1 -16
View File
@@ -37,24 +37,9 @@ class TestOkxSpotSwap(unittest.TestCase):
) )
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20) result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20)
self.assertFalse(result["ok"]) self.assertFalse(result["ok"])
self.assertEqual(result["msg"], "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)") self.assertEqual(result["msg"], "资金账户 USDT 可用余额不足")
self.assertNotIn("{", result["msg"]) self.assertNotIn("{", result["msg"])
def test_insufficient_usdc_message(self):
from lib.exchange.okx_options_lib import _okx_trade_error_message
msg = _okx_trade_error_message(
resp={
"data": [
{
"sCode": "51008",
"sMsg": "Order failed. Insufficient USDC balance in account.",
}
]
}
)
self.assertEqual(msg, "交易账户 USDC 可用余额不足")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+10 -92
View File
@@ -1,98 +1,16 @@
"""按可用余额打满 / 全仓复利定仓.""" """按可用余额打满:min(余额, 单笔预算)."""
from __future__ import annotations from __future__ import annotations
import unittest from lib.options.options_pricing_lib import resolve_budget_full_usdc
from lib.options.options_pricing_lib import (
resolve_budget_full_usdc,
resolve_compound_full_usdc,
)
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
count_live_option_positions,
)
class TestOptionsBudgetModes(unittest.TestCase): def test_balance_above_budget_uses_budget():
def test_balance_above_budget_uses_budget(self): assert resolve_budget_full_usdc(100.0, 10.0) == 10.0
self.assertEqual(resolve_budget_full_usdc(100.0, 10.0), 10.0)
def test_balance_below_budget_uses_balance(self):
self.assertEqual(resolve_budget_full_usdc(5.0, 10.0), 5.0)
def test_balance_equals_budget(self):
self.assertEqual(resolve_budget_full_usdc(10.0, 10.0), 10.0)
def test_compound_full_no_cap_uses_all(self):
self.assertEqual(
resolve_compound_full_usdc(200.0, cap_enabled=False, cap_usdc=50.0),
200.0,
)
def test_compound_full_cap_on(self):
self.assertEqual(
resolve_compound_full_usdc(200.0, cap_enabled=True, cap_usdc=50.0),
50.0,
)
self.assertEqual(
resolve_compound_full_usdc(30.0, cap_enabled=True, cap_usdc=50.0),
30.0,
)
def test_compound_full_cap_invalid_falls_back_to_balance(self):
self.assertEqual(
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=0),
80.0,
)
self.assertEqual(
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=None),
80.0,
)
def test_compound_full_blocks_when_position_open(self):
rows = [{"instId": "ETH-USD_UM-260812-1870-P", "pos": "1"}]
msg = compound_full_single_position_block_msg(
object(), fetch_positions=lambda _ex: rows
)
self.assertIsNotNone(msg)
self.assertIn("1 笔", msg or "")
def test_compound_full_allows_when_flat(self):
msg = compound_full_single_position_block_msg(
object(), fetch_positions=lambda _ex: []
)
self.assertIsNone(msg)
self.assertEqual(count_live_option_positions([]), 0)
def test_normalize_size_mode_when_compound_off(self):
import os
from unittest.mock import patch
from lib.options import options_register as reg
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "false"}):
mode, note = reg._normalize_size_mode("compound_full")
self.assertEqual(mode, "sheets")
self.assertIsNotNone(note)
mode2, note2 = reg._normalize_size_mode("budget_full")
self.assertEqual(mode2, "budget_full")
self.assertIsNone(note2)
mode3, _ = reg._normalize_size_mode("sheets")
self.assertEqual(mode3, "sheets")
def test_normalize_size_mode_when_compound_on(self):
import os
from unittest.mock import patch
from lib.options import options_register as reg
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "true"}):
mode, note = reg._normalize_size_mode("budget_full")
self.assertEqual(mode, "compound_full")
self.assertIsNone(note)
mode2, _ = reg._normalize_size_mode("compound_full")
self.assertEqual(mode2, "compound_full")
if __name__ == "__main__": def test_balance_below_budget_uses_balance():
unittest.main() assert resolve_budget_full_usdc(5.0, 10.0) == 5.0
def test_balance_equals_budget():
assert resolve_budget_full_usdc(10.0, 10.0) == 10.0
-65
View File
@@ -1,65 +0,0 @@
"""单独期权翻倍出场命中条件."""
from __future__ import annotations
import sqlite3
import tempfile
import unittest
from pathlib import Path
from lib.options.options_db import init_options_tables
from lib.options.options_profit_exit_lib import (
normalize_profit_exit_mult,
profit_exit_by_inst,
profit_exit_hit,
required_recycle_usdc,
set_profit_exit,
)
class TestOptionsProfitExit(unittest.TestCase):
def test_hit_one_x_means_profit_equals_premium(self):
# 1倍:盈利=权利金 ⇒ 回收≥2×权利金
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=20.0, mult=1.0))
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=19.9, mult=1.0))
self.assertEqual(required_recycle_usdc(10.0, 1.0), 20.0)
def test_hit_two_x(self):
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=30.0, mult=2.0))
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=29.9, mult=2.0))
def test_normalize_mult(self):
self.assertEqual(normalize_profit_exit_mult(None), 1.0)
self.assertEqual(normalize_profit_exit_mult(0), 1.0)
self.assertEqual(normalize_profit_exit_mult("1.5"), 1.5)
def test_set_and_clear(self):
with tempfile.TemporaryDirectory() as td:
db = Path(td) / "t.db"
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
init_options_tables(conn)
conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
VALUES ('ETH-X', 'ETH', 'C', 1, 0.01, 10.0, 'open')
"""
)
conn.commit()
out = set_profit_exit(conn, inst_id="ETH-X", enabled=True, mult=1.5)
self.assertTrue(out["ok"])
conn.commit()
m = profit_exit_by_inst(conn)
self.assertTrue(m["ETH-X"]["profit_exit_enabled"])
self.assertEqual(m["ETH-X"]["profit_exit_mult"], 1.5)
self.assertEqual(m["ETH-X"]["required_recycle"], 25.0)
out2 = set_profit_exit(conn, inst_id="ETH-X", enabled=False, mult=1.5)
self.assertTrue(out2["ok"])
conn.commit()
m2 = profit_exit_by_inst(conn)
self.assertNotIn("ETH-X", m2)
conn.close()
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
"""options_quote_live_lib 单元测试."""
from __future__ import annotations
import json
from lib.options.options_quote_live_lib import OptionsQuoteLive
class _FakeWs:
connected = True
last_msg_at = 0.0
def start(self) -> None:
return None
def stop(self) -> None:
return None
def set_subscriptions(self, args) -> None:
self.last_args = list(args)
def test_ticker_patch_and_flush():
live = OptionsQuoteLive()
live._ws = _FakeWs() # type: ignore[assignment]
live._started = True
live.watch(
underlying="ETH",
exp_time="1",
contracts=[{"inst_id": "ETH-USD-260811-2500-C", "opt_type": "C", "strike": 2500}],
index_inst_id="ETH-USD",
)
live._on_ws_data(
{
"arg": {"channel": "tickers", "instId": "ETH-USD-260811-2500-C"},
"data": [
{
"instId": "ETH-USD-260811-2500-C",
"askPx": "12.5",
"askSz": "3",
"bidPx": "11.0",
"bidSz": "2",
"markPx": "12.0",
}
],
}
)
live._on_ws_data(
{
"arg": {"channel": "index-tickers", "instId": "ETH-USD"},
"data": [{"idxPx": "2600"}],
}
)
raw = live._build_flush_event()
assert raw is not None
payload = json.loads(raw)
assert payload["index_px"] == 2600.0
assert payload["quotes"]
q = next(x for x in payload["quotes"] if x["inst_id"] == "ETH-USD-260811-2500-C")
assert q["ask"] == 12.5
assert q["ask_sz"] == 3.0
assert q["expiry_be_px"] == 2512.5
st = live.status()
assert st["watch_count"] == 1
+63 -6
View File
@@ -1,4 +1,4 @@
"""期权目标委托单元测试.""" """期权目标委托单元测试(盈亏比 + 旧指数兼容)."""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
@@ -8,6 +8,7 @@ from lib.options.options_target_lib import (
ensure_target_tables, ensure_target_tables,
list_active_targets, list_active_targets,
list_closing_targets, list_closing_targets,
profit_rr_hit,
run_options_target_closes, run_options_target_closes,
target_hit, target_hit,
upsert_target_monitor, upsert_target_monitor,
@@ -21,7 +22,67 @@ class OptionsTargetLibTests(unittest.TestCase):
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850)) self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850)) self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
def test_upsert_and_trigger_close(self): def test_profit_rr_hit(self):
# premium=10, rr=2 → need pnl≥20 → recycle≥30 → bid*sheets*ct ≥30
self.assertTrue(
profit_rr_hit(premium=10, bid=30, sheets=1, ct_mult=1, profit_rr=2)
)
self.assertFalse(
profit_rr_hit(premium=10, bid=29.9, sheets=1, ct_mult=1, profit_rr=2)
)
self.assertFalse(
profit_rr_hit(premium=10, bid=None, sheets=1, ct_mult=1, profit_rr=2)
)
def test_upsert_rr_and_trigger_close(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
ensure_target_tables(conn)
out = upsert_target_monitor(
conn,
inst_id="ETH-USD_UM-260717-1900-C",
profit_rr=2,
opt_type="C",
sheets=1,
)
self.assertTrue(out["ok"])
self.assertEqual(out.get("profit_rr"), 2.0)
self.assertEqual(len(list_active_targets(conn)), 1)
closed = []
def close_fn(inst_id: str):
closed.append(inst_id)
return {
"ok": True,
"submitted_sheets": 1,
"premium_received": 30.0,
"close_ord_id": "oid1",
"fully_closed": True,
"remaining_sheets": 0,
}
# bid=30, ct=1 → pnl=20 ≥ 2*10; premium 来自持仓字段
n = run_options_target_closes(
conn,
[
{
"inst_id": "ETH-USD_UM-260717-1900-C",
"idx_px": 1885,
"opt_type": "C",
"pos": 1,
"ct_mult": 1,
"premium_paid": 10,
}
],
close_fn=close_fn,
bid_fn=lambda _i: 30.0,
)
self.assertEqual(n, 1)
self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"])
self.assertEqual(len(list_active_targets(conn)), 0)
def test_upsert_and_trigger_close_legacy_index(self):
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
ensure_target_tables(conn) ensure_target_tables(conn)
@@ -107,7 +168,6 @@ class OptionsTargetLibTests(unittest.TestCase):
self.assertEqual(len(list_active_targets(conn)), 0) self.assertEqual(len(list_active_targets(conn)), 0)
self.assertEqual(len(list_closing_targets(conn)), 1) self.assertEqual(len(list_closing_targets(conn)), 1)
# 模拟后续 sync 异常也不会再推:closing 重试静默
n2 = run_options_target_closes( n2 = run_options_target_closes(
conn, conn,
pos, pos,
@@ -120,7 +180,6 @@ class OptionsTargetLibTests(unittest.TestCase):
self.assertEqual(len(list_closing_targets(conn)), 0) self.assertEqual(len(list_closing_targets(conn)), 0)
def test_commit_before_wechat_survives_later_rollback(self): def test_commit_before_wechat_survives_later_rollback(self):
"""状态在推送前已 commit,外层异常回滚不应让委托回到 active."""
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
ensure_target_tables(conn) ensure_target_tables(conn)
@@ -149,12 +208,10 @@ class OptionsTargetLibTests(unittest.TestCase):
close_fn=close_fn, close_fn=close_fn,
send_wechat=notices.append, send_wechat=notices.append,
) )
# 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit
conn.rollback() conn.rollback()
self.assertEqual(len(notices), 1) self.assertEqual(len(notices), 1)
self.assertEqual(len(list_active_targets(conn)), 0) self.assertEqual(len(list_active_targets(conn)), 0)
# 下一轮不应再次触发推送
n2 = run_options_target_closes( n2 = run_options_target_closes(
conn, conn,
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}], [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
-26
View File
@@ -88,29 +88,3 @@ def test_badge_parts():
} }
) )
assert trade_policy_badge_parts(p) == ("仅多", "BTC/ETH") assert trade_policy_badge_parts(p) == ("仅多", "BTC/ETH")
def test_default_symbol_when_whitelist_sole():
from lib.trade.trade_policy_app_lib import default_symbol_for_policy
p = load_trade_policy(
{
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
"TRADE_SYMBOL_WHITELIST": "BTC",
}
)
assert default_symbol_for_policy(p, "") == "BTC/USDT"
assert default_symbol_for_policy(p, "ETH/USDT") == "BTC/USDT"
def test_default_symbol_when_whitelist_multi():
from lib.trade.trade_policy_app_lib import default_symbol_for_policy
p = load_trade_policy(
{
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
}
)
assert default_symbol_for_policy(p, "ETH") == "ETH/USDT"
assert default_symbol_for_policy(p, "SOL/USDT") == "BTC/USDT"