Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e14c92385 | |||
| 627c27a6f8 | |||
| 48f544b1f5 | |||
| 1835a79698 | |||
| 8d846794a2 | |||
| dae5ef1146 | |||
| 67d087277a | |||
| 3d3253247e | |||
| b62107fbd5 | |||
| 03acecf015 | |||
| 117f595b54 | |||
| b31f861596 | |||
| 11c67b3c44 | |||
| fc0d76d174 | |||
| 839baea375 | |||
| d42e1c7edf | |||
| 57ca5ebc8f | |||
| 11a083470d | |||
| dd979d9dd0 | |||
| aa8b16ce27 | |||
| 1aa279f191 | |||
| 53a624633d | |||
| 42a7ee9961 | |||
| 3416996ae5 | |||
| c2c69e580c | |||
| 09989d2709 | |||
| 78f18e731a | |||
| bdc9eb532a | |||
| fe08fa5270 | |||
| 9d77a430e5 | |||
| d4de6a23ae | |||
| 9037e80376 | |||
| 678833be45 | |||
| ed4c650e4b | |||
| 549253440a | |||
| 260bfd1f29 | |||
| e0e2a94322 | |||
| 70ebfb10e8 | |||
| a0ee1fa8a0 | |||
| a16dd4f9cc | |||
| 3f621835a9 | |||
| bb9d14dd73 | |||
| 7943cd2167 | |||
| d05629c362 | |||
| 2369aae362 | |||
| 5e8b578f75 | |||
| e8f8a76753 | |||
| a45b5d1b0b | |||
| a23fb7cbdc | |||
| 23bd59c87f | |||
| 48b919bb2b | |||
| af4a501f02 | |||
| d7b3f7a8e7 | |||
| 2158a30179 | |||
| cfcc0596ff | |||
| 3328b7fcc1 | |||
| 03dde1853d | |||
| ddacdbc1e8 | |||
| c021c38e2d | |||
| 7c5a6e25b2 | |||
| 1a4eb15109 | |||
| 77fe0fa679 | |||
| ea3e029eb9 | |||
| 030b237981 | |||
| 80ef5ceccb | |||
| 066c90fd96 | |||
| 265317672c | |||
| 474e0742f7 | |||
| a957946cfd | |||
| 95e713e9e6 | |||
| d743185f05 | |||
| 7b432bc964 | |||
| 4cad929886 | |||
| 30d7b0c872 | |||
| bab26c7205 | |||
| e11651d729 | |||
| 8b584a0870 | |||
| 4bea770f5c | |||
| 740c99e910 | |||
| 131bb36930 | |||
| e33d289e39 | |||
| 07db158656 | |||
| 2ab46a6ac3 | |||
| b79f309c5b | |||
| 886ef82573 | |||
| f846f832de | |||
| 9d3effd6fc | |||
| 2903a3ac7f | |||
| 91ff1cdecc | |||
| a71042c3e1 | |||
| 6d5b71eb3d | |||
| 2eb6b28a39 | |||
| 3577a4d2fd | |||
| bb6e9e9041 | |||
| fcef3b3d37 | |||
| 34322d1731 | |||
| c1f783df9b | |||
| 193c02eda8 | |||
| e7a4934068 | |||
| ed9fd05a84 | |||
| a4a9634ba8 | |||
| 5806fe2f53 | |||
| c42529e3fd | |||
| e7cafd6dbc | |||
| ee635bcd55 | |||
| 43951a701b | |||
| fad85ae710 | |||
| 23ee7ea2e9 | |||
| 6f9e6e2a32 | |||
| 2352a5fbdf | |||
| 2e09814fe5 | |||
| 56df9a596f | |||
| 0acba5e351 | |||
| 8a62cfbb48 | |||
| 41c85c36a9 | |||
| 06f9b23f44 | |||
| a03c208a5f | |||
| a7015ec5c8 | |||
| f5104fe68b | |||
| c5cfbb34e4 | |||
| ea58a4778e | |||
| d16821c7b7 | |||
| d6bb99b5bb |
+19
-45
@@ -80,6 +80,8 @@ LIVE_TRADING_ENABLED=true
|
||||
SIM_DEFAULT_MODE=sim
|
||||
SIM_INITIAL_EQUITY_USDT=10000
|
||||
SIM_INITIAL_USDC=0
|
||||
# 实盘初始资金(U);中控收益率=已结算盈亏/该值。不填则中控回退最早余额采样
|
||||
LIVE_INITIAL_EQUITY_USDT=
|
||||
SIM_FEE_RATE=0.0005
|
||||
# =============================================================================
|
||||
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2)
|
||||
@@ -102,11 +104,11 @@ OKX_POSITION_INST_TYPE=SWAP
|
||||
EXCHANGE_DISPLAY_NAME=OKX
|
||||
# 企业微信推送里展示的账户备注
|
||||
# OKX_ACCOUNT_LABEL=
|
||||
# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧
|
||||
# 顶栏是否显示 USDT 资金/交易账户(热更);币本位买币仍会显示交易账户可用额
|
||||
OKX_SHOW_PERP_FUNDS=true
|
||||
|
||||
# =============================================================================
|
||||
# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
|
||||
# 期权模块(币本位专用;与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
|
||||
# 详见 docs/期权方案.md 与 docs/期权用法.md
|
||||
# =============================================================================
|
||||
OKX_OPTIONS_ENABLED=true
|
||||
@@ -116,7 +118,7 @@ OKX_OPTIONS_ENABLED=true
|
||||
# OKX_OPTIONS_API_PASSPHRASE=
|
||||
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
|
||||
|
||||
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
|
||||
# 仅币本位期权(USDT买币桥);USDC 期权已下线,此项恒视为 coin
|
||||
OKX_OPTIONS_MARGIN_MODE=coin
|
||||
OKX_OPTIONS_COIN_COMPOUND=true
|
||||
OKX_OPTIONS_COIN_BUDGET_USDT=10
|
||||
@@ -125,19 +127,14 @@ OKX_OPTIONS_COIN_MAX_USDT=50
|
||||
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
|
||||
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
|
||||
|
||||
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||
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=期期对冲
|
||||
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
|
||||
# 交易模式固定单独期权(历史 perp_options / options_options 读取时归一为 options)
|
||||
OKX_TRADE_MODE=options
|
||||
# 永期对冲改为: OKX_TRADE_MODE=perp_options
|
||||
# 期期对冲改为: OKX_TRADE_MODE=options_options
|
||||
# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
|
||||
# 期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
|
||||
OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0
|
||||
# 期权标的白名单(逗号分隔,仅 ETH/BTC)。只配一个则交易页只显示该标的;开仓/链/报价/条件单均校验。资金页不受影响。
|
||||
OKX_OPTIONS_UNDERLYINGS=ETH,BTC
|
||||
# 默认选中标的;须落在白名单内,否则取白名单第一项
|
||||
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
|
||||
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
|
||||
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
|
||||
@@ -148,46 +145,15 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
|
||||
OKX_OPTIONS_POLL_SECONDS=15
|
||||
OKX_OPTIONS_TD_MODE=isolated
|
||||
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
|
||||
# 开仓成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
|
||||
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
|
||||
# 目标平仓门控(目标位触发后按买一平仓前需满足)
|
||||
OKX_OPTIONS_CLOSE_GATE_MODE=premium
|
||||
# premium:可回收≥权利金×倍数;net_pnl:净盈亏(买一可回收−权利金)折USDT≥下限
|
||||
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
|
||||
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
|
||||
OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT=0
|
||||
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
|
||||
|
||||
# =============================================================================
|
||||
# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md)
|
||||
# =============================================================================
|
||||
# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读)
|
||||
HEDGE_PLAN_ENABLED=false
|
||||
HEDGE_PLAN_SHOW_PERP_OPTIONS=false
|
||||
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=false
|
||||
HEDGE_PLAN_LIVE_ORDER=false
|
||||
# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换)
|
||||
HEDGE_PLAN_OPTION_PRIMARY=true
|
||||
HEDGE_PLAN_OPEN_ORDER=options_first
|
||||
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
|
||||
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
|
||||
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
|
||||
# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平
|
||||
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
|
||||
# 期期「做多/做空」拆分口径:budget=按权利金预算(默认);sheets=先算同张数总张数(2n)再按比例拆
|
||||
HEDGE_PLAN_OO_BIAS_SPLIT_BY=budget
|
||||
# 期期「做多/做空」主腿占比(0~1,默认 0.7=7:3);做多主腿=Call,做空主腿=Put
|
||||
HEDGE_PLAN_OO_BIAS_RATIO=0.7
|
||||
# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开
|
||||
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
|
||||
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
|
||||
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
|
||||
# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更
|
||||
MAX_ACTIVE_HEDGE_PLANS=1
|
||||
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
|
||||
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false)
|
||||
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=false
|
||||
|
||||
# =============================================================================
|
||||
# =============================================================================
|
||||
#
|
||||
@@ -313,3 +279,11 @@ WECHAT_TIMEOUT_SECONDS=10
|
||||
|
||||
APP_TIMEZONE=Asia/Shanghai
|
||||
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
|
||||
|
||||
# =============================================================================
|
||||
# 中控对接(crypto_okx_hub;也可在「系统设置 → env 配置 → 中控对接」查看)
|
||||
# =============================================================================
|
||||
# 首次部署/打开 env 配置时若为空会自动生成;复制到中控「添加实例」的 Bridge Token
|
||||
HUB_BRIDGE_TOKEN=
|
||||
# 选填。允许嵌入本站的中控 Origin(空格分隔);一般留空即可;iframe 被挡时再填,调试可 *
|
||||
HUB_EMBED_FRAME_ANCESTORS=
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
Copyright (c) 2026 马建军. All rights reserved.
|
||||
|
||||
本仓库源代码及相关文档的著作权归马建军所有。
|
||||
未经著作权人书面许可,不得复制、修改、分发、销售或以其他方式使用本软件源码。
|
||||
|
||||
Author: 马建军
|
||||
QQ: 253921841
|
||||
Email: 253921841@qq.com
|
||||
Website: https://hyf2.cc
|
||||
@@ -20,7 +20,7 @@ curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.
|
||||
|
||||
安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**.
|
||||
|
||||
详见 **[部署文档.md](./部署文档.md)**、**[deploy/README.md](./deploy/README.md)**.
|
||||
详见 **[部署文档.md](./部署文档.md)**、**[新机纯实盘部署.md](./新机纯实盘部署.md)**、**[deploy/README.md](./deploy/README.md)**.
|
||||
|
||||
## 环境要求
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 交易所实盘说明(OKX)
|
||||
|
||||
本页说明如何把本实例从模拟盘切到真实交易:API 权限、OKX 账户模式、以及实例 `.env` 关键项。币本位期权开仓会先用 USDT 买 ETH/BTC,再开期权,因此 **现货交易权限** 与 **合约模式** 缺一不可。
|
||||
|
||||
## 一、创建 OKX API Key
|
||||
|
||||
1. 登录 OKX → **个人中心 → API** → 创建 API Key。
|
||||
2. 权限务必勾选 **交易(Trade)**。币本位开仓会走现货买币,只开合约/期权权限不够。
|
||||
3. 若有细分权限,请同时打开 **现货(Spot)**。
|
||||
4. 建议开启 **IP 白名单**,填入本实例服务器出口 IP。
|
||||
5. 保存后得到:`API Key` / `Secret Key` / `Passphrase`,填入本实例 env(见下文)。
|
||||
|
||||
常见报错:
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| `50123` This API Key does not have trading permission for the Crypto | Key 无现货/交易权限 | 编辑 Key,勾选交易与现货后重试 |
|
||||
| `未授权` / Bridge 探测失败 | 中控 Token 或客户端未部署接口 | 核对实例 Bridge Token;客户端需已部署 `/api/hub/version` |
|
||||
|
||||
## 二、OKX 账户模式
|
||||
|
||||
交易页 → **账户模式**(首次切换须在网页/App 手动确认)。
|
||||
|
||||
| 账户模式 | 是否推荐 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 仅现货 / 简单模式 | 否 | 无法正常开期权 |
|
||||
| **合约模式(现货和合约)** | **推荐** | 现货用 `cash`,与当前程序一致 |
|
||||
| 跨币种保证金 | 可用但不推荐 | 现货须 `cross`,程序目前按合约模式写 `cash`,会报 `tdMode` 错 |
|
||||
| 组合保证金 | 可用但不推荐 | 同上,现货也须 `cross` |
|
||||
|
||||
推荐流程:先切到 **合约模式** → 再在本实例点「买币并开仓」。
|
||||
|
||||
相关报错:
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| You can't complete this request under your current account mode (`51010`) | 账户模式不支持本次下单 | 改为合约模式 |
|
||||
| Parameter tdMode error | 跨币种/组合下仍用 `cash` 下现货 | 改回合约模式,或等待程序支持按模式自动选 `tdMode` |
|
||||
|
||||
## 三、实例 env 配置
|
||||
|
||||
在顶栏 **env配置**(或改服务器 `.env` 后重启)中设置:
|
||||
|
||||
### 1. 实盘开关与密钥
|
||||
|
||||
- `LIVE_TRADING_ENABLED=true`:允许实盘下单。
|
||||
- `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE`:上一步创建的密钥(期权与永续共用,不必单独配期权 Key)。
|
||||
- `SIM_DEFAULT_MODE=live`:运行模式切到实盘(顶栏显示 `okx_live`)。也可在运行时由系统把 `trading.mode` 写成 `live`。
|
||||
|
||||
切到实盘后:
|
||||
|
||||
- 顶栏「交易账户 / 总资金」只显示交易所真实余额;未充值时应为 `—` 或 `0`,不会再残留模拟盘数字。
|
||||
- 模拟钱包余额不会带到实盘。
|
||||
|
||||
### 2. 期权相关(币本位)
|
||||
|
||||
- `OKX_OPTIONS_ENABLED=true`:启用期权模块。
|
||||
- `OKX_OPTIONS_MARGIN_MODE=coin`:币本位(用 ETH/BTC 付权利金)。
|
||||
- `OKX_OPTIONS_MAX_ACTIVE_POSITIONS`:期权持仓上限(笔);`0`=不限制;`1`=同时最多 1 个合约(同合约加仓不占新笔数)。
|
||||
- `OKX_OPTIONS_SPOT_BUY_BUFFER`:现货买入相对权利金的缓冲倍数(如 `1.05`)。
|
||||
- 目标平仓门控:`OKX_OPTIONS_CLOSE_GATE_MODE`、`OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN`、`OKX_OPTIONS_CLOSE_HOLD_SECONDS` 等(见 env 页「期权账户」分组)。
|
||||
|
||||
保存后按提示 **保存并重启**(或 PM2 重启),再刷新页面确认顶栏为 `okx_live`。
|
||||
|
||||
## 四、开仓前自检清单
|
||||
|
||||
1. OKX 账户模式 = **合约模式**。
|
||||
2. API Key 有 **交易 + 现货**,IP 白名单正确。
|
||||
3. `.env`:`LIVE_TRADING_ENABLED=true`,密钥已填,模式为 `live`。
|
||||
4. 交易账户有足够 **USDT**(买币)以及开期权所需的标的币/保证金。
|
||||
5. 期权持仓上限、全仓复利等规则符合预期。
|
||||
|
||||
## 五、翻倍离场与指数离场(实盘同样生效)
|
||||
|
||||
后台监控轮询实盘持仓,达标后按买一限价平仓:
|
||||
|
||||
- **翻倍离场**:买一可回收 ≥ 开仓权利金 × (1 + 倍数) 时平仓。
|
||||
- **指数离场**:标的指数到位后触发,但仍须通过「目标平仓门控」(权利金倍数持续若干秒等)。
|
||||
|
||||
开仓时勾选/填写对应出场条件,并保证实例进程在跑。
|
||||
|
||||
## 六、中控远程更新注意
|
||||
|
||||
中控「系统更新 → 客户端远程更新」依赖实例的 `/api/hub/version` 与 `/api/hub/update`。若版本列报「非 JSON / 404」,请先在该实例服务器手动 `git pull` 并重启一次,再在中控刷新版本。
|
||||
@@ -0,0 +1,400 @@
|
||||
# 系统分析:期权主链条
|
||||
|
||||
> 范围:币本位期权(`OKX_OPTIONS_MARGIN_MODE=coin`)
|
||||
> 配套规格:[规格-期权交易账户盈亏结算.md](./规格-期权交易账户盈亏结算.md)、[实盘说明.md](./实盘说明.md)
|
||||
> 目的:从架构到开仓 / 平仓 / 展示 / 复盘 / 统计 / 对账 / 条件单,说明数据流、关键模块与边界
|
||||
|
||||
---
|
||||
|
||||
## 0. 总览
|
||||
|
||||
### 0.1 三条控制面
|
||||
|
||||
| 控制面 | 入口 | 职责 |
|
||||
|--------|------|------|
|
||||
| 面板 API | `/options` → `/api/options/*` | 人工开平仓、链/报价、持仓、划转、统计 |
|
||||
| 监控线程 | `options_monitor_lib`(安装时拉起) | 目标位平仓、止盈平仓、条件单、持仓同步、卖回扫尾 |
|
||||
| 中控 Hub | `/api/hub/options/*` | 快照、统计、远程平仓、条件单 CRUD |
|
||||
|
||||
### 0.2 资金主链(双口径)
|
||||
|
||||
```text
|
||||
USDT(交易户)
|
||||
│ 开仓前快照 trading_usdt_before;initial_premium = 开仓指数×权利金币量
|
||||
▼
|
||||
买 ETH/BTC(现货桥)
|
||||
▼
|
||||
买期权(IOC @ 卖一)
|
||||
▼
|
||||
持仓中(禁划转;浮盈跟盘)
|
||||
▼
|
||||
卖期权(可多次部分平;以全部平完为准)
|
||||
▼
|
||||
卖光可卖标的币(禁止按开仓权利金封顶)→ 快照 trading_usdt_after
|
||||
▼
|
||||
口径 A(复盘真盈亏): 币盈亏×卖回VWAP − 权利金×(买VWAP−卖回VWAP);卖完再结算
|
||||
口径 B(期权历史/数据统计): 仅期权腿币盈亏(×卖回价折 U);不含现货/权利金U波动修正
|
||||
```
|
||||
|
||||
### 0.3 启动装配
|
||||
|
||||
```text
|
||||
app.py
|
||||
→ install_options_trading # options_register
|
||||
→ install_options_review # options_review_register
|
||||
→ register_hub_bridge_routes # hub_bridge_register
|
||||
→ 监控守护线程 # options_monitor_lib
|
||||
```
|
||||
|
||||
核心配置包:`_build_cfg`(交易所、DB、预算、报价/下单回调)。
|
||||
UI:`lib/options/templates/options_panel.html` + `lib/common/static/options_panel.js`。
|
||||
|
||||
### 0.4 关键表(SQLite)
|
||||
|
||||
| 表 | 用途 |
|
||||
|----|------|
|
||||
| `options_trades` | 本地开平流水;含 `trading_usdt_before/after`、`realized_pnl_usdt`、`pnl_settled` |
|
||||
| `options_spot_bridge` | 买币/卖回桥状态 |
|
||||
| `options_target_monitors` | 目标位监控 |
|
||||
| `options_cond_orders` | 条件单 |
|
||||
| `options_exchange_pnl_freeze` | 实盘历史盈亏冻结辅助 |
|
||||
| `options_review_*` | 复盘成交 / 笔记 / 隐藏 / 图 |
|
||||
| `options_transfer_log` 等 | 划转等资金侧日志 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 主链条(架构)
|
||||
|
||||
### 1.1 模块分层
|
||||
|
||||
| 层 | 代表文件 | 说明 |
|
||||
|----|----------|------|
|
||||
| 路由装配 | `lib/options/options_register.py` | HTTP、尺寸归一、同步持仓 |
|
||||
| 开仓编排 | `options_coin_open_lib.py` | 预算→买币→开期权→写库 |
|
||||
| 平仓执行 | `options_close_exec_lib.py` | 买一/深度卖出 |
|
||||
| 账户结算 | `options_account_pnl_lib.py` | USDT 快照、卖回后冻结 |
|
||||
| 公式结算 | `options_pnl_settle_lib.py` | 权利金侧辅助结算(有账户快照时 U 以账户为准) |
|
||||
| 交易所 | `lib/exchange/okx_options_lib.py` | 链、报价、下单、持仓 |
|
||||
| 模式/预算 | `options_margin_mode_lib.py` | 币本位、预算、张数规划 |
|
||||
| 展示 enrichment | `options_positions_lib.py` / `options_history_lib.py` | 浮盈、平仓预览 |
|
||||
| Env UI | `lib/env/env_ui_manifest.py` | 期权相关默认与文案 |
|
||||
|
||||
### 1.2 运行模式
|
||||
|
||||
- `OKX_TRADE_MODE=options`:主站偏期权。
|
||||
- `OKX_OPTIONS_ENABLED`:模块开关。
|
||||
- 模拟 / 实盘由顶栏 `trading.mode` + `LIVE_TRADING_ENABLED` 等控制;实盘要求现货+期权权限(见实盘说明)。
|
||||
|
||||
### 1.3 与盈亏规格的关系
|
||||
|
||||
规格文档定义**产品规则**;本分析描述**代码落点**。
|
||||
凡「已实现盈亏」对外展示(复盘 / 统计 / 顶栏已实现),在新规则下均以 `pnl_settled=1` 的账户差分为准;旧单无快照字段时走遗留口径,互不回写。
|
||||
|
||||
---
|
||||
|
||||
## 2. 开仓
|
||||
|
||||
### 2.1 入口
|
||||
|
||||
| 来源 | 路径 / 调用 |
|
||||
|------|-------------|
|
||||
| 面板 | `POST /api/options/open` |
|
||||
| 条件单触发 | `options_cond_order_lib` → 同一套 `open_coin_option_buy_full` |
|
||||
|
||||
前端默认尺寸模式:**按可用余额打满**(`budget_full`);复利开时 UI 切 `compound_full`。
|
||||
后端 `_normalize_size_mode`:币本位保留 `budget_full` / `compound_full`;USDC 遗留路径才落到 `sheets`。
|
||||
|
||||
### 2.2 尺寸模式
|
||||
|
||||
| mode | 含义 |
|
||||
|------|------|
|
||||
| `budget_full` | min(可用 USDT, 单笔预算) × 预算缓冲 → 张数(默认) |
|
||||
| `compound_full` | 交易户可用 × 缓冲(受全仓上限 env 约束) |
|
||||
| `sheets` | 指定张数 |
|
||||
| `eth` / `eth_amount` | 指定币数量反推张数 |
|
||||
|
||||
预算计算:`compute_coin_budget_usdt` / `plan_coin_open_by_budget`(`options_margin_mode_lib.py`)。
|
||||
|
||||
### 2.3 流程
|
||||
|
||||
```text
|
||||
请求 {inst_id, mode, sheets?, eth?, target_index?, profit_exit_*}
|
||||
→ 交易模式门禁 / 条件单占用锁 / 持仓笔数上限
|
||||
→ 有可卖标的币 → 拒开(须先卖回)
|
||||
→ coin_budget_preview → 规划张数与买币量
|
||||
→ fetch_trading_usdt → 写入 trading_usdt_before
|
||||
→ 现货市价买币(× SPOT_BUY_BUFFER)→ options_spot_bridge=bought
|
||||
→ 期权限价 IOC @ 卖一 → 等待成交
|
||||
失败 → 回滚卖币
|
||||
成功 → bridge=holding,_insert_coin_trade(open, pnl_settled=0)
|
||||
→ 可选:目标位 / 止盈倍数 / 微信通知
|
||||
```
|
||||
|
||||
### 2.4 关键 env
|
||||
|
||||
| 变量 | 默认(代码/清单) | 作用 |
|
||||
|------|-------------------|------|
|
||||
| `OKX_OPTIONS_COIN_COMPOUND` | true | 是否按交易户复利 |
|
||||
| `OKX_OPTIONS_COIN_BUDGET_USDT` | (单笔预算) | 打满时的封顶 |
|
||||
| `OKX_OPTIONS_BUDGET_BUFFER` | 1 | 预算缓冲 |
|
||||
| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 1.05 | 现货多买缓冲 |
|
||||
| `OKX_OPTIONS_MAX_ACTIVE_POSITIONS` | 1 | 同时持仓笔数;0=不限 |
|
||||
| `OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC` | — | 开仓等待成交超时 |
|
||||
|
||||
### 2.5 边界
|
||||
|
||||
- 开仓**只快照 before**,不结算盈亏。
|
||||
- 持仓或桥未完成期间:`transfer_blocked_by_position_msg` 禁止划转。
|
||||
- 一次一仓是对账规格前提;上限默认 1。
|
||||
|
||||
---
|
||||
|
||||
## 3. 平仓
|
||||
|
||||
### 3.1 三条平仓通道
|
||||
|
||||
| 通道 | 触发 | 门控 | 执行 |
|
||||
|------|------|------|------|
|
||||
| 手动 / Hub | 用户或中控 | 一般只校验流动性 | `close_option_by_bid1(require_recycle_gate=False)` |
|
||||
| 目标位 | 指数触达目标 | 回收门控(权利金倍数 / 净盈亏 + 持续秒数) | `close_option_by_bid_depth` |
|
||||
| 止盈倍数 | 买一回收 ≥ 权利金×(1+mult) | 自有回收条件,不走 close_gate | `close_option_by_bid_profit_exit` |
|
||||
|
||||
监控循环:`run_options_target_closes` / `run_options_profit_exits`。
|
||||
|
||||
### 3.2 流程
|
||||
|
||||
```text
|
||||
卖期权(买一或深度,reduce-only)
|
||||
→ 部分成交:累计平仓张数
|
||||
→ 全平:
|
||||
settle_closed_pnl(币侧/费用;有账户快照时 U 不以公式定稿)
|
||||
→ maybe_sell_spot_after_close / sell_residual
|
||||
→ try_settle_after_sellback
|
||||
after − before → realized_pnl_usdt,pnl_settled=1
|
||||
```
|
||||
|
||||
卖回失败:本单「待结算」,已实现不进统计;可 `POST /api/options/spot-bridge/retry-sell` 或监控扫尾。
|
||||
|
||||
### 3.3 回收门控 env
|
||||
|
||||
| 变量 | 默认 | 作用 |
|
||||
|------|------|------|
|
||||
| `OKX_OPTIONS_CLOSE_GATE_MODE` | premium | `premium` / `net_pnl` |
|
||||
| `OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN` | 1.05 | 币本位回收倍数 |
|
||||
| `OKX_OPTIONS_CLOSE_HOLD_SECONDS` | 20 | 条件持续秒数 |
|
||||
| `OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT` | 0 | net_pnl 模式下限 |
|
||||
|
||||
### 3.4 边界
|
||||
|
||||
- 手动平仓可绕过回收门控;自动目标位不可。
|
||||
- 冻结后任何划转、指数、复盘同步**不得改写** `realized_pnl_usdt`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 期权显示
|
||||
|
||||
### 4.1 页面与 API
|
||||
|
||||
| UI | API | 数据源 |
|
||||
|----|-----|--------|
|
||||
| 期权链 / 到期 / 虚实度过滤 | `GET /api/options/chain` | `build_option_chain` |
|
||||
| 下单弹窗报价与估张 | `GET /api/options/quote` | `quote_option_contract` + 币本位规划 |
|
||||
| 持仓卡片 | `GET /api/options/positions` | 交易所持仓 + 本地 trade enrichment |
|
||||
| 余额 / 顶栏 | `GET /api/options/balances` + header pack | 交易户 / 资金户 |
|
||||
| 挂单 / 目标位 | `.../orders/pending`、`.../targets` | 本地 + 交易所 |
|
||||
| 中控快照 | `GET /api/hub/options/snapshot` | `build_options_hub_snapshot` |
|
||||
|
||||
前端主文件:`options_panel.js`、`options_position_cards.js`、`options_expiry_countdown.js`。
|
||||
|
||||
### 4.2 展示口径(勿与冻结混淆)
|
||||
|
||||
| 指标 | 口径 |
|
||||
|------|------|
|
||||
| 持仓浮盈 | 买一回收估算(`display_pnl_*`),跟盘变 |
|
||||
| 合计盈亏 | Σ已冻结 + 当前浮盈 |
|
||||
| 顶栏钱包 | 交易所余额,不是盈亏账本 |
|
||||
| 下单估权利金 / 杠杆 / 打满提示 | 报价瞬间估算,非成交结果 |
|
||||
|
||||
### 4.3 链过滤 env
|
||||
|
||||
`OKX_OPTIONS_CHAIN_MAX_DTE_DAYS`、`OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED`、`OKX_OPTIONS_ITM_MAX_DIST_USD`、`OKX_OPTIONS_DEFAULT_UNDERLY`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 复盘
|
||||
|
||||
### 5.1 入口
|
||||
|
||||
- 页:`/options/review`
|
||||
- 注册:`options_review_register.py`
|
||||
- 逻辑:`options_review_lib.py` + `options_review_db.py`
|
||||
- 前端:`options_review_panel.html`、`options_review.js`
|
||||
|
||||
### 5.2 数据流
|
||||
|
||||
```text
|
||||
options_trades(已平)
|
||||
→ sync_options_from_local_trades / ensure_local_review_synced
|
||||
→ options_review_trades(history_key ≈ local_opt:{id})
|
||||
→ 笔记 options_review_entries + 上传图 / 异步图表
|
||||
→ 隐藏 options_review_hidden
|
||||
```
|
||||
|
||||
主要 API:`POST /api/options/review/sync`,`GET/DELETE .../trades`,`POST .../entry`,`POST .../upload_slot`,`GET .../stats`。
|
||||
|
||||
### 5.3 盈亏读取规则
|
||||
|
||||
| 本地单状态 | 复盘展示 |
|
||||
|------------|----------|
|
||||
| 有 `trading_usdt_before` 且未 `pnl_settled` | 已实现 U 待结算(不展示假冻结) |
|
||||
| `pnl_settled=1` | 只读冻结 `realized_pnl_usdt` |
|
||||
| 无账户快照的历史单 | 遗留口径,不强制改写成账户差分 |
|
||||
|
||||
复盘是**消费者**,不负责改写结算真源。
|
||||
|
||||
---
|
||||
|
||||
## 6. 统计
|
||||
|
||||
### 6.1 入口
|
||||
|
||||
| 场景 | API / 函数 |
|
||||
|------|------------|
|
||||
| 期权面板 KPI | `GET /api/options/stats` → `compute_options_stats_auto` |
|
||||
| 复盘页统计 | `GET /api/options/review/stats` |
|
||||
| 顶栏窗口统计 | `header_options_stats_for_window` |
|
||||
| 中控 | `GET /api/hub/options/stats` |
|
||||
|
||||
核心库:`options_stats_lib.py`。
|
||||
|
||||
### 6.2 窗口与构成
|
||||
|
||||
- 窗口:`today` / `7d` / `30d` / `all`(`normalize_stats_window`)。
|
||||
- 模拟:本地已平且已结算样本。
|
||||
- 实盘:交易所历史 + `options_exchange_pnl_freeze` 对齐后同一套 KPI。
|
||||
- 面板合计:`净已实现(仅 settled) + 持仓浮盈`。
|
||||
|
||||
### 6.3 边界
|
||||
|
||||
- 待卖回、未冻结单**不计入**已实现。
|
||||
- 统计不重新推导账户差分;最多做冻结补齐 / 修复类运维函数(如 `freeze_missing_realized_pnl_usdt`),与规格「冻结后不改」一致的运维路径需谨慎使用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 对账
|
||||
|
||||
此处「对账」= **交易账户快照结算 + 本地/交易所持仓对齐**,不是已删除的永续 `reconcile_flat`。
|
||||
|
||||
### 7.1 结算真源(账户账)
|
||||
|
||||
| 步骤 | 函数 | 结果 |
|
||||
|------|------|------|
|
||||
| 开仓前 | `fetch_trading_usdt` | `trading_usdt_before` |
|
||||
| 卖回后 | `try_settle_after_sellback` / `settle_closed_trades_from_trading_account` | `after`、`realized_pnl_usdt`、`pnl_settled=1` |
|
||||
| 持仓禁划转 | `transfer_blocked_by_position_msg` | 前端灰 + API 拒 |
|
||||
|
||||
公式身份(规格 §6 思路):
|
||||
|
||||
```text
|
||||
Σ 冻结已实现 + 当前浮盈 ≈ 面板合计盈亏
|
||||
Σ 冻结已实现 + 区间净划转 ≈ 钱包 USDT 变动(在一次一仓、无并行污染前提下)
|
||||
```
|
||||
|
||||
### 7.2 持仓对齐(仓位账)
|
||||
|
||||
监控 / `_sync_options_trades`:
|
||||
|
||||
| 动作 | 含义 |
|
||||
|------|------|
|
||||
| `reconcile_live_open_trades` / `sync_open_options_trades` | 交易所仍有仓而本地已关 → 纠偏重开等 |
|
||||
| `backfill_closed_options_realized_pnl_from_history` | 历史回填;**跳过已冻结 U** |
|
||||
| `sell_spot_after_options_sync` / `sweep_spot_after_options_flat` | 平仓后残留币扫尾,推动账户结算 |
|
||||
|
||||
### 7.3 双账清晰
|
||||
|
||||
| 账本 | 看见什么 |
|
||||
|------|----------|
|
||||
| 钱包(顶栏) | 交易所余额,随划转变 |
|
||||
| 盈亏账 | 冻结历史 + 浮盈;划转不改已冻结单 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 条件单
|
||||
|
||||
### 8.1 定位
|
||||
|
||||
中控下发「价格带触发 → 自动开仓 → 挂目标/止盈 → 等平仓结束」的自动化;与面板互斥:有活跃条件单时**锁定手动开仓**(`cond_order_blocks_manual_open_msg`)。
|
||||
|
||||
### 8.2 入口
|
||||
|
||||
| API | 作用 |
|
||||
|-----|------|
|
||||
| `GET/POST /api/hub/options/cond_orders` | 列表 / 创建 |
|
||||
| `POST .../<hub_order_id>/cancel` | 取消 |
|
||||
| `POST .../exit` | 更新出场参数 |
|
||||
|
||||
库:`options_cond_order_lib.py`。
|
||||
Tick:监控循环 `run_cond_orders_tick` → `_tick_one`。
|
||||
|
||||
### 8.3 生命周期
|
||||
|
||||
```text
|
||||
Hub 创建(trigger_px, deviation_pct, 认购/认沽, 出场)
|
||||
→ options_cond_orders = armed
|
||||
→ 指数落入 band_bounds
|
||||
→ pick_nearest_itm_contract
|
||||
→ 走 open_coin_option_buy_full(同人工开仓:before 快照、买币、IOC)
|
||||
→ 挂目标位 / 止盈
|
||||
→ 等待仓位平坦 → _end_order
|
||||
→ 周四 23:00 等策略可 expire 未触发单
|
||||
```
|
||||
|
||||
### 8.4 与主链关系
|
||||
|
||||
- **不另建盈亏账本**;触发开仓后的卖回冻结与人工单相同。
|
||||
- 占用期间面板开仓按钮应不可用,避免双开破坏「一次一仓」。
|
||||
|
||||
---
|
||||
|
||||
## 9. 端到端对照表
|
||||
|
||||
| 阶段 | 写库 / 状态 | 用户可见 | 消费方 |
|
||||
|------|-------------|----------|--------|
|
||||
| 开仓成功 | trade open + before + bridge holding | 持仓卡、浮盈 | 监控、禁划转 |
|
||||
| 期权已平、币未卖回 | 待结算 | 提示待卖回 | 统计不含已实现 |
|
||||
| 卖回成功 | after、realized、settled=1 | 复盘/统计定格 | 对账完成 |
|
||||
| 条件单 armed | cond_orders | 横幅锁定手动开仓 | 监控 tick |
|
||||
| 目标位/止盈触发 | 平仓执行 | 持仓消失→卖回→冻结 | 同平仓链 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键文件索引
|
||||
|
||||
| 主题 | 路径 |
|
||||
|------|------|
|
||||
| 路由 / 开平仓 API | `lib/options/options_register.py` |
|
||||
| 币本位开仓 | `lib/options/options_coin_open_lib.py` |
|
||||
| 现货桥 | `lib/options/options_spot_bridge_lib.py` |
|
||||
| 平仓执行 | `lib/options/options_close_exec_lib.py` |
|
||||
| 回收门控 | `lib/options/options_close_gate_lib.py` |
|
||||
| 目标位 | `lib/options/options_target_lib.py` |
|
||||
| 止盈 | `lib/options/options_profit_exit_lib.py` |
|
||||
| 账户盈亏 | `lib/options/options_account_pnl_lib.py` |
|
||||
| 统计 | `lib/options/options_stats_lib.py` |
|
||||
| 复盘 | `lib/options/options_review_*.py` |
|
||||
| 条件单 | `lib/options/options_cond_order_lib.py` |
|
||||
| 监控 | `lib/options/options_monitor_lib.py` |
|
||||
| Hub 快照 | `lib/options/options_hub_lib.py` |
|
||||
| Hub 路由 | `lib/hub/hub_bridge_register.py` |
|
||||
| Schema | `lib/options/options_db.py` |
|
||||
| 面板 | `lib/options/templates/options_panel.html`、`lib/common/static/options_panel.js` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 维护时注意
|
||||
|
||||
1. **改盈亏口径**:先改规格文档,再改 `options_account_pnl_lib` 与复盘/统计读取,避免三处漂移。
|
||||
2. **改开仓默认尺寸**:同时看前端 `currentSizeMode`、HTML `checked`、后端 `_normalize_size_mode`(币本位勿再压成 `sheets`)。
|
||||
3. **改门控秒数/缓冲**:代码默认、`env_ui_manifest` 文案与 `_RUNTIME_ENV_DEFAULTS` 一并对齐;已有 `.env` 旧值不会自动覆盖。
|
||||
4. **条件单与手动开仓**:任何新开仓入口都要接 `cond_order_blocks_manual_open_msg`(或等价锁)。
|
||||
5. **对账前提**:一次一仓 + 持仓禁划转 + 卖回后冻结;破坏任一前提,钱包差分将无法与单笔盈亏对齐。
|
||||
@@ -0,0 +1,478 @@
|
||||
# 系统架构与技术实现
|
||||
|
||||
> 定位:描述本仓库(OKX 币本位期权实例)的整体架构、运行时、模块划分与关键技术实现。
|
||||
> 配套文档:
|
||||
> - 业务主链:[系统分析-期权主链条.md](./系统分析-期权主链条.md)
|
||||
> - 盈亏规格:[规格-期权交易账户盈亏结算.md](./规格-期权交易账户盈亏结算.md)
|
||||
> - 操作说明:[../使用说明.md](../使用说明.md)
|
||||
> - 部署:[../部署文档.md](../部署文档.md) · 实盘:[实盘说明.md](./实盘说明.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 角色 | 单交易所实例客户端(OKX),可独立使用,也可挂中控 `crypto_okx_hub` |
|
||||
| 主业务 | **币本位期权**:USDT → 买 ETH/BTC → 开期权 → 平仓 → 卖回 → 按交易户 USDT 差分冻结盈亏 |
|
||||
| 非目标 | 不再以永续实盘下单 / USDC 期权开仓 / 对冲计划为主路径(相关代码多已下线或残留) |
|
||||
|
||||
三条控制面:
|
||||
|
||||
```text
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ 浏览器面板 │ │ 本机监控守护线程 │ │ 中控 Hub │
|
||||
│ /options │ │ options-monitor │ │ /api/hub/* │
|
||||
│ /api/options│ │ 目标/翻倍/条件单 │ │ 快照/条件单/全平 │
|
||||
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
└─────────────────────┼────────────────────────┘
|
||||
▼
|
||||
Flask app.py(单进程)
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
SQLite ccxt.okx 模拟 SimBroker
|
||||
(crypto.db) (实盘/公开行情) (本地撮合)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 语言 / 运行时 | Python **3.10+** | 见 `README.md` / `requirements.txt` |
|
||||
| Web 框架 | Flask ≥3、<4 | `threaded=True`;Jinja2 模板 |
|
||||
| WSGI | Werkzeug | 开发/PM2 直跑 `app.py` |
|
||||
| 交易所 SDK | **ccxt** ≥4.2(`ccxt.okx`) | 期权 `defaultType=option`;可配 SOCKS |
|
||||
| HTTP / 代理 | requests、PySocks | `OKX_SOCKS_PROXY` |
|
||||
| 持久化 | **SQLite** 单文件 | 默认 `DB_PATH=crypto.db` |
|
||||
| 进程管理 | **PM2** | `ecosystem.config.cjs`:单实例、内存上限约 800M |
|
||||
| 前端 | **原生 JS/CSS** | 无 React/Vue/`package.json`;静态资源在 `lib/common/static/` |
|
||||
| 图像 | Pillow | 复盘/日记配图等 |
|
||||
|
||||
部署约定(见部署文档):常见路径 `/opt/crypto_okx`,端口 **`APP_PORT=5004`**(代码未配时 fallback 5000)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 仓库与目录结构
|
||||
|
||||
```text
|
||||
crypto_okx/
|
||||
├── app.py # 应用入口:装配路由、线程、交易所句柄
|
||||
├── ecosystem.config.cjs # PM2
|
||||
├── requirements.txt
|
||||
├── .env / .env.example
|
||||
├── crypto.db # 运行时 SQLite(勿提交)
|
||||
├── deploy/ # 安装 / 更新 / doctor 脚本
|
||||
├── docs/ # 规格、架构、实盘说明
|
||||
├── templates/ # Flask 默认模板目录(少量)
|
||||
├── static/ # 上传等(如 images)
|
||||
└── lib/
|
||||
├── paths.py # 仓库根、静态/模板路径
|
||||
├── options/ # 期权交易、监控、复盘、结算(核心)
|
||||
├── exchange/ # OKX 期权 API 封装、账本适配
|
||||
├── sim/ # 模拟钱包与 API hook
|
||||
├── hub/ # 中控 Bridge 鉴权与路由
|
||||
├── env/ # .env 读写、schema、配置页白名单
|
||||
├── instance/ # 壳层 UI、设置、embed、备份、仪表盘
|
||||
├── account_ledger/ # 资金流水同步
|
||||
├── market/ # 行情、K 线、精度
|
||||
├── trade/ # 入场模型/风控等(关键位等周边)
|
||||
├── key_monitor/ # 关键位监控
|
||||
└── common/ # 公共工具 + static 前端资源
|
||||
```
|
||||
|
||||
原则:**业务按包 install/register 挂到 Flask**,避免全部逻辑堆在 `app.py`;`app.py` 仍保留登录、部分关键位与全局交易所对象。
|
||||
|
||||
---
|
||||
|
||||
## 4. 启动与装配顺序
|
||||
|
||||
### 4.1 引导流程
|
||||
|
||||
```text
|
||||
load_env_file(.env) → os.environ
|
||||
→ Flask(__name__) + Session(含中控 iframe SameSite=None/Secure)
|
||||
→ attach_embed_templates / install_common_static
|
||||
→ 构建 ccxt.okx(永续句柄 + options 句柄,可带代理)
|
||||
→ 初始化 SQLite 核心表
|
||||
→ register_trade_records_api
|
||||
→ register_instance_dashboard_routes # 仪表盘缓存线程
|
||||
→ install_account_ledger # 账本同步线程
|
||||
→ register_instance_settings_routes
|
||||
→ start_backup_scheduler # 定时备份线程
|
||||
→ install_sim_trading # 模拟模式 + hooks
|
||||
→ install_options_trading # 期权路由 + options-monitor
|
||||
→ install_options_review
|
||||
→ install_instance_data_stats # 资金采样线程
|
||||
→ register_hub_bridge_routes
|
||||
→ register_embed_routes # /embed + live push
|
||||
→ __main__: key-monitor 后台线程 + app.run(threaded=True)
|
||||
```
|
||||
|
||||
### 4.2 期权模块装配(要点)
|
||||
|
||||
`install_options_trading`(`lib/options/options_register.py`):
|
||||
|
||||
1. `_build_cfg(app_module)`:注入 DB、交易所、预算、报价/下单回调等。
|
||||
2. `register_options_routes`:页面与 `/api/options/*`。
|
||||
3. `_register_options_hub_bridge`:把 `options_snapshot_fn` 等写入 `app.config["HUB_CTX"]`。
|
||||
4. `_start_monitor_thread`:若 `OKX_OPTIONS_ENABLED`,拉起 `options-monitor`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 运行时架构
|
||||
|
||||
### 5.1 进程模型
|
||||
|
||||
| 单元 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| PM2 应用 `crypto_okx` | 1 | `python app.py`,`PYTHONPATH` 指向仓库根 |
|
||||
| Flask 请求线程 | 多 | `threaded=True` |
|
||||
| 守护线程 | 多个 | 见下表(均为 daemon) |
|
||||
|
||||
### 5.2 守护线程一览
|
||||
|
||||
| 线程(逻辑名) | 启动位置 | 职责 |
|
||||
|----------------|----------|------|
|
||||
| `background_task` / 关键位监控 | `app.py` `__main__` | 按 `MONITOR_POLL_SECONDS` 轮询关键位规则 |
|
||||
| `options-monitor` | `options_register` | 目标位平仓、翻倍出场、条件单 tick、持仓同步、卖回扫尾 |
|
||||
| `okx-backup-scheduler` | `backup_lib` | 定时备份 |
|
||||
| `account-ledger-okx` | `account_ledger` | 交易所资金流水同步 |
|
||||
| `instance-dashboard-poll` | dashboard cache | 仪表盘数据缓存 |
|
||||
| `instance-cash-sample` | data stats | 资金样本 |
|
||||
| `instance-live-push` | embed live | SSE/推送类 |
|
||||
|
||||
短生命周期线程:日记/复盘异步出图(`journal-chart-*`、`options-review-chart-*`)。
|
||||
|
||||
### 5.3 配置三层
|
||||
|
||||
```text
|
||||
① .env 文件(启动 load + env 页写入)
|
||||
② os.environ(进程环境)
|
||||
③ SQLite app_runtime_settings
|
||||
· trading.mode = sim | live
|
||||
· display.* 导航显示偏好
|
||||
· env.<KEY> 热更新覆盖(runtime_config_lib)
|
||||
```
|
||||
|
||||
读取顺序(热更场景):运行时覆盖 → 环境变量 → 代码默认(`env_ui_manifest._RUNTIME_ENV_DEFAULTS`)。
|
||||
部分键改后需 **保存并重启**(见 `env_schema` 的 restart 标记)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 分层架构
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 表现层 Jinja 模板 + vanilla JS(options_panel.js 等) │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ 接入层 Flask routes(options_register / hub / settings) │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ 领域编排 coin_open / close_exec / cond_order / monitor │
|
||||
│ account_pnl / stats / review │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ 基础设施 okx_options_lib(ccxt) · SQLite · sim broker │
|
||||
│ env_file · wechat_notify · backup │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 层 | 代表路径 | 职责边界 |
|
||||
|----|----------|----------|
|
||||
| 表现 | `lib/options/templates/*`、`lib/common/static/options_*.js` | 交互、轮询、展示口径 |
|
||||
| 接入 | `options_register.py`、`hub_bridge_register.py`、`instance_settings_register.py` | HTTP、鉴权、参数归一 |
|
||||
| 编排 | `options_coin_open_lib.py`、`options_close_exec_lib.py`、`options_monitor_lib.py` | 多步业务流程、状态机 |
|
||||
| 领域规则 | `options_margin_mode_lib.py`、`options_close_gate_lib.py`、`options_account_pnl_lib.py` | 预算、门控、结算真源 |
|
||||
| 基础设施 | `okx_options_lib.py`、`options_db.py`、`sim/*` | IO 与持久化 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心业务技术实现
|
||||
|
||||
### 7.1 币本位开仓流水线
|
||||
|
||||
**入口:** `POST /api/options/open` → `open_coin_option_buy_full`
|
||||
**尺寸归一:** `_normalize_size_mode`(币本位保留 `budget_full` / `compound_full`;USDC 遗留才落到 `sheets`)
|
||||
|
||||
```text
|
||||
门禁(模式 / 条件单锁 / 持仓上限 / 可卖币拒开)
|
||||
→ compute_coin_budget_usdt + plan_coin_open_by_budget
|
||||
→ fetch_trading_usdt → trading_usdt_before
|
||||
→ spot_market_buy_coin_with_usdt(× SPOT_BUY_BUFFER)
|
||||
→ options_spot_bridge = bought*
|
||||
→ place_option_limit_order(IOC @ 卖一)+ wait fill
|
||||
失败 → 回滚卖币
|
||||
成功 → bridge=holding,写入 options_trades(open, pnl_settled=0)
|
||||
→ 可选 upsert 目标位 / 翻倍出场 / 企微通知
|
||||
```
|
||||
|
||||
关键文件:
|
||||
|
||||
- `lib/options/options_coin_open_lib.py`
|
||||
- `lib/options/options_spot_bridge_lib.py`
|
||||
- `lib/options/options_margin_mode_lib.py`
|
||||
- `lib/options/options_position_limit_lib.py`
|
||||
- `lib/options/options_account_pnl_lib.py`
|
||||
|
||||
### 7.2 平仓与自动出场
|
||||
|
||||
| 通道 | 实现 | 门控 |
|
||||
|------|------|------|
|
||||
| 手动 / Hub 全平 | `close_option_by_bid1`(`require_recycle_gate=False`) | 流动性 |
|
||||
| 目标位 | `run_options_target_closes` → `close_option_by_bid_depth` | `options_close_gate_lib`(倍数 + 持续秒数) |
|
||||
| 翻倍出场 | `run_options_profit_exits` → `close_option_by_bid_profit_exit` | 回收 ≥ 权利金×(1+mult) |
|
||||
|
||||
平仓后:`settle_closed_pnl`(币/费用侧)→ `maybe_sell_spot_after_close` → `try_settle_after_sellback`:
|
||||
|
||||
```text
|
||||
realized_pnl_usdt = trading_usdt_after − trading_usdt_before
|
||||
pnl_settled = 1 # 冻结,业务路径不再改写
|
||||
```
|
||||
|
||||
### 7.3 监控循环
|
||||
|
||||
`options_monitor_loop`(`options_monitor_lib.py`)周期性执行,大致包括:
|
||||
|
||||
- 同步交易所持仓 ↔ 本地 `options_trades`(纠偏误关仓等)
|
||||
- 跑目标位 / 翻倍 / 条件单 tick
|
||||
- 平仓后卖回扫尾、推动账户结算
|
||||
- 历史盈亏回填(**跳过已冻结 U**)
|
||||
|
||||
轮询间隔受期权相关 poll env / 内部 sleep 控制;与关键位监控线程相互独立。
|
||||
|
||||
### 7.4 模拟盘技术实现
|
||||
|
||||
| 组件 | 文件 | 作用 |
|
||||
|------|------|------|
|
||||
| 模式 | `sim/mode_lib.py` | `trading.mode` ∈ {sim, live};启动默认 `SIM_DEFAULT_MODE` |
|
||||
| 撮合 | `sim/broker_lib.py` | `SimBroker`:公开买卖一进本地钱包/仓位 |
|
||||
| 注入 | `sim/hooks.py` | `apply_sim_hooks` / `patch_options_cfg`:把下单、划转等切到模拟实现 |
|
||||
| 表 | `sim/db_lib.py` | `sim_wallets`、`sim_option_positions`、`sim_ledger_entries` 等 |
|
||||
|
||||
实盘闸门:`LIVE_TRADING_ENABLED`;模拟下即使有密钥也不应向交易所发真实期权单(由 hook 拦截)。
|
||||
|
||||
### 7.5 复盘与统计(只读结算真源)
|
||||
|
||||
```text
|
||||
options_trades(closed + settled)
|
||||
→ review sync → options_review_*
|
||||
→ stats:compute_options_stats_auto / header_options_stats_for_window
|
||||
```
|
||||
|
||||
- 复盘:`options_review_lib.py` + `options_review_register.py`
|
||||
- 统计:`options_stats_lib.py`
|
||||
- 规则:未 `pnl_settled` 不计已实现;有账户快照的新单只读冻结 U
|
||||
|
||||
---
|
||||
|
||||
## 8. 交易所集成
|
||||
|
||||
### 8.1 ccxt 句柄
|
||||
|
||||
`app.py` 构建:
|
||||
|
||||
- `exchange`:通用 OKX(行情/部分账户)
|
||||
- `exchange_options`:`defaultType=option`
|
||||
|
||||
期权细节封装:`lib/exchange/okx_options_lib.py`
|
||||
|
||||
| 能力 | 示例 |
|
||||
|------|------|
|
||||
| 期权链 | `build_option_chain` |
|
||||
| 报价 | `quote_option_contract` |
|
||||
| 下单/撤单/等成交 | `place_option_limit_order`、`wait_option_order_full_fill` |
|
||||
| 持仓/余额 | `fetch_option_positions`、`fetch_options_balances` |
|
||||
| 划转 | `transfer_ccy` |
|
||||
| 流动性 | `option_buy_liquidity_ok`、买一深度裁剪 |
|
||||
|
||||
凭证:`OKX_API_KEY` / `SECRET` / `PASSPHRASE`(遗留 `OKX_OPTIONS_API_*` 可回填)。
|
||||
代理:`build_okx_ccxt_proxies()` ← `OKX_SOCKS_PROXY`。
|
||||
|
||||
### 8.2 账户模式约束
|
||||
|
||||
程序现货侧按 **合约模式 + cash** 假设编写;跨币种/组合保证金易触发 `tdMode` / `51010`。详见实盘说明。
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据模型(SQLite)
|
||||
|
||||
单库文件,多模块 `CREATE TABLE IF NOT EXISTS`。
|
||||
|
||||
### 9.1 期权核心
|
||||
|
||||
| 表 | 用途 |
|
||||
|----|------|
|
||||
| `options_trades` | 开平流水;`trading_usdt_before/after`、`realized_pnl_usdt`、`pnl_settled` |
|
||||
| `options_spot_bridge` | 买币/持币/待卖回/关闭 |
|
||||
| `options_target_monitors` | 指数目标位 |
|
||||
| `options_cond_orders` | 中控条件单状态机 |
|
||||
| `options_exchange_pnl_freeze` | 实盘历史盈亏冻结辅助 |
|
||||
| `options_transfer_log` / `options_convert_log` | 划转等日志 |
|
||||
| `options_history_hidden` | 历史隐藏 |
|
||||
|
||||
### 9.2 复盘
|
||||
|
||||
`options_review_trades`、`options_review_entries`、`options_review_sync_state`、`options_review_hidden`
|
||||
|
||||
### 9.3 模拟
|
||||
|
||||
`sim_wallets`、`sim_option_positions`、`sim_option_orders`、`sim_ledger_entries` 等
|
||||
|
||||
### 9.4 实例壳层
|
||||
|
||||
`app_runtime_settings`、`key_monitors`、`trade_records`、`account_ledger_*`、`instance_cash_samples` 等
|
||||
|
||||
连接约定:业务路径多处 `sqlite3.connect(DB_PATH, timeout=…)` + `Row` factory;长事务与网络 IO 应拆分,避免「持锁等交易所」导致面板超时(历史踩坑点)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 鉴权与中控 Bridge
|
||||
|
||||
### 10.1 站点登录
|
||||
|
||||
| 项 | 实现 |
|
||||
|----|------|
|
||||
| 账号 | `APP_USERNAME` / `APP_PASSWORD` |
|
||||
| Session | `session["logged_in"]`;`APP_SESSION_DAYS`;`FLASK_SECRET_KEY` |
|
||||
| 装饰器 | `login_required`;`/api/*` 未登录返回 JSON 401 |
|
||||
| 可关闭 | `APP_AUTH_DISABLED`(仅受信环境) |
|
||||
|
||||
为支持中控 iframe:Cookie `SameSite=None` + `Secure`(按配置)。
|
||||
|
||||
### 10.2 Hub 鉴权(`lib/hub/hub_bridge_auth.py`)
|
||||
|
||||
| 机制 | 用途 |
|
||||
|------|------|
|
||||
| `HUB_BRIDGE_TOKEN` | M2M:`X-Hub-Token` 或 `Bearer`;`hmac.compare_digest` |
|
||||
| 短时 SSO | `/hub_sso?token=` → 建站内 session(TTL ~120s) |
|
||||
| 长时 embed | `embed_sso`(约 7 天)应对 iframe 第三方 Cookie 限制 |
|
||||
| 自动生成 | env 页首次打开可 `ensure_hub_bridge_token` 写入 `.env` |
|
||||
| CSP | `HUB_EMBED_FRAME_ANCESTORS` → `frame-ancestors` |
|
||||
|
||||
### 10.3 Hub API 面(`hub_bridge_register.py`)
|
||||
|
||||
| 方法 | 路径 | 作用 |
|
||||
|------|------|------|
|
||||
| GET | `/api/hub/options/snapshot` | 持仓/资金/条件单/流动性摘要 |
|
||||
| GET | `/api/hub/options/stats` | 窗口统计 |
|
||||
| POST | `/api/hub/options/close` | 买一限价**全平** |
|
||||
| GET/POST | `/api/hub/options/cond_orders` | 条件单 |
|
||||
| POST | `.../cancel` · `.../exit` | 取消 / 改出场 |
|
||||
| GET | `/api/hub/version` | 在线与版本 |
|
||||
| POST | `/api/hub/update` | 远程 pull + PM2 重启 |
|
||||
| GET | `/api/hub/backup/*` | 备份列表/下载 |
|
||||
|
||||
快照构建:`options_hub_lib.build_options_hub_snapshot`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端技术实现
|
||||
|
||||
### 11.1 资源加载
|
||||
|
||||
`install_common_static` 将 `lib/common/static/` 下白名单文件挂到 `/static/<name>`。
|
||||
模板通过 `ChoiceLoader` 合并:`lib/instance/templates`、`lib/options/templates`、`lib/sim/templates` 等。
|
||||
|
||||
### 11.2 期权前端模块
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `options_panel.js` | 链、报价、下单尺寸模式、开平仓、轮询 |
|
||||
| `options_position_cards.js` | 持仓卡 UI |
|
||||
| `options_expiry_countdown.js` | 到期倒计时 |
|
||||
| `options_settings.js` | 设置内期权相关 |
|
||||
| `options_review.js` | 复盘页 |
|
||||
| `options_data_stats.js` | 统计展示 |
|
||||
| `hub_embed_auth.js` | 嵌入鉴权辅助 |
|
||||
| `instance_settings_prefs.js` | 导航显示等偏好 |
|
||||
|
||||
交互模式:REST 轮询为主(链/持仓/报价);无独立 Node 构建步骤。
|
||||
|
||||
### 11.3 关键 API 前缀
|
||||
|
||||
| 前缀 | 模块 |
|
||||
|------|------|
|
||||
| `/api/options/*` | 交易面板 |
|
||||
| `/api/options/review/*` | 复盘 |
|
||||
| `/api/sim/*` | 模拟资金 |
|
||||
| `/api/settings/*` | env/显示/密码等 |
|
||||
| `/api/hub/*` | 中控 |
|
||||
| `/api/admin/*` | 重启等 |
|
||||
| `/embed`、`/hub_sso` | 嵌入与 SSO |
|
||||
|
||||
---
|
||||
|
||||
## 12. 部署与运维技术点
|
||||
|
||||
| 项 | 实现 |
|
||||
|----|------|
|
||||
| 进程 | PM2 `ecosystem.config.cjs`,`instances: 1` |
|
||||
| 更新 | 本机脚本 / 中控 `POST /api/hub/update`(git pull + restart) |
|
||||
| 备份 | `backup_lib` 定时 + 设置页手动;中控可下载 |
|
||||
| 代理 | `OKX_SOCKS_PROXY`;SSH 动态转发不由 PM2 托管 |
|
||||
| 日志 | PM2 日志 + Werkzeug;可静音 access log |
|
||||
| 健康 | `deploy/lib/doctor.sh`;中控看 `/api/hub/version` |
|
||||
|
||||
---
|
||||
|
||||
## 13. 关键设计决策(实现层)
|
||||
|
||||
| 决策 | 原因 | 落点 |
|
||||
|------|------|------|
|
||||
| 单进程 + 多守护线程 | 部署简单、与 SQLite 单写者模型匹配 | PM2 instances=1 |
|
||||
| 盈亏真源=交易户 USDT 差分 | 与钱包可对账、避免指数重算漂移 | `options_account_pnl_lib` |
|
||||
| 一次一仓默认 | 差分对账前提 | `OKX_OPTIONS_MAX_ACTIVE_POSITIONS=1` |
|
||||
| 现货桥表 | 买币/卖回与期权生命周期解耦可重试 | `options_spot_bridge` |
|
||||
| Sim hook 而非第二套 UI | 同一面板切模拟/实盘 | `sim/hooks.py` |
|
||||
| Hub Token + SSO 双通道 | 机器调用与 iframe 人机登录分离 | `hub_bridge_auth` |
|
||||
| 前端无框架 | 运维机交付简单、无构建链 | `lib/common/static` |
|
||||
| USDC 开仓下线 | 主路径币本位 | margin mode 固定 coin |
|
||||
|
||||
---
|
||||
|
||||
## 14. 扩展与修改指引
|
||||
|
||||
1. **新开仓入口**:必须接条件单锁、持仓上限、可卖币检查、before 快照。
|
||||
2. **新统计展示**:只消费 `pnl_settled` 冻结值;勿在展示层重算账户差分。
|
||||
3. **新自动平仓**:明确是否走 close_gate;与手动通道权限分开。
|
||||
4. **改 env 默认**:同步代码默认、`env_ui_manifest` 文案与 `_RUNTIME_ENV_DEFAULTS`。
|
||||
5. **避免在持 DB 锁时调交易所**:网络与写库分阶段,防止面板 API 超时。
|
||||
6. **中控协议变更**:同步 `hub_bridge_register`、快照 `version` 与中控仓库解析逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 15. 关键文件索引
|
||||
|
||||
| 主题 | 路径 |
|
||||
|------|------|
|
||||
| 入口 | `app.py` |
|
||||
| 路径常量 | `lib/paths.py` |
|
||||
| 期权路由/监控启动 | `lib/options/options_register.py` |
|
||||
| 监控循环 | `lib/options/options_monitor_lib.py` |
|
||||
| 开仓 | `lib/options/options_coin_open_lib.py` |
|
||||
| 现货桥 | `lib/options/options_spot_bridge_lib.py` |
|
||||
| 平仓执行 | `lib/options/options_close_exec_lib.py` |
|
||||
| 账户结算 | `lib/options/options_account_pnl_lib.py` |
|
||||
| 条件单 | `lib/options/options_cond_order_lib.py` |
|
||||
| Hub 快照 | `lib/options/options_hub_lib.py` |
|
||||
| Hub 路由/鉴权 | `lib/hub/hub_bridge_register.py`、`hub_bridge_auth.py` |
|
||||
| 交易所 | `lib/exchange/okx_options_lib.py` |
|
||||
| 模拟 | `lib/sim/register.py`、`hooks.py`、`broker_lib.py` |
|
||||
| Env UI | `lib/env/env_ui_manifest.py`、`env_schema.py` |
|
||||
| 静态资源注册 | `lib/instance/instance_static_lib.py` |
|
||||
| PM2 | `ecosystem.config.cjs` |
|
||||
|
||||
---
|
||||
|
||||
## 16. 文档关系
|
||||
|
||||
```text
|
||||
使用说明.md ← 操作员怎么用
|
||||
实盘说明.md ← 交易所前置与实盘开关
|
||||
规格-…盈亏结算.md ← 盈亏产品规则(真源)
|
||||
系统分析-期权主链条.md ← 业务功能链与数据流
|
||||
本文 ← 软件架构与技术实现
|
||||
部署文档.md ← 安装运维
|
||||
```
|
||||
@@ -0,0 +1,289 @@
|
||||
# 规格:期权盈亏双口径结算(一次一仓)
|
||||
|
||||
> 状态:已定稿(2026-10-13 修订)
|
||||
> 适用范围:币本位期权(模拟盘优先;实盘同规则)
|
||||
> 目标:复盘可对账、数据统计跟期权历史一致;初始权利金 / 盈利率可展示;错账可脚本修复
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与原则
|
||||
|
||||
### 1.1 要解决什么
|
||||
|
||||
此前单笔盈亏混用多套口径,且卖回按开仓权利金封顶,导致:
|
||||
|
||||
- 盈利单(如约 1.5 倍)在复盘被冻成亏损(账户差分在盈利币未卖回时结算)
|
||||
- 复盘与数据统计共用账户冻结,与「历史币盈亏 × 卖回价」不一致
|
||||
- 缺少初始权利金(U)、盈利率展示
|
||||
|
||||
### 1.2 原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| 双口径 | **A 复盘** = 交易账户 USDT 快照差分;**B 数据统计** = 期权历史币盈亏 × 平仓卖回价 |
|
||||
| 一次一仓 | 同一账户同一时间只允许一笔期权持仓 |
|
||||
| 卖光再结算 | A 口径:未卖光可卖标的币前,本单不冻结 |
|
||||
| 冻结 | A 结算完成后永不修改;B 按平仓卖回价冻结后不跟盘 |
|
||||
| 禁划转 | 持仓 / 待卖回期间禁止资金划转 |
|
||||
| 权利金不含多买 | 初始权利金 = 开仓指数 × 权利金币量(不含现货缓冲多买) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 双口径定义
|
||||
|
||||
### 2.1 口径 A — 真盈亏(复盘交易记录)
|
||||
|
||||
```
|
||||
真盈亏(U) = 期权历史盈亏(币) × 平仓卖回价
|
||||
− (权利金 × 开仓价 − 权利金 × 平仓卖回价)
|
||||
```
|
||||
|
||||
- **币量**只用期权腿:`realized_pnl`(历史币盈亏,**已含**开/平手续费)、`premium_paid`(权利金);**不含**现货多买/多卖数量
|
||||
- **价格**只用开仓买币 VWAP、平仓卖回 VWAP;现货多余仓位只参与均价,**数量不进公式**
|
||||
- 真盈亏**不再另扣**手续费(费已在历史币盈亏里);复盘「期权手续费」列仅展示
|
||||
- 无手续费时:`期权收回权利金×卖回价 − 权利金×开仓价`(收回=`premium_received`,不是账户全部现货)
|
||||
|
||||
**固定量 vs 一次冻结价:**
|
||||
|
||||
| 字段 | 性质 |
|
||||
|------|------|
|
||||
| `premium_paid`(权利金币) | 开仓后**固定**,不再改 |
|
||||
| `realized_pnl`(期权腿币盈亏) | 平仓后**固定**,不再改 |
|
||||
| 开仓价 | 买币完成时算一次 VWAP 写入 `open_index_px`,**冻住** |
|
||||
| 卖回价 | 平仓卖回全部完成时算一次 VWAP 写入 `close_index_px`,**冻住** |
|
||||
| `realized_pnl_usdt` | 按上式计算;**刷新时用固定量+价重算**(可纠正旧错账) |
|
||||
|
||||
- 卖回未完成 → 「待卖回」,不算真盈亏
|
||||
- 缺价 → 「缺价未核」
|
||||
- 复盘刷新:**不得**改权利金币量/币盈亏/已冻开仓价/卖回价;**要**按公式重算真盈亏 U
|
||||
|
||||
- **消费者**:期权复盘「期权交易记录」盈亏列、复盘 KPI、`realized_pnl_usdt` 冻结
|
||||
- **不含**:现货多买差分当盈亏 / 账户 after−before
|
||||
- 账户 `trading_usdt_before/after` 仅审计用,**不**作复盘真盈亏
|
||||
|
||||
### 2.2 口径 B — 期权历史 / 数据统计(仅期权腿)
|
||||
|
||||
```
|
||||
期权腿盈亏(币) = 收回权利金 − 已付权利金 − 期权开/平手续费
|
||||
统计用 U ≈ 期权腿盈亏(币) × 平仓卖回价
|
||||
```
|
||||
|
||||
- **消费者**:期权面板「期权历史」(币)、数据统计 / 顶栏窗口统计(折 U)
|
||||
- **不含**现货;**不**做「开仓价 vs 卖回价」权利金 USDT 波动修正(那是口径 A)
|
||||
- 与复盘真盈亏 A **独立**:`A = B − 权利金×(开仓价−卖回价)`
|
||||
|
||||
### 2.3 初始权利金、盈利率、期权手续费
|
||||
|
||||
| 字段 | 定义 |
|
||||
|------|------|
|
||||
| 初始权利金(U) | `open_index_px × premium_paid`(不含 `budget_usdt` 多买) |
|
||||
| 盈利率(复盘) | `真盈亏A / initial_premium_usdt` |
|
||||
| 期权手续费(U) | 仅期权开/平:`open_fee×开仓指数 + close_fee×卖回价`(不含现货) |
|
||||
|
||||
复盘「期权交易记录」须展示:初始权利金(U)、真盈亏(A)、盈利率、期权手续费(U)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 产品规则
|
||||
|
||||
### 3.1 开仓前
|
||||
|
||||
1. 有可卖标的币(ETH/BTC)→ 拒开仓(粉尘低于现货最小可卖可忽略)。
|
||||
2. 拍交易账户 USDT → `trading_usdt_before`。
|
||||
3. 开仓前若有多次划转:以**开仓前最后一次划转完成后**的余额为准(即开仓瞬间余额)。
|
||||
4. 开仓失败:回滚卖币,并拍**最新**资金快照(不留下脏 before / 半成品桥阻挡后续)。
|
||||
5. 写入 `initial_premium_usdt = open_index_px × premium_paid`(开仓成功后)。
|
||||
|
||||
### 3.2 持仓中
|
||||
|
||||
1. 划转按钮灰 + API 拒绝。
|
||||
2. 面板合计盈亏(动态)= `Σ口径B已冻结` + `当前持仓浮盈`。
|
||||
3. 本单未完成 A 结算前:复盘显示「待结算」,不写假 U。
|
||||
|
||||
### 3.3 平仓与卖回
|
||||
|
||||
1. 系统可多次部分平仓;**以最后一次全部平仓完成**为准进入卖回。
|
||||
2. 期权空仓后:**卖光**交易账户该标的可卖币(**禁止**用开仓权利金 `coin_bought` 封顶)。
|
||||
3. `coin_bought` 仅用于开仓阶段清理过量买币,不作平仓卖回上限。
|
||||
4. 卖回未完成(仍有可卖币):桥保持待卖回,**不**进入 A 结算。
|
||||
5. 真粉尘(低于现货最小):可关桥并进入 A 结算(差额含粉尘损失)。
|
||||
|
||||
### 3.4 结算与冻结(A)
|
||||
|
||||
1. `realized_pnl_usdt = after − before`,`pnl_settled=1`。
|
||||
2. 此后不得因划转、指数、复盘同步、监控公式而修改。
|
||||
3. 仅复盘读此冻结值。
|
||||
|
||||
### 3.5 划转
|
||||
|
||||
| 时机 | 行为 |
|
||||
|------|------|
|
||||
| 无持仓、无待卖回 | 允许划转 |
|
||||
| 持仓中或待卖回 | 禁止划转 |
|
||||
| 划转后 | 只影响下一笔开仓前的 `usdt_before`;不重算已冻结 A/B |
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态机
|
||||
|
||||
```text
|
||||
IDLE
|
||||
│ 有可卖币? ──是──> 拒开仓
|
||||
│ 快照 trading_usdt_before(划转取最后一次后余额)
|
||||
▼
|
||||
OPENING ──失败──> 回滚卖币 → 最新资金快照 → IDLE
|
||||
│ 成功(一次一仓);写 initial_premium_usdt
|
||||
▼
|
||||
OPEN(禁划转)
|
||||
│ 可多次部分平仓
|
||||
▼
|
||||
CLOSING(交易所仓位未清零则停留)
|
||||
│ 仓位 = 0
|
||||
▼
|
||||
SELLING_SPOT(卖光可卖标的币;禁止权利金封顶)
|
||||
│ 可卖币 < minSz(真粉尘)或已空
|
||||
▼
|
||||
SETTLE_A(after − before → 冻结;仅此态可写 pnl_settled=1)
|
||||
▼
|
||||
IDLE
|
||||
```
|
||||
|
||||
**硬门禁**
|
||||
|
||||
| 条件 | 行为 |
|
||||
|------|------|
|
||||
| OPEN / CLOSING / SELLING_SPOT | 禁止划转 |
|
||||
| 未进 SETTLE_A | 复盘 U 为待结算 |
|
||||
| pnl_settled=1 | 禁止覆盖 A |
|
||||
| SETTLE_A | 要求 open_positions=0 且无可卖标的币 |
|
||||
|
||||
实现上可为显式状态字段,或由 `options_trades.status` + 桥状态 + `pnl_settled` 等价表达,但**结算入口必须统一校验门禁**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据字段
|
||||
|
||||
### 5.1 `options_trades`
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `trading_usdt_before` | 开仓前交易账户 USDT |
|
||||
| `trading_usdt_after` | 卖光后交易账户 USDT |
|
||||
| `realized_pnl_usdt` | **口径 A** 冻结值 |
|
||||
| `pnl_settled` | 0/1,A 是否已冻结 |
|
||||
| `realized_pnl` | 币数量净盈亏(期权历史展示) |
|
||||
| `close_index_px` | 平仓/卖回价(口径 B 用) |
|
||||
| `open_index_px` | 开仓指数 |
|
||||
| `premium_paid` | 开仓权利金币量 |
|
||||
| `initial_premium_usdt` | 初始权利金 U = open_index × premium_paid |
|
||||
| `budget_usdt` | 买币花费(含缓冲),**不得**当权利金或 before |
|
||||
|
||||
### 5.2 `options_review_trades`
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `realized_pnl_total` | **仅口径 A**(或待结算空) |
|
||||
| `initial_premium_usdt` | 初始权利金 U |
|
||||
| `profit_rate` | A / 初始权利金(小数,如 1.5 = 1.5 倍) |
|
||||
|
||||
### 5.3 `options_exchange_pnl_freeze`
|
||||
|
||||
实盘统计辅助:历史 key → 口径 B 的 U 冻结。
|
||||
|
||||
---
|
||||
|
||||
## 6. 模块行为
|
||||
|
||||
### 6.1 开仓 `open_coin_option_buy_full`
|
||||
|
||||
1. 一次一仓 / 未完成桥检查;可卖币拒开。
|
||||
2. `trading_usdt_before`;买币、下单、写库(`pnl_settled=0`,`initial_premium_usdt`)。
|
||||
3. 桥 `coin_bought` 可记为权利金币量(供开仓清过量),**平仓卖回不得用其封顶**。
|
||||
|
||||
### 6.2 划转 `POST /api/options/transfer`
|
||||
|
||||
持仓或桥 ∈ {bought, holding, pending_sell} → 拒绝。
|
||||
|
||||
### 6.3 卖回与 A 结算
|
||||
|
||||
`sell_residual_after_option_flat`(及 retry / sweep):
|
||||
|
||||
1. 空仓后卖光可用标的币(`coin_amount=None`)。
|
||||
2. 仍有可卖 → 桥 `pending_sell`,返回未完成,**不结算**。
|
||||
3. 仅粉尘/已空 → 关桥 → `try_settle_after_sellback`(内再校验无可卖币)→ A 冻结。
|
||||
|
||||
### 6.4 数据统计(口径 B)
|
||||
|
||||
- `net_realized_pnl` / 胜率 / 盈亏比:历史币盈亏 × 卖回价。
|
||||
- **不读** `pnl_settled` 账户差分。
|
||||
- 数据统计页不得再打复盘 stats API 作为盈亏真源。
|
||||
|
||||
### 6.5 期权复盘(口径 A)
|
||||
|
||||
- 同步本地已平:优先 `pnl_settled=1` 的 `realized_pnl_usdt`。
|
||||
- 未结算:显示待结算,不导入假 U。
|
||||
- 同步 `initial_premium_usdt`、`profit_rate`。
|
||||
|
||||
### 6.6 期权历史列表
|
||||
|
||||
仍按**币**展示权利金/币盈亏;U 只用于复盘(A)与数据统计(B)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 对账关系
|
||||
|
||||
```
|
||||
口径 A: Σ 已冻结单笔 A + 期间净划转 ≈ 交易账户相对首笔 before 的累计变化
|
||||
口径 B: Σ (历史币盈亏 × 卖回价) ≈ 面板数据统计已实现
|
||||
```
|
||||
|
||||
差异应可归因:粉尘、滑点、手续费(A 内已含);B 不含多买缓冲。
|
||||
|
||||
---
|
||||
|
||||
## 8. 非目标 / 不做
|
||||
|
||||
- 不用 `budget_usdt` 当单笔盈利或初始权利金。
|
||||
- 不用口径 A 覆盖数据统计,也不用口径 B 覆盖复盘。
|
||||
- 持仓中不因行情重算已冻结 A/B。
|
||||
- 不把期权历史列表强制改为 U。
|
||||
|
||||
---
|
||||
|
||||
## 9. 历史数据修复
|
||||
|
||||
对已错误冻结的账户差分(典型:币盈亏>0 且 A 为负,且 `coin_sold≈premium_paid≪premium_received`):
|
||||
|
||||
1. 回填 `initial_premium_usdt = open_index_px × premium_paid`。
|
||||
2. 口径 B:按币盈亏 × 卖回价重算统计侧(含 `options_exchange_pnl_freeze`)。
|
||||
3. 口径 A:卖回完整可重建则重算;否则 `pnl_settled=0` 标 `needs_resettle` / 异常,勿盲信错账。
|
||||
4. 重刷 `options_review_trades.realized_pnl_total` / `profit_rate`。
|
||||
|
||||
脚本:`scripts/repair_options_dual_pnl.py`(dry-run → CSV → apply)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收清单
|
||||
|
||||
- [ ] 盈利约 1.5 倍单:卖回含盈利币;复盘 A 为正,约等于账户差分
|
||||
- [ ] 卖回不得按开仓权利金封顶
|
||||
- [ ] 仍有可卖币时不 `pnl_settled=1`
|
||||
- [ ] 复盘展示初始权利金、盈利率
|
||||
- [ ] 数据统计 = 历史币盈亏 × 卖回价,与复盘 A 可不同
|
||||
- [ ] 开仓写入 before + initial_premium;失败有最新快照/回滚
|
||||
- [ ] 持仓禁划转;空仓划转不影响已冻结
|
||||
- [ ] 历史错账可用脚本修复
|
||||
|
||||
---
|
||||
|
||||
## 11. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-10-09 | 初版:交易账户快照差分 + 一次一仓 + 持仓禁划转 + 卖回后冻结 |
|
||||
| 2026-10-13 | 修订:双口径 A/B;卖光再结算;初始权利金/盈利率;状态机;历史修复脚本 |
|
||||
| 2026-10-13 | 复盘「期权交易记录」盈亏与数据统计同口径 B;增加仅期权手续费展示 |
|
||||
| 2026-10-13 | 交易记录改回真盈亏 A;期权历史/统计仅期权腿 B,不含现货 |
|
||||
| 2026-10-13 | 真盈亏明确为卖光收回−买币;复盘禁止指数折 U 冒充;桥可回退 |
|
||||
| 2026-10-13 | 真盈亏改回权利金 USDT 波动式:币盈亏×卖回价−权利金×(开−平);忽略多买 |
|
||||
| 2026-10-13 | 多笔买/卖回用 VWAP;卖完再结算;待卖回/缺价未核标记 |
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
"""crypto_monitor shared libraries."""
|
||||
# Copyright (c) 2026 马建军. All rights reserved.
|
||||
# Author: 马建军 | QQ: 253921841 | Email: 253921841@qq.com | Website: https://hyf2.cc
|
||||
# See COPYRIGHT in the repository root.
|
||||
"""crypto_okx shared libraries."""
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
"""
|
||||
每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额.
|
||||
|
||||
- 交易账户 < 目标:从资金账户划入差额
|
||||
- 交易账户 > 目标:将多余划回资金账户
|
||||
- 有 active 持仓:不划转,写账簿并企业微信说明
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def run_auto_transfer_once_per_day(
|
||||
*,
|
||||
enabled: bool,
|
||||
bj_hour: int,
|
||||
target_amount: float,
|
||||
from_account: str,
|
||||
to_account: str,
|
||||
funds_decimals: int,
|
||||
get_db: Callable[[], Any],
|
||||
get_active_position_count: Callable[[Any], int],
|
||||
get_account_usdt_total: Callable[[str], float | None],
|
||||
execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]],
|
||||
send_wechat_msg: Callable[[str], None],
|
||||
utc_now_dt: Callable[[], Any],
|
||||
app_tz: Any,
|
||||
utc_calendar_date_str: Callable[[], str],
|
||||
app_now_str: Callable[[], str],
|
||||
min_transfer: float = 0.01,
|
||||
) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
utc_dt = utc_now_dt()
|
||||
bj = utc_dt.astimezone(app_tz)
|
||||
if bj.hour != bj_hour:
|
||||
return
|
||||
|
||||
transfer_day = utc_calendar_date_str()
|
||||
conn = get_db()
|
||||
exists = conn.execute(
|
||||
"SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?",
|
||||
("auto_daily", transfer_day),
|
||||
).fetchone()
|
||||
if exists:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
def _log(
|
||||
amount: float,
|
||||
fr: str,
|
||||
to: str,
|
||||
status: str,
|
||||
message: str,
|
||||
*,
|
||||
commit_close: bool = True,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)",
|
||||
("auto_daily", transfer_day, amount, fr, to, status, message[:500]),
|
||||
)
|
||||
conn.commit()
|
||||
if commit_close:
|
||||
conn.close()
|
||||
|
||||
active = get_active_position_count(conn)
|
||||
if active > 0:
|
||||
msg = f"持仓中({active}笔),本次资金无划转"
|
||||
_log(0, from_account, to_account, "skipped", msg)
|
||||
send_wechat_msg(
|
||||
f"自动划转:{msg}\n"
|
||||
f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n"
|
||||
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
|
||||
)
|
||||
return
|
||||
|
||||
target = round(float(target_amount), funds_decimals)
|
||||
trade_bal = get_account_usdt_total(to_account)
|
||||
if trade_bal is None:
|
||||
_log(
|
||||
0,
|
||||
from_account,
|
||||
to_account,
|
||||
"failed",
|
||||
f"读取{to_account}账户USDT失败",
|
||||
)
|
||||
return
|
||||
|
||||
trade = round(float(trade_bal), funds_decimals)
|
||||
diff = round(target - trade, funds_decimals)
|
||||
|
||||
if abs(diff) < min_transfer:
|
||||
_log(
|
||||
0,
|
||||
from_account,
|
||||
to_account,
|
||||
"skipped",
|
||||
f"{to_account}账户已为{trade}U(目标{target}U)",
|
||||
)
|
||||
return
|
||||
|
||||
if diff > 0:
|
||||
fr, to, amount = from_account, to_account, diff
|
||||
action = "划入"
|
||||
else:
|
||||
fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals)
|
||||
action = "划出"
|
||||
|
||||
from_bal = get_account_usdt_total(fr)
|
||||
if from_bal is not None and round(float(from_bal), funds_decimals) < amount:
|
||||
cur = round(float(from_bal), funds_decimals)
|
||||
_log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U")
|
||||
send_wechat_msg(
|
||||
f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}至{to_account}目标{target}U)\n"
|
||||
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
|
||||
)
|
||||
return
|
||||
|
||||
ok, msg, _ = execute_transfer_usdt(amount, fr, to)
|
||||
_log(amount, fr, to, "success" if ok else "failed", msg)
|
||||
if ok:
|
||||
send_wechat_msg(
|
||||
f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n"
|
||||
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
|
||||
)
|
||||
else:
|
||||
send_wechat_msg(
|
||||
f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n"
|
||||
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 中控 iframe 嵌入鉴权:跨站时第三方 Cookie 常被拦截,改用 embed_sso 头维持登录.
|
||||
* 须在其它业务脚本之前加载.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
var KEY = "hub_embed_sso";
|
||||
try {
|
||||
var q = new URLSearchParams(location.search || "");
|
||||
var fromUrl = (q.get("embed_sso") || "").trim();
|
||||
if (fromUrl) {
|
||||
sessionStorage.setItem(KEY, fromUrl);
|
||||
q.delete("embed_sso");
|
||||
var next = location.pathname;
|
||||
var rest = q.toString();
|
||||
if (rest) next += "?" + rest;
|
||||
next += location.hash || "";
|
||||
history.replaceState(null, "", next);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function currentToken() {
|
||||
try {
|
||||
return (sessionStorage.getItem(KEY) || "").trim();
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function withAuthHeaders(init) {
|
||||
var tok = currentToken();
|
||||
if (!tok) return init;
|
||||
init = init ? Object.assign({}, init) : {};
|
||||
var headers;
|
||||
try {
|
||||
headers = new Headers(init.headers || {});
|
||||
} catch (_) {
|
||||
headers = new Headers();
|
||||
}
|
||||
if (!headers.has("X-Hub-Embed-SSO")) {
|
||||
headers.set("X-Hub-Embed-SSO", tok);
|
||||
}
|
||||
init.headers = headers;
|
||||
if (!init.credentials) init.credentials = "same-origin";
|
||||
return init;
|
||||
}
|
||||
|
||||
if (typeof window.fetch === "function") {
|
||||
var origFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
return origFetch(input, withAuthHeaders(init));
|
||||
};
|
||||
}
|
||||
|
||||
window.HubEmbedAuth = {
|
||||
token: currentToken,
|
||||
clear: function () {
|
||||
try {
|
||||
sessionStorage.removeItem(KEY);
|
||||
} catch (_) {}
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 系统设置 · 备份恢复
|
||||
*/
|
||||
(function (global) {
|
||||
function statusEl() {
|
||||
return document.getElementById("backup-status");
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
const el = statusEl();
|
||||
if (!el) return;
|
||||
el.textContent = msg || "";
|
||||
el.style.color = isErr ? "#fb7185" : "";
|
||||
}
|
||||
|
||||
function fmtSize(n) {
|
||||
const v = Number(n) || 0;
|
||||
if (v < 1024) return v + " B";
|
||||
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
|
||||
return (v / (1024 * 1024)).toFixed(2) + " MB";
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const body = document.getElementById("backup-list-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
|
||||
try {
|
||||
const r = await fetch("/api/settings/backup/list", { credentials: "same-origin" });
|
||||
const j = await r.json();
|
||||
if (!j.ok) throw new Error(j.msg || "加载失败");
|
||||
const dir = document.getElementById("backup-dir-label");
|
||||
if (dir && j.backup_dir) dir.textContent = j.backup_dir;
|
||||
const items = j.items || [];
|
||||
if (!items.length) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = items
|
||||
.map(function (it) {
|
||||
const name = escapeHtml(it.name || "");
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><code style=\"font-size:.74rem\">" +
|
||||
name +
|
||||
"</code></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.mtime_bj || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtSize(it.size)) +
|
||||
"</td>" +
|
||||
"<td style=\"white-space:nowrap\">" +
|
||||
'<a class="btn-secondary" style="padding:3px 8px;font-size:.74rem;text-decoration:none" href="/api/settings/backup/download/' +
|
||||
encodeURIComponent(it.name) +
|
||||
'">下载</a> ' +
|
||||
'<button type="button" class="btn-del" style="padding:3px 8px;font-size:.74rem" data-backup-restore="' +
|
||||
encodeURIComponent(it.name) +
|
||||
'">恢复</button>' +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
} catch (e) {
|
||||
body.innerHTML =
|
||||
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
setStatus("备份中…");
|
||||
try {
|
||||
const r = await fetch("/api/settings/backup/create", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) throw new Error(j.msg || "备份失败");
|
||||
setStatus("已生成 " + (j.name || "备份"));
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "备份失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(name) {
|
||||
if (!name) return;
|
||||
if (!confirm("确定用该备份恢复?\n将覆盖 .env、数据库与复盘图片,建议恢复后重启。")) return;
|
||||
setStatus("恢复中…");
|
||||
try {
|
||||
const r = await fetch("/api/settings/backup/restore", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) throw new Error(j.msg || "恢复失败");
|
||||
setStatus(j.msg || "恢复完成");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "恢复失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const root = document.querySelector("[data-backup-root]");
|
||||
if (!root || root.dataset.bound === "1") return;
|
||||
root.dataset.bound = "1";
|
||||
const createBtn = document.getElementById("backup-create-btn");
|
||||
const refreshBtn = document.getElementById("backup-refresh-btn");
|
||||
if (createBtn) createBtn.addEventListener("click", createBackup);
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", loadList);
|
||||
root.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest("[data-backup-restore]");
|
||||
if (!btn) return;
|
||||
const name = decodeURIComponent(btn.getAttribute("data-backup-restore") || "");
|
||||
void restoreBackup(name);
|
||||
});
|
||||
void loadList();
|
||||
}
|
||||
|
||||
global.InstanceBackup = { boot: boot, loadList: loadList };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -16,6 +16,7 @@
|
||||
records: "/records",
|
||||
stats: "/stats",
|
||||
risk_policy: "/risk_policy",
|
||||
live_guide: "/live_guide",
|
||||
system_guide: "/system_guide",
|
||||
env_config: "/env_config",
|
||||
settings: "/settings",
|
||||
@@ -154,6 +155,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tab === "settings" && global.InstanceBackup && typeof global.InstanceBackup.boot === "function") {
|
||||
global.InstanceBackup.boot();
|
||||
}
|
||||
}
|
||||
if (!revisit) {
|
||||
if (typeof global.refreshAccountSnapshot === "function") {
|
||||
@@ -358,6 +362,7 @@
|
||||
const hideTopBar =
|
||||
tab === "settings" ||
|
||||
tab === "risk_policy" ||
|
||||
tab === "live_guide" ||
|
||||
tab === "system_guide" ||
|
||||
tab === "env_config" ||
|
||||
tab === "options_review";
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
records: "/records",
|
||||
stats: "/stats",
|
||||
risk_policy: "/risk_policy",
|
||||
live_guide: "/live_guide",
|
||||
system_guide: "/system_guide",
|
||||
env_config: "/env_config",
|
||||
settings: "/settings",
|
||||
|
||||
@@ -4,14 +4,24 @@
|
||||
.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem}
|
||||
.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 12px}
|
||||
/* 1920 左右用满:约 96vw / 1840,两侧留白更小 */
|
||||
.container{width:100%;max-width:min(1840px,96vw);margin:0 auto;padding:0 clamp(6px,1vw,14px)}
|
||||
/* 普通屏:铺满可用宽度;带鱼屏再锁 1800(见下方 media) */
|
||||
.container{width:100%;max-width:none;margin:0 auto;padding:0 clamp(6px,1vw,14px)}
|
||||
body[data-embed-shell="1"] .container{
|
||||
width:100%;
|
||||
max-width:min(1800px, 100%);
|
||||
margin-left:auto;
|
||||
margin-right:auto;
|
||||
padding:0 clamp(6px,1vw,14px);
|
||||
}
|
||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||
.top-nav{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:10px 12px;margin-bottom:12px}
|
||||
.top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;min-width:0}
|
||||
.top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;align-items:center;min-width:0}
|
||||
.top-nav-links > a{flex:0 0 auto;white-space:nowrap}
|
||||
/* 兜底:若链接未包在 .top-nav-links 内,勿被 1fr|auto|1fr 拉成三列变形 */
|
||||
.top-nav > a{justify-self:center;align-self:center;width:max-content;max-width:100%}
|
||||
.top-nav-spot-tickers{grid-column:3;justify-self:end;display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||
@@ -165,11 +175,12 @@
|
||||
.panel-scroll,.pos-list{max-height:420px}
|
||||
.records-card .table-wrap{max-height:620px;overflow:auto}
|
||||
}
|
||||
/* 带鱼屏(宽高比≥21:9):整页固定 1920 居中;不依赖视口宽度(避免 Win 缩放后失效) */
|
||||
/* 带鱼屏:独立打开与中控内嵌内容均锁 1800 居中留白;工作台外壳由中控全屏 */
|
||||
@media (min-aspect-ratio: 21/9) {
|
||||
.container{
|
||||
.container,
|
||||
body[data-embed-shell="1"] .container{
|
||||
width:100%;
|
||||
max-width:min(1920px, calc(100vw - 24px));
|
||||
max-width:min(1800px, calc(100vw - 24px));
|
||||
margin-left:auto;
|
||||
margin-right:auto;
|
||||
}
|
||||
|
||||
@@ -46,9 +46,8 @@
|
||||
options: "show_nav_options",
|
||||
"options-review": "show_nav_options_review",
|
||||
options_review: "show_nav_options_review",
|
||||
"hedge-plan": "show_nav_hedge_plan",
|
||||
hedge_plan: "show_nav_hedge_plan",
|
||||
risk_policy: "show_nav_risk_policy",
|
||||
live_guide: "show_nav_live_guide",
|
||||
system_guide: "show_nav_system_guide",
|
||||
env_config: "show_nav_env_config",
|
||||
};
|
||||
@@ -59,6 +58,11 @@
|
||||
.forEach((a) => {
|
||||
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
|
||||
if (tab === "more") return;
|
||||
if (tab === "hedge_plan" || tab === "hedge-plan") {
|
||||
a.classList.add("nav-hidden");
|
||||
a.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const key = map[tab];
|
||||
if (!key) return;
|
||||
const show = navPrefShow(display, key);
|
||||
@@ -70,6 +74,7 @@
|
||||
|
||||
function pageNavAllowed(tab) {
|
||||
const d = DISPLAY();
|
||||
if (tab === "hedge_plan" || tab === "hedge-plan") return false;
|
||||
const map = {
|
||||
dashboard: "show_nav_dashboard",
|
||||
account_ledger: "show_nav_account_ledger",
|
||||
@@ -82,9 +87,8 @@
|
||||
options: "show_nav_options",
|
||||
"options-review": "show_nav_options_review",
|
||||
options_review: "show_nav_options_review",
|
||||
"hedge-plan": "show_nav_hedge_plan",
|
||||
hedge_plan: "show_nav_hedge_plan",
|
||||
risk_policy: "show_nav_risk_policy",
|
||||
live_guide: "show_nav_live_guide",
|
||||
system_guide: "show_nav_system_guide",
|
||||
env_config: "show_nav_env_config",
|
||||
};
|
||||
@@ -295,7 +299,21 @@
|
||||
}
|
||||
input.dataset.envKey = field.key;
|
||||
input.className = "env-field-input";
|
||||
row.appendChild(input);
|
||||
if (field.key === "HUB_BRIDGE_TOKEN" && !field.sensitive && field.type !== "bool" && field.type !== "select") {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "env-field-input-wrap env-field-input-wrap--copy";
|
||||
wrap.appendChild(input);
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.type = "button";
|
||||
copyBtn.className = "btn-secondary btn-sm env-copy-btn";
|
||||
copyBtn.dataset.envCopy = "HUB_BRIDGE_TOKEN";
|
||||
copyBtn.title = "复制 Token";
|
||||
copyBtn.textContent = "复制";
|
||||
wrap.appendChild(copyBtn);
|
||||
row.appendChild(wrap);
|
||||
} else {
|
||||
row.appendChild(input);
|
||||
}
|
||||
if (field.hidden) {
|
||||
row.hidden = true;
|
||||
row.style.display = "none";
|
||||
@@ -397,41 +415,25 @@
|
||||
|
||||
function syncOptionsSectionVisibility(body) {
|
||||
if (!body) return;
|
||||
var margin = (envFieldValue(body, "OKX_OPTIONS_MARGIN_MODE", "coin") || "coin").toLowerCase();
|
||||
var isCoin = margin !== "usdc";
|
||||
// 仅币本位期权:USDC/永续相关 env 始终隐藏
|
||||
var isCoin = true;
|
||||
var gateMode = (envFieldValue(body, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") || "premium").toLowerCase();
|
||||
var compoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"));
|
||||
var coinCompoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_COMPOUND", "true"));
|
||||
var coinMaxOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"));
|
||||
var compoundCapOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"));
|
||||
|
||||
COIN_ONLY_ENV_KEYS.forEach(function (key) {
|
||||
setEnvRowHidden(envFieldRowByKey(body, key), !isCoin);
|
||||
});
|
||||
USDC_ONLY_ENV_KEYS.forEach(function (key) {
|
||||
setEnvRowHidden(envFieldRowByKey(body, key), isCoin);
|
||||
setEnvRowHidden(envFieldRowByKey(body, key), true);
|
||||
});
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_MARGIN_MODE"), true);
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_SHOW_PERP_FUNDS"), true);
|
||||
|
||||
// 门控子项:按模式显隐(仍受本位过滤)
|
||||
if (isCoin) {
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium");
|
||||
} else {
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT"), gateMode !== "premium");
|
||||
}
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium");
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT"), gateMode !== "net_pnl");
|
||||
|
||||
if (!isCoin) {
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"), compoundOn);
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED"), !compoundOn);
|
||||
setEnvRowHidden(
|
||||
envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC"),
|
||||
!(compoundOn && compoundCapOn)
|
||||
);
|
||||
}
|
||||
if (isCoin) {
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
|
||||
}
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
|
||||
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
|
||||
}
|
||||
|
||||
function bindOptionsSectionVisibility(body) {
|
||||
@@ -635,12 +637,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text) {
|
||||
const value = String(text || "");
|
||||
if (!value) throw new Error("内容为空");
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = value;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
if (!ok) throw new Error("复制失败");
|
||||
}
|
||||
|
||||
async function copyEnvFieldValue(key, btn) {
|
||||
const status = document.getElementById("env-config-status");
|
||||
const input = document.querySelector('.env-field-input[data-env-key="' + key + '"]');
|
||||
const value = input ? String(input.value || "").trim() : "";
|
||||
try {
|
||||
await copyTextToClipboard(value);
|
||||
if (btn) {
|
||||
const old = btn.textContent;
|
||||
btn.textContent = "已复制";
|
||||
btn.disabled = true;
|
||||
setTimeout(() => {
|
||||
btn.textContent = old || "复制";
|
||||
btn.disabled = false;
|
||||
}, 1200);
|
||||
}
|
||||
setStatus(status, "已复制到剪贴板");
|
||||
} catch (e) {
|
||||
setStatus(status, (e && e.message) || "复制失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
function installDelegatedHandlers() {
|
||||
if (document.documentElement.dataset.prefsDelegateBound === "1") return;
|
||||
document.documentElement.dataset.prefsDelegateBound = "1";
|
||||
document.addEventListener("click", (ev) => {
|
||||
const target = ev.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const copyBtn = target.closest("[data-env-copy]");
|
||||
if (copyBtn) {
|
||||
ev.preventDefault();
|
||||
void copyEnvFieldValue(copyBtn.getAttribute("data-env-copy") || "", copyBtn);
|
||||
return;
|
||||
}
|
||||
if (target.closest("#display-prefs-save")) {
|
||||
ev.preventDefault();
|
||||
void saveDisplayPrefs();
|
||||
|
||||
@@ -626,6 +626,39 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.instance-logout-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.2;
|
||||
color: var(--inst-muted, #9aa3b2);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instance-logout-btn:hover {
|
||||
color: var(--inst-text, #e8ecf4);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
html[data-theme="light"] .instance-logout-btn {
|
||||
color: #5b6472;
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
html[data-theme="light"] .instance-logout-btn:hover {
|
||||
color: #1f2937;
|
||||
border-color: rgba(0, 0, 0, 0.22);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.instance-header-toolbar-filter label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -661,9 +694,15 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-nav-links > a {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.top-nav-spot-tickers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2684,6 +2723,24 @@ html[data-theme="light"] .journal-detail-img-thumb {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.env-field-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.env-field-input-wrap .env-field-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.env-field-input-wrap--copy .env-copy-btn {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-config-loading-wrap {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
@@ -3315,6 +3372,15 @@ html[data-theme="light"] .settings-side-export-label {
|
||||
html[data-theme="light"] .opt-chain-lev {
|
||||
color: #1a4a8a;
|
||||
}
|
||||
.opt-chain-delta {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
color: #9ad5ff;
|
||||
}
|
||||
html[data-theme="light"] .opt-chain-delta {
|
||||
color: #0b5cad;
|
||||
}
|
||||
.opt-be-dist-up {
|
||||
color: #5ee89a;
|
||||
}
|
||||
@@ -3344,6 +3410,21 @@ html[data-theme="light"] .opt-be-dist-down {
|
||||
background: rgba(74, 124, 255, 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgba(140, 175, 255, 0.6);
|
||||
}
|
||||
.options-page-wrap .opt-pick-btn:disabled,
|
||||
.options-page-wrap .opt-pick-btn.is-locked {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
color: #8b95a8;
|
||||
background: rgba(20, 28, 44, 0.55);
|
||||
border-color: rgba(80, 90, 110, 0.5);
|
||||
box-shadow: none;
|
||||
}
|
||||
html[data-theme="light"] .options-page-wrap .opt-pick-btn:disabled,
|
||||
html[data-theme="light"] .options-page-wrap .opt-pick-btn.is-locked {
|
||||
color: #7a8799;
|
||||
background: rgba(148, 163, 184, 0.28);
|
||||
border-color: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
.opt-strike-row.opt-row-selected td {
|
||||
background: rgba(74, 124, 255, 0.1);
|
||||
}
|
||||
@@ -5483,12 +5564,89 @@ html[data-theme="light"] .opt-source-badge--oo {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.opt-cond-order-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
background: rgba(14, 42, 68, 0.72);
|
||||
}
|
||||
.opt-cond-order-card[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.opt-cond-order-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.opt-cond-order-head strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.opt-cond-lock-note {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #9ec0ff;
|
||||
}
|
||||
.opt-cond-st {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.opt-cond-st.is-armed {
|
||||
color: #7dd3fc;
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
}
|
||||
.opt-cond-st.is-trig {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.16);
|
||||
}
|
||||
.opt-cond-st.is-open {
|
||||
color: #6ee7b7;
|
||||
background: rgba(52, 211, 153, 0.16);
|
||||
}
|
||||
.opt-cond-order-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.opt-cond-kv .k {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
color: #8ea0c0;
|
||||
}
|
||||
.opt-cond-kv .v {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.opt-cond-order-msg {
|
||||
margin-top: 8px;
|
||||
font-size: 0.72rem;
|
||||
color: #c9d6f5;
|
||||
}
|
||||
html[data-theme="light"] .opt-cond-order-card {
|
||||
border-color: rgba(14, 116, 144, 0.28);
|
||||
background: rgba(224, 242, 254, 0.7);
|
||||
}
|
||||
html[data-theme="light"] .opt-cond-lock-note,
|
||||
html[data-theme="light"] .opt-cond-kv .k,
|
||||
html[data-theme="light"] .opt-cond-order-msg {
|
||||
color: #35506e;
|
||||
}
|
||||
|
||||
.opt-spot-sell-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* display:flex 会盖掉 UA [hidden] */
|
||||
.opt-spot-sell-head[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.opt-spot-sell-avail {
|
||||
font-size: 0.78rem;
|
||||
color: #c9d6f5;
|
||||
|
||||
@@ -171,6 +171,8 @@
|
||||
|
||||
function patchHubNavLinks(theme) {
|
||||
if (!isHubLinked()) return;
|
||||
// embed 壳由 InstanceEmbed 软切 tab,勿改写为 /xxx?embed=1(会绕开壳落到 index 顶栏变形)
|
||||
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
|
||||
const t = normalize(theme || get());
|
||||
document
|
||||
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* 实例数据统计:复盘 KPI + 已实现盈亏曲线.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var currentRange = "7d";
|
||||
var booted = false;
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function root() {
|
||||
return $("options-data-stats");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
var n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function fmtHold(sec) {
|
||||
if (sec == null) return "—";
|
||||
var s = Math.max(0, Number(sec) || 0);
|
||||
if (s < 3600) return Math.round(s / 60) + "m";
|
||||
if (s < 86400) return (s / 3600).toFixed(1) + "h";
|
||||
return (s / 86400).toFixed(1) + "d";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
var n = Number(v);
|
||||
if (Number.isNaN(n) || n === 0) return "";
|
||||
return n > 0 ? "ods-pnl-pos" : "ods-pnl-neg";
|
||||
}
|
||||
|
||||
function closeReasonLabel(r) {
|
||||
var map = {
|
||||
perp_tp: "永续止盈",
|
||||
perp_sl: "永续止损",
|
||||
oo_expiry_loss: "期期到期亏损",
|
||||
oo_expiry_win: "期期到期盈利",
|
||||
target_win_leg: "期期平盈利腿",
|
||||
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
|
||||
};
|
||||
return map[r] || String(r || "未填");
|
||||
}
|
||||
|
||||
function sourceType() {
|
||||
var el = root();
|
||||
return (el && el.getAttribute("data-source-type")) || "option_spot";
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
var el = $("ods-status");
|
||||
if (el) el.textContent = text || "";
|
||||
}
|
||||
|
||||
function renderGroup(title, items) {
|
||||
if (!items || !items.length) return "";
|
||||
var lines = items
|
||||
.slice(0, 8)
|
||||
.map(function (g) {
|
||||
var keyLabel =
|
||||
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
|
||||
return (
|
||||
'<div class="ods-stat-row">' +
|
||||
'<span class="ods-stat-key">' +
|
||||
escapeHtml(keyLabel) +
|
||||
" · " +
|
||||
g.count +
|
||||
"笔</span>" +
|
||||
'<span class="ods-stat-val">' +
|
||||
fmtPnl(g.pnl_sum) +
|
||||
" / 胜" +
|
||||
(g.win_rate || 0) +
|
||||
"%</span>" +
|
||||
"</div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return (
|
||||
'<div class="ods-stat-card"><div class="ods-stat-card-title">' +
|
||||
title +
|
||||
"</div>" +
|
||||
lines +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function loadReviewStats() {
|
||||
var kpi = $("ods-kpi");
|
||||
var groups = $("ods-stats-groups");
|
||||
if (!kpi || !groups) return Promise.resolve();
|
||||
// 口径 B:期权历史币盈亏×卖回价(/api/options/stats),与复盘账户差分独立
|
||||
return fetch("/api/options/stats", { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
|
||||
groups.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
var unit = data.pnl_unit || "U";
|
||||
var net = data.net_realized_pnl;
|
||||
var avg =
|
||||
data.total_closed > 0 && net != null
|
||||
? Number(net) / Number(data.total_closed)
|
||||
: null;
|
||||
kpi.innerHTML = [
|
||||
["已平笔数", data.total_closed || 0],
|
||||
["胜率", (data.win_rate || 0) + "%"],
|
||||
["盈亏比", data.profit_loss_ratio != null ? data.profit_loss_ratio : "—"],
|
||||
["累计盈亏", fmtPnl(net)],
|
||||
["平均盈利", data.avg_win != null ? fmtPnl(data.avg_win) : "—"],
|
||||
["平均亏损", data.avg_loss != null ? fmtPnl(-Math.abs(Number(data.avg_loss))) : "—"],
|
||||
["平均持有", fmtHold(data.avg_hold_sec)],
|
||||
]
|
||||
.map(function (pair) {
|
||||
var cls = "";
|
||||
if (pair[0] === "累计盈亏") cls = pnlClass(net);
|
||||
if (pair[0] === "平均盈利") cls = "pnl-pos";
|
||||
if (pair[0] === "平均亏损") cls = "pnl-neg";
|
||||
return (
|
||||
'<div class="ods-kpi-tile"><div class="ods-kpi-label">' +
|
||||
pair[0] +
|
||||
'</div><div class="ods-kpi-value' +
|
||||
(cls ? " " + cls : "") +
|
||||
'">' +
|
||||
pair[1] +
|
||||
"</div></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
groups.innerHTML =
|
||||
'<div class="muted" style="font-size:.76rem">数据统计口径:期权历史盈亏×平仓卖回价(与复盘账户差分独立)。单位 ' +
|
||||
unit +
|
||||
(avg != null ? " · 笔均 " + fmtPnl(avg) : "") +
|
||||
"</div>";
|
||||
})
|
||||
.catch(function () {
|
||||
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
|
||||
groups.innerHTML = "";
|
||||
});
|
||||
}
|
||||
|
||||
function fmtCash(v) {
|
||||
if (v == null || Number.isNaN(Number(v))) return "—";
|
||||
return Number(v).toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function drawCurve(points) {
|
||||
var svg = $("ods-curve-svg");
|
||||
if (!svg) return;
|
||||
var W = 640;
|
||||
var H = 240;
|
||||
var padL = 48;
|
||||
var padR = 12;
|
||||
var padT = 16;
|
||||
var padB = 28;
|
||||
var stroke = getComputedStyle(document.body).getPropertyValue("--accent") || "#5aa0ff";
|
||||
stroke = (stroke || "").trim() || "#5aa0ff";
|
||||
var grid = "rgba(127,127,127,.22)";
|
||||
var muted = "rgba(160,160,180,.75)";
|
||||
|
||||
if (!points || !points.length) {
|
||||
svg.innerHTML =
|
||||
'<text x="320" y="120" text-anchor="middle" fill="' +
|
||||
muted +
|
||||
'" font-size="13">暂无已实现盈亏数据</text>';
|
||||
return;
|
||||
}
|
||||
|
||||
var vals = points.map(function (p) {
|
||||
return Number(p.v);
|
||||
});
|
||||
var ts = points.map(function (p) {
|
||||
return Number(p.t);
|
||||
});
|
||||
var minV = Math.min.apply(null, vals);
|
||||
var maxV = Math.max.apply(null, vals);
|
||||
if (maxV - minV < 1e-6) {
|
||||
minV -= 1;
|
||||
maxV += 1;
|
||||
}
|
||||
var minT = ts[0];
|
||||
var maxT = ts[ts.length - 1];
|
||||
if (maxT <= minT) maxT = minT + 1;
|
||||
|
||||
function xOf(t) {
|
||||
return padL + ((t - minT) / (maxT - minT)) * (W - padL - padR);
|
||||
}
|
||||
function yOf(v) {
|
||||
return padT + (1 - (v - minV) / (maxV - minV)) * (H - padT - padB);
|
||||
}
|
||||
|
||||
var path = points
|
||||
.map(function (p, i) {
|
||||
var x = xOf(p.t);
|
||||
var y = yOf(p.v);
|
||||
return (i ? "L" : "M") + x.toFixed(1) + "," + y.toFixed(1);
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
var yTicks = [minV, (minV + maxV) / 2, maxV];
|
||||
var gridHtml = yTicks
|
||||
.map(function (v) {
|
||||
var y = yOf(v);
|
||||
return (
|
||||
'<line x1="' +
|
||||
padL +
|
||||
'" y1="' +
|
||||
y.toFixed(1) +
|
||||
'" x2="' +
|
||||
(W - padR) +
|
||||
'" y2="' +
|
||||
y.toFixed(1) +
|
||||
'" stroke="' +
|
||||
grid +
|
||||
'" stroke-width="1"/>' +
|
||||
'<text x="' +
|
||||
(padL - 6) +
|
||||
'" y="' +
|
||||
(y + 4).toFixed(1) +
|
||||
'" text-anchor="end" fill="' +
|
||||
muted +
|
||||
'" font-size="10">' +
|
||||
Number(v).toFixed(0) +
|
||||
"</text>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
var last = points[points.length - 1];
|
||||
svg.innerHTML =
|
||||
gridHtml +
|
||||
'<path d="' +
|
||||
path +
|
||||
'" fill="none" stroke="' +
|
||||
stroke +
|
||||
'" stroke-width="2" vector-effect="non-scaling-stroke"/>' +
|
||||
'<circle cx="' +
|
||||
xOf(last.t).toFixed(1) +
|
||||
'" cy="' +
|
||||
yOf(last.v).toFixed(1) +
|
||||
'" r="3.5" fill="' +
|
||||
stroke +
|
||||
'"/>';
|
||||
}
|
||||
|
||||
function loadCashCurve() {
|
||||
var meta = $("ods-curve-meta");
|
||||
return fetch("/api/instance/cash_curve?range=" + encodeURIComponent(currentRange), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
if (meta) meta.textContent = "曲线加载失败";
|
||||
drawCurve([]);
|
||||
return;
|
||||
}
|
||||
var pts = data.points || [];
|
||||
drawCurve(pts);
|
||||
if (meta) {
|
||||
meta.textContent =
|
||||
"已实现 " +
|
||||
(data.trade_count != null ? data.trade_count + " 笔 · " : "") +
|
||||
pts.length +
|
||||
" 点 · 累计 " +
|
||||
fmtCash(data.latest != null ? data.latest : pts.length ? pts[pts.length - 1].v : null);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (meta) meta.textContent = "曲线加载失败";
|
||||
drawCurve([]);
|
||||
});
|
||||
}
|
||||
|
||||
function reloadAll() {
|
||||
setStatus("加载中…");
|
||||
return Promise.all([loadReviewStats(), loadCashCurve()]).then(function () {
|
||||
setStatus("");
|
||||
});
|
||||
}
|
||||
|
||||
function bindOnce() {
|
||||
if (booted || !root()) return;
|
||||
booted = true;
|
||||
var btn = $("ods-reload-btn");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
reloadAll();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
currentRange = b.getAttribute("data-range") || "7d";
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
|
||||
x.classList.toggle("active", x === b);
|
||||
});
|
||||
loadCashCurve();
|
||||
});
|
||||
});
|
||||
// 默认 7 日更有曲线感;若用户点今日再切
|
||||
var def7 = document.querySelector('#options-data-stats .ods-range-btn[data-range="7d"]');
|
||||
if (def7) {
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
|
||||
x.classList.toggle("active", x === def7);
|
||||
});
|
||||
currentRange = "7d";
|
||||
}
|
||||
reloadAll();
|
||||
}
|
||||
|
||||
function bootIfPresent() {
|
||||
if (root()) bindOnce();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", bootIfPresent);
|
||||
} else {
|
||||
bootIfPresent();
|
||||
}
|
||||
|
||||
// embed 软切 tab 时脚本可能再次插入
|
||||
global.__OPTIONS_DATA_STATS_BOOT__ = bootIfPresent;
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
@@ -8,8 +8,30 @@
|
||||
|
||||
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {});
|
||||
|
||||
const allowedUnderlys = (function () {
|
||||
const raw = String(root.dataset.allowedUnderlys || "ETH,BTC");
|
||||
const list = raw
|
||||
.split(",")
|
||||
.map(function (s) {
|
||||
return String(s || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
})
|
||||
.filter(function (u) {
|
||||
return u === "ETH" || u === "BTC";
|
||||
});
|
||||
return list.length ? list : ["ETH", "BTC"];
|
||||
})();
|
||||
const bootUnderly = (function () {
|
||||
const d = String(root.dataset.defaultUnderly || "ETH")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (allowedUnderlys.indexOf(d) >= 0) return d;
|
||||
return allowedUnderlys[0] || "ETH";
|
||||
})();
|
||||
|
||||
const state = {
|
||||
underlying: root.dataset.defaultUnderly || "ETH",
|
||||
underlying: bootUnderly,
|
||||
optType: "C",
|
||||
moneyFilter: "all",
|
||||
chainView: "list",
|
||||
@@ -19,7 +41,7 @@
|
||||
budgetBuffer: (function () {
|
||||
const raw = root.dataset.budgetBuffer;
|
||||
const n = raw != null && raw !== "" ? Number(raw) : NaN;
|
||||
return !Number.isNaN(n) && n > 0 ? n : 0.95;
|
||||
return !Number.isNaN(n) && n > 0 ? n : 1;
|
||||
})(),
|
||||
chain: panelCache.chain || null,
|
||||
selectedInst: null,
|
||||
@@ -30,6 +52,7 @@
|
||||
targetDraftByInst: {},
|
||||
/** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */
|
||||
profitExitDraftByInst: {},
|
||||
condOrderLocked: false,
|
||||
};
|
||||
|
||||
let lastGoodPositions = null;
|
||||
@@ -78,7 +101,21 @@
|
||||
|
||||
async function apiJson(url, opts) {
|
||||
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||
return r.json();
|
||||
const text = await r.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (e) {
|
||||
if (/\/login/i.test(r.url || "") || /^\s*<!doctype/i.test(text) || /^\s*<html/i.test(text)) {
|
||||
throw new Error("未登录或会话过期,请从中控重新打开工作台");
|
||||
}
|
||||
const snip = String(text || "").replace(/\s+/g, " ").slice(0, 100);
|
||||
throw new Error("接口返回非 JSON(HTTP " + r.status + ")" + (snip ? ": " + snip : ""));
|
||||
}
|
||||
if (r.status === 401) {
|
||||
throw new Error((data && data.msg) || "未登录或会话过期,请从中控重新打开工作台");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function orderPanel() {
|
||||
@@ -89,14 +126,35 @@
|
||||
return document.getElementById("opt-order-panel-host");
|
||||
}
|
||||
|
||||
function applyCondPickLock() {
|
||||
const locked = !!state.condOrderLocked;
|
||||
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
|
||||
btn.disabled = locked;
|
||||
btn.classList.toggle("is-locked", locked);
|
||||
if (locked) {
|
||||
btn.classList.remove("active");
|
||||
btn.title = "中控条件单占用中,禁止手动开仓";
|
||||
if (!btn.dataset.origText) btn.dataset.origText = "选择";
|
||||
btn.textContent = btn.dataset.origText;
|
||||
} else {
|
||||
btn.removeAttribute("title");
|
||||
}
|
||||
});
|
||||
const openBtn = document.getElementById("opt-open-btn");
|
||||
if (openBtn && locked) {
|
||||
openBtn.disabled = true;
|
||||
openBtn.title = "中控条件单占用中,禁止手动开仓";
|
||||
}
|
||||
}
|
||||
|
||||
function syncPickButtons(instId) {
|
||||
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
|
||||
const on = !!instId && btn.getAttribute("data-inst") === instId;
|
||||
const on = !state.condOrderLocked && !!instId && btn.getAttribute("data-inst") === instId;
|
||||
btn.classList.toggle("active", on);
|
||||
btn.disabled = false;
|
||||
if (!btn.dataset.origText) btn.dataset.origText = "选择";
|
||||
btn.textContent = on ? "已选" : btn.dataset.origText;
|
||||
});
|
||||
applyCondPickLock();
|
||||
}
|
||||
|
||||
function parkOrderPanel() {
|
||||
@@ -282,9 +340,10 @@
|
||||
const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
|
||||
if (el) return el.value;
|
||||
const any = document.querySelector('input[name="opt-size-mode"]:checked');
|
||||
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "sheets";
|
||||
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "budget_full";
|
||||
if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full";
|
||||
return "sheets";
|
||||
// 默认按可用余额打满(复利开时等价全仓复利)
|
||||
return compoundFullEnabled() ? "compound_full" : "budget_full";
|
||||
}
|
||||
|
||||
function applyCompoundModeUi(compoundOn) {
|
||||
@@ -336,13 +395,13 @@
|
||||
compoundRadio.checked = false;
|
||||
compoundRadio.disabled = true;
|
||||
}
|
||||
// currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓
|
||||
// 无有效选中时默认「按可用余额打满」
|
||||
const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
|
||||
if (!checkedOk) {
|
||||
const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]');
|
||||
if (sheetsRadio) {
|
||||
sheetsRadio.disabled = false;
|
||||
sheetsRadio.checked = true;
|
||||
const fullRadio = document.querySelector('input[name="opt-size-mode"][value="budget_full"]');
|
||||
if (fullRadio) {
|
||||
fullRadio.disabled = false;
|
||||
fullRadio.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,7 +477,15 @@
|
||||
}
|
||||
|
||||
function strikeTableColspan() {
|
||||
return 9;
|
||||
return state.chainView === "t" ? 9 : 10;
|
||||
}
|
||||
|
||||
function fmtDelta(v) {
|
||||
if (v === null || v === undefined || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
const sign = n > 0 ? "+" : "";
|
||||
return sign + n.toFixed(3);
|
||||
}
|
||||
|
||||
function syncChainViewUI() {
|
||||
@@ -637,7 +704,16 @@
|
||||
|
||||
function pickBtnHtml(instId) {
|
||||
if (!instId) return "—";
|
||||
return '<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + instId + '">选择</button>';
|
||||
const locked = !!state.condOrderLocked;
|
||||
return (
|
||||
'<button type="button" class="btn-secondary opt-pick-btn' +
|
||||
(locked ? " is-locked" : "") +
|
||||
'" data-inst="' +
|
||||
instId +
|
||||
'"' +
|
||||
(locked ? ' disabled title="中控条件单占用中,禁止手动开仓"' : "") +
|
||||
">选择</button>"
|
||||
);
|
||||
}
|
||||
|
||||
function syncMoneyFilterButtons() {
|
||||
@@ -1031,6 +1107,25 @@
|
||||
return "约 " + Number(v).toFixed(1) + "×";
|
||||
}
|
||||
|
||||
function calcPosLeverage(p) {
|
||||
const direct = Number(p && (p.contract_leverage != null ? p.contract_leverage : p.leverage));
|
||||
if (Number.isFinite(direct) && direct > 0) return direct;
|
||||
const idx = Number(p && p.idx_px);
|
||||
const amt = Number(p && p.eth_amount);
|
||||
const prem = Number(p && p.premium_paid);
|
||||
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (isCoinPos(p)) {
|
||||
return Math.round((amt / prem) * 10) / 10;
|
||||
}
|
||||
return Math.round((idx * amt) / prem * 10) / 10;
|
||||
}
|
||||
|
||||
function fmtPosLeverage(p) {
|
||||
return fmtLeverage(calcPosLeverage(p));
|
||||
}
|
||||
|
||||
/** 链上展示:USDC=指数÷卖一(美元);币本位卖一为币报价 → 1÷卖一. */
|
||||
function calcAskLeverage(indexPx, askPx) {
|
||||
if (indexPx == null || askPx == null) return null;
|
||||
@@ -1153,6 +1248,11 @@
|
||||
|
||||
function finishStrikeRender(tbody, prevSelected, matchedSelected) {
|
||||
bindStrikePickButtons(tbody);
|
||||
applyCondPickLock();
|
||||
if (state.condOrderLocked) {
|
||||
state.selectedInst = null;
|
||||
return;
|
||||
}
|
||||
if (matchedSelected && prevSelected) {
|
||||
selectContract(prevSelected, null, true);
|
||||
} else if (!matchedSelected) {
|
||||
@@ -1216,6 +1316,7 @@
|
||||
"<td><code>" + c.inst_id + "</code></td>" +
|
||||
"<td class=\"opt-px-sz\">" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "</td>" +
|
||||
'<td class="opt-chain-lev">' + fmtChainLeverage(chainLev) + "</td>" +
|
||||
'<td class="opt-chain-delta" title="Delta">' + fmtDelta(c.delta) + "</td>" +
|
||||
"<td class=\"opt-px-sz\">" + fmtPxSz(c.bid, c.bid_sz) + "</td>" +
|
||||
"<td>" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "</td>" +
|
||||
'<td class="' + distBeClass(c.dist_expiry_be) + '">' + fmtDist(c.dist_expiry_be) + "</td>" +
|
||||
@@ -1393,6 +1494,10 @@
|
||||
}
|
||||
|
||||
async function selectContract(instId, pickBtn, silent) {
|
||||
if (state.condOrderLocked) {
|
||||
applyCondPickLock();
|
||||
return null;
|
||||
}
|
||||
const seq = ++selectSeq;
|
||||
state.selectedInst = instId;
|
||||
placeOrderPanelAfter(instId);
|
||||
@@ -1637,6 +1742,10 @@
|
||||
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
|
||||
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
|
||||
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
|
||||
'<span class="pos-meta-item" title="名义价值÷权利金,测算用">杠杆倍数: ' + fmtPosLeverage(p) + "</span>" +
|
||||
'<span class="pos-meta-item" title="OKX deltaBS(单位 Delta)">Delta: ' +
|
||||
fmtDelta(p.delta != null ? p.delta : p.delta_bs) +
|
||||
"</span>" +
|
||||
(expAttr
|
||||
? '<span class="pos-meta-item">到期倒计时: <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
|
||||
: "") +
|
||||
@@ -1645,6 +1754,9 @@
|
||||
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">Delta</span><span class="pos-value" title="OKX deltaBS">' +
|
||||
fmtDelta(p.delta != null ? p.delta : p.delta_bs) +
|
||||
"</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
|
||||
@@ -1674,6 +1786,21 @@
|
||||
return fmt(n, 2) + "倍";
|
||||
}
|
||||
|
||||
function profitExitHint(mult, req, premCcy) {
|
||||
const n = Number(mult);
|
||||
const m = Number.isFinite(n) && n > 0 ? n : 1;
|
||||
const needFactor = 1 + m;
|
||||
const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9
|
||||
? String(Math.round(needFactor))
|
||||
: fmt(needFactor, 2);
|
||||
let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2)
|
||||
+ " → 需回收≥权利金×" + factorTxt;
|
||||
if (req != null) {
|
||||
hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy;
|
||||
}
|
||||
return hint;
|
||||
}
|
||||
|
||||
function renderProfitExitRow(p) {
|
||||
const inst = p.inst_id || "";
|
||||
if (p.hedge_plan_target) {
|
||||
@@ -1694,6 +1821,7 @@
|
||||
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
||||
const req = p.profit_exit_required_recycle;
|
||||
const premCcy = posPremiumCcy(p);
|
||||
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
|
||||
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
|
||||
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
|
||||
return (
|
||||
@@ -1710,7 +1838,7 @@
|
||||
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(enabled
|
||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
|
||||
? profitExitHint(hintMult, req, premCcy)
|
||||
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
||||
"</span>" +
|
||||
"</div>"
|
||||
@@ -2227,26 +2355,53 @@
|
||||
return n.toFixed(8).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function spotSellMinSz(ss, uly) {
|
||||
const u = String(uly || "ETH").toUpperCase();
|
||||
const fromApi = u === "BTC" ? Number(ss && ss.min_sz_btc) : Number(ss && ss.min_sz_eth);
|
||||
if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
|
||||
return u === "BTC" ? 0.00001 : 0.0001;
|
||||
}
|
||||
|
||||
function shouldShowSpotSellBanner(d, positions) {
|
||||
if (!isCoinMarginMode()) return false;
|
||||
const ss = d && d.spot_sell;
|
||||
if (!ss) return false;
|
||||
const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); });
|
||||
if (hasCoinPos) return false;
|
||||
if (ss.bridge_status === "pending_sell_spot") return true;
|
||||
// 仅当交易户有「可卖」(>= 现货 minSz)残留时展示;粉尘不展示、不提示重试
|
||||
const ss = (d && d.spot_sell) || {};
|
||||
const eth = Number(ss.trading_eth || 0);
|
||||
const btc = Number(ss.trading_btc || 0);
|
||||
return eth > 1e-12 || btc > 1e-12;
|
||||
const ethOk = eth >= spotSellMinSz(ss, "ETH");
|
||||
const btcOk = btc >= spotSellMinSz(ss, "BTC");
|
||||
if (ethOk || btcOk) return true;
|
||||
const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); });
|
||||
const coinMode =
|
||||
isCoinMarginMode() ||
|
||||
hasCoinPos ||
|
||||
(d && d.options_margin_mode === "coin");
|
||||
// 待卖回桥且仍有可卖数量时才露按钮(粉尘桥后端会关掉)
|
||||
if (coinMode && ss.bridge_status === "pending_sell_spot") {
|
||||
const bu = String(ss.bridge_underlying || "").toUpperCase();
|
||||
const amt = bu === "BTC" ? btc : eth;
|
||||
return amt >= spotSellMinSz(ss, bu || "ETH");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function spotSellUnderlying(ss) {
|
||||
if (!ss) return (state.underlying || "ETH").toUpperCase();
|
||||
if (ss.bridge_underlying) return String(ss.bridge_underlying).toUpperCase();
|
||||
const selected = (state.underlying || "ETH").toUpperCase();
|
||||
if (!ss) return selected;
|
||||
const bridgeUly = String(ss.bridge_underlying || "").trim().toUpperCase();
|
||||
const eth = Number(ss.trading_eth || 0);
|
||||
const btc = Number(ss.trading_btc || 0);
|
||||
if (btc > eth && btc > 1e-12) return "BTC";
|
||||
if (eth > 1e-12) return "ETH";
|
||||
return (ss.default_underly || state.underlying || "ETH").toUpperCase();
|
||||
const ethMin = spotSellMinSz(ss, "ETH");
|
||||
const btcMin = spotSellMinSz(ss, "BTC");
|
||||
// 待卖回桥优先;否则优先当前选中标的上的可卖残留
|
||||
if (bridgeUly === "BTC" || bridgeUly === "ETH") {
|
||||
if (ss.bridge_status === "pending_sell_spot") return bridgeUly;
|
||||
}
|
||||
if (selected === "BTC" && btc >= btcMin) return "BTC";
|
||||
if (selected === "ETH" && eth >= ethMin) return "ETH";
|
||||
if (btc >= btcMin && btc >= eth) return "BTC";
|
||||
if (eth >= ethMin) return "ETH";
|
||||
if (bridgeUly === "BTC" || bridgeUly === "ETH") return bridgeUly;
|
||||
return (ss.default_underly || selected || "ETH").toUpperCase();
|
||||
}
|
||||
|
||||
function paintSpotSellBanner(d, positions) {
|
||||
@@ -2271,6 +2426,90 @@
|
||||
head.hidden = false;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function condStatusLabel(st) {
|
||||
const map = { armed: "待命", triggering: "触发中", open: "持仓中" };
|
||||
return map[st] || st || "已接收";
|
||||
}
|
||||
|
||||
function condStatusClass(st) {
|
||||
if (st === "open") return "is-open";
|
||||
if (st === "triggering") return "is-trig";
|
||||
return "is-armed";
|
||||
}
|
||||
|
||||
function paintCondOrderLock(d) {
|
||||
const card = document.getElementById("opt-cond-order-card");
|
||||
if (!card) return;
|
||||
const order = d && d.cond_order;
|
||||
const locked = !!(d && d.cond_order_locked);
|
||||
const wasLocked = !!state.condOrderLocked;
|
||||
state.condOrderLocked = locked || !!order;
|
||||
if (state.condOrderLocked && !wasLocked) closeOrderDialog();
|
||||
applyCondPickLock();
|
||||
if (!order && !locked) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
const st = order ? String(order.status || "") : "";
|
||||
const stEl = document.getElementById("opt-cond-order-st");
|
||||
if (stEl) {
|
||||
stEl.textContent = condStatusLabel(st);
|
||||
stEl.className = "opt-cond-st " + condStatusClass(st);
|
||||
}
|
||||
const grid = document.getElementById("opt-cond-order-grid");
|
||||
if (grid) {
|
||||
if (!order) {
|
||||
grid.innerHTML = "";
|
||||
} else {
|
||||
const put = String(order.opt_type || "").toUpperCase() !== "C";
|
||||
const side = put ? "上穿 ≥" : "下穿 ≤";
|
||||
const exitParts = [];
|
||||
if (order.profit_exit_enabled) {
|
||||
exitParts.push("翻倍×" + (order.profit_exit_mult != null ? order.profit_exit_mult : ""));
|
||||
}
|
||||
if (order.target_index != null && order.target_index !== "") {
|
||||
exitParts.push("目标 " + order.target_index);
|
||||
}
|
||||
const items = [
|
||||
["标的", (order.underlying || "") + " " + (put ? "Put" : "Call")],
|
||||
["触发", side + " " + (order.trigger_px != null ? order.trigger_px : "—")],
|
||||
["偏离", order.deviation_pct != null ? order.deviation_pct + "%" : "—"],
|
||||
["最短到期", order.min_expiry_hours != null ? order.min_expiry_hours + "h" : "—"],
|
||||
["出场", exitParts.join(" · ") || "—"],
|
||||
["当前指数", order.last_index != null && order.last_index !== "" ? String(order.last_index) : "—"],
|
||||
];
|
||||
if (order.opened_inst_id) {
|
||||
items.push(["已开合约", String(order.opened_inst_id)]);
|
||||
}
|
||||
grid.innerHTML = items
|
||||
.map(function (it) {
|
||||
return (
|
||||
'<div class="opt-cond-kv"><span class="k">' +
|
||||
escHtml(it[0]) +
|
||||
'</span><span class="v">' +
|
||||
escHtml(it[1]) +
|
||||
"</span></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
const msgEl = document.getElementById("opt-cond-order-msg");
|
||||
if (msgEl) {
|
||||
const msg = (order && order.message) || (d && d.cond_order_msg) || "";
|
||||
msgEl.textContent = msg ? String(msg) : "";
|
||||
}
|
||||
card.hidden = false;
|
||||
}
|
||||
|
||||
async function retrySpotSell() {
|
||||
const btn = document.getElementById("opt-spot-sell-retry-btn");
|
||||
const uly = (state.spotSellUnderlying || state.underlying || "ETH").toUpperCase();
|
||||
@@ -2396,6 +2635,13 @@
|
||||
const list = resolvePositionsList(d);
|
||||
paintPositions(list);
|
||||
paintSpotSellBanner(d, list);
|
||||
paintCondOrderLock(d);
|
||||
if (typeof window.OptionsFundsSetTransferBlocked === "function") {
|
||||
window.OptionsFundsSetTransferBlocked(
|
||||
!!(d && d.transfer_blocked),
|
||||
(d && d.transfer_block_msg) || "持仓或待卖回期间不可划转"
|
||||
);
|
||||
}
|
||||
const fromPos = list.reduce(function (targets, p) {
|
||||
if (!p) return targets;
|
||||
if (p.target_index != null) {
|
||||
@@ -2741,6 +2987,8 @@
|
||||
btn.classList.add("active");
|
||||
state.underlying = btn.getAttribute("data-uly");
|
||||
loadChain();
|
||||
// 切换 ETH/BTC 时重刷持仓区,以便「重试卖回」标的与当前选择同步
|
||||
refreshAllPositions();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,38 @@
|
||||
return "";
|
||||
}
|
||||
|
||||
function isCoinPos(p) {
|
||||
const mode = String((p && p.margin_mode) || "").toLowerCase();
|
||||
if (mode === "coin") return true;
|
||||
if (mode === "usdc") return false;
|
||||
const ccy = String((p && p.premium_ccy) || "").toUpperCase();
|
||||
if (ccy === "USDC") return false;
|
||||
if (ccy === "BTC" || ccy === "ETH") return true;
|
||||
const inst = String((p && p.inst_id) || "");
|
||||
return inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0;
|
||||
}
|
||||
|
||||
function calcPosLeverage(p) {
|
||||
const direct = Number(p && (p.contract_leverage != null ? p.contract_leverage : p.leverage));
|
||||
if (Number.isFinite(direct) && direct > 0) return direct;
|
||||
const idx = Number(p && p.idx_px);
|
||||
const amt = Number(p && p.eth_amount);
|
||||
const prem = Number(p && p.premium_paid);
|
||||
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (isCoinPos(p)) {
|
||||
return Math.round((amt / prem) * 10) / 10;
|
||||
}
|
||||
return Math.round((idx * amt) / prem * 10) / 10;
|
||||
}
|
||||
|
||||
function fmtPosLeverage(p) {
|
||||
const lev = calcPosLeverage(p);
|
||||
if (lev == null || Number.isNaN(Number(lev))) return "—";
|
||||
return "约 " + Number(lev).toFixed(1) + "×";
|
||||
}
|
||||
|
||||
function fmtPxSz(px, sz, tickSz) {
|
||||
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
|
||||
let price = fmtOptionPx(px, tickSz);
|
||||
@@ -224,6 +256,7 @@
|
||||
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
|
||||
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
|
||||
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
|
||||
'<span class="pos-meta-item" title="名义价值÷权利金,测算用">杠杆倍数: ' + fmtPosLeverage(p) + "</span>" +
|
||||
(expAttr
|
||||
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
|
||||
: "") +
|
||||
|
||||
+1669
-1362
File diff suppressed because it is too large
Load Diff
@@ -353,4 +353,32 @@
|
||||
intMsg: "opt-page-int-msg",
|
||||
});
|
||||
}
|
||||
|
||||
/** 持仓/待卖回时划转按钮置灰(规格). */
|
||||
window.OptionsFundsSetTransferBlocked = function (blocked, msg) {
|
||||
const idsList = [
|
||||
["opt-page-int-btn", "opt-page-int-all-btn", "opt-page-int-amount", "opt-page-int-msg"],
|
||||
["opt-set-int-btn", "opt-set-int-all-btn", "opt-set-int-amount", "opt-set-int-msg"],
|
||||
];
|
||||
idsList.forEach(function (ids) {
|
||||
const btn = document.getElementById(ids[0]);
|
||||
const allBtn = document.getElementById(ids[1]);
|
||||
const amount = document.getElementById(ids[2]);
|
||||
const msgEl = document.getElementById(ids[3]);
|
||||
[btn, allBtn].forEach(function (el) {
|
||||
if (!el) return;
|
||||
el.disabled = !!blocked;
|
||||
el.style.opacity = blocked ? "0.45" : "";
|
||||
el.title = blocked ? String(msg || "持仓中不可划转") : "";
|
||||
});
|
||||
if (amount) amount.disabled = !!blocked;
|
||||
if (blocked && msgEl && msg) {
|
||||
msgEl.textContent = String(msg);
|
||||
msgEl.classList.add("text-warn");
|
||||
} else if (!blocked && msgEl && /持仓|划转|卖回/.test(String(msgEl.textContent || ""))) {
|
||||
msgEl.textContent = "";
|
||||
msgEl.classList.remove("text-warn");
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults).
|
||||
* BTC/ETH 在 order_defaults 无价时回退 /api/nav_spot_tickers(与顶栏同源).
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
@@ -65,6 +66,41 @@
|
||||
el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)";
|
||||
}
|
||||
|
||||
function symbolBase(sym) {
|
||||
return String(sym || "")
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.split("/")[0]
|
||||
.split(":")[0];
|
||||
}
|
||||
|
||||
function fetchNavLast(sym) {
|
||||
const base = symbolBase(sym);
|
||||
if (base !== "BTC" && base !== "ETH") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return fetch("/api/nav_spot_tickers")
|
||||
.then(function (r) {
|
||||
return r.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d || !d.ok || !Array.isArray(d.tickers)) return null;
|
||||
for (let i = 0; i < d.tickers.length; i++) {
|
||||
const t = d.tickers[i] || {};
|
||||
if (String(t.base || "").toUpperCase() === base && t.last != null) {
|
||||
const n = Number(t.last);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function bindOne(el) {
|
||||
if (!el || bound.has(el)) return;
|
||||
bound.add(el);
|
||||
@@ -87,6 +123,15 @@
|
||||
pollTimer = setInterval(refresh, pollMs());
|
||||
}
|
||||
|
||||
function applyPrice(sym, px) {
|
||||
if (px === null || typeof px === "undefined") {
|
||||
paint(el, sym, null, "无法读取交易所价格");
|
||||
return;
|
||||
}
|
||||
paint(el, sym, px, "");
|
||||
if (!pollTimer) startPoll();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const symEl = $(symId);
|
||||
const sym = symbolValue(symEl);
|
||||
@@ -116,21 +161,35 @@
|
||||
el.classList.remove("symbol-live-price--loading");
|
||||
const data = res.data || {};
|
||||
if (res.status >= 400 || !data || !data.ok) {
|
||||
paint(el, sym, null, (data && data.msg) || "读取失败");
|
||||
return;
|
||||
return fetchNavLast(sym).then(function (navPx) {
|
||||
if (seq !== fetchSeq) return;
|
||||
if (navPx != null) {
|
||||
applyPrice(sym, navPx);
|
||||
return;
|
||||
}
|
||||
paint(el, sym, null, (data && data.msg) || "读取失败");
|
||||
});
|
||||
}
|
||||
const px = data.last_price != null ? data.last_price : data.price;
|
||||
if (px === null || typeof px === "undefined") {
|
||||
paint(el, data.symbol || sym, null, "无法读取交易所价格");
|
||||
return;
|
||||
return fetchNavLast(sym).then(function (navPx) {
|
||||
if (seq !== fetchSeq) return;
|
||||
applyPrice(data.symbol || sym, navPx);
|
||||
});
|
||||
}
|
||||
paint(el, data.symbol || sym, px, "");
|
||||
if (!pollTimer) startPoll();
|
||||
applyPrice(data.symbol || sym, px);
|
||||
})
|
||||
.catch(function () {
|
||||
if (seq !== fetchSeq) return;
|
||||
el.classList.remove("symbol-live-price--loading");
|
||||
paint(el, sym, null, "网络错误");
|
||||
fetchNavLast(sym).then(function (navPx) {
|
||||
if (seq !== fetchSeq) return;
|
||||
if (navPx != null) {
|
||||
applyPrice(sym, navPx);
|
||||
return;
|
||||
}
|
||||
paint(el, sym, null, "网络错误");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-40
@@ -88,18 +88,13 @@ HOT_RELOAD_EXACT = frozenset({
|
||||
"APP_AUTH_DISABLED",
|
||||
"APP_SESSION_DAYS",
|
||||
"WECHAT_WEBHOOK",
|
||||
"HEDGE_PLAN_ENABLED",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"HUB_BRIDGE_TOKEN",
|
||||
"HUB_EMBED_FRAME_ANCESTORS",
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS",
|
||||
"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_OPTIONS_COIN_COMPOUND",
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
@@ -107,33 +102,15 @@ HOT_RELOAD_EXACT = frozenset({
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
|
||||
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
|
||||
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
|
||||
"OKX_TRADE_MODE",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_LIVE_ORDER",
|
||||
"HEDGE_PLAN_OPTION_PRIMARY",
|
||||
"HEDGE_PLAN_OPEN_ORDER",
|
||||
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
|
||||
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
|
||||
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO",
|
||||
"HEDGE_PLAN_BUDGET_BUFFER",
|
||||
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
|
||||
})
|
||||
|
||||
SENSITIVE_EXACT = frozenset({
|
||||
"APP_PASSWORD",
|
||||
"FLASK_SECRET_KEY",
|
||||
"HUB_BRIDGE_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
})
|
||||
|
||||
@@ -164,26 +141,13 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
("spot", "spot 现货"),
|
||||
),
|
||||
"TRANSFER_CCY": (("USDT", "USDT"),),
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
|
||||
("budget", "预算金额"),
|
||||
("sheets", "张数"),
|
||||
),
|
||||
"OKX_TRADE_MODE": (
|
||||
("options", "单独期权"),
|
||||
("options_options", "期期对冲"),
|
||||
),
|
||||
"OKX_OPTIONS_MARGIN_MODE": (
|
||||
("coin", "币本位(USDT买币桥)"),
|
||||
("usdc", "USDC(USDⓈ权利金)"),
|
||||
),
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE": (
|
||||
("premium", "权利金倍数"),
|
||||
("net_pnl", "净盈亏(USDT)"),
|
||||
),
|
||||
"HEDGE_PLAN_OPTION_PRIMARY": (
|
||||
("true", "以期权为主"),
|
||||
("false", "保险模式"),
|
||||
),
|
||||
}
|
||||
|
||||
_SELECT_ALIASES: dict[str, dict[str, str]] = {
|
||||
@@ -248,8 +212,6 @@ def _field_type(key: str, value: str) -> str:
|
||||
return "bool"
|
||||
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
):
|
||||
return "bool"
|
||||
try:
|
||||
|
||||
Vendored
+77
-292
@@ -20,15 +20,15 @@ from lib.env.env_schema import (
|
||||
# 合约计仓/杠杆/自动单等已从 env UI 移除;永续划转等见系统设置开关(默认关)
|
||||
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
|
||||
"okx": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权/对冲共用)"),
|
||||
("OKX_API_KEY", "API Key", "账户 API(永续腿+期权共用)"),
|
||||
("OKX_API_SECRET", "API Secret", "账户 API(永续腿+期权共用)"),
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权共用)"),
|
||||
("OKX_API_KEY", "API Key", "账户 API(期权共用)"),
|
||||
("OKX_API_SECRET", "API Secret", "账户 API(期权共用)"),
|
||||
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
|
||||
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
(
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"显示永续资金",
|
||||
"默认关闭;开启后顶栏显示 USDT 资金/交易账户。币本位期权仍会显示交易账户以便买币",
|
||||
"显示USDT资金/交易账户",
|
||||
"默认关闭;开启后顶栏显示 USDT 资金与交易账户。币本位期权买币仍会显示交易账户可用额",
|
||||
),
|
||||
],
|
||||
"binance": [
|
||||
@@ -49,7 +49,7 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
|
||||
],
|
||||
}
|
||||
|
||||
# 仅保留与期权/对冲产品相关的共享区块(永续执行/风控/冷静期/划转/当日资金已从 UI 移除)
|
||||
# 共享区块(企业微信等);对冲配置已从 UI 移除
|
||||
_SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "企业微信",
|
||||
@@ -58,83 +58,66 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "中控对接",
|
||||
"fields": [
|
||||
(
|
||||
"HUB_BRIDGE_TOKEN",
|
||||
"中控 Bridge Token",
|
||||
"首次打开本页自动生成;复制到 crypto_okx_hub「添加实例」即可;也可手动改后保存",
|
||||
),
|
||||
(
|
||||
"HUB_EMBED_FRAME_ANCESTORS",
|
||||
"允许嵌入的中控 Origin",
|
||||
"选填。一般可留空;仅当中控 iframe 打不开时再填中控地址(空格分隔),或调试填 *",
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_MODE_SECTION: dict[str, Any] = {
|
||||
"title": "期权/对冲模式",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
(
|
||||
"OKX_TRADE_MODE",
|
||||
"交易模式",
|
||||
"二选一:单独期权 / 期期对冲。选单独期权时隐藏对冲导航与对冲配置;选期期对冲时不可单独开期权。永期对冲(含合约腿)已从配置页移除",
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
_OPTIONS_SECTION: dict[str, Any] = {
|
||||
"title": "期权账户",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
|
||||
("OKX_OPTIONS_ENABLED", "启用期权模块", "与上方 OKX_API_* 同源;不再单独配置期权密钥"),
|
||||
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
|
||||
(
|
||||
"OKX_OPTIONS_MARGIN_MODE",
|
||||
"单笔期权本位",
|
||||
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"单笔预算(USDC)",
|
||||
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
|
||||
),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "默认 1;币本位复利/固定预算共用"),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_COMPOUND",
|
||||
"币本位按交易户USDT复利",
|
||||
"按交易户USDT复利",
|
||||
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
"币本位固定预算(USDT)",
|
||||
"仅币本位且复利关闭时生效",
|
||||
"固定预算(USDT)",
|
||||
"仅复利关闭时生效",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
|
||||
"币本位单笔上限开关",
|
||||
"单笔上限开关",
|
||||
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"币本位单笔上限(USDT)",
|
||||
"单笔上限(USDT)",
|
||||
"仅上限开关开启时生效",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
|
||||
"币本位现货买入缓冲",
|
||||
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"全仓复利开关",
|
||||
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
"全仓复利上限开关",
|
||||
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"全仓复利上限(USDC)",
|
||||
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
|
||||
"现货买入缓冲",
|
||||
"相对权利金倍数,默认 1.05(=多买5%);也可写 0.05 表示+5%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
||||
"期权持仓上限(笔)",
|
||||
"仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
|
||||
"默认 1=一次一仓;0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
|
||||
),
|
||||
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
|
||||
(
|
||||
"OKX_OPTIONS_UNDERLYINGS",
|
||||
"标的白名单",
|
||||
"逗号分隔,仅 ETH/BTC;只配一个则交易页只显示该标的(开仓/链/报价/条件单校验);资金页不受影响;默认 ETH,BTC",
|
||||
),
|
||||
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH;须在白名单内,否则取白名单第一项"),
|
||||
(
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
||||
"期权链展示天数",
|
||||
@@ -143,7 +126,7 @@ _OPTIONS_SECTION: dict[str, Any] = {
|
||||
(
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS",
|
||||
"开仓最大剩余天数",
|
||||
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
|
||||
"默认 2;开仓时拒绝更远到期(与链展示天数独立)",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
|
||||
@@ -153,17 +136,12 @@ _OPTIONS_SECTION: dict[str, Any] = {
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE",
|
||||
"目标平仓门控模式",
|
||||
"premium=可回收≥权利金×倍数(币本位默认×1.05);net_pnl=净盈亏折USDT≥阈值",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
|
||||
"门控权利金倍数(USDC)",
|
||||
"仅 premium 模式;USDC 权利金仓默认 2",
|
||||
"premium=可回收≥权利金×倍数(默认×1.05);net_pnl=净盈亏折USDT≥阈值",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
|
||||
"门控权利金倍数(币本位)",
|
||||
"仅 premium 模式;ETH/BTC 权利金仓默认 1.05",
|
||||
"门控权利金倍数",
|
||||
"仅 premium 模式;默认 1.05",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
|
||||
@@ -173,98 +151,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
|
||||
(
|
||||
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
|
||||
"门控持续秒数",
|
||||
"条件达标后需持续满足的秒数,默认 120",
|
||||
"条件达标后需持续满足的秒数,默认 20",
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
|
||||
_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
|
||||
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
|
||||
(
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"对冲组数上限",
|
||||
"默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
|
||||
),
|
||||
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
|
||||
(
|
||||
"HEDGE_PLAN_BUDGET_BUFFER",
|
||||
"对冲预算缓冲比例",
|
||||
"默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
|
||||
"半腿失败改手动补开",
|
||||
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
|
||||
"半腿失败时自动平期权",
|
||||
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
|
||||
),
|
||||
]
|
||||
|
||||
_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
|
||||
(
|
||||
"HEDGE_PLAN_OPTION_PRIMARY",
|
||||
"永期模式(以期权为主/保险)",
|
||||
"默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
|
||||
),
|
||||
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
|
||||
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
|
||||
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
|
||||
(
|
||||
"HEDGE_PLAN_ITM_MAX_DIST_USD",
|
||||
"永期实值最大深度(U)",
|
||||
"默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_MIN_OPTION_HOURS",
|
||||
"对冲期权最低剩余小时",
|
||||
"默认 8;测算/启动时若传 hours_to_expiry 则校验",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_MIN_OPTION_LEVERAGE",
|
||||
"对冲期权最低杠杆(S/ask)",
|
||||
"默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
|
||||
),
|
||||
]
|
||||
|
||||
_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
|
||||
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
|
||||
(
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
|
||||
"期期平仓模式(方案C)",
|
||||
"默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
|
||||
"期期做多做空拆分口径",
|
||||
"默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
|
||||
),
|
||||
(
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO",
|
||||
"期期做多做空主腿占比",
|
||||
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
|
||||
),
|
||||
]
|
||||
|
||||
# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
|
||||
_HEDGE_PLAN_SECTION: dict[str, Any] = {
|
||||
"title": "对冲计划",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
|
||||
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
|
||||
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
|
||||
("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
|
||||
*_HEDGE_COMMON_FIELDS,
|
||||
*_HEDGE_PO_FIELDS,
|
||||
*_HEDGE_OO_FIELDS,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
|
||||
_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
|
||||
"RISK_CONTROL_ENABLED": "true",
|
||||
@@ -276,46 +167,31 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
|
||||
"AUTO_TRANSFER_FROM": "funding",
|
||||
"AUTO_TRANSFER_TO": "swap",
|
||||
"TRANSFER_CCY": "USDT",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "false",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false",
|
||||
"OKX_SHOW_PERP_FUNDS": "false",
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS": "2",
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
|
||||
"OKX_OPTIONS_MARGIN_MODE": "coin",
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "1",
|
||||
"OKX_OPTIONS_BUDGET_BUFFER": "1",
|
||||
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER": "1.05",
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT": "2",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
|
||||
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT": "0",
|
||||
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
|
||||
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "20",
|
||||
"OKX_TRADE_MODE": "options",
|
||||
"HEDGE_PLAN_ENABLED": "false",
|
||||
"MAX_ACTIVE_HEDGE_PLANS": "1",
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
|
||||
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
|
||||
"HEDGE_PLAN_OPTION_PRIMARY": "true",
|
||||
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
|
||||
}
|
||||
|
||||
|
||||
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
|
||||
if key == "OKX_TRADE_MODE":
|
||||
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
|
||||
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
|
||||
if file_val:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
|
||||
|
||||
return normalize_okx_trade_mode(file_val) or file_val
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
||||
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
|
||||
|
||||
return get_okx_trade_mode()
|
||||
except Exception:
|
||||
pass
|
||||
return "options"
|
||||
if key == "OKX_OPTIONS_MARGIN_MODE":
|
||||
return "coin"
|
||||
if key in file_values:
|
||||
file_val = str(file_values.get(key) or "").strip()
|
||||
if file_val:
|
||||
@@ -350,13 +226,6 @@ def _build_field(
|
||||
meta = schema.get(key) or {}
|
||||
schema_default = meta.get("default") or ""
|
||||
val = _effective_env_value(key, values, schema_default)
|
||||
# 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
|
||||
if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
|
||||
manual = _effective_env_value(
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
|
||||
)
|
||||
if _env_truthy(manual):
|
||||
val = "false"
|
||||
masked = _mask_value(key, val)
|
||||
ftype = meta.get("type") or _field_type(key, val or schema_default)
|
||||
options = select_options_for(key)
|
||||
@@ -381,86 +250,34 @@ def _build_field(
|
||||
if options:
|
||||
cur = (out["current"] or out["default"] or "").strip()
|
||||
opt_vals = {o["value"] for o in options}
|
||||
# 永期对冲不再作为可选模式;已选历史值也不塞回下拉,避免继续强化合约 UI
|
||||
if key == "OKX_TRADE_MODE" and cur == "perp_options":
|
||||
cur = ""
|
||||
out["current"] = "options"
|
||||
out["default"] = "options"
|
||||
out["note"] = (
|
||||
(out.get("note") or "")
|
||||
+ "(当前 .env 仍为永期对冲:请改选「单独期权」或「期期对冲」后保存并重启)"
|
||||
).strip()
|
||||
if cur and cur not in opt_vals:
|
||||
options = [{"value": cur, "label": cur}] + options
|
||||
out["options"] = options
|
||||
return out
|
||||
|
||||
|
||||
def _okx_mode_for_env_ui() -> str:
|
||||
try:
|
||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
||||
|
||||
return get_okx_trade_mode()
|
||||
except Exception:
|
||||
return "options"
|
||||
|
||||
|
||||
def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
|
||||
fields = list(_OPTIONS_SECTION["fields"])
|
||||
if mode != "options":
|
||||
fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
|
||||
return fields
|
||||
|
||||
|
||||
def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
|
||||
if mode == "perp_options":
|
||||
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
|
||||
if mode == "options_options":
|
||||
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
|
||||
return []
|
||||
|
||||
|
||||
def ui_sections_for_exchange(
|
||||
exchange_key: str,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
del mode # 对冲模式已移除;保留参数兼容旧调用
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
sections: list[dict[str, Any]] = []
|
||||
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
|
||||
sections.append({"title": "交易所与实盘", "fields": live_fields})
|
||||
sections.extend(_SHARED_SECTIONS)
|
||||
if ex in _MODE_SECTION.get("exchanges", frozenset()):
|
||||
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
|
||||
|
||||
m = normalize_okx_trade_mode(mode) if mode else ""
|
||||
if not m:
|
||||
m = _okx_mode_for_env_ui()
|
||||
sections.append(_MODE_SECTION)
|
||||
sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
|
||||
# 永期对冲配置块不再展示(含合约腿);期期仍展示
|
||||
if m != "perp_options":
|
||||
hedge_fields = _hedge_fields_for_mode(m)
|
||||
if hedge_fields:
|
||||
title = "对冲计划·期期"
|
||||
sections.append({"title": title, "fields": hedge_fields})
|
||||
if ex == "okx":
|
||||
sections.append({"title": "期权账户", "fields": list(_OPTIONS_SECTION["fields"])})
|
||||
return sections
|
||||
|
||||
|
||||
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
|
||||
"""可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
|
||||
"""可写键=当前可见字段。.env 里遗留对冲键仍保留在文件中,但 UI 不可再写入."""
|
||||
keys: set[str] = set()
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
for item in sec["fields"]:
|
||||
keys.add(item[0])
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if ex == "okx":
|
||||
keys.add("OKX_TRADE_MODE")
|
||||
# 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
|
||||
for item in _HEDGE_PLAN_SECTION["fields"]:
|
||||
keys.add(item[0])
|
||||
for item in _OPTIONS_SECTION["fields"]:
|
||||
keys.add(item[0])
|
||||
return frozenset(keys)
|
||||
|
||||
|
||||
@@ -469,13 +286,18 @@ def build_env_ui_payload(
|
||||
example_path: str,
|
||||
env_path: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
# 首次打开 env 配置时若 Token 为空则自动生成,便于复制到中控
|
||||
try:
|
||||
from lib.hub.hub_bridge_auth import ensure_hub_bridge_token
|
||||
|
||||
ensure_hub_bridge_token(env_path)
|
||||
except Exception:
|
||||
pass
|
||||
schema = _schema_field_map(example_path)
|
||||
env_lines = read_env_lines(env_path)
|
||||
values = env_get_all(env_lines)
|
||||
groups: list[dict[str, Any]] = []
|
||||
for sec in ui_sections_for_exchange(
|
||||
exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
|
||||
):
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
fields = [
|
||||
_build_field(key, label, note, schema, values)
|
||||
for key, label, note in sec["fields"]
|
||||
@@ -489,24 +311,7 @@ def build_env_ui_payload(
|
||||
return groups
|
||||
|
||||
|
||||
# 币本位专属 / USDC 专属(配置页互斥隐藏)
|
||||
_COIN_ONLY_ENV_KEYS = frozenset({
|
||||
"OKX_OPTIONS_COIN_COMPOUND",
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
|
||||
})
|
||||
_USDC_ONLY_ENV_KEYS = frozenset({
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
|
||||
})
|
||||
|
||||
|
||||
# 币本位专用:按复利/上限/门控模式标记 hidden
|
||||
def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") -> str:
|
||||
for f in fields:
|
||||
if f.get("key") == key:
|
||||
@@ -515,37 +320,30 @@ def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") ->
|
||||
|
||||
|
||||
def _mark_options_section_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""按本位/门控模式/全仓复利标记 hidden(供 SSR/前端;切换开关仍可再显示)."""
|
||||
margin = (_field_current(fields, "OKX_OPTIONS_MARGIN_MODE", "coin") or "coin").lower()
|
||||
is_coin = margin != "usdc"
|
||||
"""按门控模式/复利/单笔上限标记 hidden;USDC/永续相关项始终隐藏."""
|
||||
gate_mode = (_field_current(fields, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") or "premium").lower()
|
||||
compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"))
|
||||
coin_compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_COMPOUND", "true"))
|
||||
coin_max_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"))
|
||||
compound_cap_on = _env_truthy(
|
||||
_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false")
|
||||
)
|
||||
retired = {
|
||||
"OKX_OPTIONS_MARGIN_MODE",
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
}
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for f in fields:
|
||||
key = str(f.get("key") or "")
|
||||
hide = False
|
||||
if is_coin and key in _USDC_ONLY_ENV_KEYS:
|
||||
hide = True
|
||||
elif (not is_coin) and key in _COIN_ONLY_ENV_KEYS:
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT" and gate_mode != "premium":
|
||||
if key in retired:
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium":
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT" and gate_mode != "net_pnl":
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED" and not compound_on:
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC" and not (compound_on and compound_cap_on):
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_COIN_BUDGET_USDT" and coin_compound_on:
|
||||
hide = True
|
||||
elif key == "OKX_OPTIONS_COIN_MAX_USDT" and not coin_max_on:
|
||||
@@ -609,19 +407,6 @@ def coerce_hedge_partial_close_with_manual(
|
||||
*,
|
||||
env_path: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
|
||||
out = dict(clean or {})
|
||||
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
|
||||
if manual is None and env_path:
|
||||
try:
|
||||
from lib.env.env_file_lib import env_get_all, read_env_lines
|
||||
|
||||
file_vals = env_get_all(read_env_lines(env_path))
|
||||
manual = _effective_env_value(
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
|
||||
)
|
||||
except Exception:
|
||||
manual = "true"
|
||||
if _env_truthy(str(manual or "")):
|
||||
out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
|
||||
return out
|
||||
"""兼容旧调用名;对冲已移除,原样返回."""
|
||||
del env_path
|
||||
return dict(clean or {})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Gate.io 资金划转(crypto_monitor_gate 共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
INVALID_KEY_HINT = (
|
||||
".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;"
|
||||
"③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);"
|
||||
"④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单."
|
||||
)
|
||||
|
||||
|
||||
def execute_transfer_usdt(
|
||||
exchange,
|
||||
amount: float,
|
||||
from_account: str,
|
||||
to_account: str,
|
||||
*,
|
||||
transfer_ccy: str = "USDT",
|
||||
ensure_live_ready: Callable[[], tuple[bool, str]],
|
||||
ensure_markets_loaded: Optional[Callable[[], None]] = None,
|
||||
) -> tuple[bool, str, Any]:
|
||||
if amount <= 0:
|
||||
return False, "划转金额必须大于0", None
|
||||
ccy = (transfer_ccy or "USDT").strip().upper() or "USDT"
|
||||
ok_live, reason = ensure_live_ready()
|
||||
if not ok_live:
|
||||
return False, reason, None
|
||||
if ensure_markets_loaded:
|
||||
try:
|
||||
ensure_markets_loaded()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
resp = exchange.transfer(ccy, float(amount), from_account, to_account)
|
||||
return True, "划转成功", resp
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "INVALID_KEY" in msg or "Invalid key" in msg:
|
||||
msg += INVALID_KEY_HINT
|
||||
return False, msg, None
|
||||
|
||||
|
||||
def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int:
|
||||
"""自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划."""
|
||||
n = int(count_order_monitors(conn) or 0)
|
||||
if n > 0:
|
||||
return n
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM trend_pullback_plans "
|
||||
"WHERE status='active' AND COALESCE(first_order_done, 0) != 0"
|
||||
).fetchone()
|
||||
return int(row[0] or 0) if row else 0
|
||||
except Exception:
|
||||
return n
|
||||
@@ -213,16 +213,10 @@ def option_history_row_key(
|
||||
|
||||
|
||||
def _ms_to_iso(ms: Any) -> str | None:
|
||||
val = _safe_float(ms)
|
||||
if val is None or val <= 0:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
"""OKX ms → 业务时区墙钟(默认 Asia/Shanghai);勿用系统本地时区."""
|
||||
from lib.options.options_time_lib import ms_to_options_app_str
|
||||
|
||||
dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone()
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
return ms_to_options_app_str(ms)
|
||||
|
||||
|
||||
def option_instrument_meta_cached(
|
||||
@@ -716,7 +710,12 @@ def options_header_balance_pack(
|
||||
margin_mode = normalize_options_margin_mode()
|
||||
except Exception:
|
||||
margin_mode = "usdc"
|
||||
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
|
||||
try:
|
||||
from lib.options.options_underly_lib import resolve_default_underly
|
||||
|
||||
underly = resolve_default_underly()
|
||||
except Exception:
|
||||
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
|
||||
coin_key = "btc" if underly == "BTC" else "eth"
|
||||
return {
|
||||
"trading_usdc": _round(bal.get("trading_usdc")),
|
||||
@@ -800,6 +799,37 @@ def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str,
|
||||
return out
|
||||
|
||||
|
||||
def fetch_option_summaries(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
|
||||
"""GET /api/v5/public/opt-summary — 含 delta / deltaBS 等希腊字母.
|
||||
市场 tickers 不含 Delta,期权链展示必须走本接口.
|
||||
"""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
rows = ex.public_get_public_opt_summary({"instFamily": inst_family}).get("data") or []
|
||||
for r in rows:
|
||||
if isinstance(r, dict) and r.get("instId"):
|
||||
out[str(r["instId"])] = r
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def option_delta_from_summary(
|
||||
summary: dict[str, Any] | None = None,
|
||||
*,
|
||||
ticker: dict[str, Any] | None = None,
|
||||
) -> float | None:
|
||||
"""优先 Black-Scholes 单位 Delta(deltaBS),其次 delta;兼容 ticker 偶发字段."""
|
||||
for src in (summary, ticker):
|
||||
if not isinstance(src, dict):
|
||||
continue
|
||||
for key in ("deltaBS", "delta", "deltaBs", "delta_bs"):
|
||||
v = _safe_float(src.get(key))
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def build_option_chain(
|
||||
ex: ccxt.okx,
|
||||
underlying: str,
|
||||
@@ -820,7 +850,7 @@ def build_option_chain(
|
||||
|
||||
family = inst_family_for_underlying(u, margin_mode=margin_mode)
|
||||
except Exception:
|
||||
family = f"{u}-USD_UM"
|
||||
family = f"{u}-USD"
|
||||
uly = f"{u}-USD"
|
||||
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
|
||||
now_ms = time.time() * 1000
|
||||
@@ -848,6 +878,7 @@ def build_option_chain(
|
||||
except Exception as e2:
|
||||
instruments_err = str(e2) or e2.__class__.__name__
|
||||
tickers = fetch_option_tickers(ex, family)
|
||||
summaries = fetch_option_summaries(ex, family)
|
||||
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||
skipped_no_index = 0
|
||||
for meta in instruments:
|
||||
@@ -873,6 +904,7 @@ def build_option_chain(
|
||||
continue
|
||||
inst_id = str(meta.get("instId") or "")
|
||||
t = tickers.get(inst_id) or {}
|
||||
s = summaries.get(inst_id) or {}
|
||||
q = _resolve_chain_quote(
|
||||
ticker=t,
|
||||
meta=meta,
|
||||
@@ -885,6 +917,7 @@ def build_option_chain(
|
||||
mark = q["mark_px"]
|
||||
ask_sz = q["ask_sz"]
|
||||
bid_sz = q["bid_sz"]
|
||||
delta = option_delta_from_summary(s, ticker=t)
|
||||
expiry_be = expiry_breakeven_from_ask(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
@@ -905,6 +938,7 @@ def build_option_chain(
|
||||
"ask_sz": ask_sz,
|
||||
"bid_sz": bid_sz,
|
||||
"mark_px": mark,
|
||||
"delta": delta,
|
||||
"ask_estimated": q["ask_estimated"],
|
||||
"expiry_be_px": expiry_be,
|
||||
"dist_expiry_be": strike_distance_to_be(strike, expiry_be),
|
||||
@@ -1153,6 +1187,8 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
|
||||
if avg is None or avg <= 0:
|
||||
avg = fill_px
|
||||
state = str(o.get("state") or "").strip().lower()
|
||||
fee = _safe_float(o.get("fee"))
|
||||
fee_ccy = str(o.get("feeCcy") or o.get("fee_ccy") or "").strip().upper()
|
||||
return {
|
||||
"ok": True,
|
||||
"ord_id": str(o.get("ordId") or ord_id),
|
||||
@@ -1163,6 +1199,9 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
|
||||
"avg_px": avg,
|
||||
"side": str(o.get("side") or "").lower(),
|
||||
"ord_type": str(o.get("ordType") or ""),
|
||||
# OKX fee 扣费多为负;此处保留原始值,结算侧取绝对值
|
||||
"fee": fee,
|
||||
"fee_ccy": fee_ccy,
|
||||
"raw": o,
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -1508,6 +1547,7 @@ def format_option_history_row(
|
||||
"premium_ccy": premium_ccy,
|
||||
"margin_mode": row_mode,
|
||||
"realized_pnl": realized,
|
||||
"realized_pnl_usdt": _safe_float(raw.get("_sim_realized_pnl_usdt")),
|
||||
"realized_pnl_fmt": format_premium_amount(realized, ccy=premium_ccy),
|
||||
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
|
||||
"status": "closed",
|
||||
@@ -1601,6 +1641,9 @@ def resolve_option_close_from_history(
|
||||
u_i, row = item
|
||||
c_ms = _safe_float(row.get("cTime"))
|
||||
parts: list[float] = []
|
||||
# 全平/强平/ADL 优先于部分平仓(同合约多条时避免只吃到 type=1)
|
||||
close_type = str(row.get("type") or "").strip()
|
||||
parts.append(0.0 if close_type in ("2", "3", "6") else 1.0)
|
||||
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
|
||||
if want_sheets is not None:
|
||||
hist_sheets = _safe_float(row.get("closeTotalPos"))
|
||||
@@ -1615,7 +1658,7 @@ def resolve_option_close_from_history(
|
||||
parts.append(float(abs(int(c_ms) - int(open_ms))))
|
||||
if close_ms is not None:
|
||||
parts.append(float(abs(u_i - int(close_ms))))
|
||||
if not parts:
|
||||
if len(parts) <= 1:
|
||||
parts.append(float(-u_i))
|
||||
# 同距时偏向更晚平仓
|
||||
parts.append(float(-u_i))
|
||||
@@ -1809,6 +1852,7 @@ def format_position_row(
|
||||
) -> dict[str, Any]:
|
||||
from lib.options.options_pricing_lib import (
|
||||
close_breakeven_idx,
|
||||
equivalent_contract_leverage,
|
||||
expiry_breakeven_px,
|
||||
idx_distance_to_be,
|
||||
strike_distance_to_be,
|
||||
@@ -1834,6 +1878,9 @@ def format_position_row(
|
||||
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
|
||||
)
|
||||
delta_pa = _safe_float(pos.get("deltaPA"))
|
||||
delta_bs = _safe_float(pos.get("deltaBS"))
|
||||
if delta_bs is None:
|
||||
delta_bs = _safe_float(pos.get("delta"))
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
|
||||
|
||||
@@ -1894,6 +1941,16 @@ def format_position_row(
|
||||
"close_be_px": close_be,
|
||||
"dist_expiry_be": strike_distance_to_be(strike, expiry_be),
|
||||
"dist_close_be": idx_distance_to_be(idx_px, close_be),
|
||||
"contract_leverage": equivalent_contract_leverage(
|
||||
index_px=idx_px,
|
||||
eth_amount=eth_amount,
|
||||
total_premium=premium_paid,
|
||||
margin_mode=row_mode,
|
||||
),
|
||||
# 展示用单位 Delta(BS);delta_pa 为仓位美元敏感度
|
||||
"delta": delta_bs,
|
||||
"delta_bs": delta_bs,
|
||||
"delta_pa": delta_pa,
|
||||
"raw": pos,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""
|
||||
OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger).
|
||||
交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _order_dedupe_key(order: dict) -> str:
|
||||
info = order.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
return str(order.get("id") or info.get("algoId") or info.get("ordId") or "")
|
||||
|
||||
|
||||
def _okx_algo_cancel_id(order_id: str) -> str:
|
||||
oid = str(order_id or "")
|
||||
if ":" in oid:
|
||||
return oid.split(":", 1)[0]
|
||||
return oid
|
||||
|
||||
|
||||
def _okx_order_needs_stop_cancel_param(order: dict) -> bool:
|
||||
"""OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败."""
|
||||
if not isinstance(order, dict):
|
||||
return False
|
||||
info = order.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None:
|
||||
return True
|
||||
if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"):
|
||||
return True
|
||||
typ = str(order.get("type") or info.get("ordType") or "").lower()
|
||||
for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"):
|
||||
if token in typ:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]:
|
||||
"""合并 OKX 普通挂单与算法挂单(去重)."""
|
||||
if not exchange_symbol:
|
||||
return []
|
||||
ex.load_markets()
|
||||
sym = exchange_symbol
|
||||
try:
|
||||
sym = ex.market(exchange_symbol)["symbol"]
|
||||
except Exception:
|
||||
pass
|
||||
seen: set[str] = set()
|
||||
out: list[dict] = []
|
||||
|
||||
def add_batch(batch: list | None) -> None:
|
||||
for o in batch or []:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
k = _order_dedupe_key(o)
|
||||
if not k or k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
out.append(o)
|
||||
|
||||
try:
|
||||
add_batch(ex.fetch_open_orders(sym))
|
||||
except Exception:
|
||||
pass
|
||||
for params in (
|
||||
{"ordType": "conditional"},
|
||||
{"ordType": "oco"},
|
||||
{"trigger": True},
|
||||
):
|
||||
try:
|
||||
add_batch(ex.fetch_open_orders(sym, params=dict(params)))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int:
|
||||
"""
|
||||
撤销某合约全部挂单(普通 + 条件/算法).
|
||||
OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉.
|
||||
"""
|
||||
if not exchange_symbol:
|
||||
return 0
|
||||
ex.load_markets()
|
||||
sym = exchange_symbol
|
||||
try:
|
||||
sym = ex.market(exchange_symbol)["symbol"]
|
||||
except Exception:
|
||||
pass
|
||||
n = 0
|
||||
for o in fetch_okx_all_open_orders(ex, sym):
|
||||
oid = _order_dedupe_key(o)
|
||||
if not oid:
|
||||
continue
|
||||
cancel_id = _okx_algo_cancel_id(oid)
|
||||
params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None
|
||||
try:
|
||||
ex.cancel_order(cancel_id, sym, params)
|
||||
n += 1
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ex.cancel_order(oid, sym, params)
|
||||
n += 1
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ex.cancel_all_orders(sym)
|
||||
except Exception:
|
||||
pass
|
||||
return n
|
||||
@@ -1 +0,0 @@
|
||||
# hedge_plan package
|
||||
@@ -1,86 +0,0 @@
|
||||
"""对冲计划与单独期权开仓互斥门控.
|
||||
|
||||
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
|
||||
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
v = (os.getenv(key) or "").strip().lower()
|
||||
if not v:
|
||||
return default
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def mutual_exclusive_enabled() -> bool:
|
||||
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
|
||||
|
||||
|
||||
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
|
||||
"""若应拦截单独开期权,返回中文原因;否则 None."""
|
||||
if not mutual_exclusive_enabled():
|
||||
return None
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
|
||||
|
||||
init_hedge_plan_tables(conn)
|
||||
if count_active_plans(conn) > 0:
|
||||
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
|
||||
except Exception:
|
||||
return "互斥门控校验失败,暂禁止单独开期权"
|
||||
return None
|
||||
|
||||
|
||||
def _pos_nonzero(raw: dict[str, Any]) -> bool:
|
||||
try:
|
||||
return abs(float(raw.get("pos") or 0)) > 1e-12
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
|
||||
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
|
||||
if not raw_positions:
|
||||
return False
|
||||
from lib.instance.instance_dashboard_lib import _resolve_options_source
|
||||
|
||||
for p in raw_positions:
|
||||
if not isinstance(p, dict) or not _pos_nonzero(p):
|
||||
continue
|
||||
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
|
||||
if not inst:
|
||||
continue
|
||||
source, _, _ = _resolve_options_source(conn, inst)
|
||||
if source == "option":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def block_hedge_plan_start_msg(
|
||||
conn: Any,
|
||||
*,
|
||||
fetch_positions: Optional[Callable[[Any], Any]] = None,
|
||||
exchange: Any = None,
|
||||
raw_positions: list[dict[str, Any]] | None = None,
|
||||
) -> Optional[str]:
|
||||
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
|
||||
if not mutual_exclusive_enabled():
|
||||
return None
|
||||
rows = raw_positions
|
||||
if rows is None:
|
||||
if fetch_positions is None or exchange is None:
|
||||
return None
|
||||
try:
|
||||
rows = fetch_positions(exchange) or []
|
||||
except Exception:
|
||||
return "获取期权持仓失败,暂禁止启动对冲计划"
|
||||
try:
|
||||
if has_standalone_option_position(conn, rows):
|
||||
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
|
||||
except Exception:
|
||||
return "互斥门控校验失败,暂禁止启动对冲计划"
|
||||
return None
|
||||
@@ -1,782 +0,0 @@
|
||||
"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def perp_coin_amount(*, contracts: float, contract_size: float) -> float:
|
||||
return float(contracts) * float(contract_size or 1.0)
|
||||
|
||||
|
||||
def perp_pnl(
|
||||
*,
|
||||
direction: str,
|
||||
entry: float,
|
||||
exit_px: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
) -> float:
|
||||
coins = perp_coin_amount(contracts=contracts, contract_size=contract_size)
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return (float(entry) - float(exit_px)) * coins
|
||||
return (float(exit_px) - float(entry)) * coins
|
||||
|
||||
|
||||
def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float:
|
||||
"""卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult."""
|
||||
return float(ask) * float(sheets) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_expiry_pnl(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
premium_paid: float,
|
||||
) -> float:
|
||||
o = (opt_type or "").strip().upper()
|
||||
intrinsic_per_coin = 0.0
|
||||
if o in ("C", "CALL"):
|
||||
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
|
||||
elif o in ("P", "PUT"):
|
||||
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
|
||||
else:
|
||||
return -float(premium_paid)
|
||||
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
|
||||
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(
|
||||
*,
|
||||
notional: float,
|
||||
entry: float,
|
||||
contract_size: float,
|
||||
) -> float:
|
||||
if entry <= 0 or contract_size <= 0 or notional <= 0:
|
||||
return 0.0
|
||||
return float(notional) / (float(entry) * float(contract_size))
|
||||
|
||||
|
||||
def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
|
||||
"""按交易所张数精度向下取整,避免建议张数超过可用保证金."""
|
||||
import math
|
||||
|
||||
raw = float(contracts or 0.0)
|
||||
if raw <= 0:
|
||||
return 0.0
|
||||
try:
|
||||
d = int(decimals)
|
||||
except (TypeError, ValueError):
|
||||
d = 0
|
||||
if d <= 0:
|
||||
return float(math.floor(raw + 1e-12))
|
||||
scale = 10**d
|
||||
return math.floor(raw * scale + 1e-12) / scale
|
||||
|
||||
|
||||
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
|
||||
"""单张权利金(USDC) = 卖一价 × ct_mult."""
|
||||
a = _f(ask)
|
||||
if a is None or a <= 0:
|
||||
return 0.0
|
||||
return float(a) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def resolve_oo_budget_usdc(
|
||||
*,
|
||||
trading_usdc: Any,
|
||||
trade_budget_usdc: Any,
|
||||
buffer_ratio: Any = 0.95,
|
||||
) -> dict[str, Any]:
|
||||
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
|
||||
import math
|
||||
|
||||
trading = _f(trading_usdc)
|
||||
cap = _f(trade_budget_usdc)
|
||||
buf = _f(buffer_ratio)
|
||||
if buf is None or buf <= 0:
|
||||
buf = 0.95
|
||||
if buf > 1:
|
||||
buf = 1.0
|
||||
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
|
||||
trade_cap = None if cap is None else max(0.0, float(cap))
|
||||
if trading_cap is None and trade_cap is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"budget_usdc": 0.0,
|
||||
"trading_cap": None,
|
||||
"trade_budget_cap": None,
|
||||
"buffer_ratio": float(buf),
|
||||
"msg": "缺少交易户余额与单笔预算",
|
||||
}
|
||||
if trading_cap is None:
|
||||
budget = float(trade_cap or 0.0)
|
||||
elif trade_cap is None:
|
||||
budget = float(trading_cap)
|
||||
else:
|
||||
budget = min(float(trading_cap), float(trade_cap))
|
||||
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
|
||||
return {
|
||||
"ok": budget > 0,
|
||||
"budget_usdc": budget,
|
||||
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
|
||||
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
|
||||
"buffer_ratio": float(buf),
|
||||
"msg": "" if budget > 0 else "可用预算为 0",
|
||||
}
|
||||
|
||||
|
||||
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
|
||||
import math
|
||||
|
||||
n = max(0, int(sheets))
|
||||
depth = _f(ask_sz)
|
||||
if depth is None:
|
||||
return n
|
||||
if depth <= 0:
|
||||
return 0
|
||||
return min(n, int(math.floor(float(depth) + 1e-12)))
|
||||
|
||||
|
||||
def _normalize_oo_sheets_mode(mode: str) -> str:
|
||||
m = (mode or "same_sheets").strip().lower()
|
||||
if m in ("long_bias", "bias_long", "long", "做多"):
|
||||
return "long_bias"
|
||||
if m in ("short_bias", "bias_short", "short", "做空"):
|
||||
return "short_bias"
|
||||
# 旧「均分」兼容:按预算 50/50(页面已移除)
|
||||
if m in ("split", "equal_budget", "split_budget", "均分"):
|
||||
return "split_budget"
|
||||
return "same_sheets"
|
||||
|
||||
|
||||
def _normalize_oo_bias_split_by(raw: Any) -> str:
|
||||
v = str(raw or "budget").strip().lower()
|
||||
if v in ("sheets", "qty", "quantity", "张数"):
|
||||
return "sheets"
|
||||
return "budget"
|
||||
|
||||
|
||||
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
|
||||
try:
|
||||
r = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
r = float(default)
|
||||
if r <= 0 or r >= 1:
|
||||
r = float(default)
|
||||
return r
|
||||
|
||||
|
||||
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
|
||||
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
|
||||
a = (opt_type_a or "").strip().upper()
|
||||
b = (opt_type_b or "").strip().upper()
|
||||
if a.startswith("C"):
|
||||
a = "C"
|
||||
elif a.startswith("P"):
|
||||
a = "P"
|
||||
if b.startswith("C"):
|
||||
b = "C"
|
||||
elif b.startswith("P"):
|
||||
b = "P"
|
||||
if {a, b} != {"C", "P"}:
|
||||
return None, None, "做多/做空需一腿 Call、一腿 Put"
|
||||
call_side = "a" if a == "C" else "b"
|
||||
put_side = "b" if call_side == "a" else "a"
|
||||
return call_side, put_side, ""
|
||||
|
||||
|
||||
def suggest_oo_sheets(
|
||||
*,
|
||||
mode: str,
|
||||
budget_usdc: float,
|
||||
ask_a: float,
|
||||
ct_mult_a: float = 0.01,
|
||||
ask_sz_a: Any = None,
|
||||
opt_type_a: str = "",
|
||||
ask_b: float,
|
||||
ct_mult_b: float = 0.01,
|
||||
ask_sz_b: Any = None,
|
||||
opt_type_b: str = "",
|
||||
bias_split_by: str = "budget",
|
||||
bias_ratio: float = 0.7,
|
||||
) -> dict[str, Any]:
|
||||
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
|
||||
import math
|
||||
|
||||
m = _normalize_oo_sheets_mode(mode)
|
||||
split_by = _normalize_oo_bias_split_by(bias_split_by)
|
||||
ratio = _clamp_oo_bias_ratio(bias_ratio)
|
||||
budget = max(0.0, float(budget_usdc or 0.0))
|
||||
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
|
||||
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
|
||||
|
||||
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
|
||||
return {
|
||||
"mode": m,
|
||||
"sheets_a": n_a,
|
||||
"sheets_b": n_b,
|
||||
"cost_a": round(cost_a, 8),
|
||||
"cost_b": round(cost_b, 8),
|
||||
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
|
||||
"ok": False,
|
||||
"msg": msg,
|
||||
"bias_split_by": split_by,
|
||||
"bias_ratio": ratio,
|
||||
}
|
||||
|
||||
if budget <= 0:
|
||||
return _fail("可用预算为 0")
|
||||
if cost_a <= 0 or cost_b <= 0:
|
||||
return _fail("缺少有效卖一价,无法建议张数")
|
||||
|
||||
pair = cost_a + cost_b
|
||||
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
|
||||
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
|
||||
n_same = min(
|
||||
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
|
||||
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
|
||||
)
|
||||
|
||||
if m == "same_sheets":
|
||||
n_a = n_same
|
||||
n_b = n_same
|
||||
elif m == "split_budget":
|
||||
half = budget / 2.0
|
||||
n_a = int(math.floor(half / cost_a + 1e-12))
|
||||
n_b = int(math.floor(half / cost_b + 1e-12))
|
||||
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
|
||||
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
|
||||
else:
|
||||
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
|
||||
if err:
|
||||
return _fail(err)
|
||||
major_is_call = m == "long_bias"
|
||||
if split_by == "sheets":
|
||||
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
|
||||
total = int(n_same) * 2
|
||||
if total < 2:
|
||||
return _fail("同张数总规模不足 2,无法按比例拆分")
|
||||
major_n = int(round(total * ratio))
|
||||
major_n = max(1, min(major_n, total - 1))
|
||||
minor_n = total - major_n
|
||||
n_call = major_n if major_is_call else minor_n
|
||||
n_put = minor_n if major_is_call else major_n
|
||||
else:
|
||||
maj_budget = budget * ratio
|
||||
min_budget = budget * (1.0 - ratio)
|
||||
cost_call = cost_a if call_side == "a" else cost_b
|
||||
cost_put = cost_b if call_side == "a" else cost_a
|
||||
if major_is_call:
|
||||
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
|
||||
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
|
||||
else:
|
||||
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
|
||||
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
|
||||
n_a = n_call if call_side == "a" else n_put
|
||||
n_b = n_put if call_side == "a" else n_call
|
||||
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
|
||||
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
|
||||
|
||||
prem = cost_a * n_a + cost_b * n_b
|
||||
ok = n_a >= 1 and n_b >= 1
|
||||
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
|
||||
return {
|
||||
"mode": m,
|
||||
"sheets_a": n_a,
|
||||
"sheets_b": n_b,
|
||||
"cost_a": round(cost_a, 8),
|
||||
"cost_b": round(cost_b, 8),
|
||||
"premium_est": round(prem, 6),
|
||||
"ok": ok,
|
||||
"msg": msg,
|
||||
"bias_split_by": split_by,
|
||||
"bias_ratio": ratio,
|
||||
}
|
||||
|
||||
|
||||
def build_perp_options_preview(
|
||||
*,
|
||||
direction: str,
|
||||
entry: float,
|
||||
tp: float,
|
||||
sl: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
premium_paid: float,
|
||||
index_px: Optional[float] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
永期情景.
|
||||
止盈账:永续止盈盈利 - 权利金.
|
||||
止损账:期权到期内在(按 SL 价) - 永续止损亏损额.
|
||||
"""
|
||||
d = (direction or "long").strip().lower()
|
||||
pnl_tp_perp = perp_pnl(
|
||||
direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size
|
||||
)
|
||||
pnl_sl_perp = perp_pnl(
|
||||
direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size
|
||||
)
|
||||
# 止盈统计口径
|
||||
tp_total = float(pnl_tp_perp) - float(premium_paid)
|
||||
# 止损:期权按 SL 价结算内在 - |永续亏损|
|
||||
opt_at_sl = option_expiry_pnl(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
spot=sl,
|
||||
sheets=sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=premium_paid,
|
||||
)
|
||||
sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float(
|
||||
pnl_sl_perp
|
||||
)
|
||||
# 有符号相加更稳:期权盈亏 + 永续盈亏
|
||||
sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp)
|
||||
|
||||
spot = float(index_px) if index_px is not None else float(entry)
|
||||
opt_flat = option_expiry_pnl(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
spot=spot,
|
||||
sheets=sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=premium_paid,
|
||||
)
|
||||
flat_total = 0.0 + float(opt_flat)
|
||||
|
||||
opt_at_tp = option_expiry_pnl(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
spot=tp,
|
||||
sheets=sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=premium_paid,
|
||||
)
|
||||
|
||||
return {
|
||||
"plan_type": "perp_options",
|
||||
"direction": d,
|
||||
"contracts": contracts,
|
||||
"coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size),
|
||||
"premium_paid": round(float(premium_paid), 6),
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "tp",
|
||||
"label": "止盈(计划结束口径)",
|
||||
"spot": tp,
|
||||
"perp_pnl": round(pnl_tp_perp, 4),
|
||||
"options_pnl": round(-float(premium_paid), 4),
|
||||
"total": round(tp_total, 4),
|
||||
"note": "止盈盈利 − 权利金;期权可不强平",
|
||||
},
|
||||
{
|
||||
"id": "sl",
|
||||
"label": "止损(计划结束口径)",
|
||||
"spot": sl,
|
||||
"perp_pnl": round(pnl_sl_perp, 4),
|
||||
"options_pnl": round(opt_at_sl, 4),
|
||||
"total": round(sl_total_signed, 4),
|
||||
"note": "期权盈利 − 永续亏损(有符号相加);期权须强平",
|
||||
},
|
||||
{
|
||||
"id": "flat",
|
||||
"label": "到期·现价附近",
|
||||
"spot": spot,
|
||||
"perp_pnl": 0.0,
|
||||
"options_pnl": round(opt_flat, 4),
|
||||
"total": round(flat_total, 4),
|
||||
"note": "示意:永续未动,期权按到期内在",
|
||||
},
|
||||
{
|
||||
"id": "expiry_tp",
|
||||
"label": "到期·止盈价",
|
||||
"spot": tp,
|
||||
"perp_pnl": round(pnl_tp_perp, 4),
|
||||
"options_pnl": round(opt_at_tp, 4),
|
||||
"total": round(pnl_tp_perp + opt_at_tp, 4),
|
||||
"note": "若期权拿到 TP 价到期(参考)",
|
||||
},
|
||||
{
|
||||
"id": "expiry_sl",
|
||||
"label": "到期·止损价",
|
||||
"spot": sl,
|
||||
"perp_pnl": round(pnl_sl_perp, 4),
|
||||
"options_pnl": round(opt_at_sl, 4),
|
||||
"total": round(pnl_sl_perp + opt_at_sl, 4),
|
||||
"note": "与止损口径相近(期权用内在)",
|
||||
},
|
||||
],
|
||||
"summary": {
|
||||
"tp_total": round(tp_total, 4),
|
||||
"sl_total": round(sl_total_signed, 4),
|
||||
"premium_paid": round(float(premium_paid), 4),
|
||||
"hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
|
||||
loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0
|
||||
if loss <= 1e-12:
|
||||
return None
|
||||
if float(opt_pnl) <= 0:
|
||||
return 0.0
|
||||
return round(float(opt_pnl) / loss * 100.0, 2)
|
||||
|
||||
|
||||
def build_options_options_preview(
|
||||
*,
|
||||
target_price: float | None = None,
|
||||
target_price_up: float | None = None,
|
||||
target_price_down: float | None = None,
|
||||
profit_rr: float | None = None,
|
||||
index_px: float,
|
||||
leg_a: dict[str, Any],
|
||||
leg_b: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
|
||||
|
||||
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
|
||||
残值按亏损腿本合约权利金的 20% 计.
|
||||
"""
|
||||
|
||||
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
||||
return option_expiry_pnl(
|
||||
opt_type=str(leg.get("opt_type") or ""),
|
||||
strike=float(leg["strike"]),
|
||||
spot=spot,
|
||||
sheets=float(leg.get("sheets") or 0),
|
||||
ct_mult=float(leg.get("ct_mult") or 0.01),
|
||||
premium_paid=float(leg.get("premium_paid") or 0),
|
||||
)
|
||||
|
||||
prem_a = float(leg_a.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)
|
||||
b_flat = _leg_pnl(leg_b, index_px)
|
||||
flat_total = a_flat + b_flat
|
||||
|
||||
return {
|
||||
"plan_type": "options_options",
|
||||
"premium_paid": round(prem, 6),
|
||||
"profit_rr": rr,
|
||||
"target_price": None,
|
||||
"target_price_up": None,
|
||||
"target_price_down": None,
|
||||
"winner_at_up": "a",
|
||||
"winner_at_down": "b",
|
||||
"winner_at_target": "a",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rr_leg_a_full",
|
||||
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
|
||||
"spot": spot_a,
|
||||
"leg_a_pnl": round(a_at_a, 4),
|
||||
"leg_b_pnl": round(b_at_a_full, 4),
|
||||
"total": round(a_at_a + b_at_a_full, 4),
|
||||
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"label": "到期·现价",
|
||||
"spot": index_px,
|
||||
"leg_a_pnl": round(a_flat, 4),
|
||||
"leg_b_pnl": round(b_flat, 4),
|
||||
"total": round(flat_total, 4),
|
||||
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
|
||||
},
|
||||
{
|
||||
"id": "max_premium_loss",
|
||||
"label": "最大保费损耗",
|
||||
"spot": None,
|
||||
"leg_a_pnl": round(-prem_a, 4),
|
||||
"leg_b_pnl": round(-prem_b, 4),
|
||||
"total": round(-prem, 4),
|
||||
"note": "双腿权利金全部损失",
|
||||
},
|
||||
],
|
||||
"summary": {
|
||||
"profit_rr": rr,
|
||||
"spot_at_rr_a": spot_a,
|
||||
"spot_at_rr_b": spot_b,
|
||||
"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),
|
||||
"premium_paid": round(prem, 6),
|
||||
"expiry_is_loss": flat_total <= 0,
|
||||
"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_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
|
||||
down = target_price_down if target_price_down is not None else target_price
|
||||
if up is None or down is None:
|
||||
raise ValueError("缺少盈亏比或上破/下破目标价")
|
||||
up_f = float(up)
|
||||
down_f = float(down)
|
||||
|
||||
a_up = _leg_pnl(leg_a, up_f)
|
||||
b_up = _leg_pnl(leg_b, up_f)
|
||||
at_up = a_up + b_up
|
||||
win_up = "a" if a_up >= b_up else "b"
|
||||
|
||||
a_dn = _leg_pnl(leg_a, down_f)
|
||||
b_dn = _leg_pnl(leg_b, down_f)
|
||||
at_dn = a_dn + b_dn
|
||||
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 {
|
||||
"plan_type": "options_options",
|
||||
"premium_paid": round(prem, 6),
|
||||
"target_price": up_f, # 兼容旧字段,取上破
|
||||
"target_price_up": up_f,
|
||||
"target_price_down": down_f,
|
||||
"winner_at_up": win_up,
|
||||
"winner_at_down": win_dn,
|
||||
"winner_at_target": win_up,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "target_up",
|
||||
"label": "上破目标",
|
||||
"spot": up_f,
|
||||
"leg_a_pnl": round(a_up, 4),
|
||||
"leg_b_pnl": round(b_up, 4),
|
||||
"total": round(at_up, 4),
|
||||
"note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期",
|
||||
},
|
||||
{
|
||||
"id": "target_down",
|
||||
"label": "下破目标",
|
||||
"spot": down_f,
|
||||
"leg_a_pnl": round(a_dn, 4),
|
||||
"leg_b_pnl": round(b_dn, 4),
|
||||
"total": round(at_dn, 4),
|
||||
"note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期",
|
||||
},
|
||||
{
|
||||
"id": "expiry_flat",
|
||||
"label": "到期·现价(无突破)",
|
||||
"spot": index_px,
|
||||
"leg_a_pnl": round(a_flat, 4),
|
||||
"leg_b_pnl": round(b_flat, 4),
|
||||
"total": round(flat_total, 4),
|
||||
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
|
||||
},
|
||||
{
|
||||
"id": "max_premium_loss",
|
||||
"label": "最大保费损耗",
|
||||
"spot": None,
|
||||
"leg_a_pnl": round(-prem_a, 4),
|
||||
"leg_b_pnl": round(-prem_b, 4),
|
||||
"total": round(-prem, 4),
|
||||
"note": "双腿权利金全部损失",
|
||||
},
|
||||
],
|
||||
"summary": {
|
||||
"at_target_up_total": round(at_up, 4),
|
||||
"at_target_down_total": round(at_dn, 4),
|
||||
"at_target_total": round(at_up, 4),
|
||||
"expiry_flat_total": round(expiry_loss, 4),
|
||||
"premium_paid": round(prem, 6),
|
||||
"expiry_is_loss": flat_total <= 0,
|
||||
# 盈亏比:盈利/全亏保费(风险=权利金全损)
|
||||
"rr_risk_premium": round(prem, 6),
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def gate_status(
|
||||
*,
|
||||
hedge_enabled: bool,
|
||||
sizing_mode: str,
|
||||
plan_type: str,
|
||||
options_enabled: bool,
|
||||
live_order: bool = False,
|
||||
live_trading: bool = False,
|
||||
active_count: int = 0,
|
||||
max_active: int = 1,
|
||||
show_perp_options: bool = True,
|
||||
show_options_options: bool = True,
|
||||
mutual_exclusive: bool = True,
|
||||
has_standalone_option: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from lib.trade.position_sizing_lib import is_full_margin_mode
|
||||
|
||||
full = is_full_margin_mode(sizing_mode)
|
||||
pt = (plan_type or "").strip().lower()
|
||||
can_preview = True
|
||||
can_start = True
|
||||
reasons: list[str] = []
|
||||
if not hedge_enabled:
|
||||
can_start = False
|
||||
reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)")
|
||||
if not options_enabled:
|
||||
can_preview = False
|
||||
can_start = False
|
||||
reasons.append("期权模块未启用")
|
||||
if pt == "perp_options" and not show_perp_options:
|
||||
can_preview = False
|
||||
can_start = False
|
||||
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
|
||||
if pt == "options_options" and not show_options_options:
|
||||
can_preview = False
|
||||
can_start = False
|
||||
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
|
||||
if not live_order:
|
||||
can_start = False
|
||||
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
|
||||
if active_count >= max(1, int(max_active or 1)):
|
||||
can_start = False
|
||||
reasons.append(f"活跃计划已达上限({max_active})")
|
||||
if mutual_exclusive and has_standalone_option:
|
||||
can_start = False
|
||||
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
|
||||
if pt == "perp_options":
|
||||
if not full:
|
||||
can_start = False
|
||||
reasons.append("永期开仓仅全仓模式可用(当前可测算)")
|
||||
if not live_trading:
|
||||
can_start = False
|
||||
reasons.append("未开启实盘(LIVE_TRADING_ENABLED)")
|
||||
elif pt == "options_options":
|
||||
pass
|
||||
else:
|
||||
can_start = False
|
||||
reasons.append("未知计划类型")
|
||||
if can_start:
|
||||
reasons = []
|
||||
return {
|
||||
"hedge_enabled": hedge_enabled,
|
||||
"options_enabled": options_enabled,
|
||||
"sizing_mode": sizing_mode,
|
||||
"is_full_margin": full,
|
||||
"plan_type": pt,
|
||||
"live_order": live_order,
|
||||
"live_trading": live_trading,
|
||||
"active_count": active_count,
|
||||
"max_active": max_active,
|
||||
"show_perp_options": bool(show_perp_options),
|
||||
"show_options_options": bool(show_options_options),
|
||||
"mutual_exclusive": bool(mutual_exclusive),
|
||||
"has_standalone_option": bool(has_standalone_option),
|
||||
"can_preview": can_preview,
|
||||
"can_start": can_start,
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
"""对冲计划 SQLite 表."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hedge_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
underlying TEXT NOT NULL,
|
||||
direction TEXT,
|
||||
entry_mark REAL,
|
||||
tp REAL,
|
||||
sl REAL,
|
||||
target_price REAL,
|
||||
sizing_mode_at_open TEXT,
|
||||
perp_size REAL,
|
||||
margin REAL,
|
||||
leverage REAL,
|
||||
premium_total REAL,
|
||||
realized_pnl_perp REAL,
|
||||
realized_pnl_options REAL,
|
||||
realized_pnl_total REAL,
|
||||
stats_bucket TEXT,
|
||||
close_reason TEXT,
|
||||
wechat_start_sent INTEGER DEFAULT 0,
|
||||
wechat_end_sent INTEGER DEFAULT 0,
|
||||
note TEXT,
|
||||
preview_json TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
opened_at TIMESTAMP,
|
||||
closed_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hedge_plan_legs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL,
|
||||
leg_role TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
inst_id TEXT,
|
||||
opt_type TEXT,
|
||||
strike REAL,
|
||||
side TEXT,
|
||||
size REAL,
|
||||
avg_open REAL,
|
||||
premium REAL,
|
||||
status TEXT,
|
||||
linked_monitor_id INTEGER,
|
||||
options_trade_id INTEGER,
|
||||
exchange_ord_id TEXT,
|
||||
realized_pnl REAL,
|
||||
close_reason TEXT,
|
||||
opened_at TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
FOREIGN KEY(plan_id) REFERENCES hedge_plans(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)"
|
||||
)
|
||||
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
||||
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
|
||||
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
|
||||
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
|
||||
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
||||
# 永期「以期权为主」
|
||||
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
|
||||
_ensure_column(conn, "hedge_plans", "option_target_points", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "perp_target_points", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "premium_budget", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
|
||||
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
|
||||
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
|
||||
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
|
||||
|
||||
|
||||
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
|
||||
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
|
||||
if plan_type:
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
|
||||
(plan_type,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
|
||||
).fetchone()
|
||||
return int((row["c"] if row else 0) or 0)
|
||||
|
||||
|
||||
def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
|
||||
cols = list(row.keys())
|
||||
placeholders = ",".join(["?"] * len(cols))
|
||||
conn.execute(
|
||||
f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})",
|
||||
[row[c] for c in cols],
|
||||
)
|
||||
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
|
||||
|
||||
def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
|
||||
cols = list(row.keys())
|
||||
placeholders = ",".join(["?"] * len(cols))
|
||||
conn.execute(
|
||||
f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})",
|
||||
[row[c] for c in cols],
|
||||
)
|
||||
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
|
||||
|
||||
def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
|
||||
if not fields:
|
||||
return
|
||||
sets = ", ".join(f"{k}=?" for k in fields)
|
||||
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
|
||||
|
||||
|
||||
def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
|
||||
if not fields:
|
||||
return
|
||||
sets = ", ".join(f"{k}=?" for k in fields)
|
||||
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
|
||||
|
||||
|
||||
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
|
||||
for leg in legs or []:
|
||||
if str(leg.get("status") or "").strip().lower() == "pending":
|
||||
role = str(leg.get("leg_role") or "").strip()
|
||||
if role:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def list_plans(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
plan_type: Optional[str] = None,
|
||||
underlying: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
wheres: list[str] = []
|
||||
args: list[Any] = []
|
||||
if status:
|
||||
wheres.append("status=?")
|
||||
args.append(status)
|
||||
if plan_type:
|
||||
wheres.append("plan_type=?")
|
||||
args.append(plan_type)
|
||||
if underlying:
|
||||
wheres.append("underlying=?")
|
||||
args.append(underlying)
|
||||
where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?",
|
||||
[*args, int(limit)],
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]:
|
||||
row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
|
||||
"""删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除."""
|
||||
plan = get_plan(conn, int(plan_id))
|
||||
if not plan:
|
||||
return {"ok": False, "msg": "计划不存在"}
|
||||
st = str(plan.get("status") or "")
|
||||
if st in ("opening", "active", "partial", "watching"):
|
||||
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
|
||||
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
|
||||
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
|
||||
return {"ok": True, "deleted_id": int(plan_id)}
|
||||
|
||||
|
||||
def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
|
||||
parts: list[str] = []
|
||||
for leg in legs:
|
||||
role = str(leg.get("leg_role") or "")
|
||||
st = str(leg.get("status") or "").strip().lower()
|
||||
if st == "pending":
|
||||
suffix = "(待补)"
|
||||
elif st in ("cancelled", "canceled"):
|
||||
suffix = "(未成交)"
|
||||
else:
|
||||
suffix = ""
|
||||
if role == "perp":
|
||||
name = str(leg.get("symbol") or "永续")
|
||||
parts.append(f"永续 {name}{suffix}")
|
||||
else:
|
||||
inst = str(leg.get("inst_id") or "")
|
||||
ot = str(leg.get("opt_type") or "").upper()
|
||||
strike = leg.get("strike")
|
||||
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
|
||||
parts.append(f"{label}{suffix}")
|
||||
return " · ".join(parts) if parts else "—"
|
||||
|
||||
|
||||
def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in plans:
|
||||
legs = get_plan_legs(conn, int(p["id"]))
|
||||
row = dict(p)
|
||||
row["legs"] = legs
|
||||
summary = legs_contract_summary(legs)
|
||||
if str(p.get("status") or "") == "watching" and (not legs or summary == "—"):
|
||||
money = str(p.get("option_moneyness") or "otm")
|
||||
money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
|
||||
parts = [f"盯盘·{money_lab}"]
|
||||
try:
|
||||
if p.get("strike_interval") not in (None, ""):
|
||||
parts.append(f"间隔{float(p.get('strike_interval')):g}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
if p.get("option_leverage") not in (None, ""):
|
||||
parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
summary = "·".join(parts)
|
||||
row["contracts_summary"] = summary
|
||||
row["missing_leg"] = missing_leg_role(legs)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
|
||||
|
||||
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
|
||||
否则两套监控会同时尝试平掉同一条期权腿。
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
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
|
||||
FROM hedge_plans p
|
||||
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
||||
WHERE p.plan_type = 'options_options'
|
||||
AND p.status IN ('opening', 'active', 'partial')
|
||||
AND l.status = 'open'
|
||||
AND l.inst_id IS NOT NULL
|
||||
AND l.inst_id != ''
|
||||
ORDER BY p.id DESC, l.id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for raw in rows:
|
||||
row = dict(raw)
|
||||
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:
|
||||
continue
|
||||
profit_rr = _sf(row.get("profit_rr"))
|
||||
if profit_rr is not None and profit_rr > 0:
|
||||
out[inst_id] = {
|
||||
"plan_id": int(row["plan_id"]),
|
||||
"inst_id": inst_id,
|
||||
"underlying": row.get("underlying"),
|
||||
"opt_type": opt_type,
|
||||
"profit_rr": profit_rr,
|
||||
"target_index": None,
|
||||
"exit_mode": "profit_rr",
|
||||
"managed_by": "hedge_plan",
|
||||
}
|
||||
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:
|
||||
continue
|
||||
out[inst_id] = {
|
||||
"plan_id": int(row["plan_id"]),
|
||||
"inst_id": inst_id,
|
||||
"underlying": row.get("underlying"),
|
||||
"opt_type": opt_type,
|
||||
"target_index": target_f,
|
||||
"plan_type": "options_options",
|
||||
"managed_by": "hedge_plan",
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]:
|
||||
"""进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组."""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT l.inst_id
|
||||
FROM hedge_plan_legs l
|
||||
JOIN hedge_plans p ON p.id = l.plan_id
|
||||
WHERE p.status IN ('opening', 'active', 'partial')
|
||||
AND l.status IN ('open', 'hold_to_expiry')
|
||||
AND l.inst_id IS NOT NULL
|
||||
AND TRIM(l.inst_id) != ''
|
||||
AND (
|
||||
l.leg_role LIKE 'option%'
|
||||
OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '')
|
||||
)
|
||||
"""
|
||||
).fetchall()
|
||||
return {str(r[0]).strip() for r in rows if r and r[0]}
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤."""
|
||||
pnls: list[float] = []
|
||||
timed: list[tuple[str, float]] = []
|
||||
for r in rows:
|
||||
pnl = _sf(r.get("realized_pnl_total"))
|
||||
if pnl is None:
|
||||
continue
|
||||
pnls.append(pnl)
|
||||
t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "")
|
||||
timed.append((t, pnl))
|
||||
n = len(pnls)
|
||||
if n == 0:
|
||||
return {
|
||||
"count": 0,
|
||||
"wins": 0,
|
||||
"losses": 0,
|
||||
"win_rate": None,
|
||||
"net_pnl": 0.0,
|
||||
"avg_pnl": None,
|
||||
"avg_premium": None,
|
||||
"profit_factor": None,
|
||||
"max_profit": None,
|
||||
"max_loss": None,
|
||||
"max_drawdown": None,
|
||||
}
|
||||
wins = [x for x in pnls if x > 0]
|
||||
losses = [x for x in pnls if x < 0]
|
||||
gross_win = sum(wins)
|
||||
gross_loss = abs(sum(losses))
|
||||
if gross_loss > 0:
|
||||
profit_factor = round(gross_win / gross_loss, 4)
|
||||
elif gross_win > 0:
|
||||
profit_factor = None # 全胜,标无限
|
||||
else:
|
||||
profit_factor = 0.0
|
||||
|
||||
timed.sort(key=lambda x: x[0] or "")
|
||||
cum = 0.0
|
||||
peak = 0.0
|
||||
mdd = 0.0
|
||||
for _, p in timed:
|
||||
cum += p
|
||||
if cum > peak:
|
||||
peak = cum
|
||||
dd = peak - cum
|
||||
if dd > mdd:
|
||||
mdd = dd
|
||||
|
||||
premiums = [_sf(r.get("premium_total")) for r in rows]
|
||||
premiums_f = [x for x in premiums if x is not None]
|
||||
return {
|
||||
"count": n,
|
||||
"wins": len(wins),
|
||||
"losses": len(losses),
|
||||
"win_rate": round(len(wins) / n, 4),
|
||||
"net_pnl": round(sum(pnls), 4),
|
||||
"avg_pnl": round(sum(pnls) / n, 4),
|
||||
"avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None,
|
||||
"profit_factor": profit_factor,
|
||||
"profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0),
|
||||
"max_profit": round(max(pnls), 4),
|
||||
"max_loss": round(min(pnls), 4),
|
||||
"max_drawdown": round(mdd, 4),
|
||||
}
|
||||
|
||||
|
||||
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
reason_rows = conn.execute(
|
||||
"""
|
||||
SELECT plan_type, close_reason, COUNT(1) AS n,
|
||||
COALESCE(SUM(realized_pnl_total), 0) AS pnl
|
||||
FROM hedge_plans
|
||||
WHERE status='closed'
|
||||
GROUP BY plan_type, close_reason
|
||||
"""
|
||||
).fetchall()
|
||||
closed_rows = [
|
||||
dict(r)
|
||||
for r in conn.execute(
|
||||
"SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id"
|
||||
).fetchall()
|
||||
]
|
||||
active = count_active_plans(conn)
|
||||
overall = _metrics_from_pnls(closed_rows)
|
||||
by_type = {
|
||||
"perp_options": _metrics_from_pnls(
|
||||
[r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
||||
),
|
||||
"options_options": _metrics_from_pnls(
|
||||
[r for r in closed_rows if r.get("plan_type") == "options_options"]
|
||||
),
|
||||
}
|
||||
# 永期止盈/止损分桶
|
||||
po = [r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
||||
by_type["perp_options"]["buckets"] = {
|
||||
"tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]),
|
||||
"sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]),
|
||||
}
|
||||
oo = [r for r in closed_rows if r.get("plan_type") == "options_options"]
|
||||
by_type["options_options"]["buckets"] = {
|
||||
"expiry_loss": _metrics_from_pnls(
|
||||
[r for r in oo if r.get("close_reason") == "oo_expiry_loss"]
|
||||
),
|
||||
"expiry_win": _metrics_from_pnls(
|
||||
[r for r in oo if r.get("close_reason") == "oo_expiry_win"]
|
||||
),
|
||||
}
|
||||
return {
|
||||
"active": active,
|
||||
"closed_count": overall["count"],
|
||||
"closed_pnl_total": overall["net_pnl"],
|
||||
"overall": overall,
|
||||
"by_type": by_type,
|
||||
"by_reason": [dict(r) for r in reason_rows],
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
"""对冲计划虚实值选约与校验.
|
||||
|
||||
永期(perp_options):期权腿仅允许实值或平值(禁虚值).
|
||||
期期(options_options):两腿仅允许平值或虚值(禁实值).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name) or default)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def itm_max_dist_usd() -> float:
|
||||
"""过深实值上限(USD).优先对冲专用,否则回退期权页."""
|
||||
raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0))
|
||||
|
||||
|
||||
def min_option_hours() -> float:
|
||||
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0))
|
||||
|
||||
|
||||
def min_option_leverage() -> float:
|
||||
"""指数/卖一 最低杠杆门槛;0=不启用."""
|
||||
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0))
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str:
|
||||
o = str(opt_type or "").strip().upper()
|
||||
if o in ("C", "CALL"):
|
||||
return "C"
|
||||
if o in ("P", "PUT"):
|
||||
return "P"
|
||||
inst = str(inst_id or "").upper()
|
||||
if inst.endswith("-C") or inst.endswith("-CALL"):
|
||||
return "C"
|
||||
if inst.endswith("-P") or inst.endswith("-PUT"):
|
||||
return "P"
|
||||
return ""
|
||||
|
||||
|
||||
def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
|
||||
"""itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径."""
|
||||
from lib.options.options_pricing_lib import option_moneyness
|
||||
|
||||
return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
||||
|
||||
|
||||
def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
||||
"""Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm."""
|
||||
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
||||
if m in ("itm", "atm"):
|
||||
return True
|
||||
# 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if o == "C":
|
||||
return k <= s + 1e-9
|
||||
if o == "P":
|
||||
return k >= s - 1e-9
|
||||
return False
|
||||
|
||||
|
||||
def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
||||
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
||||
if m in ("atm", "otm"):
|
||||
return True
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if o == "C":
|
||||
return k >= s - 1e-9 # 平值带内或虚值
|
||||
if o == "P":
|
||||
return k <= s + 1e-9
|
||||
return False
|
||||
|
||||
|
||||
def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float:
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if o == "C" and k < s:
|
||||
return s - k
|
||||
if o == "P" and k > s:
|
||||
return k - s
|
||||
return 0.0
|
||||
|
||||
|
||||
def parse_strike_from_inst(inst_id: str) -> Optional[float]:
|
||||
"""从 OKX 合约名解析行权价: ETH-USD-260731-1800-P."""
|
||||
parts = str(inst_id or "").strip().upper().split("-")
|
||||
if len(parts) < 5:
|
||||
return None
|
||||
return _sf(parts[-2])
|
||||
|
||||
|
||||
def pick_itm_or_atm_contract(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
opt_type: str,
|
||||
index_px: float,
|
||||
itm_max_dist: Optional[float] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""在合约列表中选距标的最近的实值/平值腿."""
|
||||
want = normalize_opt_type(opt_type)
|
||||
if not want or index_px <= 0:
|
||||
return None
|
||||
max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd()
|
||||
cands: list[tuple[float, float, dict[str, Any]]] = []
|
||||
for c in contracts or []:
|
||||
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
||||
continue
|
||||
k = _sf(c.get("strike"))
|
||||
if k is None:
|
||||
continue
|
||||
if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px):
|
||||
continue
|
||||
depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px)
|
||||
if max_dist > 0 and depth > max_dist:
|
||||
continue
|
||||
cands.append((abs(k - index_px), k, c))
|
||||
if not cands:
|
||||
return None
|
||||
cands.sort(key=lambda x: (x[0], x[1]))
|
||||
return cands[0][2]
|
||||
|
||||
|
||||
def pick_atm_or_otm_contract(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
opt_type: str,
|
||||
index_px: float,
|
||||
prefer: str = "atm",
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值)."""
|
||||
want = normalize_opt_type(opt_type)
|
||||
if not want or index_px <= 0:
|
||||
return None
|
||||
prefer_l = (prefer or "atm").strip().lower()
|
||||
cands: list[tuple[float, float, dict[str, Any]]] = []
|
||||
for c in contracts or []:
|
||||
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
||||
continue
|
||||
k = _sf(c.get("strike"))
|
||||
if k is None:
|
||||
continue
|
||||
if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px):
|
||||
continue
|
||||
m = classify_moneyness(opt_type=want, strike=k, index_px=index_px)
|
||||
if prefer_l == "otm" and m != "otm":
|
||||
continue
|
||||
if prefer_l == "atm" and m == "otm":
|
||||
# 仍可入选,但排序靠后(先 atm)
|
||||
cands.append((1_000_000 + abs(k - index_px), k, c))
|
||||
else:
|
||||
cands.append((abs(k - index_px), k, c))
|
||||
if not cands:
|
||||
return None
|
||||
cands.sort(key=lambda x: (x[0], x[1]))
|
||||
return cands[0][2]
|
||||
|
||||
|
||||
def recommend_oo_legs(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
index_px: float,
|
||||
template: str = "atm_straddle",
|
||||
) -> Optional[tuple[dict[str, Any], dict[str, Any]]]:
|
||||
"""期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put."""
|
||||
tpl = (template or "atm_straddle").strip().lower()
|
||||
prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm"
|
||||
call = pick_atm_or_otm_contract(
|
||||
contracts, opt_type="C", index_px=index_px, prefer=prefer
|
||||
)
|
||||
put = pick_atm_or_otm_contract(
|
||||
contracts, opt_type="P", index_px=index_px, prefer=prefer
|
||||
)
|
||||
if not call or not put:
|
||||
return None
|
||||
if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""):
|
||||
return None
|
||||
return call, put
|
||||
|
||||
|
||||
def validate_po_option_moneyness(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: Any,
|
||||
index_px: Any,
|
||||
ask: Any = None,
|
||||
hours_to_expiry: Any = None,
|
||||
) -> Optional[str]:
|
||||
"""永期保险腿校验;返回错误文案或 None."""
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = _sf(strike)
|
||||
s = _sf(index_px)
|
||||
if o not in ("C", "P"):
|
||||
return "期权类型无效"
|
||||
if k is None or s is None or s <= 0:
|
||||
return "行权价或指数无效,无法校验虚实值"
|
||||
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
||||
return "永期保险腿须为实值或平值,不可选虚值"
|
||||
max_dist = itm_max_dist_usd()
|
||||
depth = itm_depth_usd(opt_type=o, strike=k, index_px=s)
|
||||
if max_dist > 0 and depth > max_dist:
|
||||
return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档"
|
||||
min_h = min_option_hours()
|
||||
h = _sf(hours_to_expiry)
|
||||
if min_h > 0 and h is not None and h < min_h:
|
||||
return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h"
|
||||
min_lev = min_option_leverage()
|
||||
a = _sf(ask)
|
||||
if min_lev > 0 and a is not None and a > 0:
|
||||
lev = s / a
|
||||
if lev < min_lev:
|
||||
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_oo_leg_moneyness(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: Any,
|
||||
index_px: Any,
|
||||
role: str = "腿",
|
||||
) -> Optional[str]:
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = _sf(strike)
|
||||
s = _sf(index_px)
|
||||
if o not in ("C", "P"):
|
||||
return f"{role}期权类型无效"
|
||||
if k is None or s is None or s <= 0:
|
||||
return f"{role}行权价或指数无效,无法校验虚实值"
|
||||
m = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
||||
if m == "itm":
|
||||
return f"{role}须为平值或虚值,不可选实值"
|
||||
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
||||
return f"{role}须为平值或虚值"
|
||||
return None
|
||||
|
||||
|
||||
def validate_oo_legs_moneyness(
|
||||
leg_a: dict[str, Any],
|
||||
leg_b: dict[str, Any],
|
||||
*,
|
||||
index_px: Any,
|
||||
) -> Optional[str]:
|
||||
err = validate_oo_leg_moneyness(
|
||||
opt_type=leg_a.get("opt_type"),
|
||||
strike=leg_a.get("strike"),
|
||||
index_px=index_px,
|
||||
role="腿A",
|
||||
)
|
||||
if err:
|
||||
return err
|
||||
err = validate_oo_leg_moneyness(
|
||||
opt_type=leg_b.get("opt_type"),
|
||||
strike=leg_b.get("strike"),
|
||||
index_px=index_px,
|
||||
role="腿B",
|
||||
)
|
||||
if err:
|
||||
return err
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
||||
"""对冲计划企业微信推送(起止必发,幂等落库标记)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import update_plan
|
||||
|
||||
|
||||
def _fmt(v: Any, d: int = 2) -> str:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return "—"
|
||||
return f"{float(v):.{d}f}"
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
def _type_label(plan_type: str) -> str:
|
||||
return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲"
|
||||
|
||||
|
||||
def _dir_label(direction: str) -> str:
|
||||
d = (direction or "").lower()
|
||||
if d == "long":
|
||||
return "做多"
|
||||
if d == "short":
|
||||
return "做空"
|
||||
return "—"
|
||||
|
||||
|
||||
def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str:
|
||||
pt = plan.get("plan_type") or ""
|
||||
lines = [
|
||||
f"🟢 对冲计划启动 #{plan.get('id')}",
|
||||
f"📌 类型:{_type_label(pt)}",
|
||||
f"🪙 标的:{plan.get('underlying') or '—'}",
|
||||
]
|
||||
if pt == "perp_options":
|
||||
lines.extend(
|
||||
[
|
||||
f"📈 方向:{_dir_label(plan.get('direction') or '')}",
|
||||
f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}",
|
||||
f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}",
|
||||
f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
else:
|
||||
rr = plan.get("profit_rr")
|
||||
if rr not in (None, ""):
|
||||
lines.extend(
|
||||
[
|
||||
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
||||
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
if legs:
|
||||
for leg in legs:
|
||||
role = leg.get("leg_role") or ""
|
||||
if role == "perp":
|
||||
lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}")
|
||||
else:
|
||||
lines.append(
|
||||
f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} "
|
||||
f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}"
|
||||
)
|
||||
lines.append("📎 独立模块推送,不进普通交易复盘")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_hedge_end_message(plan: dict[str, Any]) -> str:
|
||||
reason = plan.get("close_reason") or "—"
|
||||
total = plan.get("realized_pnl_total")
|
||||
try:
|
||||
tv = float(total) if total is not None else None
|
||||
except (TypeError, ValueError):
|
||||
tv = None
|
||||
head = "🔴" if (tv is not None and tv < 0) else "🟢"
|
||||
reason_map = {
|
||||
"perp_tp": "永续止盈(期权默认不平)",
|
||||
"perp_sl": "永续止损(期权强制平)",
|
||||
"target_win_leg": "期期已平盈利腿(中间态)",
|
||||
"target_up_win_leg": "期期上破·已平盈利腿",
|
||||
"target_down_win_leg": "期期下破·已平盈利腿",
|
||||
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
|
||||
"oo_rest_closing": "期期残值平·清亏损腿中",
|
||||
"oo_rest_closed": "期期残值平·两腿已平",
|
||||
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
||||
"oo_expiry_win": "期期到期仍盈利",
|
||||
"expiry": "到期收口",
|
||||
"manual": "人工结束",
|
||||
"partial_fail": "半腿失败收尾",
|
||||
"cancelled": "已取消",
|
||||
}
|
||||
lines = [
|
||||
f"{head} 对冲计划结束 #{plan.get('id')}",
|
||||
f"📌 类型:{_type_label(plan.get('plan_type') or '')}",
|
||||
f"🪙 标的:{plan.get('underlying') or '—'}",
|
||||
f"📎 原因:{reason_map.get(reason, reason)}",
|
||||
f"💰 合计≈U:{_fmt(total)}",
|
||||
f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT",
|
||||
f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)",
|
||||
f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_hedge_alert_message(
|
||||
*,
|
||||
title: str,
|
||||
plan_id: Any = None,
|
||||
detail: str = "",
|
||||
) -> str:
|
||||
lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"]
|
||||
if detail:
|
||||
lines.append(str(detail)[:800])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def notify_hedge(
|
||||
cfg: dict[str, Any],
|
||||
content: str,
|
||||
) -> bool:
|
||||
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
|
||||
if not callable(send):
|
||||
return False
|
||||
try:
|
||||
send(content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def notify_plan_start(
|
||||
cfg: dict[str, Any],
|
||||
conn: Any,
|
||||
plan: dict[str, Any],
|
||||
legs: Optional[list[dict[str, Any]]] = None,
|
||||
) -> bool:
|
||||
if int(plan.get("wechat_start_sent") or 0):
|
||||
return False
|
||||
ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs))
|
||||
if ok and plan.get("id") is not None:
|
||||
update_plan(conn, int(plan["id"]), wechat_start_sent=1)
|
||||
plan["wechat_start_sent"] = 1
|
||||
return ok
|
||||
|
||||
|
||||
def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool:
|
||||
if int(plan.get("wechat_end_sent") or 0):
|
||||
return False
|
||||
# 中间态 target_win_leg 不算正式结束推送(用告警)
|
||||
if (plan.get("close_reason") or "") in (
|
||||
"target_win_leg",
|
||||
"target_up_win_leg",
|
||||
"target_down_win_leg",
|
||||
"profit_rr_win_leg",
|
||||
"oo_rest_closing",
|
||||
) and (plan.get("status") or "") != "closed":
|
||||
cr = str(plan.get("close_reason") or "")
|
||||
if "profit_rr" in cr:
|
||||
side = "盈亏比达标"
|
||||
elif "up" in cr:
|
||||
side = "上破"
|
||||
elif "down" in cr:
|
||||
side = "下破"
|
||||
else:
|
||||
side = "目标"
|
||||
mode = (plan.get("oo_close_mode") or "").strip().lower()
|
||||
if mode in ("close_all", "全平", "残值平"):
|
||||
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
|
||||
else:
|
||||
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(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title=f"期期{side}已平盈利腿 · {rest_txt}",
|
||||
plan_id=plan.get("id"),
|
||||
detail=detail,
|
||||
),
|
||||
)
|
||||
return True
|
||||
ok = notify_hedge(cfg, build_hedge_end_message(plan))
|
||||
if ok and plan.get("id") is not None:
|
||||
update_plan(conn, int(plan["id"]), wechat_end_sent=1)
|
||||
plan["wechat_end_sent"] = 1
|
||||
return ok
|
||||
|
||||
|
||||
def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool:
|
||||
detail = msg
|
||||
if results:
|
||||
try:
|
||||
detail = f"{msg}\n路径结果:{results}"[:800]
|
||||
except Exception:
|
||||
pass
|
||||
return notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail),
|
||||
)
|
||||
@@ -1,527 +0,0 @@
|
||||
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
PREMIUM_EXEC_FACTOR = 0.95
|
||||
DEFAULT_MIN_HOURS = 36.0
|
||||
DEFAULT_STRIKE_INTERVAL = 15.0
|
||||
DEFAULT_PERP_LEVERAGE = 100
|
||||
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
|
||||
DEFAULT_OPT_LEVERAGE_OTM = 200.0
|
||||
DEFAULT_RATIO_ITM_ATM = 2.0
|
||||
DEFAULT_RATIO_OTM = 4.0
|
||||
OTM_LEV_FLOOR = 180.0
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
|
||||
if not body_or_plan:
|
||||
return False
|
||||
v = body_or_plan.get("option_primary")
|
||||
if v in (True, 1, "1", "true", "yes", "on"):
|
||||
return True
|
||||
try:
|
||||
return int(v or 0) == 1
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def fee_rate() -> float:
|
||||
try:
|
||||
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0005
|
||||
|
||||
|
||||
def floor2(v: float) -> float:
|
||||
"""ETH 数量向下取两位小数."""
|
||||
if v <= 0:
|
||||
return 0.0
|
||||
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
|
||||
|
||||
|
||||
def opt_type_for_view(direction: str) -> str:
|
||||
"""看法做多→Call,做空→Put."""
|
||||
return "P" if str(direction or "").strip().lower() == "short" else "C"
|
||||
|
||||
|
||||
def perp_direction_for_view(direction: str) -> str:
|
||||
"""看法做多→永续空,做空→永续多."""
|
||||
return "long" if str(direction or "").strip().lower() == "short" else "short"
|
||||
|
||||
|
||||
def default_opt_leverage(moneyness: str) -> float:
|
||||
m = (moneyness or "").strip().lower()
|
||||
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
|
||||
|
||||
|
||||
def default_ratio(moneyness: str) -> float:
|
||||
m = (moneyness or "").strip().lower()
|
||||
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
|
||||
|
||||
|
||||
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
|
||||
cfg = _sf(configured)
|
||||
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
|
||||
if (moneyness or "").strip().lower() == "otm":
|
||||
return max(base, OTM_LEV_FLOOR)
|
||||
return base
|
||||
|
||||
|
||||
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
|
||||
exp = _sf(exp_ms)
|
||||
if exp is None or exp <= 0:
|
||||
return None
|
||||
# OKX exp 多为毫秒
|
||||
if exp < 1e12:
|
||||
exp *= 1000.0
|
||||
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
|
||||
return (exp - now) / 3600000.0
|
||||
|
||||
|
||||
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
|
||||
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0."""
|
||||
n = float(points or 0)
|
||||
k = float(strike)
|
||||
s = float(index_px)
|
||||
if n <= 0 or k <= 0 or s <= 0:
|
||||
return False
|
||||
side = str(view_side or "").strip().lower()
|
||||
if side == "short":
|
||||
return s <= (k - n)
|
||||
return s >= (k + n)
|
||||
|
||||
|
||||
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
|
||||
b = _sf(bid)
|
||||
if b is None or b <= 0:
|
||||
return False, "暂无买一报价,无法平期权"
|
||||
sz = _sf(bid_sz)
|
||||
if sz is not None and sz <= 0:
|
||||
return False, "买一深度为 0,无法平期权"
|
||||
need = float(need_sheets or 0)
|
||||
if need > 0 and sz is not None and sz + 1e-12 < need:
|
||||
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
|
||||
return True, ""
|
||||
|
||||
|
||||
def size_from_premium(
|
||||
*,
|
||||
premium_budget: float,
|
||||
ask: float,
|
||||
ct_mult: float,
|
||||
ratio: float,
|
||||
contract_size: float,
|
||||
exec_factor: float = PREMIUM_EXEC_FACTOR,
|
||||
) -> dict[str, Any]:
|
||||
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
|
||||
budget = float(premium_budget or 0)
|
||||
a = float(ask or 0)
|
||||
ct = float(ct_mult or 0.01)
|
||||
r = float(ratio or 0)
|
||||
cs = float(contract_size or 0.01)
|
||||
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
|
||||
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "定仓参数无效",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": 0.0,
|
||||
"sheets": 0.0,
|
||||
"perp_eth": 0.0,
|
||||
"contracts": 0.0,
|
||||
}
|
||||
# ask 为每 1 币权利金;ETH 数量 = usable / ask
|
||||
eth_qty = floor2(usable / a)
|
||||
if eth_qty <= 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "权利金不足以买入 0.01 ETH 名义期权",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": 0.0,
|
||||
"sheets": 0.0,
|
||||
"perp_eth": 0.0,
|
||||
"contracts": 0.0,
|
||||
}
|
||||
sheets = eth_qty / ct
|
||||
# 张数向下取整到整数张(OKX 期权常见整张)
|
||||
sheets_i = float(math.floor(sheets + 1e-12))
|
||||
if sheets_i <= 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "换算期权张数不足 1 张",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": eth_qty,
|
||||
"sheets": 0.0,
|
||||
"perp_eth": 0.0,
|
||||
"contracts": 0.0,
|
||||
}
|
||||
# 用整张回写 ETH,保持与下单一致
|
||||
eth_qty = round(sheets_i * ct, 2)
|
||||
perp_eth = eth_qty / r
|
||||
contracts = perp_eth / cs
|
||||
premium_est = a * sheets_i * ct
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "",
|
||||
"usable_premium": round(usable, 4),
|
||||
"eth_qty": eth_qty,
|
||||
"sheets": sheets_i,
|
||||
"perp_eth": round(perp_eth, 6),
|
||||
"contracts": contracts,
|
||||
"premium_est": round(premium_est, 4),
|
||||
"ratio": r,
|
||||
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
|
||||
}
|
||||
|
||||
|
||||
def estimate_combo_net_pnl(
|
||||
*,
|
||||
view_side: str,
|
||||
strike: float,
|
||||
index_px: float,
|
||||
ask_open: float,
|
||||
bid: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
perp_direction: str,
|
||||
perp_entry: float,
|
||||
perp_mark: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
fee: Optional[float] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
|
||||
fr = fee if fee is not None else fee_rate()
|
||||
ct = float(ct_mult or 0.01)
|
||||
sh = float(sheets or 0)
|
||||
a = float(ask_open or 0)
|
||||
b = float(bid or 0)
|
||||
premium = a * sh * ct
|
||||
opt_proceeds = b * sh * ct
|
||||
opt_open_fee = premium * fr
|
||||
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
|
||||
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
|
||||
|
||||
coins = float(contracts or 0) * float(contract_size or 0.01)
|
||||
entry = float(perp_entry or 0)
|
||||
mark = float(perp_mark or 0)
|
||||
pd = str(perp_direction or "").strip().lower()
|
||||
if pd == "short":
|
||||
perp_gross = (entry - mark) * coins
|
||||
else:
|
||||
perp_gross = (mark - entry) * coins
|
||||
perp_notional_open = abs(entry * coins)
|
||||
perp_notional_close = abs(mark * coins)
|
||||
perp_open_fee = perp_notional_open * fr
|
||||
perp_close_fee = perp_notional_close * fr
|
||||
perp_net = perp_gross - perp_open_fee - perp_close_fee
|
||||
total = opt_net + perp_net
|
||||
return {
|
||||
"opt_net": round(opt_net, 4),
|
||||
"perp_net": round(perp_net, 4),
|
||||
"net": round(total, 4),
|
||||
"fee_rate": fr,
|
||||
"premium": round(premium, 4),
|
||||
"opt_proceeds": round(opt_proceeds, 4),
|
||||
}
|
||||
|
||||
|
||||
def validate_option_primary_moneyness(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: Any,
|
||||
index_px: Any,
|
||||
ask: Any = None,
|
||||
moneyness: str = "atm",
|
||||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
||||
min_hours: Any = DEFAULT_MIN_HOURS,
|
||||
hours_to_expiry: Any = None,
|
||||
min_opt_leverage: Any = None,
|
||||
) -> Optional[str]:
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||||
classify_moneyness,
|
||||
is_atm_or_otm,
|
||||
is_itm_or_atm,
|
||||
normalize_opt_type,
|
||||
)
|
||||
|
||||
o = normalize_opt_type(opt_type)
|
||||
k = _sf(strike)
|
||||
s = _sf(index_px)
|
||||
if o not in ("C", "P"):
|
||||
return "期权类型无效"
|
||||
if k is None or s is None or s <= 0:
|
||||
return "行权价或指数无效"
|
||||
m_want = (moneyness or "atm").strip().lower()
|
||||
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
||||
if m_want == "itm":
|
||||
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
||||
return "所选须为实值或平值"
|
||||
elif m_want == "atm":
|
||||
# 平值:距指数在间隔内即可(不强制 classify==atm)
|
||||
pass
|
||||
elif m_want == "otm":
|
||||
if m_got == "itm":
|
||||
return "虚值模式不可选实值"
|
||||
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
||||
return "虚值模式须选虚值或平值档"
|
||||
else:
|
||||
return "期权类型(实/平/虚)无效"
|
||||
|
||||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
||||
if interval > 0 and abs(k - s) > interval + 1e-9:
|
||||
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
|
||||
|
||||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
||||
h = _sf(hours_to_expiry)
|
||||
if min_h > 0 and h is not None and h < min_h:
|
||||
return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h"
|
||||
|
||||
a = _sf(ask)
|
||||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
|
||||
if min_lev > 0 and a is not None and a > 0:
|
||||
lev = s / a
|
||||
if lev < min_lev:
|
||||
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
|
||||
"""盯盘启动校验:只要参数,不要求已选具体合约."""
|
||||
need = (
|
||||
"direction",
|
||||
"exchange_symbol",
|
||||
"premium_budget",
|
||||
"option_target_points",
|
||||
"perp_target_points",
|
||||
"option_perp_ratio",
|
||||
"option_leverage",
|
||||
)
|
||||
for k in need:
|
||||
if body.get(k) in (None, ""):
|
||||
return f"缺少字段: {k}"
|
||||
try:
|
||||
if float(body["premium_budget"]) <= 0:
|
||||
return "权利金须大于 0"
|
||||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
||||
return "目标位点数须大于 0"
|
||||
if float(body["option_perp_ratio"]) <= 0:
|
||||
return "期权永续比例须大于 0"
|
||||
if float(body["option_leverage"]) <= 0:
|
||||
return "期权杠杆须大于 0"
|
||||
lev_perp = _sf(body.get("leverage"))
|
||||
if lev_perp is not None and lev_perp <= 0:
|
||||
return "永续杠杆须大于 0"
|
||||
except (TypeError, ValueError):
|
||||
return "数值字段无效"
|
||||
direction = str(body.get("direction") or "").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long 或 short"
|
||||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
||||
if moneyness not in ("itm", "atm", "otm"):
|
||||
return "期权类型(实/平/虚)无效"
|
||||
return None
|
||||
|
||||
|
||||
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
|
||||
need = (
|
||||
"direction",
|
||||
"contracts",
|
||||
"opt_inst_id",
|
||||
"sheets",
|
||||
"exchange_symbol",
|
||||
"premium_budget",
|
||||
"option_target_points",
|
||||
"perp_target_points",
|
||||
"option_perp_ratio",
|
||||
)
|
||||
for k in need:
|
||||
if body.get(k) in (None, ""):
|
||||
return f"缺少字段: {k}"
|
||||
try:
|
||||
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
|
||||
return "张数必须大于 0"
|
||||
if float(body["premium_budget"]) <= 0:
|
||||
return "权利金须大于 0"
|
||||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
||||
return "目标位点数须大于 0"
|
||||
if float(body["option_perp_ratio"]) <= 0:
|
||||
return "期权永续比例须大于 0"
|
||||
except (TypeError, ValueError):
|
||||
return "数值字段无效"
|
||||
direction = str(body.get("direction") or "").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long 或 short"
|
||||
opt_type = str(body.get("opt_type") or "").strip().upper()
|
||||
if not opt_type:
|
||||
inst = str(body.get("opt_inst_id") or "")
|
||||
if inst.upper().endswith("-P"):
|
||||
opt_type = "P"
|
||||
elif inst.upper().endswith("-C"):
|
||||
opt_type = "C"
|
||||
want = opt_type_for_view(direction)
|
||||
if opt_type != want:
|
||||
return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}"
|
||||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
|
||||
|
||||
strike = body.get("strike")
|
||||
if strike in (None, ""):
|
||||
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
|
||||
index_px = body.get("index_px") or body.get("entry")
|
||||
return validate_option_primary_moneyness(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
index_px=index_px,
|
||||
ask=body.get("ask"),
|
||||
moneyness=moneyness,
|
||||
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
|
||||
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
|
||||
hours_to_expiry=body.get("hours_to_expiry"),
|
||||
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
|
||||
)
|
||||
|
||||
|
||||
def pick_option_primary_candidate(
|
||||
chain: dict[str, Any],
|
||||
*,
|
||||
direction: str,
|
||||
moneyness: str = "otm",
|
||||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
||||
min_hours: Any = DEFAULT_MIN_HOURS,
|
||||
min_opt_leverage: Any = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
|
||||
|
||||
want = opt_type_for_view(direction)
|
||||
m_want = (moneyness or "otm").strip().lower()
|
||||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
||||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
||||
try:
|
||||
idx = float(chain.get("index_px") or 0)
|
||||
except (TypeError, ValueError):
|
||||
idx = 0.0
|
||||
if idx <= 0:
|
||||
return None
|
||||
|
||||
best: Optional[dict[str, Any]] = None
|
||||
best_dist: Optional[float] = None
|
||||
for exp in chain.get("expiries") or []:
|
||||
h = hours_to_expiry_from_ms(exp.get("exp_time"))
|
||||
if min_h > 0 and h is not None and h < min_h:
|
||||
continue
|
||||
for c in exp.get("contracts") or []:
|
||||
if str(c.get("opt_type") or "").upper() != want:
|
||||
continue
|
||||
try:
|
||||
k = float(c.get("strike") or 0)
|
||||
ask = float(c.get("ask") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if k <= 0 or ask <= 0:
|
||||
continue
|
||||
if interval > 0 and abs(k - idx) > interval + 1e-9:
|
||||
continue
|
||||
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
|
||||
if m_want == "itm" and m_got not in ("itm", "atm"):
|
||||
continue
|
||||
if m_want == "atm" and m_got != "atm":
|
||||
continue
|
||||
if m_want == "otm" and m_got == "itm":
|
||||
continue
|
||||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
|
||||
if min_lev > 0 and idx / ask < min_lev - 1e-9:
|
||||
continue
|
||||
dist = abs(k - idx)
|
||||
if best is None or best_dist is None or dist < best_dist:
|
||||
best = {
|
||||
**dict(c),
|
||||
"hours_to_expiry": h,
|
||||
"exp_time": exp.get("exp_time"),
|
||||
"moneyness": m_got,
|
||||
"index_px": idx,
|
||||
"leverage": round(idx / ask, 1),
|
||||
}
|
||||
best_dist = dist
|
||||
return best
|
||||
|
||||
|
||||
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""情景:期权目标 / 永续目标粗估净利."""
|
||||
view = str(body.get("direction") or "long").lower()
|
||||
strike = float(body["strike"])
|
||||
n = float(body.get("option_target_points") or 0)
|
||||
m = float(body.get("perp_target_points") or 0)
|
||||
ask = float(body.get("ask") or 0)
|
||||
sheets = float(body.get("sheets") or 0)
|
||||
ct = float(body.get("ct_mult") or 0.01)
|
||||
contracts = float(body.get("contracts") or 0)
|
||||
cs = float(body.get("contract_size") or 0.01)
|
||||
entry = float(body.get("entry") or body.get("index_px") or 0)
|
||||
perp_dir = perp_direction_for_view(view)
|
||||
# 粗估到点时期权卖价:按内在价值近似(下限 0)
|
||||
def intrinsic(spot: float) -> float:
|
||||
o = opt_type_for_view(view)
|
||||
if o == "C":
|
||||
return max(0.0, spot - strike)
|
||||
return max(0.0, strike - spot)
|
||||
|
||||
scenarios = []
|
||||
for label, pts, reason in (
|
||||
("期权目标", n, "opt_target_points"),
|
||||
("永续目标", m, "perp_target_points"),
|
||||
):
|
||||
spot = strike + pts if view != "short" else strike - pts
|
||||
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
|
||||
net = estimate_combo_net_pnl(
|
||||
view_side=view,
|
||||
strike=strike,
|
||||
index_px=spot,
|
||||
ask_open=ask,
|
||||
bid=bid_est,
|
||||
sheets=sheets,
|
||||
ct_mult=ct,
|
||||
perp_direction=perp_dir,
|
||||
perp_entry=entry,
|
||||
perp_mark=spot,
|
||||
contracts=contracts,
|
||||
contract_size=cs,
|
||||
)
|
||||
scenarios.append(
|
||||
{
|
||||
"label": label,
|
||||
"reason": reason,
|
||||
"index": spot,
|
||||
"perp_pnl": net["perp_net"],
|
||||
"options_pnl": net["opt_net"],
|
||||
"total": net["net"],
|
||||
"note": "扣费净利估价;平仓费按买入费率",
|
||||
}
|
||||
)
|
||||
premium = ask * sheets * ct
|
||||
return {
|
||||
"plan_type": "perp_options",
|
||||
"option_primary": True,
|
||||
"summary": {
|
||||
"premium_paid": round(premium, 4),
|
||||
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
|
||||
"opt_target_total": scenarios[0]["total"] if scenarios else None,
|
||||
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
|
||||
"perp_direction": perp_dir,
|
||||
"opt_type": opt_type_for_view(view),
|
||||
},
|
||||
"scenarios": scenarios,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,217 +0,0 @@
|
||||
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
|
||||
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
|
||||
|
||||
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]:
|
||||
return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or ""))
|
||||
|
||||
|
||||
def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool:
|
||||
exp = leg_exp_ms(leg)
|
||||
if exp is None:
|
||||
return False
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
return now >= int(exp)
|
||||
|
||||
|
||||
def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float:
|
||||
"""按到期结算口径估算腿盈亏(USDC)."""
|
||||
premium = float(leg.get("premium") or 0)
|
||||
strike = _sf(leg.get("strike"))
|
||||
if strike is None:
|
||||
return -premium
|
||||
sheets = float(leg.get("size") or 1)
|
||||
# ct_mult 未入库时默认 0.01
|
||||
ct = float(leg.get("ct_mult") or 0.01)
|
||||
return float(
|
||||
option_expiry_pnl(
|
||||
opt_type=str(leg.get("opt_type") or "P"),
|
||||
strike=float(strike),
|
||||
spot=float(spot),
|
||||
sheets=sheets,
|
||||
ct_mult=ct,
|
||||
premium_paid=premium,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool:
|
||||
opts = [
|
||||
x
|
||||
for x in legs
|
||||
if str(x.get("leg_role") or "").startswith("option")
|
||||
and str(x.get("status") or "") in ("open", "hold_to_expiry")
|
||||
]
|
||||
if not opts:
|
||||
return False
|
||||
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
|
||||
|
||||
|
||||
def _parse_opened_ms(raw: Any) -> Optional[int]:
|
||||
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
|
||||
try:
|
||||
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def resolve_option_leg_realized_pnl(
|
||||
*,
|
||||
ex: Any = None,
|
||||
leg: dict[str, Any],
|
||||
fallback: Optional[float] = None,
|
||||
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
|
||||
hist_rows: Optional[list[dict[str, Any]]] = None,
|
||||
) -> tuple[Optional[float], str]:
|
||||
"""
|
||||
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
|
||||
返回 (pnl, source) source=exchange|fallback|none.
|
||||
"""
|
||||
inst_id = str(leg.get("inst_id") or "").strip()
|
||||
open_ms = _parse_opened_ms(leg.get("opened_at"))
|
||||
rows = hist_rows
|
||||
if rows is None and inst_id:
|
||||
try:
|
||||
if callable(fetch_history_fn):
|
||||
rows = fetch_history_fn(inst_id)
|
||||
elif ex is not None:
|
||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||
|
||||
rows = fetch_option_position_history(ex, inst_id)
|
||||
except Exception:
|
||||
rows = None
|
||||
if rows:
|
||||
close_ms = _parse_opened_ms(leg.get("closed_at"))
|
||||
sheets = _sf(leg.get("size")) or _sf(leg.get("sheets"))
|
||||
info = resolve_option_close_from_history(
|
||||
rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets
|
||||
)
|
||||
pnl = _sf((info or {}).get("realized_pnl")) if info else None
|
||||
if pnl is not None:
|
||||
return round(float(pnl), 4), "exchange"
|
||||
if fallback is not None:
|
||||
return round(float(fallback), 4), "fallback"
|
||||
return None, "none"
|
||||
|
||||
|
||||
def backfill_hedge_option_legs_realized_pnl(
|
||||
conn: Any,
|
||||
hist_rows: list[dict[str, Any]],
|
||||
*,
|
||||
update_plan_fn: Optional[Callable[..., Any]] = None,
|
||||
) -> dict[str, int]:
|
||||
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
|
||||
|
||||
by_inst: dict[str, list[dict[str, Any]]] = {}
|
||||
for raw in hist_rows or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
inst = str(raw.get("instId") or "").strip()
|
||||
if inst:
|
||||
by_inst.setdefault(inst, []).append(raw)
|
||||
|
||||
legs = conn.execute(
|
||||
"""
|
||||
SELECT * FROM hedge_plan_legs
|
||||
WHERE status = 'closed'
|
||||
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
|
||||
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
|
||||
ORDER BY id DESC
|
||||
LIMIT 400
|
||||
"""
|
||||
).fetchall()
|
||||
updated_legs = 0
|
||||
touched_plans: set[int] = set()
|
||||
for row in legs:
|
||||
leg = dict(row)
|
||||
inst = str(leg.get("inst_id") or "").strip()
|
||||
if not inst or inst not in by_inst:
|
||||
continue
|
||||
pnl, src = resolve_option_leg_realized_pnl(
|
||||
leg=leg,
|
||||
hist_rows=by_inst[inst],
|
||||
fallback=None,
|
||||
)
|
||||
if src != "exchange" or pnl is None:
|
||||
continue
|
||||
local = _sf(leg.get("realized_pnl"))
|
||||
if local is not None and abs(local - pnl) < 1e-6:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
|
||||
(pnl, int(leg["id"])),
|
||||
)
|
||||
updated_legs += 1
|
||||
touched_plans.add(int(leg["plan_id"]))
|
||||
|
||||
updated_plans = 0
|
||||
updater = update_plan_fn or update_plan
|
||||
for pid in touched_plans:
|
||||
plan = get_plan(conn, pid)
|
||||
if not plan or str(plan.get("status") or "") != "closed":
|
||||
continue
|
||||
plan_legs = get_plan_legs(conn, pid)
|
||||
opt_sum = 0.0
|
||||
for lg in plan_legs:
|
||||
role = str(lg.get("leg_role") or "")
|
||||
if not (role.startswith("option") or lg.get("opt_type")):
|
||||
continue
|
||||
if str(lg.get("status") or "") != "closed":
|
||||
continue
|
||||
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
|
||||
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
|
||||
ptype = str(plan.get("plan_type") or "")
|
||||
if ptype == "options_options":
|
||||
total = opt_sum
|
||||
kwargs: dict[str, Any] = {
|
||||
"realized_pnl_options": round(opt_sum, 4),
|
||||
"realized_pnl_total": round(total, 4),
|
||||
}
|
||||
else:
|
||||
total = perp + opt_sum
|
||||
kwargs = {
|
||||
"realized_pnl_perp": round(perp, 4),
|
||||
"realized_pnl_options": round(opt_sum, 4),
|
||||
"realized_pnl_total": round(total, 4),
|
||||
}
|
||||
old_total = _sf(plan.get("realized_pnl_total"))
|
||||
old_opts = _sf(plan.get("realized_pnl_options"))
|
||||
if (
|
||||
old_total is not None
|
||||
and abs(old_total - total) < 1e-6
|
||||
and old_opts is not None
|
||||
and abs(old_opts - opt_sum) < 1e-6
|
||||
):
|
||||
continue
|
||||
updater(conn, pid, **kwargs)
|
||||
updated_plans += 1
|
||||
return {"legs": updated_legs, "plans": updated_plans}
|
||||
@@ -1,96 +0,0 @@
|
||||
"""OKX 期权/对冲模式(env: OKX_TRADE_MODE).
|
||||
|
||||
options → 仅单独期权(隐藏对冲导航与对冲 env 配置)
|
||||
options_options → 仅期期对冲(不可单独开期权)
|
||||
perp_options → 永期对冲(含合约腿);产品 UI 已隐藏,仅兼容旧 .env / 历史计划
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
MODE_OPTIONS = "options"
|
||||
MODE_PERP = "perp_options"
|
||||
MODE_OO = "options_options"
|
||||
VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO})
|
||||
# 配置页可选模式(不含永期)
|
||||
UI_MODES = frozenset({MODE_OPTIONS, MODE_OO})
|
||||
|
||||
_ALIASES = {
|
||||
"option": MODE_OPTIONS,
|
||||
"standalone": MODE_OPTIONS,
|
||||
"期权": MODE_OPTIONS,
|
||||
"单独期权": MODE_OPTIONS,
|
||||
"po": MODE_PERP,
|
||||
"perp": MODE_PERP,
|
||||
"永期": MODE_PERP,
|
||||
"永期对冲": MODE_PERP,
|
||||
"oo": MODE_OO,
|
||||
"期期": MODE_OO,
|
||||
"期期对冲": MODE_OO,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def normalize_okx_trade_mode(raw: Optional[str]) -> str:
|
||||
s = str(raw or "").strip().lower()
|
||||
if s in VALID_MODES:
|
||||
return s
|
||||
if s in _ALIASES:
|
||||
return _ALIASES[s]
|
||||
return ""
|
||||
|
||||
|
||||
def legacy_infer_okx_trade_mode() -> str:
|
||||
"""未配置 OKX_TRADE_MODE 时默认单独期权(对冲关闭).
|
||||
|
||||
旧 HEDGE_PLAN_* 开关不再自动打开对冲,避免误开;需显式设 OKX_TRADE_MODE=options_options.
|
||||
"""
|
||||
return MODE_OPTIONS
|
||||
|
||||
|
||||
def get_okx_trade_mode() -> str:
|
||||
m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
|
||||
if m:
|
||||
return m
|
||||
return legacy_infer_okx_trade_mode()
|
||||
|
||||
|
||||
def hedge_module_enabled() -> bool:
|
||||
return get_okx_trade_mode() in (MODE_PERP, MODE_OO)
|
||||
|
||||
|
||||
def show_perp_options() -> bool:
|
||||
return get_okx_trade_mode() == MODE_PERP
|
||||
|
||||
|
||||
def show_options_options() -> bool:
|
||||
return get_okx_trade_mode() == MODE_OO
|
||||
|
||||
|
||||
def standalone_options_open_allowed() -> bool:
|
||||
return get_okx_trade_mode() == MODE_OPTIONS
|
||||
|
||||
|
||||
def mode_label(mode: Optional[str] = None) -> str:
|
||||
m = mode or get_okx_trade_mode()
|
||||
return {
|
||||
MODE_OPTIONS: "单独期权",
|
||||
MODE_PERP: "永期对冲",
|
||||
MODE_OO: "期期对冲",
|
||||
}.get(m, m or "—")
|
||||
|
||||
|
||||
def block_standalone_open_by_mode_msg() -> Optional[str]:
|
||||
if standalone_options_open_allowed():
|
||||
return None
|
||||
return (
|
||||
f"当前交易模式为「{mode_label()}」,不可单独开期权;"
|
||||
"请在 env「交易模式」切换为「单独期权」"
|
||||
)
|
||||
@@ -1,408 +0,0 @@
|
||||
<div class="hedge-plan-page-wrap" style="grid-column:1/-1" id="hedge-plan-root"
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
||||
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
|
||||
data-options-enabled="{{ '1' if options_enabled else '0' }}"
|
||||
data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
|
||||
data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}"
|
||||
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}"
|
||||
data-option-primary="{{ '1' if hedge_plan_option_primary|default(true) else '0' }}"
|
||||
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
|
||||
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
|
||||
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
|
||||
{% if not hedge_plan_enabled %}
|
||||
<div class="flash" style="margin-bottom:12px">对冲计划未启用:请在 <code>env配置 → 对冲计划</code> 打开 <code>HEDGE_PLAN_ENABLED</code>(可热更).</div>
|
||||
{% endif %}
|
||||
{% if not options_enabled %}
|
||||
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
|
||||
{% endif %}
|
||||
{% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %}
|
||||
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card hp-head-card">
|
||||
<div class="hp-head-row">
|
||||
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
|
||||
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
|
||||
</h2>
|
||||
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
|
||||
</div>
|
||||
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
|
||||
{% if hedge_plan_show_perp_options %}
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
|
||||
{% endif %}
|
||||
{% if hedge_plan_show_options_options %}
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
|
||||
{% endif %}
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="history">历史记录</button>
|
||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
|
||||
</div>
|
||||
<p class="muted" id="hp-gate-line"></p>
|
||||
<p class="muted hp-acct-hint" id="hp-acct-hint">永续腿→合约账户 · 期权腿→期权账户</p>
|
||||
</div>
|
||||
|
||||
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
|
||||
<div class="options-dual-grid" id="hp-po-layout">
|
||||
<div class="card hp-po-perp-card">
|
||||
<h2>
|
||||
<span id="hp-po-mode-badge" class="hp-po-mode-badge">以期权为主</span>
|
||||
<span id="hp-po-card-title">执行参数</span>
|
||||
· <span id="hp-perp-uly-label">ETH</span>
|
||||
<span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span>
|
||||
</h2>
|
||||
<details class="tip-collapse hp-rule-collapse">
|
||||
<summary class="tip-collapse-summary">规则说明</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
|
||||
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
|
||||
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
|
||||
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row hp-uly-row">
|
||||
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
|
||||
</div>
|
||||
<div class="hp-po-top">
|
||||
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
|
||||
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多=买Call+永续空"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
|
||||
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空=买Put+永续多"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
|
||||
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
|
||||
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field hp-po-field--tp">
|
||||
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
|
||||
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field hp-po-field--sl">
|
||||
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
|
||||
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
<div id="hp-po-fields-option-primary">
|
||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
|
||||
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆配置</h3>
|
||||
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">权利金 <em>USDC</em></span>
|
||||
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">永续杠杆</span>
|
||||
<input type="number" step="1" id="hp-perp-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">期权杠杆 <em>启动校验</em></span>
|
||||
<input type="number" step="1" id="hp-opt-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-select">
|
||||
<h3 id="hp-po-sec-select" class="hp-po-section-title">选约条件</h3>
|
||||
<div class="hp-po-fields hp-po-fields--section hp-po-fields--select">
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">到期时间 <em>最短h</em></span>
|
||||
<input type="number" step="1" id="hp-min-hours" value="36" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">期权间隔 <em>点</em></span>
|
||||
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field hp-po-field--type">
|
||||
<span class="hp-po-field-lab">类型</span>
|
||||
<select id="hp-money-select" aria-label="虚实值类型">
|
||||
<option value="otm" selected>虚值</option>
|
||||
<option value="itm">实值/平值</option>
|
||||
<option value="atm">仅平值</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
|
||||
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
|
||||
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
|
||||
<div class="hp-po-fields hp-po-fields--section">
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
|
||||
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
<label class="hp-po-field">
|
||||
<span class="hp-po-field-lab">永续目标位 <em>相对K</em></span>
|
||||
<input type="number" step="any" id="hp-perp-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="hp-po-summary">
|
||||
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
|
||||
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card hp-po-right-card">
|
||||
<div class="hp-po-right-stack">
|
||||
<div class="hp-po-inner-card hp-po-perp-quote-card">
|
||||
<h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
|
||||
<div class="hp-po-quote-head">
|
||||
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
|
||||
</div>
|
||||
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
|
||||
<p id="hp-po-perp-quote-right" class="muted hp-po-meta" hidden></p>
|
||||
</div>
|
||||
<div class="hp-po-inner-card hp-opt-card">
|
||||
<h2>期权 · <span id="hp-opt-type-label">Call</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
|
||||
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
|
||||
<select id="hp-exp-select"><option value="">选择到期日</option></select>
|
||||
<span class="hp-po-ins-money" id="hp-po-ins-money" hidden>
|
||||
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
|
||||
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平值</button>
|
||||
</span>
|
||||
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="按当前类型自动匹配最近合约">自动匹配</button>
|
||||
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
|
||||
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
|
||||
</div>
|
||||
<div class="options-strike-table-wrap hp-strike-table-wrap--6">
|
||||
<table class="options-strike-table" id="hp-strike-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>行权价</th>
|
||||
<th>实虚值</th>
|
||||
<th title="指数÷卖一">杠杆</th>
|
||||
<th>卖一/张</th>
|
||||
<th>买一/张</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-strike-tbody">
|
||||
<tr><td colspan="6" class="muted">请刷新期权链</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-row hp-pick-row">
|
||||
<label>已选 <code id="hp-sel-inst">—</code></label>
|
||||
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
||||
<span class="muted" id="hp-premium-line"></span>
|
||||
</div>
|
||||
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row hp-action-row hp-po-action-row">
|
||||
<span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
|
||||
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
||||
<div class="options-dual-grid" id="hp-oo-layout">
|
||||
<div class="card">
|
||||
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
|
||||
<details class="tip-collapse hp-rule-collapse">
|
||||
<summary class="tip-collapse-summary">规则说明</summary>
|
||||
<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>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
|
||||
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row hp-uly-row">
|
||||
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
|
||||
</div>
|
||||
<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>
|
||||
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
|
||||
</div>
|
||||
<div class="hp-oo-controls">
|
||||
<div class="hp-oo-ctrl">
|
||||
<span class="hp-oo-ctrl-lab">张数</span>
|
||||
<div class="hp-oo-seg" role="group" aria-label="自动张数">
|
||||
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true">✓</span>同张数</button>
|
||||
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
|
||||
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
|
||||
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
|
||||
<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" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true">✓</span>到期平</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
|
||||
<div id="hp-oo-legs" class="hp-oo-legs">
|
||||
<div class="hp-oo-leg-row" data-leg="a">
|
||||
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
|
||||
<label>张数 <span class="hp-unit">期权张</span>
|
||||
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="hp-oo-leg-row" data-leg="b">
|
||||
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
|
||||
<label>张数 <span class="hp-unit">期权张</span>
|
||||
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted" id="hp-oo-prem-line"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card hp-opt-card">
|
||||
<h2>期权 T 型报价</h2>
|
||||
<div class="form-row">
|
||||
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn is-selected active" data-oo-money="atm_otm" title="平值+虚值" aria-pressed="true"><span class="hp-oo-check" aria-hidden="true">✓</span>平/虚</button>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅平值</button>
|
||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅虚值</button>
|
||||
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-atm" data-oo-rec="atm_straddle" title="最近平值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐跨式</button>
|
||||
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-otm" data-oo-rec="double_otm" title="最近虚值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐双虚</button>
|
||||
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
|
||||
<button type="button" class="btn-secondary hp-oo-expand-btn" id="hp-oo-expand-all" title="展开该到期全部平值/虚值行权价;若当前为「仅平值」会自动切到「平/虚」" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>显示全部</button>
|
||||
</div>
|
||||
<div class="options-strike-table-wrap hp-oo-table-wrap" id="hp-oo-table-wrap">
|
||||
<table class="options-strike-table options-strike-table--t" id="hp-oo-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="4" class="opt-t-head-call">Call</th>
|
||||
<th class="opt-t-head-mid">行权</th>
|
||||
<th colspan="4" class="opt-t-head-put">Put</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
|
||||
<th>K</th>
|
||||
<th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-oo-tbody">
|
||||
<tr><td colspan="9" class="muted">请刷新期权链</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
|
||||
<div class="hp-oo-transfer-bals muted">
|
||||
<span>资金 <strong id="hp-oo-funding-usdc">—</strong></span>
|
||||
<span class="hp-oo-transfer-sep">·</span>
|
||||
<span>交易 <strong id="hp-oo-trading-usdc">—</strong></span>
|
||||
<span class="hp-oo-transfer-unit">USDC</span>
|
||||
<span class="muted" id="hp-oo-xfer-msg"></span>
|
||||
</div>
|
||||
<div class="form-row hp-oo-transfer-form" autocomplete="off">
|
||||
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
|
||||
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
|
||||
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
|
||||
<option value="funding_to_trading" selected>资金 → 交易</option>
|
||||
<option value="trading_to_funding">交易 → 资金</option>
|
||||
</select>
|
||||
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
|
||||
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row hp-action-row">
|
||||
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
||||
<div class="card">
|
||||
<h2>进行中的计划</h2>
|
||||
<p class="muted">仅显示已启动但尚未结束的计划;可查看每条腿的当前记录状态。</p>
|
||||
<div class="options-strike-table-wrap">
|
||||
<table class="options-strike-table hp-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>目标/止盈止损</th><th>开仓</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-active-tbody">
|
||||
<tr><td colspan="8" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
||||
<div class="card">
|
||||
<h2>历史记录</h2>
|
||||
<p class="muted">独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。</p>
|
||||
<div class="options-strike-table-wrap">
|
||||
<table class="options-strike-table hp-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-history-tbody">
|
||||
<tr><td colspan="10" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hp-tab-stats" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
||||
<div class="card">
|
||||
<h2>统计分析</h2>
|
||||
<p class="muted">按永期 / 期期分别统计:胜率、盈亏比、最大盈利、最大亏损、最大回撤(按结束时间累积)</p>
|
||||
<div id="hp-stats-box" class="muted">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hp-detail-modal" class="hp-modal-backdrop" hidden>
|
||||
<div class="hp-modal" role="dialog" aria-modal="true" aria-labelledby="hp-detail-title">
|
||||
<div class="hp-modal-head">
|
||||
<h3 id="hp-detail-title">成交细节</h3>
|
||||
<button type="button" class="btn-secondary" id="hp-detail-close">关闭</button>
|
||||
</div>
|
||||
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
|
||||
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
|
||||
<div class="hp-modal-head">
|
||||
<h3 id="hp-preview-title">情景测算</h3>
|
||||
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
|
||||
</div>
|
||||
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
|
||||
<div class="options-strike-table-wrap">
|
||||
<table class="options-strike-table" id="hp-result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>情景</th>
|
||||
<th>现货价</th>
|
||||
<th id="hp-preview-mid-th">永续/腿盈亏</th>
|
||||
<th>期权盈亏</th>
|
||||
<th>合计≈U</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-result-tbody">
|
||||
<tr><td colspan="6" class="muted">计算中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-row hp-preview-actions">
|
||||
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
|
||||
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/hedge_plan.js?v=46"></script>
|
||||
@@ -0,0 +1 @@
|
||||
"""Hub bridge package (中控对接)."""
|
||||
@@ -0,0 +1,141 @@
|
||||
"""中控 Bridge 鉴权与 SSO(短期登录链)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
def hub_bridge_token() -> str:
|
||||
return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def extract_hub_token_from_request(headers) -> str:
|
||||
raw = (headers.get("X-Hub-Token") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
auth = (headers.get("Authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def hub_bridge_token_ok(headers) -> bool:
|
||||
expected = hub_bridge_token()
|
||||
if not expected:
|
||||
return False
|
||||
got = extract_hub_token_from_request(headers)
|
||||
if not got:
|
||||
return False
|
||||
return hmac.compare_digest(got, expected)
|
||||
|
||||
|
||||
def make_sso_token(bridge_token: str, *, ttl_sec: int = 120) -> str:
|
||||
"""exp.hex_hmac — 中控与客户端共用 bridge_token 签名."""
|
||||
exp = int(time.time()) + max(30, int(ttl_sec))
|
||||
payload = str(exp)
|
||||
sig = hmac.new(
|
||||
bridge_token.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{payload}.{sig}"
|
||||
|
||||
|
||||
def verify_sso_token(token: str, bridge_token: Optional[str] = None) -> bool:
|
||||
expected = (bridge_token if bridge_token is not None else hub_bridge_token()).strip()
|
||||
raw = (token or "").strip()
|
||||
if not expected or not raw or "." not in raw:
|
||||
return False
|
||||
payload, sig = raw.rsplit(".", 1)
|
||||
try:
|
||||
exp = int(payload)
|
||||
except ValueError:
|
||||
return False
|
||||
if exp < int(time.time()):
|
||||
return False
|
||||
want = hmac.new(
|
||||
expected.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(sig, want)
|
||||
|
||||
|
||||
def build_embed_open_url(
|
||||
base_url: str,
|
||||
*,
|
||||
bridge_token: str,
|
||||
embed_path: str = "/embed",
|
||||
ttl_sec: int = 120,
|
||||
) -> str:
|
||||
"""中控生成带 SSO 的客户端打开地址."""
|
||||
root = (base_url or "").rstrip("/")
|
||||
path = embed_path or "/embed"
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
|
||||
q = urlencode({"token": token, "next": path})
|
||||
return f"{root}/hub_sso?{q}"
|
||||
|
||||
|
||||
def extract_embed_sso(request) -> str:
|
||||
"""中控 iframe 内用的短期会话令牌(Cookie 被拦时的后备)."""
|
||||
raw = (request.headers.get("X-Hub-Embed-SSO") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
return (request.args.get("embed_sso") or "").strip()
|
||||
|
||||
|
||||
def hub_embed_sso_ok(request) -> bool:
|
||||
tok = extract_embed_sso(request)
|
||||
return bool(tok and verify_sso_token(tok))
|
||||
|
||||
|
||||
def make_embed_session_token(bridge_token: str | None = None, *, ttl_sec: int = 604800) -> str:
|
||||
"""嵌入会话令牌(默认 7 天),算法与 SSO 相同."""
|
||||
secret = (bridge_token if bridge_token is not None else hub_bridge_token()).strip()
|
||||
return make_sso_token(secret, ttl_sec=ttl_sec)
|
||||
|
||||
|
||||
def frame_ancestors_header_value() -> Optional[str]:
|
||||
"""
|
||||
HUB_EMBED_FRAME_ANCESTORS: 空格分隔 Origin,如 https://hub.local http://192.168.1.10:5100
|
||||
未配置则不改响应头(保持现状);配 * 则允许任意父页(仅内网慎用).
|
||||
"""
|
||||
raw = (os.getenv("HUB_EMBED_FRAME_ANCESTORS") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw == "*":
|
||||
return "*"
|
||||
parts = [p.strip() for p in raw.split() if p.strip()]
|
||||
return " ".join(parts) if parts else None
|
||||
|
||||
|
||||
def ensure_hub_bridge_token(env_path: str) -> str:
|
||||
"""若 .env 中 HUB_BRIDGE_TOKEN 为空则生成并写入;返回当前 token."""
|
||||
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
|
||||
|
||||
path = (env_path or "").strip()
|
||||
if not path or not os.path.isfile(path):
|
||||
existing = hub_bridge_token()
|
||||
if existing:
|
||||
return existing
|
||||
token = secrets.token_urlsafe(32)
|
||||
os.environ["HUB_BRIDGE_TOKEN"] = token
|
||||
return token
|
||||
|
||||
lines = read_env_lines(path)
|
||||
cur = (env_get(lines, "HUB_BRIDGE_TOKEN") or "").strip()
|
||||
if cur:
|
||||
os.environ["HUB_BRIDGE_TOKEN"] = cur
|
||||
return cur
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
apply_env_updates(path, {"HUB_BRIDGE_TOKEN": token})
|
||||
os.environ["HUB_BRIDGE_TOKEN"] = token
|
||||
return token
|
||||
@@ -0,0 +1,646 @@
|
||||
"""中控 Bridge:只读快照 + 受限写(买一全平) + SSO 登录入口."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, redirect, request, session
|
||||
from urllib.parse import quote
|
||||
|
||||
from lib.hub.hub_bridge_auth import (
|
||||
frame_ancestors_header_value,
|
||||
hub_bridge_token,
|
||||
hub_bridge_token_ok,
|
||||
make_embed_session_token,
|
||||
verify_sso_token,
|
||||
)
|
||||
|
||||
|
||||
def _safe_next_path(raw: str, default: str = "/embed") -> str:
|
||||
p = (raw or "").strip() or default
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
if "://" in p or p.startswith("//"):
|
||||
return default
|
||||
return p
|
||||
|
||||
|
||||
def _enrich_hub_protocol(snap: dict[str, Any], *, app_module: Any) -> dict[str, Any]:
|
||||
"""对齐中控设计文档 version=1 字段,同时保留原 snapshot 键."""
|
||||
out = dict(snap or {})
|
||||
out["version"] = 1
|
||||
mode = "live"
|
||||
try:
|
||||
from lib.sim.mode_lib import is_sim_mode
|
||||
|
||||
get_db = getattr(app_module, "get_db", None)
|
||||
if callable(get_db) and is_sim_mode(get_db):
|
||||
mode = "sim"
|
||||
except Exception:
|
||||
mode = "live"
|
||||
label = ""
|
||||
try:
|
||||
from lib.sim.mode_lib import exchange_mode_label
|
||||
|
||||
get_db = getattr(app_module, "get_db", None)
|
||||
if callable(get_db):
|
||||
label = exchange_mode_label(get_db=get_db, exchange_key="okx") or ""
|
||||
except Exception:
|
||||
label = ""
|
||||
if not label:
|
||||
label = "okx_sim" if mode == "sim" else "okx_live"
|
||||
|
||||
positions = out.get("positions") if isinstance(out.get("positions"), list) else []
|
||||
slim_pos = []
|
||||
premium_total_usdt = 0.0
|
||||
has_premium = False
|
||||
for p in positions:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
preview = p.get("close_preview") if isinstance(p.get("close_preview"), dict) else {}
|
||||
# 仅在实例已附带 close_preview 时判定残档;缺字段=未同步,勿假装「无买一/残档」
|
||||
preview_present = bool(preview) and (
|
||||
"bid_invalid" in preview
|
||||
or "levels" in preview
|
||||
or "covered_sheets" in preview
|
||||
or "liquidity_ok" in preview
|
||||
)
|
||||
levels = preview.get("levels") if isinstance(preview.get("levels"), list) else []
|
||||
top = levels[0] if levels and isinstance(levels[0], dict) else {}
|
||||
bid_px = top.get("px")
|
||||
if bid_px is None:
|
||||
bid_px = preview.get("raw_bid_px")
|
||||
bid_sz = top.get("available_sheets")
|
||||
try:
|
||||
uncovered = int(float(preview.get("uncovered_sheets") or 0)) if preview_present else 0
|
||||
except (TypeError, ValueError):
|
||||
uncovered = 0
|
||||
try:
|
||||
covered = int(float(preview.get("covered_sheets") or 0)) if preview_present else 0
|
||||
except (TypeError, ValueError):
|
||||
covered = 0
|
||||
bid_invalid = bool(preview.get("bid_invalid")) if preview_present else False
|
||||
bid_invalid_reason = preview.get("bid_invalid_reason") if preview_present else None
|
||||
# 残档/无效买一,或买一深度盖不住持仓 → 流动性不足;未同步时不标黄残档
|
||||
if not preview_present:
|
||||
liq_ok = None
|
||||
else:
|
||||
liq_ok = (not bid_invalid) and uncovered <= 0 and (
|
||||
preview.get("liquidity_ok") is not False
|
||||
)
|
||||
if not levels and not bid_invalid and covered <= 0:
|
||||
# 已拉盘口但无可用买一档
|
||||
liq_ok = False
|
||||
bid_invalid = True
|
||||
if not bid_invalid_reason:
|
||||
bid_invalid_reason = "无买一"
|
||||
pnl_usdt = p.get("display_pnl_usdt")
|
||||
if pnl_usdt is None:
|
||||
try:
|
||||
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
|
||||
|
||||
pnl_usdt = display_pnl_usdt_from_option_row(p)
|
||||
except Exception:
|
||||
pnl_usdt = p.get("net_pnl_usdt")
|
||||
premium_paid = p.get("premium_paid")
|
||||
premium_usdt = None
|
||||
try:
|
||||
from lib.options.options_positions_lib import premium_paid_usdt_from_option_row
|
||||
|
||||
premium_usdt = premium_paid_usdt_from_option_row(p)
|
||||
except Exception:
|
||||
premium_usdt = None
|
||||
if premium_usdt is not None:
|
||||
has_premium = True
|
||||
premium_total_usdt += float(premium_usdt)
|
||||
contract_leverage = p.get("contract_leverage")
|
||||
if contract_leverage is None:
|
||||
try:
|
||||
from lib.options.options_pricing_lib import equivalent_contract_leverage
|
||||
|
||||
contract_leverage = equivalent_contract_leverage(
|
||||
index_px=p.get("idx_px") or p.get("index_px"),
|
||||
eth_amount=p.get("eth_amount"),
|
||||
total_premium=premium_paid,
|
||||
margin_mode=p.get("margin_mode"),
|
||||
)
|
||||
except Exception:
|
||||
contract_leverage = None
|
||||
slim_pos.append(
|
||||
{
|
||||
"inst_id": p.get("inst_id"),
|
||||
"opt_type": p.get("opt_type"),
|
||||
"sheets": p.get("sheets") or p.get("pos"),
|
||||
"pnl_usdt": pnl_usdt,
|
||||
"premium_paid": premium_paid,
|
||||
"premium_paid_usdt": round(float(premium_usdt), 4) if premium_usdt is not None else None,
|
||||
"premium_ccy": p.get("premium_ccy"),
|
||||
"contract_leverage": contract_leverage,
|
||||
"delta": p.get("delta") if p.get("delta") is not None else p.get("delta_bs"),
|
||||
"mark": p.get("mark") or p.get("mark_px"),
|
||||
"underlying": p.get("underlying"),
|
||||
"source_label": p.get("source_label"),
|
||||
"target_index": p.get("target_index"),
|
||||
"profit_exit_enabled": p.get("profit_exit_enabled"),
|
||||
"profit_exit_mult": p.get("profit_exit_mult"),
|
||||
"profit_exit_state": p.get("profit_exit_state"),
|
||||
"target_monitor_text": p.get("target_monitor_text"),
|
||||
"bid_px": bid_px,
|
||||
"bid_sz": bid_sz,
|
||||
"covered_sheets": covered if preview_present else None,
|
||||
"uncovered_sheets": uncovered if preview_present else None,
|
||||
"bid_invalid": bid_invalid,
|
||||
"bid_invalid_reason": bid_invalid_reason,
|
||||
"liquidity_ok": liq_ok,
|
||||
}
|
||||
)
|
||||
|
||||
_allowed = out.get("options_allowed_underlys")
|
||||
if not isinstance(_allowed, list) or not _allowed:
|
||||
_u0 = str(out.get("options_underly") or "ETH").strip().upper() or "ETH"
|
||||
_allowed = [_u0] if _u0 in ("ETH", "BTC") else ["ETH", "BTC"]
|
||||
else:
|
||||
_allowed = [
|
||||
str(x).strip().upper()
|
||||
for x in _allowed
|
||||
if str(x).strip().upper() in ("ETH", "BTC")
|
||||
] or ["ETH", "BTC"]
|
||||
out["instance"] = {
|
||||
"label": label,
|
||||
"mode": mode,
|
||||
"options_enabled": bool(out.get("enabled")),
|
||||
"account_label": out.get("account_label") or "",
|
||||
"margin_mode": out.get("options_margin_mode") or "coin",
|
||||
"underly": out.get("options_underly") or "ETH",
|
||||
"allowed_underlys": _allowed,
|
||||
}
|
||||
out["options_allowed_underlys"] = _allowed
|
||||
funding_usdt = out.get("funding_usdt")
|
||||
trading_usdt = out.get("trading_usdt")
|
||||
funding_usdc = out.get("funding_usdc")
|
||||
trading_usdc = out.get("trading_usdc")
|
||||
out["funds"] = {
|
||||
"funding_usdt": funding_usdt,
|
||||
"trading_usdt": trading_usdt,
|
||||
"funding_usdc": funding_usdc,
|
||||
"trading_usdc": trading_usdc,
|
||||
}
|
||||
|
||||
def _f(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# 账户可用余额:优先 USDT 资+交;都空则回退 USDC(≈U)
|
||||
cash_parts = [_f(trading_usdt), _f(funding_usdt)]
|
||||
if all(x is None for x in cash_parts):
|
||||
cash_parts = [_f(trading_usdc), _f(funding_usdc)]
|
||||
cash_known = [x for x in cash_parts if x is not None]
|
||||
cash_usdt = round(sum(cash_known), 4) if cash_known else None
|
||||
premium_usdt = round(premium_total_usdt, 4) if has_premium else (0.0 if not slim_pos else None)
|
||||
# 占比分母 = 当前余额 + 已付权利金(权利金已从余额扣出,需加回才是完整账户资金)
|
||||
equity_usdt = None
|
||||
if cash_usdt is not None or premium_usdt is not None:
|
||||
equity_usdt = round(float(cash_usdt or 0.0) + float(premium_usdt or 0.0), 4)
|
||||
ratio_pct = None
|
||||
if premium_usdt is not None and equity_usdt is not None and equity_usdt > 1e-9:
|
||||
ratio_pct = round(float(premium_usdt) / float(equity_usdt) * 100.0, 2)
|
||||
|
||||
out["pnl"] = {
|
||||
"unrealized_usdt": out.get("upl_total_usdc"),
|
||||
}
|
||||
initial_cash_usdt = None
|
||||
try:
|
||||
import os
|
||||
|
||||
if mode == "sim":
|
||||
initial_cash_usdt = float(os.getenv("SIM_INITIAL_EQUITY_USDT") or 10000)
|
||||
else:
|
||||
for key in (
|
||||
"LIVE_INITIAL_EQUITY_USDT",
|
||||
"OPTIONS_INITIAL_EQUITY_USDT",
|
||||
"HUB_INITIAL_CASH_USDT",
|
||||
):
|
||||
raw = (os.getenv(key) or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
initial_cash_usdt = float(raw)
|
||||
break
|
||||
if initial_cash_usdt is not None and initial_cash_usdt <= 0:
|
||||
initial_cash_usdt = None
|
||||
elif initial_cash_usdt is not None:
|
||||
initial_cash_usdt = round(float(initial_cash_usdt), 4)
|
||||
except (TypeError, ValueError):
|
||||
initial_cash_usdt = None
|
||||
|
||||
out["premium"] = {
|
||||
"paid_usdt": premium_usdt,
|
||||
"cash_usdt": cash_usdt,
|
||||
"equity_usdt": equity_usdt,
|
||||
"ratio_pct": ratio_pct,
|
||||
"initial_cash_usdt": initial_cash_usdt,
|
||||
}
|
||||
out["positions_brief"] = slim_pos
|
||||
out["cond_order_locked"] = bool(out.get("cond_order_locked"))
|
||||
active_cond = out.get("cond_orders_active")
|
||||
out["cond_orders_active"] = active_cond if isinstance(active_cond, list) else []
|
||||
try:
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
out["ts"] = options_app_now_str()
|
||||
except Exception:
|
||||
from datetime import datetime
|
||||
|
||||
out["ts"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
quotes = {"tickers": [], "updated_at": out.get("ts") or ""}
|
||||
try:
|
||||
from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers
|
||||
|
||||
ex = getattr(app_module, "exchange", None)
|
||||
reset_hour = int(getattr(app_module, "TRADING_DAY_RESET_HOUR", 8) or 8)
|
||||
tickers = build_nav_spot_tickers(ex, reset_hour=reset_hour) if ex is not None else []
|
||||
quotes = {
|
||||
"tickers": tickers if isinstance(tickers, list) else [],
|
||||
"updated_at": out.get("ts") or "",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
out["quotes"] = quotes
|
||||
return out
|
||||
|
||||
|
||||
def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
|
||||
@app.route("/api/hub/options/snapshot")
|
||||
def api_hub_options_snapshot():
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
fn = hub_ctx.get("options_snapshot_fn")
|
||||
if not callable(fn):
|
||||
return jsonify(
|
||||
{"ok": False, "msg": "期权快照未注册", "version": 1, "enabled": False}
|
||||
), 503
|
||||
try:
|
||||
snap = fn() or {}
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e), "version": 1, "enabled": True}), 500
|
||||
return jsonify(_enrich_hub_protocol(snap, app_module=app_module))
|
||||
|
||||
@app.route("/api/hub/options/pnl_curve")
|
||||
def api_hub_options_pnl_curve():
|
||||
"""中控汇总用:累计已实现盈亏曲线(划转/提现不影响)."""
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
cfg = hub_ctx.get("options_cfg")
|
||||
if not isinstance(cfg, dict) or not callable(cfg.get("get_db")):
|
||||
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
|
||||
range_key = (request.args.get("range") or request.args.get("window") or "7d").strip()
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.instance.instance_cash_sample_lib import (
|
||||
query_realized_pnl_series_with_db,
|
||||
)
|
||||
|
||||
out = query_realized_pnl_series_with_db(conn, range_key=range_key)
|
||||
out["version"] = 1
|
||||
return jsonify(out)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e) or "曲线失败", "version": 1}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/hub/options/stats")
|
||||
def api_hub_options_stats():
|
||||
"""中控汇总用:窗口化已实现盈亏统计(不含浮盈进净盈亏主指标以外的展示字段仍附带)."""
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
cfg = hub_ctx.get("options_cfg")
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode
|
||||
from lib.options.options_positions_lib import sum_options_net_pnl_usdc
|
||||
from lib.options.options_stats_lib import (
|
||||
compute_options_stats_for_window,
|
||||
history_pnl_to_usdt,
|
||||
load_live_exchange_stats_history,
|
||||
load_local_options_stats_history,
|
||||
normalize_stats_window,
|
||||
)
|
||||
|
||||
window = normalize_stats_window(request.args.get("window") or "7d")
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": "交易所未就绪", "version": 1}), 503
|
||||
try:
|
||||
raw_live = cfg["fetch_option_positions"](ex)
|
||||
if raw_live is None:
|
||||
return jsonify({"ok": False, "msg": "获取期权持仓失败", "version": 1})
|
||||
use_sim = False
|
||||
try:
|
||||
from lib.sim.mode_lib import is_sim_mode
|
||||
|
||||
use_sim = is_sim_mode(cfg["get_db"])
|
||||
except Exception:
|
||||
use_sim = False
|
||||
if use_sim:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
history = load_local_options_stats_history(conn, ex)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
# 实盘:先网后短事务,禁止握着 DB 锁拉行情(否则中控整机超时)
|
||||
history = load_live_exchange_stats_history(cfg, ex)
|
||||
stats = compute_options_stats_for_window(history_pnl_to_usdt(history, ex), window)
|
||||
open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live)
|
||||
net_realized = stats.get("net_realized_pnl")
|
||||
try:
|
||||
net_f = float(net_realized) if net_realized is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
net_f = 0.0
|
||||
total_pnl = None
|
||||
if open_float is not None:
|
||||
total_pnl = round(net_f + float(open_float), 4)
|
||||
elif stats.get("total_closed"):
|
||||
total_pnl = round(net_f, 4)
|
||||
pnl_unit = "U" if is_coin_margin_mode() else "USDC"
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"version": 1,
|
||||
"pnl_unit": pnl_unit,
|
||||
"open_float_pnl": open_float,
|
||||
"total_pnl": total_pnl,
|
||||
**stats,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e) or "统计失败", "version": 1}), 500
|
||||
|
||||
@app.route("/api/hub/options/close", methods=["POST"])
|
||||
def api_hub_options_close():
|
||||
"""中控下发全平指令:客户端本地买一限价执行;无效买一拒绝."""
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
data = request.get_json(silent=True) or {}
|
||||
if data.get("market"):
|
||||
return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价", "version": 1})
|
||||
# 总览约定只全平,忽略/拒绝自定义张数
|
||||
if data.get("sheets") is not None and str(data.get("sheets")).strip() != "":
|
||||
return jsonify({"ok": False, "msg": "中控平仓仅支持全平,勿传 sheets", "version": 1})
|
||||
inst_id = (data.get("inst_id") or "").strip()
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id", "version": 1})
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
fn = hub_ctx.get("options_close_fn")
|
||||
if not callable(fn):
|
||||
return jsonify({"ok": False, "msg": "平仓接口未注册", "version": 1}), 503
|
||||
try:
|
||||
result = fn(inst_id) or {}
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e) or "平仓失败", "version": 1}), 500
|
||||
if not isinstance(result, dict):
|
||||
return jsonify({"ok": False, "msg": "平仓返回异常", "version": 1}), 500
|
||||
out = dict(result)
|
||||
out["version"] = 1
|
||||
return jsonify(out)
|
||||
|
||||
@app.route("/api/hub/options/transfer", methods=["POST"])
|
||||
def api_hub_options_transfer():
|
||||
"""中控指令:实例内资金户↔交易户划转(默认资金→交易,USDT)."""
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
cfg = hub_ctx.get("options_cfg")
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
|
||||
data = request.get_json(silent=True) or {}
|
||||
from lib.options.options_transfer_exec_lib import execute_options_internal_transfer
|
||||
|
||||
out = execute_options_internal_transfer(
|
||||
cfg,
|
||||
amount=data.get("amount"),
|
||||
from_acct=(data.get("from") or "funding"),
|
||||
to_acct=(data.get("to") or "trading"),
|
||||
ccy=(data.get("ccy") or "USDT"),
|
||||
)
|
||||
if not isinstance(out, dict):
|
||||
return jsonify({"ok": False, "msg": "划转返回异常", "version": 1}), 500
|
||||
body = dict(out)
|
||||
body["version"] = 1
|
||||
return jsonify(body)
|
||||
|
||||
@app.route("/api/hub/options/cond_orders", methods=["GET"])
|
||||
def api_hub_options_cond_orders_list():
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
cfg = hub_ctx.get("options_cfg")
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
|
||||
from lib.options.options_cond_order_lib import list_cond_orders
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
orders = list_cond_orders(conn, active_only=False)
|
||||
return jsonify({"ok": True, "version": 1, "orders": orders})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/hub/options/cond_orders", methods=["POST"])
|
||||
def api_hub_options_cond_orders_create():
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
cfg = hub_ctx.get("options_cfg")
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
|
||||
from lib.options.options_cond_order_lib import (
|
||||
create_cond_order,
|
||||
machine_has_option_position,
|
||||
)
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_position_limit_lib import option_position_limit_block_msg
|
||||
|
||||
ex = cfg.get("exchange_options")
|
||||
if machine_has_option_position(cfg, ex):
|
||||
return jsonify({"ok": False, "msg": "本机已有期权仓,拒绝条件单", "version": 1})
|
||||
limit_msg = option_position_limit_block_msg(
|
||||
ex,
|
||||
opening_inst_id="",
|
||||
fetch_positions=cfg.get("fetch_option_positions"),
|
||||
)
|
||||
if limit_msg:
|
||||
return jsonify({"ok": False, "msg": limit_msg, "version": 1})
|
||||
data = request.get_json(silent=True) or {}
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
out = create_cond_order(
|
||||
conn,
|
||||
data,
|
||||
send_wechat=cfg.get("send_wechat"),
|
||||
account_label=str(cfg.get("account_label") or ""),
|
||||
)
|
||||
out["version"] = 1
|
||||
return jsonify(out)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/hub/options/cond_orders/<hub_order_id>/cancel", methods=["POST"])
|
||||
def api_hub_options_cond_orders_cancel(hub_order_id: str):
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
cfg = hub_ctx.get("options_cfg")
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
|
||||
from lib.options.options_cond_order_lib import cancel_cond_order
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
out = cancel_cond_order(
|
||||
conn,
|
||||
hub_order_id,
|
||||
send_wechat=cfg.get("send_wechat"),
|
||||
account_label=str(cfg.get("account_label") or ""),
|
||||
)
|
||||
out["version"] = 1
|
||||
return jsonify(out)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/hub/options/cond_orders/<hub_order_id>/exit", methods=["POST"])
|
||||
def api_hub_options_cond_orders_exit(hub_order_id: str):
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
|
||||
hub_ctx = app.config.get("HUB_CTX") or {}
|
||||
cfg = hub_ctx.get("options_cfg")
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
|
||||
from lib.options.options_cond_order_lib import update_cond_order_exit
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
out = update_cond_order_exit(
|
||||
conn,
|
||||
hub_order_id,
|
||||
target_index=data.get("target_index"),
|
||||
profit_exit_enabled=data.get("profit_exit_enabled"),
|
||||
profit_exit_mult=data.get("profit_exit_mult"),
|
||||
)
|
||||
out["version"] = 1
|
||||
return jsonify(out)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/hub/version", methods=["GET"])
|
||||
def hub_version():
|
||||
"""中控探测客户端当前 git 版本/在线."""
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权"}), 401
|
||||
from lib.instance.instance_update_lib import read_git_version
|
||||
|
||||
ver = read_git_version()
|
||||
ver["online"] = True
|
||||
mode = "live"
|
||||
try:
|
||||
from lib.sim.mode_lib import exchange_mode_label, is_sim_mode
|
||||
|
||||
get_db = getattr(app_module, "get_db", None)
|
||||
if callable(get_db) and is_sim_mode(get_db):
|
||||
mode = "sim"
|
||||
ver["mode"] = mode
|
||||
if callable(get_db):
|
||||
ver["mode_label"] = exchange_mode_label(get_db=get_db, exchange_key="okx") or ""
|
||||
except Exception:
|
||||
ver["mode"] = mode
|
||||
return jsonify(ver)
|
||||
|
||||
@app.route("/api/hub/update", methods=["POST"])
|
||||
def hub_update():
|
||||
"""中控远程快速更新:git pull + pm2 restart(异步)."""
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权"}), 401
|
||||
from lib.instance.instance_update_lib import schedule_pull_and_restart
|
||||
|
||||
out = schedule_pull_and_restart(delay_seconds=1.5)
|
||||
return jsonify(out), (200 if out.get("ok") else 400)
|
||||
|
||||
@app.route("/api/hub/backup/list", methods=["GET"])
|
||||
def hub_backup_list():
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权"}), 401
|
||||
from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"items": list_backups(),
|
||||
"backup_dir": backup_dir(),
|
||||
"keep_days": backup_keep_days(),
|
||||
"auto_hour_bj": 3,
|
||||
"includes": [".env", "database", "options_journal"],
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/hub/backup/download/<name>", methods=["GET"])
|
||||
def hub_backup_download(name: str):
|
||||
if not hub_bridge_token_ok(request.headers):
|
||||
return jsonify({"ok": False, "msg": "未授权"}), 401
|
||||
import os
|
||||
|
||||
from flask import send_file
|
||||
|
||||
from lib.instance.backup_lib import backup_path
|
||||
|
||||
path = backup_path(name)
|
||||
if not path or not os.path.isfile(path):
|
||||
return jsonify({"ok": False, "msg": "备份不存在"}), 404
|
||||
return send_file(
|
||||
path,
|
||||
mimetype="application/gzip",
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(path),
|
||||
)
|
||||
|
||||
@app.route("/hub_sso")
|
||||
def hub_sso_login():
|
||||
token = (request.args.get("token") or "").strip()
|
||||
next_path = _safe_next_path(request.args.get("next") or "/embed")
|
||||
if not verify_sso_token(token):
|
||||
return (
|
||||
"<!doctype html><meta charset=utf-8><title>SSO 失败</title>"
|
||||
"<p>中控登录链无效或已过期,请从中控重新打开.</p>",
|
||||
403,
|
||||
)
|
||||
session["logged_in"] = True
|
||||
session.permanent = True
|
||||
session.modified = True
|
||||
# iframe 跨站时常丢 Cookie:附带可放入 sessionStorage 的 embed_sso
|
||||
embed_tok = make_embed_session_token(hub_bridge_token(), ttl_sec=604800)
|
||||
joiner = "&" if "?" in next_path else "?"
|
||||
return redirect(f"{next_path}{joiner}embed_sso={quote(embed_tok, safe='')}")
|
||||
|
||||
@app.after_request
|
||||
def _hub_frame_ancestors(resp):
|
||||
ancestors = frame_ancestors_header_value()
|
||||
if ancestors:
|
||||
resp.headers["Content-Security-Policy"] = f"frame-ancestors {ancestors}"
|
||||
resp.headers.pop("X-Frame-Options", None)
|
||||
return resp
|
||||
@@ -0,0 +1,278 @@
|
||||
"""实例备份/恢复: .env + 数据库 + 期权复盘图片;默认落盘 /root/crypto_okx_backups."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
_BJ = timezone(timedelta(hours=8))
|
||||
_SAFE_NAME_RE = re.compile(r"^crypto_okx_backup_\d{8}_\d{6}\.tar\.gz$")
|
||||
_LOCK = threading.Lock()
|
||||
_LAST_AUTO_DAY = ""
|
||||
|
||||
|
||||
def beijing_now() -> datetime:
|
||||
return datetime.now(_BJ)
|
||||
|
||||
|
||||
def backup_keep_days() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10))
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
|
||||
def backup_dir() -> str:
|
||||
"""备份目录:默认 /root/crypto_okx_backups,可用 BACKUP_DIR 覆盖."""
|
||||
root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root"
|
||||
path = os.path.join(root, "crypto_okx_backups")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _safe_backup_name(name: str) -> str | None:
|
||||
n = os.path.basename((name or "").strip())
|
||||
if not _SAFE_NAME_RE.match(n):
|
||||
return None
|
||||
return n
|
||||
|
||||
|
||||
def backup_path(name: str) -> str | None:
|
||||
safe = _safe_backup_name(name)
|
||||
if not safe:
|
||||
return None
|
||||
path = os.path.abspath(os.path.join(backup_dir(), safe))
|
||||
root = os.path.abspath(backup_dir())
|
||||
if not path.startswith(root + os.sep) and path != root:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def list_backups() -> list[dict[str, Any]]:
|
||||
root = backup_dir()
|
||||
items: list[dict[str, Any]] = []
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except OSError:
|
||||
return []
|
||||
for name in names:
|
||||
if not _SAFE_NAME_RE.match(name):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"size": int(st.st_size),
|
||||
"mtime": int(st.st_mtime),
|
||||
"mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def prune_backups(*, keep_days: int | None = None) -> int:
|
||||
days = backup_keep_days() if keep_days is None else max(1, int(keep_days))
|
||||
cutoff = time.time() - days * 86400
|
||||
removed = 0
|
||||
for item in list_backups():
|
||||
if float(item["mtime"]) >= cutoff:
|
||||
continue
|
||||
path = backup_path(item["name"])
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
os.remove(path)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def _add_path(tar: tarfile.TarFile, abs_path: str, arcname: str) -> None:
|
||||
if not abs_path or not os.path.exists(abs_path):
|
||||
return
|
||||
tar.add(abs_path, arcname=arcname, recursive=True)
|
||||
|
||||
|
||||
def create_backup(
|
||||
*,
|
||||
base_dir: str,
|
||||
db_path: str,
|
||||
upload_folder: str,
|
||||
label: str = "manual",
|
||||
) -> dict[str, Any]:
|
||||
"""打包 .env / 数据库 / options_journal 到 tar.gz."""
|
||||
with _LOCK:
|
||||
now = beijing_now()
|
||||
name = f"crypto_okx_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
|
||||
out_path = os.path.join(backup_dir(), name)
|
||||
manifest = {
|
||||
"kind": "crypto_okx",
|
||||
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"label": (label or "manual").strip() or "manual",
|
||||
"includes": [".env", "database", "options_journal"],
|
||||
}
|
||||
env_path = os.path.join(base_dir, ".env")
|
||||
try:
|
||||
from lib.options.options_review_images_lib import options_review_upload_dir
|
||||
|
||||
journal_dir = options_review_upload_dir(upload_folder)
|
||||
except Exception:
|
||||
journal_dir = os.path.join(upload_folder or "", "options_journal")
|
||||
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="okx_bak_")
|
||||
os.close(tmp_fd)
|
||||
try:
|
||||
with tarfile.open(tmp_path, "w:gz") as tar:
|
||||
manifest_path = tmp_path + ".manifest.json"
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
try:
|
||||
tar.add(manifest_path, arcname="manifest.json")
|
||||
finally:
|
||||
try:
|
||||
os.remove(manifest_path)
|
||||
except OSError:
|
||||
pass
|
||||
_add_path(tar, env_path, ".env")
|
||||
if db_path and os.path.isfile(db_path):
|
||||
_add_path(tar, db_path, "database/" + os.path.basename(db_path))
|
||||
if journal_dir and os.path.isdir(journal_dir):
|
||||
_add_path(tar, journal_dir, "options_journal")
|
||||
shutil.move(tmp_path, out_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
prune_backups()
|
||||
st = os.stat(out_path)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"path": out_path,
|
||||
"size": int(st.st_size),
|
||||
"mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"keep_days": backup_keep_days(),
|
||||
}
|
||||
|
||||
|
||||
def restore_backup(
|
||||
name: str,
|
||||
*,
|
||||
base_dir: str,
|
||||
db_path: str,
|
||||
upload_folder: str,
|
||||
) -> dict[str, Any]:
|
||||
path = backup_path(name)
|
||||
if not path or not os.path.isfile(path):
|
||||
return {"ok": False, "msg": "备份不存在"}
|
||||
with _LOCK:
|
||||
try:
|
||||
from lib.options.options_review_images_lib import options_review_upload_dir
|
||||
|
||||
journal_dir = options_review_upload_dir(upload_folder)
|
||||
except Exception:
|
||||
journal_dir = os.path.join(upload_folder or "", "options_journal")
|
||||
os.makedirs(journal_dir, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="okx_restore_") as tmp:
|
||||
with tarfile.open(path, "r:gz") as tar:
|
||||
tar.extractall(tmp)
|
||||
env_src = os.path.join(tmp, ".env")
|
||||
if os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, os.path.join(base_dir, ".env"))
|
||||
db_src_dir = os.path.join(tmp, "database")
|
||||
if os.path.isdir(db_src_dir) and db_path:
|
||||
for fn in os.listdir(db_src_dir):
|
||||
src = os.path.join(db_src_dir, fn)
|
||||
if os.path.isfile(src):
|
||||
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
|
||||
shutil.copy2(src, db_path)
|
||||
break
|
||||
journal_src = os.path.join(tmp, "options_journal")
|
||||
if os.path.isdir(journal_src):
|
||||
if os.path.isdir(journal_dir):
|
||||
# 覆盖同名,保留其它本地文件
|
||||
for root, _dirs, files in os.walk(journal_src):
|
||||
rel = os.path.relpath(root, journal_src)
|
||||
dest_root = journal_dir if rel == "." else os.path.join(journal_dir, rel)
|
||||
os.makedirs(dest_root, exist_ok=True)
|
||||
for fn in files:
|
||||
shutil.copy2(os.path.join(root, fn), os.path.join(dest_root, fn))
|
||||
else:
|
||||
shutil.copytree(journal_src, journal_dir)
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "已恢复 .env / 数据库 / 复盘图片,建议重启进程使配置完全生效",
|
||||
"name": os.path.basename(path),
|
||||
}
|
||||
|
||||
|
||||
def maybe_run_auto_backup(
|
||||
*,
|
||||
base_dir: str,
|
||||
db_path: str,
|
||||
upload_folder: str,
|
||||
hour: int = 3,
|
||||
) -> dict[str, Any] | None:
|
||||
"""北京时间指定整点触发一次自动备份(同日不重复)."""
|
||||
global _LAST_AUTO_DAY
|
||||
now = beijing_now()
|
||||
day = now.strftime("%Y-%m-%d")
|
||||
if now.hour != int(hour):
|
||||
return None
|
||||
if _LAST_AUTO_DAY == day:
|
||||
return None
|
||||
# 已有今日自动备份则跳过创建 prefix = f"crypto_okx_backup_{now.strftime('%Y%m%d')}_"
|
||||
for item in list_backups():
|
||||
if str(item["name"]).startswith(prefix):
|
||||
_LAST_AUTO_DAY = day
|
||||
return None
|
||||
result = create_backup(
|
||||
base_dir=base_dir,
|
||||
db_path=db_path,
|
||||
upload_folder=upload_folder,
|
||||
label="auto",
|
||||
)
|
||||
_LAST_AUTO_DAY = day
|
||||
return result
|
||||
|
||||
|
||||
def start_backup_scheduler(
|
||||
*,
|
||||
base_dir: str,
|
||||
db_path_fn: Callable[[], str],
|
||||
upload_folder_fn: Callable[[], str],
|
||||
hour: int = 3,
|
||||
) -> None:
|
||||
def loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
maybe_run_auto_backup(
|
||||
base_dir=base_dir,
|
||||
db_path=db_path_fn(),
|
||||
upload_folder=upload_folder_fn(),
|
||||
hour=hour,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(30)
|
||||
|
||||
t = threading.Thread(target=loop, name="okx-backup-scheduler", daemon=True)
|
||||
t.start()
|
||||
@@ -8,10 +8,12 @@ from lib.market.ohlcv_lib import (
|
||||
price_tick_from_market,
|
||||
round_ohlcv_bars_to_tick,
|
||||
)
|
||||
from lib.trade.order_monitor_display_lib import (
|
||||
apply_order_live_price_display,
|
||||
apply_order_price_display_fields,
|
||||
)
|
||||
def apply_order_price_display_fields(payload, **kwargs):
|
||||
return payload
|
||||
|
||||
|
||||
def apply_order_live_price_display(payload, *args, **kwargs):
|
||||
return payload
|
||||
|
||||
|
||||
def resolve_kline_price_tick(
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""实例资金曲线采样:权益(现金+现货币+权利金+浮盈),供数据统计页使用."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
_lock = threading.Lock()
|
||||
_last_sample_at = 0.0
|
||||
_thread: threading.Thread | None = None
|
||||
_stop = threading.Event()
|
||||
|
||||
|
||||
def cash_sample_interval_sec() -> float:
|
||||
try:
|
||||
return max(30.0, float(os.getenv("INSTANCE_CASH_SAMPLE_SEC") or 60))
|
||||
except (TypeError, ValueError):
|
||||
return 60.0
|
||||
|
||||
|
||||
def cash_sample_keep_days() -> int:
|
||||
try:
|
||||
return max(7, int(float(os.getenv("INSTANCE_CASH_SAMPLE_KEEP_DAYS") or 90)))
|
||||
except (TypeError, ValueError):
|
||||
return 90
|
||||
|
||||
|
||||
def init_cash_sample_table(conn) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS instance_cash_samples (
|
||||
ts_ms INTEGER PRIMARY KEY,
|
||||
cash_usdt REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE instance_cash_samples ADD COLUMN equity_usdt REAL")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def cash_usdt_from_balances(bal: dict[str, Any] | None) -> Optional[float]:
|
||||
"""稳定币现金 = 交易账户 + 资金账户(优先 USDT,否则 USDC)."""
|
||||
if not isinstance(bal, dict):
|
||||
return None
|
||||
parts = [_safe_float(bal.get("trading_usdt")), _safe_float(bal.get("funding_usdt"))]
|
||||
if all(x is None for x in parts):
|
||||
parts = [_safe_float(bal.get("trading_usdc")), _safe_float(bal.get("funding_usdc"))]
|
||||
known = [x for x in parts if x is not None]
|
||||
if not known:
|
||||
return None
|
||||
return round(sum(known), 4)
|
||||
|
||||
|
||||
def spot_coin_usdt_from_balances(
|
||||
bal: dict[str, Any] | None,
|
||||
*,
|
||||
btc_px: float | None = None,
|
||||
eth_px: float | None = None,
|
||||
) -> float:
|
||||
"""交易+资金账户 BTC/ETH 现货按指数折 U(缓冲买币占用)."""
|
||||
if not isinstance(bal, dict):
|
||||
return 0.0
|
||||
total = 0.0
|
||||
btc = (_safe_float(bal.get("trading_btc")) or 0.0) + (
|
||||
_safe_float(bal.get("funding_btc")) or 0.0
|
||||
)
|
||||
eth = (_safe_float(bal.get("trading_eth")) or 0.0) + (
|
||||
_safe_float(bal.get("funding_eth")) or 0.0
|
||||
)
|
||||
if btc > 0 and btc_px is not None and btc_px > 0:
|
||||
total += btc * float(btc_px)
|
||||
if eth > 0 and eth_px is not None and eth_px > 0:
|
||||
total += eth * float(eth_px)
|
||||
return round(total, 4)
|
||||
|
||||
|
||||
def option_premium_and_upl_usdt(positions: list[dict[str, Any]] | None) -> tuple[float, float]:
|
||||
"""持仓权利金U + 浮盈U(权利金+浮盈≈期权市值)."""
|
||||
prem = 0.0
|
||||
upl = 0.0
|
||||
if not positions:
|
||||
return 0.0, 0.0
|
||||
from lib.options.options_positions_lib import (
|
||||
display_pnl_usdt_from_option_row,
|
||||
premium_paid_usdt_from_option_row,
|
||||
)
|
||||
|
||||
for p in positions:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
try:
|
||||
pu = premium_paid_usdt_from_option_row(p)
|
||||
except Exception:
|
||||
pu = None
|
||||
if pu is not None:
|
||||
prem += float(pu)
|
||||
try:
|
||||
du = display_pnl_usdt_from_option_row(p)
|
||||
except Exception:
|
||||
du = None
|
||||
if du is not None:
|
||||
upl += float(du)
|
||||
return round(prem, 4), round(upl, 4)
|
||||
|
||||
|
||||
def equity_usdt_from_snapshot(
|
||||
bal: dict[str, Any] | None,
|
||||
positions: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
btc_px: float | None = None,
|
||||
eth_px: float | None = None,
|
||||
) -> Optional[float]:
|
||||
"""权益 = 稳定币现金 + 现货币折U + 权利金U + 浮盈U."""
|
||||
cash = cash_usdt_from_balances(bal)
|
||||
spot = spot_coin_usdt_from_balances(bal, btc_px=btc_px, eth_px=eth_px)
|
||||
prem, upl = option_premium_and_upl_usdt(positions)
|
||||
if cash is None and spot <= 0 and prem == 0 and upl == 0:
|
||||
return None
|
||||
return round(float(cash or 0.0) + float(spot) + float(prem) + float(upl), 4)
|
||||
|
||||
|
||||
def record_cash_sample(
|
||||
conn,
|
||||
cash_usdt: float,
|
||||
*,
|
||||
equity_usdt: float | None = None,
|
||||
ts_ms: int | None = None,
|
||||
) -> None:
|
||||
init_cash_sample_table(conn)
|
||||
t = int(ts_ms if ts_ms is not None else time.time() * 1000)
|
||||
eq = float(equity_usdt) if equity_usdt is not None else float(cash_usdt)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO instance_cash_samples(ts_ms, cash_usdt, equity_usdt)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(ts_ms) DO UPDATE SET
|
||||
cash_usdt=excluded.cash_usdt,
|
||||
equity_usdt=excluded.equity_usdt
|
||||
""",
|
||||
(t, float(cash_usdt), eq),
|
||||
)
|
||||
keep_ms = cash_sample_keep_days() * 86400 * 1000
|
||||
cutoff = t - keep_ms
|
||||
conn.execute("DELETE FROM instance_cash_samples WHERE ts_ms < ?", (cutoff,))
|
||||
|
||||
|
||||
def _range_start_ms(range_key: str) -> tuple[str, int]:
|
||||
rk = (range_key or "7d").strip().lower()
|
||||
now_ms = int(time.time() * 1000)
|
||||
if rk in ("today", "1d"):
|
||||
start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return "today", int(start.timestamp() * 1000)
|
||||
if rk in ("30d", "30"):
|
||||
return "30d", now_ms - 30 * 86400 * 1000
|
||||
if rk in ("all", "0"):
|
||||
return "all", 0
|
||||
return "7d", now_ms - 7 * 86400 * 1000
|
||||
|
||||
|
||||
def _closed_at_to_ms(raw: Any) -> int | None:
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
from lib.options.options_stats_lib import _parse_ts
|
||||
|
||||
d = _parse_ts(raw)
|
||||
if d is None:
|
||||
return None
|
||||
return int(d.timestamp() * 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _trade_realized_usdt(row: Any) -> float | None:
|
||||
u = _safe_float(row["realized_pnl_usdt"] if hasattr(row, "keys") else None)
|
||||
if u is not None:
|
||||
return round(float(u), 4)
|
||||
# 兼容未冻结 U:币盈亏×平仓价
|
||||
try:
|
||||
coin = _safe_float(row["realized_pnl"])
|
||||
px = _safe_float(row["close_index_px"])
|
||||
except Exception:
|
||||
return None
|
||||
if coin is None or px is None or px <= 0:
|
||||
return None
|
||||
return round(float(coin) * float(px), 4)
|
||||
|
||||
|
||||
def query_realized_pnl_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
|
||||
"""累计已实现盈亏曲线(只随平仓变化,划转/提现不影响)."""
|
||||
rk, start_ms = _range_start_ms(range_key)
|
||||
now_ms = int(time.time() * 1000)
|
||||
try:
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
init_options_tables(conn)
|
||||
except Exception:
|
||||
pass
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, closed_at, created_at, realized_pnl_usdt, realized_pnl, close_index_px
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
ORDER BY COALESCE(closed_at, created_at) ASC, id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
events: list[tuple[int, float]] = []
|
||||
for r in rows:
|
||||
pnl_u = _trade_realized_usdt(r)
|
||||
if pnl_u is None:
|
||||
continue
|
||||
ts = _closed_at_to_ms(r["closed_at"]) or _closed_at_to_ms(r["created_at"])
|
||||
if ts is None:
|
||||
continue
|
||||
events.append((ts, float(pnl_u)))
|
||||
|
||||
cum = 0.0
|
||||
all_points: list[dict[str, Any]] = []
|
||||
for ts, pnl in events:
|
||||
cum = round(cum + pnl, 4)
|
||||
all_points.append({"t": int(ts), "v": cum})
|
||||
|
||||
baseline = 0.0
|
||||
series: list[dict[str, Any]] = []
|
||||
for p in all_points:
|
||||
if int(p["t"]) < start_ms:
|
||||
baseline = float(p["v"])
|
||||
continue
|
||||
if not series:
|
||||
series.append({"t": int(start_ms), "v": baseline})
|
||||
series.append({"t": int(p["t"]), "v": float(p["v"])})
|
||||
if not series:
|
||||
# 窗口内无新平仓:画水平线(历史累计或 0)
|
||||
series = [
|
||||
{"t": int(start_ms), "v": baseline},
|
||||
{"t": int(now_ms), "v": baseline},
|
||||
]
|
||||
else:
|
||||
last_v = float(series[-1]["v"])
|
||||
if int(series[-1]["t"]) < now_ms:
|
||||
series.append({"t": int(now_ms), "v": last_v})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"range": rk,
|
||||
"metric": "realized_pnl",
|
||||
"points": series,
|
||||
"start_ms": start_ms,
|
||||
"end_ms": now_ms,
|
||||
"latest": series[-1]["v"] if series else None,
|
||||
"trade_count": len(events),
|
||||
}
|
||||
|
||||
|
||||
def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
|
||||
"""兼容旧名:数据统计曲线改为累计已实现盈亏."""
|
||||
return query_realized_pnl_series_with_db(conn, range_key=range_key)
|
||||
|
||||
|
||||
def sample_once(get_db: Callable, fetch_snapshot: Callable) -> bool:
|
||||
"""拉取快照并写入采样.
|
||||
|
||||
fetch_snapshot 可返回:
|
||||
- float: 视为权益(兼容旧现金回调)
|
||||
- dict: {cash_usdt, equity_usdt}
|
||||
"""
|
||||
global _last_sample_at
|
||||
try:
|
||||
snap = fetch_snapshot()
|
||||
except Exception:
|
||||
return False
|
||||
cash: float | None
|
||||
equity: float | None
|
||||
if isinstance(snap, dict):
|
||||
cash = _safe_float(snap.get("cash_usdt"))
|
||||
equity = _safe_float(snap.get("equity_usdt"))
|
||||
else:
|
||||
cash = _safe_float(snap)
|
||||
equity = cash
|
||||
if equity is None and cash is None:
|
||||
return False
|
||||
if cash is None:
|
||||
cash = float(equity or 0.0)
|
||||
if equity is None:
|
||||
equity = float(cash)
|
||||
with _lock:
|
||||
conn = get_db()
|
||||
try:
|
||||
record_cash_sample(conn, float(cash), equity_usdt=float(equity))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_last_sample_at = time.time()
|
||||
return True
|
||||
|
||||
|
||||
def start_cash_sample_loop(get_db: Callable, fetch_snapshot: Callable) -> None:
|
||||
global _thread
|
||||
if _thread and _thread.is_alive():
|
||||
return
|
||||
_stop.clear()
|
||||
|
||||
def _loop() -> None:
|
||||
try:
|
||||
sample_once(get_db, fetch_snapshot)
|
||||
except Exception:
|
||||
pass
|
||||
while not _stop.is_set():
|
||||
if _stop.wait(cash_sample_interval_sec()):
|
||||
break
|
||||
try:
|
||||
sample_once(get_db, fetch_snapshot)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_thread = threading.Thread(target=_loop, daemon=True, name="instance-cash-sample")
|
||||
_thread.start()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
"""实例数据统计:复盘 KPI + 已实现盈亏曲线 API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
|
||||
def install_instance_data_stats(app: Flask, app_module: Any) -> None:
|
||||
get_db: Callable = app_module.get_db
|
||||
login_required = app_module.login_required
|
||||
|
||||
@app.route("/api/instance/cash_curve")
|
||||
@login_required
|
||||
def api_instance_cash_curve():
|
||||
from lib.instance.instance_cash_sample_lib import query_realized_pnl_series_with_db
|
||||
|
||||
range_key = (request.args.get("range") or "7d").strip()
|
||||
conn = get_db()
|
||||
try:
|
||||
return jsonify(query_realized_pnl_series_with_db(conn, range_key=range_key))
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"加载盈亏曲线失败: {e}"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,144 +1,143 @@
|
||||
"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
|
||||
|
||||
DISPLAY_RUNTIME_PREFIX = "display."
|
||||
|
||||
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
"show_nav_dashboard": False,
|
||||
"show_nav_account_ledger": False,
|
||||
"show_nav_key_monitor": True,
|
||||
"show_nav_trade": False, # 实盘下单界面已移除
|
||||
"show_nav_strategy": False,
|
||||
"show_nav_strategy_records": False,
|
||||
"show_nav_records": False, # 永续交易记录与复盘已移除
|
||||
"show_nav_stats": False,
|
||||
"show_nav_risk_policy": True,
|
||||
"show_nav_system_guide": False,
|
||||
"show_nav_env_config": True,
|
||||
"show_nav_options": True,
|
||||
"show_nav_options_review": True,
|
||||
"show_nav_hedge_plan": True,
|
||||
"show_settings_transfer": False, # 永续资金划转默认隐藏
|
||||
"show_settings_export": True,
|
||||
"show_settings_password": True,
|
||||
"show_settings_options_swap": True,
|
||||
"show_settings_options_transfer": True,
|
||||
}
|
||||
|
||||
DISPLAY_LABELS: dict[str, str] = {
|
||||
"show_nav_dashboard": "数据看板",
|
||||
"show_nav_account_ledger": "账户流水",
|
||||
"show_nav_key_monitor": "关键位监控",
|
||||
"show_nav_trade": "实盘下单",
|
||||
"show_nav_strategy": "策略交易",
|
||||
"show_nav_strategy_records": "策略交易记录",
|
||||
"show_nav_records": "交易记录与复盘",
|
||||
"show_nav_stats": "统计分析",
|
||||
"show_nav_risk_policy": "风控说明",
|
||||
"show_nav_system_guide": "系统说明",
|
||||
"show_nav_env_config": "env配置",
|
||||
"show_nav_options": "期权",
|
||||
"show_nav_options_review": "期权复盘",
|
||||
"show_nav_hedge_plan": "对冲计划",
|
||||
"show_settings_transfer": "永续资金划转",
|
||||
"show_settings_export": "数据导出",
|
||||
"show_settings_password": "账户密码修改",
|
||||
"show_settings_options_swap": "期权币种兑换",
|
||||
"show_settings_options_transfer": "期权资金划转",
|
||||
}
|
||||
|
||||
NAV_TAB_ALLOWED: dict[str, str] = {
|
||||
"dashboard": "show_nav_dashboard",
|
||||
"account_ledger": "show_nav_account_ledger",
|
||||
"key_monitor": "show_nav_key_monitor",
|
||||
"trade": "show_nav_trade",
|
||||
"strategy": "show_nav_strategy",
|
||||
"strategy_records": "show_nav_strategy_records",
|
||||
"records": "show_nav_records",
|
||||
"stats": "show_nav_stats",
|
||||
"risk_policy": "show_nav_risk_policy",
|
||||
"system_guide": "show_nav_system_guide",
|
||||
"env_config": "show_nav_env_config",
|
||||
"options": "show_nav_options",
|
||||
"options_review": "show_nav_options_review",
|
||||
"hedge_plan": "show_nav_hedge_plan",
|
||||
}
|
||||
|
||||
|
||||
def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
|
||||
out = dict(DEFAULT_INSTANCE_DISPLAY)
|
||||
if isinstance(raw, dict):
|
||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||
if key in raw:
|
||||
out[key] = bool(raw[key])
|
||||
return out
|
||||
|
||||
|
||||
def _load_from_conn(conn) -> dict[str, bool]:
|
||||
stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
|
||||
merged: dict[str, Any] = {}
|
||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||
sk = key
|
||||
if sk in stored:
|
||||
merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
|
||||
return normalize_display_prefs(merged)
|
||||
|
||||
|
||||
def get_display_prefs(get_db: Callable) -> dict[str, bool]:
|
||||
return with_db(get_db, _load_from_conn)
|
||||
|
||||
|
||||
def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
|
||||
normalized = normalize_display_prefs(prefs)
|
||||
|
||||
def _save(conn):
|
||||
mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
|
||||
runtime_set_many(conn, mapping)
|
||||
return normalized
|
||||
|
||||
return with_db(get_db, _save)
|
||||
|
||||
|
||||
def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
|
||||
prefs = get_display_prefs(get_db)
|
||||
return {"display": prefs, "display_meta": display_meta_for_ui()}
|
||||
|
||||
|
||||
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
||||
prefs = normalize_display_prefs(display or {})
|
||||
t = (tab or "").strip()
|
||||
if t in ("trade", "records", "stats"):
|
||||
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除
|
||||
key = NAV_TAB_ALLOWED.get(t)
|
||||
if not key:
|
||||
return True
|
||||
return bool(prefs.get(key, True))
|
||||
|
||||
|
||||
def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||
nav_keys = [
|
||||
"show_nav_dashboard",
|
||||
"show_nav_account_ledger",
|
||||
"show_nav_key_monitor",
|
||||
"show_nav_risk_policy",
|
||||
"show_nav_system_guide",
|
||||
"show_nav_env_config",
|
||||
"show_nav_options",
|
||||
"show_nav_options_review",
|
||||
"show_nav_hedge_plan",
|
||||
]
|
||||
settings_keys = [
|
||||
"show_settings_transfer",
|
||||
"show_settings_export",
|
||||
"show_settings_password",
|
||||
"show_settings_options_swap",
|
||||
"show_settings_options_transfer",
|
||||
]
|
||||
return [
|
||||
{"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
|
||||
{"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
|
||||
]
|
||||
"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
|
||||
|
||||
DISPLAY_RUNTIME_PREFIX = "display."
|
||||
|
||||
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
"show_nav_dashboard": False,
|
||||
"show_nav_account_ledger": False,
|
||||
"show_nav_key_monitor": True, # 关键位监控保留
|
||||
"show_nav_trade": False, # 永续实盘下单已下线
|
||||
"show_nav_strategy": False,
|
||||
"show_nav_strategy_records": False,
|
||||
"show_nav_records": False, # 永续交易记录与复盘已移除
|
||||
"show_nav_stats": True, # 数据统计(复盘 KPI + 资金曲线);可在系统设置关闭
|
||||
"show_nav_risk_policy": True,
|
||||
"show_nav_live_guide": True,
|
||||
"show_nav_system_guide": False,
|
||||
"show_nav_env_config": True,
|
||||
"show_nav_options": True,
|
||||
"show_nav_options_review": True,
|
||||
"show_settings_transfer": False, # 永续资金划转已下线
|
||||
"show_settings_export": True,
|
||||
"show_settings_password": True,
|
||||
"show_settings_options_swap": False, # USDC 币种兑换已下线
|
||||
"show_settings_options_transfer": True,
|
||||
}
|
||||
|
||||
DISPLAY_LABELS: dict[str, str] = {
|
||||
"show_nav_dashboard": "数据看板",
|
||||
"show_nav_account_ledger": "账户流水",
|
||||
"show_nav_key_monitor": "关键位监控",
|
||||
"show_nav_trade": "实盘下单",
|
||||
"show_nav_strategy": "策略交易",
|
||||
"show_nav_strategy_records": "策略交易记录",
|
||||
"show_nav_records": "交易记录与复盘",
|
||||
"show_nav_stats": "数据统计",
|
||||
"show_nav_risk_policy": "风控说明",
|
||||
"show_nav_live_guide": "实盘说明",
|
||||
"show_nav_system_guide": "系统说明",
|
||||
"show_nav_env_config": "env配置",
|
||||
"show_nav_options": "期权",
|
||||
"show_nav_options_review": "期权复盘",
|
||||
"show_settings_transfer": "永续资金划转",
|
||||
"show_settings_export": "数据导出",
|
||||
"show_settings_password": "账户密码修改",
|
||||
"show_settings_options_swap": "期权币种兑换",
|
||||
"show_settings_options_transfer": "期权资金划转",
|
||||
}
|
||||
|
||||
NAV_TAB_ALLOWED: dict[str, str] = {
|
||||
"dashboard": "show_nav_dashboard",
|
||||
"account_ledger": "show_nav_account_ledger",
|
||||
"key_monitor": "show_nav_key_monitor",
|
||||
"trade": "show_nav_trade",
|
||||
"strategy": "show_nav_strategy",
|
||||
"strategy_records": "show_nav_strategy_records",
|
||||
"records": "show_nav_records",
|
||||
"stats": "show_nav_stats",
|
||||
"risk_policy": "show_nav_risk_policy",
|
||||
"live_guide": "show_nav_live_guide",
|
||||
"system_guide": "show_nav_system_guide",
|
||||
"env_config": "show_nav_env_config",
|
||||
"options": "show_nav_options",
|
||||
"options_review": "show_nav_options_review",
|
||||
}
|
||||
|
||||
|
||||
def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
|
||||
out = dict(DEFAULT_INSTANCE_DISPLAY)
|
||||
if isinstance(raw, dict):
|
||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||
if key in raw:
|
||||
out[key] = bool(raw[key])
|
||||
return out
|
||||
|
||||
|
||||
def _load_from_conn(conn) -> dict[str, bool]:
|
||||
stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
|
||||
merged: dict[str, Any] = {}
|
||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||
sk = key
|
||||
if sk in stored:
|
||||
merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
|
||||
return normalize_display_prefs(merged)
|
||||
|
||||
|
||||
def get_display_prefs(get_db: Callable) -> dict[str, bool]:
|
||||
return with_db(get_db, _load_from_conn)
|
||||
|
||||
|
||||
def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
|
||||
normalized = normalize_display_prefs(prefs)
|
||||
|
||||
def _save(conn):
|
||||
mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
|
||||
runtime_set_many(conn, mapping)
|
||||
return normalized
|
||||
|
||||
return with_db(get_db, _save)
|
||||
|
||||
|
||||
def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
|
||||
prefs = get_display_prefs(get_db)
|
||||
return {"display": prefs, "display_meta": display_meta_for_ui()}
|
||||
|
||||
|
||||
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
||||
prefs = normalize_display_prefs(display or {})
|
||||
t = (tab or "").strip()
|
||||
if t in ("trade", "records", "hedge_plan"):
|
||||
return False # 永续下单/记录已下线;关键位监控保留
|
||||
key = NAV_TAB_ALLOWED.get(t)
|
||||
if not key:
|
||||
return True
|
||||
return bool(prefs.get(key, True))
|
||||
|
||||
|
||||
def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||
nav_keys = [
|
||||
"show_nav_dashboard",
|
||||
"show_nav_account_ledger",
|
||||
"show_nav_key_monitor",
|
||||
"show_nav_stats",
|
||||
"show_nav_risk_policy",
|
||||
"show_nav_live_guide",
|
||||
"show_nav_system_guide",
|
||||
"show_nav_env_config",
|
||||
"show_nav_options",
|
||||
"show_nav_options_review",
|
||||
]
|
||||
settings_keys = [
|
||||
"show_settings_export",
|
||||
"show_settings_password",
|
||||
"show_settings_options_transfer",
|
||||
]
|
||||
return [
|
||||
{"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
|
||||
{"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
|
||||
]
|
||||
|
||||
@@ -62,7 +62,7 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
|
||||
key_history=page == "key_monitor",
|
||||
key_list=page in ("key_monitor", "trade") or is_strategy,
|
||||
orders=page == "trade" or is_strategy,
|
||||
stats_bundle=page == "stats",
|
||||
stats_bundle=False, # 旧永续统计已弃用;数据统计页走复盘 API + 资金曲线
|
||||
strategy=is_strategy,
|
||||
orphan_live=page == "trade" and is_shell,
|
||||
)
|
||||
@@ -161,10 +161,18 @@ def trading_account_label(
|
||||
parts.append(f"{float(usdt):.2f} USDT")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
|
||||
# 低于现货最小可卖的粉尘不展示(与卖回逻辑一致)
|
||||
try:
|
||||
from lib.options.options_spot_bridge_lib import spot_min_sell_sz
|
||||
|
||||
eth_min = spot_min_sell_sz(None, "ETH")
|
||||
btc_min = spot_min_sell_sz(None, "BTC")
|
||||
except Exception:
|
||||
eth_min, btc_min = 0.0001, 0.00001
|
||||
eth_txt = _fmt_coin_amount(eth, min_amt=eth_min)
|
||||
if eth_txt is not None:
|
||||
parts.append(f"{eth_txt} ETH")
|
||||
btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
|
||||
btc_txt = _fmt_coin_amount(btc, min_amt=btc_min)
|
||||
if btc_txt is not None:
|
||||
parts.append(f"{btc_txt} BTC")
|
||||
return "\n".join(parts) if parts else "—"
|
||||
|
||||
@@ -16,8 +16,9 @@ EMBED_TABS: tuple[str, ...] = (
|
||||
"key_monitor",
|
||||
"options",
|
||||
"options_review",
|
||||
"hedge_plan",
|
||||
"stats",
|
||||
"risk_policy",
|
||||
"live_guide",
|
||||
"system_guide",
|
||||
"env_config",
|
||||
"settings",
|
||||
@@ -27,14 +28,15 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
|
||||
"/": "options",
|
||||
"/trade": "options", # 实盘下单界面已移除
|
||||
"/records": "options_review", # 永续交易记录与复盘已移除
|
||||
"/stats": "options",
|
||||
"/stats": "stats",
|
||||
"/dashboard": "dashboard",
|
||||
"/account_ledger": "account_ledger",
|
||||
"/key_monitor": "key_monitor",
|
||||
"/options": "options",
|
||||
"/options/review": "options_review",
|
||||
"/hedge-plan": "hedge_plan",
|
||||
"/hedge-plan": "options", # 对冲计划已移除,旧链接落到期权
|
||||
"/risk_policy": "risk_policy",
|
||||
"/live_guide": "live_guide",
|
||||
"/system_guide": "system_guide",
|
||||
"/env_config": "env_config",
|
||||
"/settings": "settings",
|
||||
@@ -53,8 +55,9 @@ def order_rule_tips_template(exchange_key: str) -> str:
|
||||
|
||||
|
||||
def include_transfer_block(exchange_key: str) -> bool:
|
||||
"""三所 standalone / embed 壳均在顶栏展示划转区块."""
|
||||
return (exchange_key or "").strip().lower() in ORDER_RULE_TIPS_BY_EXCHANGE
|
||||
"""永续划转区块已下线."""
|
||||
del exchange_key
|
||||
return False
|
||||
|
||||
|
||||
def ui_open_guard_enabled(exchange_key: str) -> bool:
|
||||
@@ -79,16 +82,18 @@ def embed_shell_enabled() -> bool:
|
||||
|
||||
|
||||
_SETTINGS_SUB_TABS = frozenset(
|
||||
{"nav", "password", "transfer", "export", "options_swap", "options_transfer"}
|
||||
{"nav", "password", "transfer", "export", "backup", "options_swap", "options_transfer"}
|
||||
)
|
||||
|
||||
|
||||
def redirect_to_embed_shell_if_enabled(page: str):
|
||||
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换)."""
|
||||
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换).
|
||||
|
||||
注意:中控 iframe 会给导航链加 embed=1;若因此跳过重定向,会落到 index 整页,
|
||||
顶栏链接变成 3 列 grid 子项而变形.embed=1 时仍应进 /embed 壳.
|
||||
"""
|
||||
if not embed_shell_enabled():
|
||||
return None
|
||||
if (request.args.get("embed") or "").strip() == "1":
|
||||
return None
|
||||
if (request.path or "").rstrip("/") == "/embed":
|
||||
return None
|
||||
q = {k: v for k, v in request.args.items()}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""实例「实盘说明」:加载 Markdown,生成 h2 目录与带锚点正文."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.common.markdown_html_lib import render_markdown_html
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
|
||||
def live_guide_md_path() -> Path:
|
||||
return REPO_ROOT / "docs" / "实盘说明.md"
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
raw = re.sub(r"<[^>]+>", "", text or "")
|
||||
raw = re.sub(r"\s+", "-", raw.strip())
|
||||
raw = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", raw)
|
||||
return raw[:80] or "section"
|
||||
|
||||
|
||||
def _inject_h2_ids(html: str) -> tuple[str, list[dict[str, str]]]:
|
||||
toc: list[dict[str, str]] = []
|
||||
used: dict[str, int] = {}
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
inner = m.group(1)
|
||||
base = _slugify(inner)
|
||||
n = used.get(base, 0) + 1
|
||||
used[base] = n
|
||||
hid = base if n == 1 else f"{base}-{n}"
|
||||
toc.append({"id": hid, "title": re.sub(r"<[^>]+>", "", inner).strip()})
|
||||
return f'<h2 id="{escape(hid)}">{inner}</h2>'
|
||||
|
||||
out = re.sub(r"<h2>(.*?)</h2>", repl, html, flags=re.I | re.S)
|
||||
return out, toc
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_payload_cached(mtime_ns: int, path_str: str) -> dict[str, Any]:
|
||||
path = Path(path_str)
|
||||
try:
|
||||
md_text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
md_text = "# 实盘说明缺失\n\n未找到 `docs/实盘说明.md`。"
|
||||
body = render_markdown_html(md_text)
|
||||
body, toc = _inject_h2_ids(body)
|
||||
return {"html": body, "toc": toc, "mtime_ns": mtime_ns}
|
||||
|
||||
|
||||
def load_live_guide_payload() -> dict[str, Any]:
|
||||
path = live_guide_md_path()
|
||||
try:
|
||||
mtime_ns = path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mtime_ns = 0
|
||||
return dict(_load_payload_cached(mtime_ns, str(path)))
|
||||
|
||||
|
||||
def live_guide_template_context() -> dict[str, Any]:
|
||||
payload = load_live_guide_payload()
|
||||
return {
|
||||
"live_guide_html": payload.get("html") or "",
|
||||
"live_guide_toc": payload.get("toc") or [],
|
||||
}
|
||||
@@ -127,20 +127,10 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[
|
||||
tabs.append({"key": "sim_funds", "title": "模拟资金"})
|
||||
if disp.get("show_settings_password", True):
|
||||
tabs.append({"key": "password", "title": "账户密码"})
|
||||
if inst.get("show_transfer") and disp.get("show_settings_transfer", False):
|
||||
tabs.append({"key": "transfer", "title": "永续划转"})
|
||||
# 永续划转 / USDC 币种兑换已下线
|
||||
if disp.get("show_settings_export", True):
|
||||
tabs.append({"key": "export", "title": "数据导出"})
|
||||
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True):
|
||||
_coin = False
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode
|
||||
|
||||
_coin = bool(is_coin_margin_mode())
|
||||
except Exception:
|
||||
_coin = False
|
||||
if not _coin:
|
||||
tabs.append({"key": "options_swap", "title": "币种兑换"})
|
||||
tabs.append({"key": "backup", "title": "备份恢复"})
|
||||
if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True):
|
||||
tabs.append({"key": "options_transfer", "title": "期权划转"})
|
||||
return tabs
|
||||
@@ -152,6 +142,10 @@ def settings_page_context(page: str, *, instance_base_dir: str | None = None, **
|
||||
from lib.instance.instance_system_guide_lib import system_guide_template_context
|
||||
|
||||
return system_guide_template_context()
|
||||
if p == "live_guide":
|
||||
from lib.instance.instance_live_guide_lib import live_guide_template_context
|
||||
|
||||
return live_guide_template_context()
|
||||
if p not in ("settings", "risk_policy", "env_config"):
|
||||
return {}
|
||||
display = kwargs.pop("display", None)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
|
||||
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启,备份恢复."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import jsonify, request, session
|
||||
from flask import jsonify, request, send_file, session
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
|
||||
from lib.env.env_file_lib import apply_env_updates
|
||||
from lib.env.env_ui_manifest import (
|
||||
build_env_ui_payload,
|
||||
filter_updates_for_ui,
|
||||
@@ -44,6 +44,17 @@ def _api_login_required(hub_token_write_allowed: bool = False):
|
||||
|
||||
# crypto_okx 可无 lib.hub;鉴权与中控 request_allowed 对齐
|
||||
allowed = bool(auth_disabled or logged_in)
|
||||
if not allowed:
|
||||
try:
|
||||
from lib.hub.hub_bridge_auth import hub_embed_sso_ok
|
||||
|
||||
if hub_embed_sso_ok(request):
|
||||
session["logged_in"] = True
|
||||
session.permanent = True
|
||||
session.modified = True
|
||||
allowed = True
|
||||
except Exception:
|
||||
pass
|
||||
if not allowed and hub_hdr and bridge and hub_hdr == bridge:
|
||||
allowed = True
|
||||
if not allowed:
|
||||
@@ -72,10 +83,14 @@ def register_instance_settings_routes(
|
||||
exchange_key: str,
|
||||
username: str,
|
||||
password: str,
|
||||
db_path: str | None = None,
|
||||
upload_folder: str | None = None,
|
||||
) -> None:
|
||||
env_path = os.path.join(base_dir, ".env")
|
||||
example_path = os.path.join(base_dir, ".env.example")
|
||||
api_auth = _api_login_required()
|
||||
resolved_db = (db_path or "").strip() or os.path.join(base_dir, "crypto.db")
|
||||
resolved_upload = (upload_folder or "").strip() or os.path.join(base_dir, "static", "images")
|
||||
|
||||
@app.route("/api/settings/display", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
@@ -118,23 +133,8 @@ def register_instance_settings_routes(
|
||||
if not clean:
|
||||
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
|
||||
if "OKX_OPTIONS_MARGIN_MODE" in clean:
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
|
||||
|
||||
lines = read_env_lines(env_path)
|
||||
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
|
||||
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
|
||||
if old_mode != new_mode:
|
||||
conn_m = get_db()
|
||||
try:
|
||||
block = mode_switch_block_msg(conn_m, None)
|
||||
if block:
|
||||
return jsonify({"ok": False, "msg": block}), 400
|
||||
finally:
|
||||
conn_m.close()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
|
||||
# 币本位专用机:忽略任何写入,强制 coin
|
||||
clean["OKX_OPTIONS_MARGIN_MODE"] = "coin"
|
||||
changed = apply_env_updates(env_path, clean)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
reload_info = apply_env_reload(env_path, get_db, changed, groups)
|
||||
@@ -179,6 +179,71 @@ def register_instance_settings_routes(
|
||||
def api_admin_health():
|
||||
return jsonify({"ok": True, "status": "up"})
|
||||
|
||||
@app.route("/api/settings/backup/list", methods=["GET"])
|
||||
@api_auth
|
||||
def api_settings_backup_list():
|
||||
from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"items": list_backups(),
|
||||
"backup_dir": backup_dir(),
|
||||
"keep_days": backup_keep_days(),
|
||||
"auto_hour_bj": 3,
|
||||
"includes": [".env", "database", "options_journal"],
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/settings/backup/create", methods=["POST"])
|
||||
@api_auth
|
||||
def api_settings_backup_create():
|
||||
from lib.instance.backup_lib import create_backup
|
||||
|
||||
try:
|
||||
result = create_backup(
|
||||
base_dir=base_dir,
|
||||
db_path=resolved_db,
|
||||
upload_folder=resolved_upload,
|
||||
label="manual",
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "msg": str(exc) or "备份失败"}), 500
|
||||
|
||||
@app.route("/api/settings/backup/download/<name>", methods=["GET"])
|
||||
@api_auth
|
||||
def api_settings_backup_download(name: str):
|
||||
from lib.instance.backup_lib import backup_path
|
||||
|
||||
path = backup_path(name)
|
||||
if not path or not os.path.isfile(path):
|
||||
return jsonify({"ok": False, "msg": "备份不存在"}), 404
|
||||
return send_file(
|
||||
path,
|
||||
mimetype="application/gzip",
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(path),
|
||||
)
|
||||
|
||||
@app.route("/api/settings/backup/restore", methods=["POST"])
|
||||
@api_auth
|
||||
def api_settings_backup_restore():
|
||||
from lib.instance.backup_lib import restore_backup
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "msg": "缺少备份文件名"}), 400
|
||||
result = restore_backup(
|
||||
name,
|
||||
base_dir=base_dir,
|
||||
db_path=resolved_db,
|
||||
upload_folder=resolved_upload,
|
||||
)
|
||||
code = 200 if result.get("ok") else 400
|
||||
return jsonify(result), code
|
||||
|
||||
def tab_allowed_fn(tab: str) -> bool:
|
||||
prefs = get_display_prefs(get_db)
|
||||
return tab_allowed(tab, prefs)
|
||||
|
||||
@@ -28,10 +28,12 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
|
||||
"journal_form_save.js": "application/javascript; charset=utf-8",
|
||||
"instance_page.css": "text/css; charset=utf-8",
|
||||
"instance_embed.js": "application/javascript; charset=utf-8",
|
||||
"hub_embed_auth.js": "application/javascript; charset=utf-8",
|
||||
"instance_mobile_nav.js": "application/javascript; charset=utf-8",
|
||||
"instance_stats.js": "application/javascript; charset=utf-8",
|
||||
"instance_live.js": "application/javascript; charset=utf-8",
|
||||
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
|
||||
"instance_backup.js": "application/javascript; charset=utf-8",
|
||||
"instance_dashboard.js": "application/javascript; charset=utf-8",
|
||||
"account_ledger.js": "application/javascript; charset=utf-8",
|
||||
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
|
||||
@@ -40,7 +42,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
|
||||
"options_settings.js": "application/javascript; charset=utf-8",
|
||||
"options_position_cards.js": "application/javascript; charset=utf-8",
|
||||
"options_review.js": "application/javascript; charset=utf-8",
|
||||
"hedge_plan.js": "application/javascript; charset=utf-8",
|
||||
"options_data_stats.js": "application/javascript; charset=utf-8",
|
||||
"order_entry_model.js": "application/javascript; charset=utf-8",
|
||||
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||
"focus_chart_page.css": "text/css; charset=utf-8",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""客户端远程快速更新:git pull + pm2 restart(供中控 Bridge 触发)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
_update_lock = threading.Lock()
|
||||
_update_pending_until = 0.0
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
env_root = (os.getenv("REPO") or "").strip()
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
return Path(REPO_ROOT)
|
||||
|
||||
|
||||
def _run_git(args: list[str], *, cwd: Path) -> tuple[bool, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False, "未找到 git"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "git 超时"
|
||||
except Exception as e:
|
||||
return False, str(e) or "git 失败"
|
||||
out = (proc.stdout or "").strip() or (proc.stderr or "").strip()
|
||||
return proc.returncode == 0, out
|
||||
|
||||
|
||||
def read_git_version() -> dict[str, Any]:
|
||||
"""读取当前部署目录 git 版本信息."""
|
||||
root = _repo_root()
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"repo": str(root),
|
||||
"sha": "",
|
||||
"short_sha": "",
|
||||
"branch": "",
|
||||
"dirty": False,
|
||||
"msg": "",
|
||||
}
|
||||
if not (root / ".git").exists() and not (root / ".git").is_file():
|
||||
# worktree 时 .git 可能是文件
|
||||
out["ok"] = False
|
||||
out["msg"] = "非 git 仓库"
|
||||
return out
|
||||
ok, sha = _run_git(["rev-parse", "HEAD"], cwd=root)
|
||||
if not ok or not sha:
|
||||
out["ok"] = False
|
||||
out["msg"] = sha or "无法读取 HEAD"
|
||||
return out
|
||||
out["sha"] = sha
|
||||
out["short_sha"] = sha[:7]
|
||||
ok_b, branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
|
||||
if ok_b and branch:
|
||||
out["branch"] = branch
|
||||
ok_d, dirty = _run_git(["status", "--porcelain"], cwd=root)
|
||||
if ok_d:
|
||||
out["dirty"] = bool(dirty.strip())
|
||||
return out
|
||||
|
||||
|
||||
def schedule_pull_and_restart(*, delay_seconds: float = 1.5) -> dict[str, Any]:
|
||||
"""延迟执行 deploy/pull_and_restart.sh,便于 Bridge 响应先返回."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return {"ok": False, "msg": "仅 Linux 服务器支持远程更新", "deferred": False}
|
||||
root = _repo_root()
|
||||
script = root / "deploy" / "pull_and_restart.sh"
|
||||
if not script.is_file():
|
||||
return {"ok": False, "msg": f"缺少更新脚本: {script}", "deferred": False}
|
||||
|
||||
global _update_pending_until
|
||||
now = time.time()
|
||||
with _update_lock:
|
||||
if now < _update_pending_until:
|
||||
return {"ok": False, "msg": "已有更新任务进行中,请稍后再试", "deferred": False}
|
||||
_update_pending_until = now + 120.0
|
||||
|
||||
version = read_git_version()
|
||||
try:
|
||||
delay = max(0.5, float(delay_seconds))
|
||||
cmd = (
|
||||
f"sleep {delay} && "
|
||||
f"cd {shlex.quote(str(root))} && "
|
||||
f"exec bash {shlex.quote(str(script))}"
|
||||
)
|
||||
subprocess.Popen(
|
||||
["bash", "-c", cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(root),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "更新已受理,即将 git pull 并重启",
|
||||
"deferred": True,
|
||||
"before_sha": version.get("sha") or "",
|
||||
"before_short_sha": version.get("short_sha") or "",
|
||||
"branch": version.get("branch") or "",
|
||||
}
|
||||
except FileNotFoundError:
|
||||
with _update_lock:
|
||||
_update_pending_until = 0.0
|
||||
return {"ok": False, "msg": "未找到 bash", "deferred": False}
|
||||
except Exception as e:
|
||||
with _update_lock:
|
||||
_update_pending_until = 0.0
|
||||
return {"ok": False, "msg": str(e) or "触发更新失败", "deferred": False}
|
||||
@@ -71,6 +71,13 @@ def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], g
|
||||
set_trading_mode(get_db, raw)
|
||||
except Exception:
|
||||
pass
|
||||
# 清余额缓存,避免模拟盘资金残留到实盘顶栏
|
||||
try:
|
||||
from app import invalidate_account_balance_cache
|
||||
|
||||
invalidate_account_balance_cache()
|
||||
except Exception:
|
||||
pass
|
||||
from lib.env.env_schema import updates_need_restart
|
||||
|
||||
return {"restart_required": updates_need_restart(groups, changed_keys)}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{# 备份恢复:本地 /root/crypto_okx_backups,凌晨3点自动,保留10天 #}
|
||||
<div class="backup-settings" data-backup-root="1">
|
||||
<h2>备份恢复</h2>
|
||||
<p class="settings-subcard-desc muted">
|
||||
含 <code>.env</code>、数据库、期权复盘图片。每天北京时间 <strong>03:00</strong> 自动备份,保留最近
|
||||
<strong>10</strong> 天。目录:
|
||||
<code id="backup-dir-label">/root/crypto_okx_backups</code>
|
||||
</p>
|
||||
<div class="settings-export-links-inline settings-export-links-block" style="gap:8px;margin-bottom:12px">
|
||||
<button type="button" class="btn" id="backup-create-btn">立即备份</button>
|
||||
<button type="button" class="btn-secondary" id="backup-refresh-btn">刷新列表</button>
|
||||
<span class="muted" id="backup-status" style="font-size:.78rem"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="or-trades-table" style="width:100%;font-size:.8rem">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件</th>
|
||||
<th>时间(北京)</th>
|
||||
<th>大小</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="backup-list-body">
|
||||
<tr><td colspan="4" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/instance_backup.js?v=1"></script>
|
||||
<script>
|
||||
(function () {
|
||||
if (window.InstanceBackup && typeof window.InstanceBackup.boot === "function") {
|
||||
window.InstanceBackup.boot();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -1158,7 +1158,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
|
||||
const pushCoin = (v, ccy) => {
|
||||
if (v === null || v === undefined || v === "") return;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
|
||||
// 低于 OKX 现货常见 minSz 的粉尘不展示
|
||||
const minSz = ccy === "BTC" ? 0.00001 : 0.0001;
|
||||
if (Number.isNaN(n) || !(n >= minSz)) return;
|
||||
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
|
||||
parts.push(`${txt || "0"} ${ccy}`);
|
||||
};
|
||||
@@ -1200,7 +1202,23 @@ function accountSnapshotFundingMissing(data){
|
||||
const hasTrading = data.current_capital != null && data.current_capital !== "";
|
||||
return !hasFunding && !hasTotal && !hasTrading;
|
||||
}
|
||||
let accountSnapshotRetryCount = 0;
|
||||
let accountSnapshotMissRetryTimer = null;
|
||||
let accountSnapshotMissRetryDone = false;
|
||||
function clearAccountSnapshotMissRetry(){
|
||||
accountSnapshotMissRetryDone = false;
|
||||
if(accountSnapshotMissRetryTimer){
|
||||
clearTimeout(accountSnapshotMissRetryTimer);
|
||||
accountSnapshotMissRetryTimer = null;
|
||||
}
|
||||
}
|
||||
function scheduleAccountSnapshotMissRetry(){
|
||||
if(accountSnapshotMissRetryDone || accountSnapshotMissRetryTimer) return;
|
||||
accountSnapshotMissRetryTimer = setTimeout(function(){
|
||||
accountSnapshotMissRetryTimer = null;
|
||||
accountSnapshotMissRetryDone = true;
|
||||
refreshAccountSnapshot({ silent: true, force: true, missRetry: true });
|
||||
}, 10000);
|
||||
}
|
||||
function applyAccountSnapshot(data){
|
||||
if(!data || typeof data !== "object") return;
|
||||
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
|
||||
@@ -1212,9 +1230,13 @@ function applyAccountSnapshot(data){
|
||||
applyOptionsFundsVisibility(!coinMode);
|
||||
if(data.funding_usdt != null && data.funding_usdt !== ""){
|
||||
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
|
||||
} else if("funding_usdt" in data){
|
||||
setFundsFieldText("total-capital", "—");
|
||||
}
|
||||
if(data.total_funds != null && data.total_funds !== ""){
|
||||
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
|
||||
} else if("total_funds" in data){
|
||||
setFundsFieldText("total-funds", "—");
|
||||
}
|
||||
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
|
||||
setFundsFieldText(
|
||||
@@ -1226,6 +1248,16 @@ function applyAccountSnapshot(data){
|
||||
data.options_margin_mode
|
||||
)
|
||||
);
|
||||
} else if("current_capital" in data){
|
||||
setFundsFieldText(
|
||||
"current-capital",
|
||||
formatTradingAccountLabel(
|
||||
null,
|
||||
data.options_trading_eth,
|
||||
data.options_trading_btc,
|
||||
data.options_margin_mode
|
||||
)
|
||||
);
|
||||
}
|
||||
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
|
||||
const optFunding = formatOptionsFundingLabel(
|
||||
@@ -1315,20 +1347,20 @@ function applyAccountSnapshot(data){
|
||||
}
|
||||
function refreshAccountSnapshot(opts){
|
||||
const options = opts || {};
|
||||
const qs = options.force ? "?force=1" : "";
|
||||
let useForce = !!options.force;
|
||||
if(useForce && !options.missRetry && (accountSnapshotMissRetryTimer || accountSnapshotMissRetryDone)){
|
||||
useForce = false;
|
||||
}
|
||||
const qs = useForce ? "?force=1" : "";
|
||||
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
|
||||
applyAccountSnapshot(data);
|
||||
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
|
||||
accountSnapshotRetryCount += 1;
|
||||
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount);
|
||||
}else if(!accountSnapshotFundingMissing(data)){
|
||||
accountSnapshotRetryCount = 0;
|
||||
if(!accountSnapshotFundingMissing(data)){
|
||||
clearAccountSnapshotMissRetry();
|
||||
return;
|
||||
}
|
||||
scheduleAccountSnapshotMissRetry();
|
||||
}).catch(()=>{
|
||||
if(!options.silent && accountSnapshotRetryCount < 3){
|
||||
accountSnapshotRetryCount += 1;
|
||||
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
|
||||
}
|
||||
scheduleAccountSnapshotMissRetry();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,8 @@
|
||||
{% include 'options_panel.html' %}
|
||||
{% elif page == 'options_review' %}
|
||||
{% include 'options_review_panel.html' %}
|
||||
{% elif page == 'hedge_plan' %}
|
||||
{% include 'hedge_plan_panel.html' %}
|
||||
{% elif page == 'stats' %}
|
||||
{% include 'options_data_stats_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
{% include 'system_guide_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'live_guide' %}
|
||||
{% include 'live_guide_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'settings' %}
|
||||
{% include 'settings_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<script src="/static/instance_theme.js?v=51"></script>
|
||||
<script src="/static/hub_embed_auth.js?v=1"></script>
|
||||
<script src="/static/instance_theme.js?v=52"></script>
|
||||
<script src="/static/autofill_guard.js?v=1"></script>
|
||||
<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/instance_page.css?v=21">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=124">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=26">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=129">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<script src="/static/open_submit_gate.js?v=1"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
@@ -35,7 +36,7 @@
|
||||
>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>加密货币|OKX 期权与对冲</h1>
|
||||
<h1>加密货币|OKX 期权</h1>
|
||||
</div>
|
||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||
<div class="top-nav-links">
|
||||
@@ -48,12 +49,13 @@
|
||||
{% if options_nav_visible and display.show_nav_options_review %}
|
||||
<a href="/options/review" data-embed-tab="options_review" class="{% if initial_tab == 'options_review' %}active{% endif %}">期权复盘</a>
|
||||
{% endif %}
|
||||
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
||||
<a href="/hedge-plan" data-embed-tab="hedge_plan" class="{% if initial_tab == 'hedge_plan' %}active{% endif %}">对冲计划</a>
|
||||
{% endif %}
|
||||
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
|
||||
{% if display.show_nav_risk_policy %}
|
||||
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||
{% endif %}
|
||||
{% if display.get('show_nav_live_guide', true) %}
|
||||
<a href="/live_guide" data-embed-tab="live_guide" class="{% if initial_tab == 'live_guide' %}active{% endif %}">实盘说明</a>
|
||||
{% endif %}
|
||||
<a href="/system_guide" data-embed-tab="system_guide" class="{% if initial_tab == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
|
||||
{% if display.show_nav_env_config %}
|
||||
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
|
||||
@@ -65,7 +67,7 @@
|
||||
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||
|
||||
{% include 'instance_header_panel.html' %}
|
||||
{% if initial_tab not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options_review') and include_transfer_block %}
|
||||
{% if initial_tab not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options_review', 'stats') and include_transfer_block %}
|
||||
{% include 'instance_top_bar.html' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -97,9 +99,6 @@
|
||||
{% if options_nav_visible and display.show_nav_options %}
|
||||
<a href="/options" class="inst-m-tab{% if initial_tab == 'options' %} active{% endif %}" data-embed-tab="options">期权</a>
|
||||
{% endif %}
|
||||
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
||||
<a href="/hedge-plan" class="inst-m-tab{% if initial_tab == 'hedge_plan' %} active{% endif %}" data-embed-tab="hedge_plan">对冲</a>
|
||||
{% endif %}
|
||||
<a href="/key_monitor" class="inst-m-tab{% if initial_tab == 'key_monitor' %} active{% endif %}" data-embed-tab="key_monitor"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位</a>
|
||||
<button type="button" class="inst-m-tab" data-embed-tab="more" id="inst-m-tab-more" aria-haspopup="dialog" aria-expanded="false">更多</button>
|
||||
</nav>
|
||||
@@ -115,12 +114,13 @@
|
||||
{% if options_nav_visible and display.show_nav_options_review %}
|
||||
<a href="/options/review" data-embed-tab="options_review">期权复盘</a>
|
||||
{% endif %}
|
||||
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
||||
<a href="/hedge-plan" data-embed-tab="hedge_plan">对冲计划</a>
|
||||
{% endif %}
|
||||
<a href="/stats" data-embed-tab="stats"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
|
||||
{% if display.show_nav_risk_policy %}
|
||||
<a href="/risk_policy" data-embed-tab="risk_policy">风控说明</a>
|
||||
{% endif %}
|
||||
{% if display.get('show_nav_live_guide', true) %}
|
||||
<a href="/live_guide" data-embed-tab="live_guide">实盘说明</a>
|
||||
{% endif %}
|
||||
<a href="/system_guide" data-embed-tab="system_guide"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
|
||||
{% if display.show_nav_env_config %}
|
||||
<a href="/env_config" data-embed-tab="env_config">env配置</a>
|
||||
@@ -144,7 +144,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
||||
</script>
|
||||
<script src="/static/order_entry_model.js?v=5"></script>
|
||||
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||
<script src="/static/symbol_live_price.js?v=2"></script>
|
||||
<script src="/static/symbol_live_price.js?v=3"></script>
|
||||
<script src="/static/key_monitor_form.js?v=2"></script>
|
||||
<script src="/static/instance_stats.js?v=5"></script>
|
||||
{% include 'embed_boot_scripts.html' %}
|
||||
@@ -157,7 +157,7 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||
</script>
|
||||
<script src="/static/instance_settings_prefs.js?v=24"></script>
|
||||
<script src="/static/instance_live.js?v=7"></script>
|
||||
<script src="/static/instance_embed.js?v=32"></script>
|
||||
<script src="/static/instance_embed.js?v=33"></script>
|
||||
<script src="/static/instance_mobile_nav.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,11 +16,7 @@
|
||||
</div>
|
||||
|
||||
{% if env_config_groups %}
|
||||
{% set ns = namespace(mode_idx=0) %}
|
||||
{% for group in env_config_groups %}
|
||||
{% if '期权/对冲模式' in (group.title or '') %}{% set ns.mode_idx = loop.index0 %}{% endif %}
|
||||
{% endfor %}
|
||||
<div class="env-config-body card" data-env-ssr="1" id="env-config-body" data-env-mode-section-idx="{{ ns.mode_idx }}">
|
||||
<div class="env-config-body card" data-env-ssr="1" id="env-config-body">
|
||||
{% for group in env_config_groups %}
|
||||
<input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
|
||||
{% endfor %}
|
||||
@@ -77,6 +73,7 @@
|
||||
onfocus="this.removeAttribute('readonly')"
|
||||
>
|
||||
{% else %}
|
||||
<div class="env-field-input-wrap{% if field.key == 'HUB_BRIDGE_TOKEN' %} env-field-input-wrap--copy{% endif %}">
|
||||
<input
|
||||
class="env-field-input"
|
||||
id="env-f-{{ field.key }}"
|
||||
@@ -92,6 +89,15 @@
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
>
|
||||
{% if field.key == 'HUB_BRIDGE_TOKEN' %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary btn-sm env-copy-btn"
|
||||
data-env-copy="HUB_BRIDGE_TOKEN"
|
||||
title="复制 Token"
|
||||
>复制</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="force-close-badge" id="force-close-header-badge" role="status"
|
||||
title="北京时间 {{ force_close.hour_label }} 整点未平仓将市价强制清仓(result=强制清仓)"
|
||||
data-force-close-at-ms="{{ force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if force_close.active else '0' }}">
|
||||
{{ force_close.label }} 已开启 · <span class="force-close-header-cd">{{ force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -1,8 +0,0 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="pos-symbol-force-close pos-force-close-meta" id="order-force-close-wrap-{{ o.id }}"
|
||||
data-force-close-at-ms="{{ o.force_close_at_ms or force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if (o.force_close_active or force_close.active) else '0' }}">
|
||||
<span class="pos-force-close-label">{{ o.force_close_label or force_close.label }}</span>
|
||||
· <span class="pos-force-close-cd" id="order-force-close-cd-{{ o.id }}">{{ o.force_close_countdown or force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -1,26 +0,0 @@
|
||||
<details class="tip-collapse gate-top-tips-collapse">
|
||||
<summary class="tip-collapse-summary">
|
||||
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)
|
||||
<span class="tip-collapse-hint">· 划转规则</span>
|
||||
</summary>
|
||||
<div class="tip-collapse-body rule-tip gate-transfer-tip">
|
||||
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};<strong>持仓中不划转</strong>并微信通知)
|
||||
</div>
|
||||
</details>
|
||||
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form" autocomplete="off">
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
|
||||
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
|
||||
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
|
||||
<select name="from_account" autocomplete="off">
|
||||
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
|
||||
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
|
||||
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
|
||||
</select>
|
||||
<select name="to_account" autocomplete="off">
|
||||
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
|
||||
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
|
||||
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
|
||||
</select>
|
||||
<button type="submit">手动划转</button>
|
||||
</form>
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<script src="/static/instance_theme.js?v=50"></script>
|
||||
<script src="/static/instance_theme.js?v=52"></script>
|
||||
<script src="/static/autofill_guard.js?v=1"></script>
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
@@ -18,8 +18,8 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||
<title>{{ pwa_app_name }}</title>
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=21">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=118">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=26">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=129">
|
||||
|
||||
</head>
|
||||
<body
|
||||
@@ -153,45 +153,46 @@
|
||||
<div class="header">
|
||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||
</div>
|
||||
<div class="top-nav">
|
||||
<nav class="top-nav" aria-label="实例导航">
|
||||
<div class="top-nav-links">
|
||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||
<a href="/trade" data-embed-tab="trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
||||
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
|
||||
<a href="/strategy" data-embed-tab="strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
|
||||
{% endif %}
|
||||
{% if not intraday_discipline and display.show_nav_strategy_records %}
|
||||
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
||||
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
||||
{% endif %}
|
||||
{% if display.show_nav_records %}
|
||||
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||
{% endif %}
|
||||
{% if display.show_nav_stats %}
|
||||
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
||||
<a href="/records" data-embed-tab="records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||
{% endif %}
|
||||
{% if options_nav_visible and display.show_nav_options %}
|
||||
<a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a>
|
||||
<a href="/options" data-embed-tab="options" class="{% if page == 'options' %}active{% endif %}">期权</a>
|
||||
{% endif %}
|
||||
{% if options_nav_visible and display.show_nav_options_review %}
|
||||
<a href="/options/review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a>
|
||||
{% endif %}
|
||||
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
||||
<a href="/hedge-plan" class="{% if page == 'hedge_plan' %}active{% endif %}">对冲计划</a>
|
||||
<a href="/options/review" data-embed-tab="options_review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a>
|
||||
{% endif %}
|
||||
<a href="/stats" data-embed-tab="stats" class="{% if page == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
|
||||
{% if display.show_nav_risk_policy %}
|
||||
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||
{% endif %}
|
||||
<a href="/system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
|
||||
{% if display.get('show_nav_live_guide', true) %}
|
||||
<a href="/live_guide" data-embed-tab="live_guide" class="{% if page == 'live_guide' %}active{% endif %}">实盘说明</a>
|
||||
{% endif %}
|
||||
<a href="/system_guide" data-embed-tab="system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
|
||||
{% if display.show_nav_env_config %}
|
||||
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
|
||||
<a href="/env_config" data-embed-tab="env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
|
||||
{% endif %}
|
||||
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
||||
</div>
|
||||
<a href="/settings" data-embed-tab="settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
||||
</div>
|
||||
{% include 'nav_spot_tickers.html' %}
|
||||
</nav>
|
||||
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
||||
|
||||
{% include 'instance_header_panel.html' %}
|
||||
{% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %}
|
||||
{% if page not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options', 'options_review', 'stats') %}
|
||||
{% include 'instance_top_bar.html' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -205,16 +206,8 @@
|
||||
{% elif page == 'trade' %}
|
||||
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
||||
{% if focus_order_id %}
|
||||
<a href="/order_focus?order_id={{ focus_order_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
|
||||
{% else %}
|
||||
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include order_rule_tips_tpl %}
|
||||
{% include 'order_monitor_open_form.html' %}
|
||||
<h2 style="margin-bottom:8px">实盘下单监控</h2>
|
||||
<p class="muted">永续下单已下线,请使用「期权」页。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
@@ -252,7 +245,6 @@
|
||||
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_order_badge.html' %}
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
@@ -365,8 +357,8 @@
|
||||
{% include 'options_panel.html' %}
|
||||
{% elif page == 'options_review' %}
|
||||
{% include 'options_review_panel.html' %}
|
||||
{% elif page == 'hedge_plan' %}
|
||||
{% include 'hedge_plan_panel.html' %}
|
||||
{% elif page == 'stats' %}
|
||||
{% include 'options_data_stats_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -387,48 +379,14 @@
|
||||
{% include 'system_guide_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'live_guide' %}
|
||||
{% include 'live_guide_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'settings' %}
|
||||
{% include 'settings_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'stats' %}
|
||||
<div class="card stats-card full" id="stats-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
|
||||
<h2 style="margin-bottom:0">数据统计</h2>
|
||||
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
|
||||
</div>
|
||||
<div class="stats-content" id="stats-content">
|
||||
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
|
||||
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计):
|
||||
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong> 次
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:14px;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
|
||||
统计品类
|
||||
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
|
||||
{% for seg in stats_bundle.segments %}
|
||||
<option value="{{ seg.key }}">{{ seg.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{% for seg in stats_bundle.segments %}
|
||||
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
|
||||
<div class="stats-period-tabs" role="tablist" aria-label="统计周期">
|
||||
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
|
||||
</div>
|
||||
{{ period_stats_pane("day", seg.day) }}
|
||||
{{ period_stats_pane("week", seg.week) }}
|
||||
{{ period_stats_pane("month", seg.month) }}
|
||||
{{ period_stats_pane("all", seg.all) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal" id="imgModal" onclick="closeModal()">
|
||||
@@ -463,7 +421,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
||||
</script>
|
||||
<script src="/static/order_entry_model.js?v=5"></script>
|
||||
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||
<script src="/static/symbol_live_price.js?v=2"></script>
|
||||
<script src="/static/symbol_live_price.js?v=3"></script>
|
||||
<script src="/static/strategy_roll.js?v=6"></script>
|
||||
<script src="/static/instance_stats.js?v=5"></script>
|
||||
<script>
|
||||
@@ -1639,7 +1597,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
|
||||
const pushCoin = (v, ccy) => {
|
||||
if (v == null || v === "") return;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
|
||||
// 低于 OKX 现货常见 minSz 的粉尘不展示
|
||||
const minSz = ccy === "BTC" ? 0.00001 : 0.0001;
|
||||
if (Number.isNaN(n) || !(n >= minSz)) return;
|
||||
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
|
||||
parts.push(`${txt || "0"} ${ccy}`);
|
||||
};
|
||||
@@ -1681,7 +1641,23 @@ function accountSnapshotFundingMissing(data){
|
||||
const hasTrading = data.current_capital != null && data.current_capital !== "";
|
||||
return !hasFunding && !hasTotal && !hasTrading;
|
||||
}
|
||||
let accountSnapshotRetryCount = 0;
|
||||
let accountSnapshotMissRetryTimer = null;
|
||||
let accountSnapshotMissRetryDone = false;
|
||||
function clearAccountSnapshotMissRetry(){
|
||||
accountSnapshotMissRetryDone = false;
|
||||
if(accountSnapshotMissRetryTimer){
|
||||
clearTimeout(accountSnapshotMissRetryTimer);
|
||||
accountSnapshotMissRetryTimer = null;
|
||||
}
|
||||
}
|
||||
function scheduleAccountSnapshotMissRetry(){
|
||||
if(accountSnapshotMissRetryDone || accountSnapshotMissRetryTimer) return;
|
||||
accountSnapshotMissRetryTimer = setTimeout(function(){
|
||||
accountSnapshotMissRetryTimer = null;
|
||||
accountSnapshotMissRetryDone = true;
|
||||
refreshAccountSnapshot({ silent: true, force: true, missRetry: true });
|
||||
}, 10000);
|
||||
}
|
||||
function applyAccountSnapshot(data){
|
||||
if(!data || typeof data !== "object") return;
|
||||
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
|
||||
@@ -1693,9 +1669,13 @@ function applyAccountSnapshot(data){
|
||||
applyOptionsFundsVisibility(!coinMode);
|
||||
if(data.funding_usdt != null && data.funding_usdt !== ""){
|
||||
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
|
||||
} else if("funding_usdt" in data){
|
||||
setFundsFieldText("total-capital", "—");
|
||||
}
|
||||
if(data.total_funds != null && data.total_funds !== ""){
|
||||
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
|
||||
} else if("total_funds" in data){
|
||||
setFundsFieldText("total-funds", "—");
|
||||
}
|
||||
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
|
||||
setFundsFieldText(
|
||||
@@ -1707,6 +1687,16 @@ function applyAccountSnapshot(data){
|
||||
data.options_margin_mode
|
||||
)
|
||||
);
|
||||
} else if("current_capital" in data){
|
||||
setFundsFieldText(
|
||||
"current-capital",
|
||||
formatTradingAccountLabel(
|
||||
null,
|
||||
data.options_trading_eth,
|
||||
data.options_trading_btc,
|
||||
data.options_margin_mode
|
||||
)
|
||||
);
|
||||
}
|
||||
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
|
||||
const optFunding = formatOptionsFundingLabel(
|
||||
@@ -1805,20 +1795,20 @@ function applyAccountSnapshot(data){
|
||||
}
|
||||
function refreshAccountSnapshot(opts){
|
||||
const options = opts || {};
|
||||
const qs = options.force ? "?force=1" : "";
|
||||
let useForce = !!options.force;
|
||||
if(useForce && !options.missRetry && (accountSnapshotMissRetryTimer || accountSnapshotMissRetryDone)){
|
||||
useForce = false;
|
||||
}
|
||||
const qs = useForce ? "?force=1" : "";
|
||||
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
|
||||
applyAccountSnapshot(data);
|
||||
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
|
||||
accountSnapshotRetryCount += 1;
|
||||
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount);
|
||||
}else if(!accountSnapshotFundingMissing(data)){
|
||||
accountSnapshotRetryCount = 0;
|
||||
if(!accountSnapshotFundingMissing(data)){
|
||||
clearAccountSnapshotMissRetry();
|
||||
return;
|
||||
}
|
||||
scheduleAccountSnapshotMissRetry();
|
||||
}).catch(()=>{
|
||||
if(!options.silent && accountSnapshotRetryCount < 3){
|
||||
accountSnapshotRetryCount += 1;
|
||||
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
|
||||
}
|
||||
scheduleAccountSnapshotMissRetry();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2082,6 +2072,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
|
||||
</script>
|
||||
<script src="/static/records_review_page.js?v=4"></script>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/nav_spot_tickers.js?v=3"></script>
|
||||
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||
<script src="/static/account_ledger.js?v=1"></script>
|
||||
<script>
|
||||
|
||||
@@ -28,10 +28,12 @@
|
||||
{% if trade_policy.badge_text %}
|
||||
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_header_badge.html' %}
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||
</div>
|
||||
{% include 'instance_theme_toggle.html' %}
|
||||
{% if show_logout|default(true) %}
|
||||
<a href="/logout" class="instance-logout-btn" title="退出登录">退出</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="instance-header-stats-wrap instance-desktop-only">
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{# 系统设置 · 资金划转(三所共用) #}
|
||||
<div class="settings-transfer-panel">
|
||||
<p class="rule-tip settings-transfer-auto">
|
||||
自动划转 <strong>{{ '开启' if auto_transfer_enabled else '关闭' }}</strong>:
|
||||
每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong> 起该整点小时内尝试;
|
||||
账簿按 <strong>UTC 自然日</strong> 去重;
|
||||
将 <code>{{ auto_transfer_to }}</code> 调整至 <strong>{{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U</strong>:
|
||||
不足从 <code>{{ auto_transfer_from }}</code> 划入,超出划回 <code>{{ auto_transfer_from }}</code>;
|
||||
<strong>持仓中不划转</strong>并微信通知.
|
||||
</p>
|
||||
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form" autocomplete="off">
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
|
||||
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
|
||||
<input name="amount" id="manual-xfer-amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
|
||||
<select name="from_account" aria-label="划出账户" autocomplete="off">
|
||||
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
|
||||
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
|
||||
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
|
||||
</select>
|
||||
<select name="to_account" aria-label="划入账户" autocomplete="off">
|
||||
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
|
||||
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
|
||||
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
|
||||
</select>
|
||||
<button type="submit">执行手动划转</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
<script src="/static/instance_theme.js?v=5"></script>
|
||||
<title>{{ exchange_display }} | 关键位放大</title>
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=50">
|
||||
<script src="/static/symbol_live_price.js?v=2"></script>
|
||||
<script src="/static/symbol_live_price.js?v=3"></script>
|
||||
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
|
||||
</head>
|
||||
<body class="focus-page" data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}">
|
||||
|
||||
@@ -140,8 +140,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<form id="key-form" action="/add_key" method="post" class="form-row">
|
||||
{% from 'trade_policy_fields.html' import trade_policy_symbol with context %}
|
||||
{{ trade_policy_symbol('symbol', 'key-symbol') }}
|
||||
<select name="symbol" id="key-symbol" required class="trade-policy-symbol-select" title="仅 BTC/ETH,与期权标的一致">
|
||||
<option value="BTC/USDT" selected>BTC/USDT</option>
|
||||
<option value="ETH/USDT">ETH/USDT</option>
|
||||
</select>
|
||||
<select name="type" id="key-type-select" required>
|
||||
<option value="关键支撑阻力" selected>关键支撑阻力</option>
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{# 实盘说明: docs/实盘说明.md + h2 目录 #}
|
||||
<div class="system-guide-page full">
|
||||
<div class="card system-guide-card">
|
||||
<div class="system-guide-head">
|
||||
<h2 style="margin:0">实盘说明</h2>
|
||||
<p class="muted" style="margin:6px 0 0;font-size:.85rem">OKX API、账户模式与实例 env。可在系统设置 → 导航显示中关闭顶栏入口。</p>
|
||||
</div>
|
||||
<div class="system-guide-layout">
|
||||
{% if live_guide_toc %}
|
||||
<aside class="system-guide-toc" aria-label="章节目录">
|
||||
<div class="system-guide-toc-title">目录</div>
|
||||
<nav>
|
||||
{% for item in live_guide_toc %}
|
||||
<a href="#{{ item.id }}">{{ item.title }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</aside>
|
||||
{% endif %}
|
||||
<article class="system-guide-body prose">
|
||||
{{ live_guide_html|safe }}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.system-guide-page { grid-column: 1 / -1; }
|
||||
.system-guide-card { padding: 16px 18px 28px; }
|
||||
.system-guide-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
.system-guide-toc {
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(127,127,127,.25);
|
||||
border-radius: 8px;
|
||||
background: rgba(127,127,127,.06);
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
}
|
||||
.system-guide-toc-title {
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
opacity: .75;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.system-guide-toc a {
|
||||
display: block;
|
||||
font-size: .84rem;
|
||||
line-height: 1.35;
|
||||
padding: 5px 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
opacity: .9;
|
||||
}
|
||||
.system-guide-toc a:hover { opacity: 1; text-decoration: underline; }
|
||||
.system-guide-body { min-width: 0; line-height: 1.65; font-size: .92rem; }
|
||||
.system-guide-body h1 { font-size: 1.35rem; margin: 0 0 12px; }
|
||||
.system-guide-body h2 { font-size: 1.12rem; margin: 22px 0 10px; padding-top: 4px; scroll-margin-top: 12px; }
|
||||
.system-guide-body h3 { font-size: 1rem; margin: 16px 0 8px; }
|
||||
.system-guide-body p, .system-guide-body li { margin: 0 0 8px; }
|
||||
.system-guide-body ul, .system-guide-body ol { padding-left: 1.35em; margin: 0 0 10px; }
|
||||
.system-guide-body table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: .86rem; }
|
||||
.system-guide-body th, .system-guide-body td {
|
||||
border: 1px solid rgba(127,127,127,.35);
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.system-guide-body code {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: .86em;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(127,127,127,.12);
|
||||
}
|
||||
.system-guide-body hr { border: 0; border-top: 1px solid rgba(127,127,127,.28); margin: 18px 0; }
|
||||
@media (max-width: 820px) {
|
||||
.system-guide-layout { grid-template-columns: 1fr; }
|
||||
.system-guide-toc { position: static; max-height: none; }
|
||||
.system-guide-toc nav { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
{# 实例数据统计:上方期权复盘 KPI/分组,下方资金曲线 #}
|
||||
<div class="card ods-page full" id="options-data-stats" data-source-type="option_spot">
|
||||
<style>
|
||||
.ods-page{grid-column:1/-1}
|
||||
.ods-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.ods-head h2{margin:0;font-size:1.05rem}
|
||||
.ods-section{margin-bottom:22px}
|
||||
.ods-section-title{font-weight:650;font-size:.9rem;margin-bottom:4px}
|
||||
.ods-section-desc{font-size:.72rem;opacity:.7;margin:0 0 12px}
|
||||
.ods-kpi-row{
|
||||
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
|
||||
gap:8px;margin-bottom:12px;
|
||||
}
|
||||
.ods-kpi-tile{
|
||||
border:1px solid rgba(127,127,127,.22);border-radius:10px;
|
||||
padding:10px 12px;background:rgba(0,0,0,.2);min-width:0;
|
||||
}
|
||||
.ods-kpi-label{font-size:.7rem;opacity:.7;margin-bottom:4px}
|
||||
.ods-kpi-value{font-size:.95rem;font-weight:650;letter-spacing:.01em;word-break:break-all}
|
||||
.ods-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
|
||||
.ods-stat-card{
|
||||
border:1px solid rgba(127,127,127,.22);border-radius:10px;
|
||||
padding:10px 12px;background:rgba(0,0,0,.16);font-size:.76rem;
|
||||
}
|
||||
.ods-stat-card-title{
|
||||
font-weight:650;margin-bottom:8px;font-size:.78rem;
|
||||
padding-bottom:6px;border-bottom:1px solid rgba(127,127,127,.18);
|
||||
}
|
||||
.ods-stat-row{
|
||||
display:flex;justify-content:space-between;align-items:baseline;gap:10px;
|
||||
padding:5px 0;border-bottom:1px solid rgba(127,127,127,.1);
|
||||
}
|
||||
.ods-stat-row:last-child{border-bottom:none;padding-bottom:0}
|
||||
.ods-stat-key{opacity:.9;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
||||
.ods-stat-val{flex-shrink:0;font-variant-numeric:tabular-nums;opacity:.85}
|
||||
.ods-curve-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.ods-range-btn{
|
||||
font-size:.76rem;padding:4px 10px;border-radius:6px;cursor:pointer;
|
||||
border:1px solid rgba(127,127,127,.3);background:transparent;color:inherit;
|
||||
}
|
||||
.ods-range-btn.active{border-color:rgba(90,160,255,.7);background:rgba(90,160,255,.12)}
|
||||
.ods-curve-meta{font-size:.72rem;opacity:.75;margin-left:auto}
|
||||
.ods-curve-wrap{
|
||||
border:1px solid rgba(127,127,127,.22);border-radius:10px;
|
||||
padding:12px 10px 8px;background:rgba(0,0,0,.14);
|
||||
}
|
||||
.ods-curve-svg{width:100%;height:240px;display:block}
|
||||
.ods-pnl-pos{color:#3dd68c}
|
||||
.ods-pnl-neg{color:#f07178}
|
||||
@media (max-width:900px){.ods-kpi-row{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media (max-width:640px){
|
||||
.ods-kpi-row{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.ods-curve-svg{height:200px}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ods-head">
|
||||
<h2>数据统计</h2>
|
||||
<span class="muted" id="ods-status" style="font-size:.72rem"></span>
|
||||
<button type="button" class="btn-secondary" id="ods-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
|
||||
</div>
|
||||
|
||||
<section class="ods-section" aria-labelledby="ods-review-heading">
|
||||
<div class="ods-section-title" id="ods-review-heading">期权复盘统计</div>
|
||||
<p class="ods-section-desc">与「期权复盘」页统计同源;复盘页内统计仍保留。</p>
|
||||
<div id="ods-kpi" class="ods-kpi-row"></div>
|
||||
<div id="ods-stats-groups" class="ods-stats-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="ods-section" aria-labelledby="ods-curve-heading">
|
||||
<div class="ods-section-title" id="ods-curve-heading">已实现盈亏曲线</div>
|
||||
<p class="ods-section-desc">累计已实现盈亏(期权平仓真盈亏U累加);划转/提现不影响。</p>
|
||||
<div class="ods-curve-toolbar">
|
||||
<button type="button" class="ods-range-btn active" data-range="today">今日</button>
|
||||
<button type="button" class="ods-range-btn" data-range="7d">7日</button>
|
||||
<button type="button" class="ods-range-btn" data-range="30d">30日</button>
|
||||
<button type="button" class="ods-range-btn" data-range="all">全部</button>
|
||||
<span class="ods-curve-meta" id="ods-curve-meta">—</span>
|
||||
</div>
|
||||
<div class="ods-curve-wrap">
|
||||
<svg id="ods-curve-svg" class="ods-curve-svg" viewBox="0 0 640 240" preserveAspectRatio="none" role="img" aria-label="已实现盈亏曲线"></svg>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/static/options_data_stats.js?v=4"></script>
|
||||
@@ -1,72 +0,0 @@
|
||||
{# 趋势户:两级开仓类型 → 自动 trade_style;日内户:假破 / 结构突破 #}
|
||||
|
||||
{% macro order_entry_type_fields() -%}
|
||||
|
||||
{% if order_entry_profile == 'trend_div' %}
|
||||
|
||||
<div class="order-entry-model-row">
|
||||
|
||||
<select id="order-entry-category" class="order-entry-category" required title="反转 / 顺势 / 波段" aria-label="开仓性质">
|
||||
|
||||
<option value="">性质</option>
|
||||
|
||||
{% for cat in entry_model_categories %}
|
||||
|
||||
<option value="{{ cat.key }}">{{ cat.label }}</option>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
|
||||
<select name="entry_model" id="order-entry-model" class="order-entry-model-sub" required disabled title="启动A/B,大分歧A/B,小分歧" aria-label="开仓类型">
|
||||
|
||||
<option value="">类型</option>
|
||||
|
||||
{% for cat in entry_model_categories %}
|
||||
|
||||
{% for opt in cat.options %}
|
||||
|
||||
<option value="{{ opt.code }}" data-entry-category="{{ cat.key }}" data-trade-style="{{ opt.trade_style }}"{% if opt.help %} title="{{ opt.help }}"{% endif %} hidden disabled>{{ opt.label }}</option>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
|
||||
<input type="hidden" name="trade_style" id="order-trade-style-hidden" value="trend">
|
||||
|
||||
<span id="order-trade-style-hint" class="order-trade-style-hint" title="由开仓类型自动设定">趋势单</span>
|
||||
|
||||
</div>
|
||||
|
||||
{% elif order_entry_profile == 'intraday' %}
|
||||
|
||||
<select name="entry_model" id="order-entry-model" class="order-entry-intraday" required title="日内开仓类型" aria-label="开仓类型">
|
||||
|
||||
<option value="">开仓类型</option>
|
||||
|
||||
{% for opt in intraday_entry_model_options %}
|
||||
|
||||
<option value="{{ opt.code }}"{% if opt.help %} title="{{ opt.help }}"{% endif %}>{{ opt.label }}</option>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
|
||||
<input type="hidden" name="trade_style" value="trend">
|
||||
|
||||
{% else %}
|
||||
|
||||
<select name="trade_style" required>
|
||||
|
||||
<option value="trend">趋势单</option>
|
||||
|
||||
<option value="swing">波段单</option>
|
||||
|
||||
</select>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script src="/static/instance_theme.js?v=5"></script>
|
||||
<title>{{ exchange_display }} | 实盘下单放大</title>
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
|
||||
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
|
||||
</head>
|
||||
<body class="focus-page">
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a class="btn" href="/">返回首页</a>
|
||||
<strong class="focus-title">实盘下单放大(100根K线)</strong><span class="exchange-tag">{{ exchange_display }}</span>
|
||||
</div>
|
||||
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
||||
</div>
|
||||
{% if orders %}
|
||||
<div class="row" style="margin-top:10px">
|
||||
<label>订单</label>
|
||||
<select id="order-id">
|
||||
{% for o in orders %}
|
||||
<option value="{{ o.id }}" {% if selected_order and o.id == selected_order.id %}selected{% endif %}>
|
||||
#{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>周期</label>
|
||||
<select id="timeframe">
|
||||
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
|
||||
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button id="manual-refresh" type="button">刷新</button>
|
||||
<span id="load-status" class="status"></span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">当前没有激活订单,无法展示放大K线.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if orders %}
|
||||
<div class="card">
|
||||
<div class="meta">
|
||||
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
|
||||
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
|
||||
<div class="meta-item"><div class="k">成交价</div><div class="v" id="m-entry">-</div></div>
|
||||
<div class="meta-item"><div class="k">止损</div><div class="v" id="m-sl">-</div></div>
|
||||
<div class="meta-item"><div class="k">止盈</div><div class="v" id="m-tp">-</div></div>
|
||||
<div class="meta-item"><div class="k">盈亏比</div><div class="v" id="m-rr">-</div></div>
|
||||
<div class="meta-item"><div class="k">移动保本</div><div class="v" id="m-breakeven">-</div></div>
|
||||
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
|
||||
<div class="meta-item meta-item--emph meta-item--pnl"><div class="k">浮盈亏</div><div class="v" id="m-pnl">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div id="chart-wrap"><div id="chart"></div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if orders %}
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="/static/focus_chart_page.js?v=2"></script>
|
||||
<script>
|
||||
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
||||
const orderSelect = document.getElementById("order-id");
|
||||
const tfSelect = document.getElementById("timeframe");
|
||||
const statusEl = document.getElementById("load-status");
|
||||
const updatedAtEl = document.getElementById("updated-at");
|
||||
const chartHost = document.getElementById("chart");
|
||||
const FCP = window.FocusChartPage;
|
||||
let fc = null;
|
||||
|
||||
function ensureChart(){
|
||||
if(fc && fc.ensureSeries()) return true;
|
||||
if(!window.LightweightCharts){
|
||||
statusEl.className = "status err";
|
||||
statusEl.innerText = "图表库加载失败";
|
||||
return false;
|
||||
}
|
||||
fc = FCP.createFocusChart(chartHost);
|
||||
if(!fc || !fc.ensureSeries()){
|
||||
statusEl.className = "status err";
|
||||
statusEl.innerText = "K线序列初始化失败";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadOrderKline(){
|
||||
if(!ensureChart()) return;
|
||||
const orderId = orderSelect.value;
|
||||
const timeframe = tfSelect.value;
|
||||
if(!orderId) return;
|
||||
statusEl.className = "status";
|
||||
statusEl.innerText = "加载中...";
|
||||
try{
|
||||
const resp = await fetch(`/api/order_kline?order_id=${encodeURIComponent(orderId)}&timeframe=${encodeURIComponent(timeframe)}`);
|
||||
const data = await resp.json();
|
||||
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
|
||||
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
|
||||
else FCP.setActivePriceTick(data.price_tick);
|
||||
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||
if(!candles.length){
|
||||
statusEl.className = "status err";
|
||||
statusEl.innerText = "暂无K线数据";
|
||||
return;
|
||||
}
|
||||
fc.candleSeries.setData(candles);
|
||||
fc.resetPriceLines();
|
||||
const o = data.order || {};
|
||||
fc.addLine(o.trigger_price, FCP.lineTitle("成交价", o.trigger_price_display), "#42a5f5");
|
||||
fc.addLine(o.stop_loss, FCP.lineTitle("止损", o.stop_loss_display), "#ff6666");
|
||||
fc.addLine(o.take_profit, FCP.lineTitle("止盈", o.take_profit_display), "#4cd97f");
|
||||
const markPx = o.current_price;
|
||||
if(markPx) fc.addLine(markPx, FCP.lineTitle("现价", o.current_price_display), "#ffb74d");
|
||||
fc.chart.timeScale().fitContent();
|
||||
FCP.paintOrderMeta(o);
|
||||
updatedAtEl.innerText = data.updated_at || "--";
|
||||
statusEl.className = "status";
|
||||
statusEl.innerText = `已加载 ${candles.length} 根K线`;
|
||||
}catch(err){
|
||||
statusEl.className = "status err";
|
||||
statusEl.innerText = err && err.message ? err.message : "加载失败";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("manual-refresh").addEventListener("click", loadOrderKline);
|
||||
orderSelect.addEventListener("change", loadOrderKline);
|
||||
tfSelect.addEventListener("change", loadOrderKline);
|
||||
loadOrderKline();
|
||||
setInterval(loadOrderKline, refreshMs);
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
{# 以损定仓:杠杆按币种默认(BTC/ETH 10x,其它 5x),不可选手输 #}
|
||||
{% macro order_leverage_fields() -%}
|
||||
{% if position_sizing_mode != 'full_margin' %}
|
||||
<input type="hidden" id="order-leverage" name="leverage" value="">
|
||||
<span id="order-leverage-hint" class="order-leverage-hint" title="BTC/ETH 默认10x,其它默认5x">杠杆 —</span>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
@@ -1,77 +0,0 @@
|
||||
{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #}
|
||||
<form id="add-order-form" action="/add_order" method="post" class="order-monitor-form" data-risk-percent="{{ risk_percent }}">
|
||||
<div class="om-row om-row-policy">
|
||||
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
|
||||
{{ trade_policy_symbol('symbol', 'order-symbol') }}
|
||||
{{ trade_policy_direction('direction', 'order-direction') }}
|
||||
<select id="sltp-mode" name="sltp_mode" title="止盈止损模式">
|
||||
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
|
||||
<option value="price">止盈止损:价格模式</option>
|
||||
<option value="pct">止盈止损:百分比模式</option>
|
||||
</select>
|
||||
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
|
||||
{{ order_entry_type_fields() }}
|
||||
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
|
||||
{{ order_leverage_fields() }}
|
||||
</div>
|
||||
|
||||
<div class="om-row om-row-levels">
|
||||
<label class="om-field" id="om-field-sl">
|
||||
<span class="om-field-lab">止损价格</span>
|
||||
<input id="order-sl" name="sl" step="any" placeholder="必填" required>
|
||||
</label>
|
||||
<label class="om-field" id="om-field-rr">
|
||||
<span class="om-field-lab">盈亏比</span>
|
||||
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="默认1.5" value="1.5" title="止盈距离=止损距离×盈亏比">
|
||||
</label>
|
||||
<label class="om-field" id="om-field-tp" style="display:none">
|
||||
<span class="om-field-lab">止盈价格</span>
|
||||
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格">
|
||||
</label>
|
||||
<label class="om-field" id="om-field-sl-pct" style="display:none">
|
||||
<span class="om-field-lab">止损%</span>
|
||||
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%">
|
||||
</label>
|
||||
<label class="om-field" id="om-field-tp-pct" style="display:none">
|
||||
<span class="om-field-lab">止盈%</span>
|
||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%">
|
||||
</label>
|
||||
<div class="om-live-meta">
|
||||
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
|
||||
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
|
||||
<span class="symbol-live-price-note">成交价以交易所回报为准</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="om-row om-row-opts">
|
||||
{% if not intraday_discipline %}
|
||||
<label class="om-check">
|
||||
<input type="checkbox" name="breakeven_enabled" value="1" checked>
|
||||
<span>移动保本</span>
|
||||
</label>
|
||||
<span id="order-time-close-wrap" class="order-time-close-wrap om-time-close">
|
||||
<label class="om-check" style="margin:0">
|
||||
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb">
|
||||
<span>时间平仓</span>
|
||||
</label>
|
||||
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
|
||||
<option value="1">1h</option>
|
||||
<option value="2">2h</option>
|
||||
<option value="4" selected>4h</option>
|
||||
</select>
|
||||
</span>
|
||||
{% else %}
|
||||
<input type="hidden" name="breakeven_enabled" value="0">
|
||||
{% endif %}
|
||||
<label class="om-check" title="开仓后生成多周期K线图(各周期100根,含开平仓标记)">
|
||||
<input type="checkbox" name="order_chart" value="true">
|
||||
<span>开仓后生成多周期K线</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="om-row om-row-action">
|
||||
<button type="submit" class="om-submit{% if not can_trade %} is-blocked{% endif %}" id="om-submit-btn"{% if not can_trade %} disabled aria-disabled="true"{% endif %}>{{ open_position_button_label }}</button>
|
||||
<span id="om-open-block-note" class="om-open-block-note"{% if can_trade or not (open_block_note|default('')) %} hidden{% endif %}>{% if not can_trade and (open_block_note|default('')) %}{{ open_block_note }}{% endif %}</span>
|
||||
</div>
|
||||
</form>
|
||||
{% include 'order_plan_preview_bar.html' %}
|
||||
@@ -1,38 +0,0 @@
|
||||
<details class="tip-collapse order-rule-collapse">
|
||||
<summary class="tip-collapse-summary">开仓规则说明</summary>
|
||||
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
|
||||
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
||||
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
|
||||
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
|
||||
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
|
||||
</div>
|
||||
</details>
|
||||
<details class="tip-collapse order-exit-collapse">
|
||||
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
|
||||
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
|
||||
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
|
||||
<li><strong>强制清仓</strong>:
|
||||
{% if force_close is defined and force_close.enabled %}
|
||||
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
|
||||
{% else %}
|
||||
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
|
||||
{% endif %}
|
||||
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<details class="tip-collapse order-sizing-collapse">
|
||||
<summary class="tip-collapse-summary">计仓与保本说明</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
|
||||
{% if position_sizing_mode == 'full_margin' %}
|
||||
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
|
||||
{% else %}
|
||||
|以损定仓:风险 {{ risk_percent }}%
|
||||
{% endif %}
|
||||
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
||||
</div>
|
||||
</details>
|
||||
@@ -1,38 +0,0 @@
|
||||
<details class="tip-collapse order-rule-collapse">
|
||||
<summary class="tip-collapse-summary">开仓规则说明</summary>
|
||||
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
|
||||
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
||||
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
|
||||
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
|
||||
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
|
||||
</div>
|
||||
</details>
|
||||
<details class="tip-collapse order-exit-collapse">
|
||||
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
|
||||
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
|
||||
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
|
||||
<li><strong>强制清仓</strong>:
|
||||
{% if force_close is defined and force_close.enabled %}
|
||||
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
|
||||
{% else %}
|
||||
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
|
||||
{% endif %}
|
||||
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓;交易所裸仓且无本地监控时不会被此逻辑平掉。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<details class="tip-collapse order-sizing-collapse">
|
||||
<summary class="tip-collapse-summary">计仓与保本说明</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
|
||||
{% if position_sizing_mode == 'full_margin' %}
|
||||
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
|
||||
{% else %}
|
||||
|以损定仓:风险 {{ risk_percent }}%
|
||||
{% endif %}
|
||||
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
||||
</div>
|
||||
</details>
|
||||
@@ -1,21 +0,0 @@
|
||||
<details class="tip-collapse order-rule-collapse">
|
||||
<summary class="tip-collapse-summary">开仓规则说明</summary>
|
||||
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
|
||||
规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
||||
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
|
||||
{% if can_trade %}可开仓{% else %}不可开仓(持仓达上限,单日开仓达上限,有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %};
|
||||
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
|
||||
</div>
|
||||
</details>
|
||||
<details class="tip-collapse order-sizing-collapse">
|
||||
<summary class="tip-collapse-summary">计仓与保本说明</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
|
||||
{% if position_sizing_mode == 'full_margin' %}
|
||||
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
|
||||
{% else %}
|
||||
|以损定仓:风险 {{ risk_percent }}%
|
||||
{% endif %}
|
||||
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
||||
</div>
|
||||
</details>
|
||||
@@ -1,38 +0,0 @@
|
||||
<details class="tip-collapse order-rule-collapse">
|
||||
<summary class="tip-collapse-summary">开仓规则说明</summary>
|
||||
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
|
||||
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
|
||||
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
|
||||
{% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}){% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00){% endif %}{% endif %};
|
||||
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
|
||||
</div>
|
||||
</details>
|
||||
<details class="tip-collapse order-exit-collapse">
|
||||
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
|
||||
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
|
||||
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
|
||||
<li><strong>强制清仓</strong>:
|
||||
{% if force_close is defined and force_close.enabled %}
|
||||
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
|
||||
{% else %}
|
||||
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
|
||||
{% endif %}
|
||||
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<details class="tip-collapse order-sizing-collapse">
|
||||
<summary class="tip-collapse-summary">计仓与保本说明</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
|
||||
{% if position_sizing_mode == 'full_margin' %}
|
||||
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
|
||||
{% else %}
|
||||
|以损定仓:风险 {{ risk_percent }}%
|
||||
{% endif %}
|
||||
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
|
||||
</div>
|
||||
</details>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div id="order-plan-preview" class="order-plan-preview">
|
||||
<span id="order-risk-preview" class="order-preview-risk">预估风险:<strong>—</strong></span>
|
||||
<span id="order-profit-preview" class="order-preview-profit">预估盈利:<strong>—</strong></span>
|
||||
<span id="order-rr-preview" class="order-preview-rr">预估盈亏比:<strong>—</strong></span>
|
||||
</div>
|
||||
@@ -37,9 +37,8 @@
|
||||
{% elif tab.key == 'password' %}
|
||||
{% include 'password_settings_panel.html' %}
|
||||
{% elif tab.key == 'transfer' %}
|
||||
<h2>永续资金划转</h2>
|
||||
<p class="settings-subcard-desc">账户内:资金账户与交易账户之间划转 USDT.</p>
|
||||
{% include 'instance_transfer_panel.html' %}
|
||||
<h2>资金划转</h2>
|
||||
<p class="settings-subcard-desc muted">永续账户划转已下线;期权/模拟盘请使用对应页内划转.</p>
|
||||
{% elif tab.key == 'export' %}
|
||||
<h2>数据导出</h2>
|
||||
<p class="settings-subcard-desc muted">CSV · v{{ instance_settings.data_export_version }}</p>
|
||||
@@ -47,6 +46,8 @@
|
||||
<a href="/export/key_monitors">关键位(当前)</a>
|
||||
<a href="/export/key_monitor_history">关键位历史</a>
|
||||
</div>
|
||||
{% elif tab.key == 'backup' %}
|
||||
{% include 'backup_settings_panel.html' %}
|
||||
{% elif tab.key == 'options_swap' %}
|
||||
<h2>币种兑换</h2>
|
||||
{% include 'options_settings_swap.html' %}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Sync order_monitors after an external/market flat."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def reconcile_hub_external_close_impl(
|
||||
conn,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
*,
|
||||
exchange_configured: Callable[[], bool],
|
||||
not_configured_msg: str,
|
||||
symbols_match: Callable[[str, str], bool],
|
||||
get_opened_at_value: Callable[[Any], str],
|
||||
resolve_monitor_exchange_symbol: Callable[[Any], str],
|
||||
get_live_position_contracts: Callable[[str, str], float | None],
|
||||
cancel_conditional_orders: Callable[[str], None],
|
||||
resolve_synced_flat_close: Callable[..., tuple],
|
||||
finalize_stopped_monitor: Callable[..., None],
|
||||
sync_trade_records: Callable[..., None] | None = None,
|
||||
reconcile_flat_streak: dict | None = None,
|
||||
to_ms_with_fallback: Callable[..., int | None] | None = None,
|
||||
prefer_manual_resolve: bool = False,
|
||||
order_row_monitor_type: Callable[[Any], str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not exchange_configured():
|
||||
return {"ok": False, "msg": not_configured_msg, "synced": 0}
|
||||
sym_req = (symbol or "").strip()
|
||||
dir_l = (direction or "").strip().lower()
|
||||
if dir_l not in ("long", "short"):
|
||||
return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0}
|
||||
synced = 0
|
||||
streak = reconcile_flat_streak if reconcile_flat_streak is not None else {}
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if not symbols_match(str(r["symbol"] or ""), sym_req):
|
||||
continue
|
||||
if (r["direction"] or "").strip().lower() != dir_l:
|
||||
continue
|
||||
oid = int(r["id"])
|
||||
if r["status"] == "error":
|
||||
opened_at_chk = get_opened_at_value(r)
|
||||
mtype = order_row_monitor_type(r) if order_row_monitor_type else r["monitor_type"]
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1",
|
||||
(r["symbol"], opened_at_chk, mtype),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
|
||||
synced += 1
|
||||
continue
|
||||
exchange_symbol = resolve_monitor_exchange_symbol(r)
|
||||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||||
if live_contracts is None:
|
||||
continue
|
||||
if live_contracts > 0:
|
||||
time.sleep(0.6)
|
||||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||||
if live_contracts is None or live_contracts > 0:
|
||||
continue
|
||||
streak.pop(oid, None)
|
||||
cancel_conditional_orders(exchange_symbol)
|
||||
opened_at = get_opened_at_value(r)
|
||||
opened_at_ms = None
|
||||
if to_ms_with_fallback is not None:
|
||||
keys = r.keys() if hasattr(r, "keys") else ()
|
||||
opened_at_ms = to_ms_with_fallback(
|
||||
r["opened_at_ms"] if "opened_at_ms" in keys else None,
|
||||
opened_at,
|
||||
)
|
||||
resolve_kw = {"opened_at_ms": opened_at_ms}
|
||||
if prefer_manual_resolve:
|
||||
resolve_kw["prefer_manual"] = True
|
||||
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(
|
||||
r, opened_at, **resolve_kw
|
||||
)
|
||||
finalize_stopped_monitor(
|
||||
conn,
|
||||
r,
|
||||
result=result,
|
||||
pnl_amount=pnl_amount,
|
||||
closed_at=closed_at,
|
||||
miss_reason=miss_reason,
|
||||
)
|
||||
synced += 1
|
||||
if sync_trade_records is not None:
|
||||
try:
|
||||
sync_trade_records(conn, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "synced": synced}
|
||||
|
||||
|
||||
reconcile_external_close_impl = reconcile_hub_external_close_impl
|
||||
@@ -0,0 +1,74 @@
|
||||
"""OKX 交易模式(币本位期权专用机:仅单独期权).
|
||||
|
||||
历史 .env 若仍为 perp_options / options_options,读取时归一为 options,
|
||||
对冲模块已移除,不可再启用.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
MODE_OPTIONS = "options"
|
||||
# 兼容旧配置名,不再作为可选模式
|
||||
MODE_PERP = "perp_options"
|
||||
MODE_OO = "options_options"
|
||||
VALID_MODES = frozenset({MODE_OPTIONS})
|
||||
UI_MODES = frozenset({MODE_OPTIONS})
|
||||
|
||||
_ALIASES = {
|
||||
"option": MODE_OPTIONS,
|
||||
"standalone": MODE_OPTIONS,
|
||||
"期权": MODE_OPTIONS,
|
||||
"单独期权": MODE_OPTIONS,
|
||||
"po": MODE_OPTIONS,
|
||||
"perp": MODE_OPTIONS,
|
||||
"perp_options": MODE_OPTIONS,
|
||||
"永期": MODE_OPTIONS,
|
||||
"永期对冲": MODE_OPTIONS,
|
||||
"oo": MODE_OPTIONS,
|
||||
"options_options": MODE_OPTIONS,
|
||||
"期期": MODE_OPTIONS,
|
||||
"期期对冲": MODE_OPTIONS,
|
||||
}
|
||||
|
||||
|
||||
def normalize_okx_trade_mode(raw: Optional[str] = None) -> str:
|
||||
s = str(raw or "").strip().lower()
|
||||
if s in VALID_MODES:
|
||||
return s
|
||||
if s in _ALIASES:
|
||||
return _ALIASES[s]
|
||||
# 未知或空 → 单独期权
|
||||
return MODE_OPTIONS
|
||||
|
||||
|
||||
def legacy_infer_okx_trade_mode() -> str:
|
||||
return MODE_OPTIONS
|
||||
|
||||
|
||||
def get_okx_trade_mode() -> str:
|
||||
return normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
|
||||
|
||||
|
||||
def hedge_module_enabled() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def show_perp_options() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def show_options_options() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def standalone_options_open_allowed() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def mode_label(mode: Optional[str] = None) -> str:
|
||||
return "单独期权"
|
||||
|
||||
|
||||
def block_standalone_open_by_mode_msg() -> Optional[str]:
|
||||
return None
|
||||
@@ -0,0 +1,334 @@
|
||||
"""期权已实现盈亏:交易账户 USDT 快照差分(一次一仓).
|
||||
|
||||
规格见 docs/规格-期权交易账户盈亏结算.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
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 fetch_trading_usdt(cfg: dict[str, Any] | None, ex: Any) -> float | None:
|
||||
"""读取交易账户 USDT(可用优先)."""
|
||||
fetch = (cfg or {}).get("fetch_options_balances") if isinstance(cfg, dict) else None
|
||||
if not callable(fetch):
|
||||
from lib.exchange.okx_options_lib import fetch_options_balances
|
||||
|
||||
fetch = fetch_options_balances
|
||||
try:
|
||||
bal = fetch(ex, force=True) or {}
|
||||
except TypeError:
|
||||
try:
|
||||
bal = fetch(ex) or {}
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
v = _safe_float(bal.get("trading_usdt_avail"))
|
||||
if v is None:
|
||||
v = _safe_float(bal.get("trading_usdt"))
|
||||
return round(float(v), 8) if v is not None else None
|
||||
|
||||
|
||||
def sellable_coin_blocks_open_msg(ex: Any, underlying: str) -> str | None:
|
||||
"""开仓前有可卖标的币则拒开."""
|
||||
from lib.options.options_spot_bridge_lib import (
|
||||
fetch_trading_coin_available,
|
||||
is_sellable_spot_coin,
|
||||
)
|
||||
|
||||
uly = (underlying or "").strip().upper()
|
||||
if uly not in ("ETH", "BTC"):
|
||||
return None
|
||||
try:
|
||||
avail = fetch_trading_coin_available(ex, uly)
|
||||
except Exception:
|
||||
return None
|
||||
if avail is None:
|
||||
return None
|
||||
if is_sellable_spot_coin(ex, uly, avail):
|
||||
return f"交易账户仍有可卖 {uly}({float(avail):g}),请先卖回 USDT 后再开仓"
|
||||
return None
|
||||
|
||||
|
||||
def transfer_blocked_by_position_msg(conn, ex: Any = None) -> str | None:
|
||||
"""持仓中或待卖回时禁止划转."""
|
||||
from lib.options.options_spot_bridge_lib import has_unfinished_bridge
|
||||
|
||||
try:
|
||||
if has_unfinished_bridge(conn):
|
||||
return "期权持仓或待卖回期间禁止划转,请先平仓并卖回 USDT"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM options_trades WHERE status='open'"
|
||||
).fetchone()
|
||||
n = int(row["n"] if hasattr(row, "keys") else row[0])
|
||||
if n > 0:
|
||||
return "期权持仓中禁止划转"
|
||||
except Exception:
|
||||
pass
|
||||
if ex is not None:
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import fetch_option_positions
|
||||
|
||||
for p in fetch_option_positions(ex) or []:
|
||||
try:
|
||||
if abs(float(p.get("pos") or 0)) > 1e-12:
|
||||
return "存在未平期权持仓,禁止划转"
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def sellable_coin_blocks_settle(
|
||||
ex: Any,
|
||||
underlying: str | None,
|
||||
) -> str | None:
|
||||
"""仍有可卖标的币则禁止账户差分结算(规格:卖光再冻结)."""
|
||||
from lib.options.options_spot_bridge_lib import (
|
||||
fetch_trading_coin_available,
|
||||
is_sellable_spot_coin,
|
||||
)
|
||||
|
||||
uly = (underlying or "").strip().upper()
|
||||
if uly not in ("ETH", "BTC"):
|
||||
return None
|
||||
try:
|
||||
avail = fetch_trading_coin_available(ex, uly)
|
||||
except Exception:
|
||||
return None
|
||||
if avail is None:
|
||||
return None
|
||||
if is_sellable_spot_coin(ex, uly, avail):
|
||||
return f"仍有可卖 {uly}({float(avail):g}),暂不结算账户盈亏"
|
||||
return None
|
||||
|
||||
|
||||
def _underlying_for_settle(
|
||||
conn,
|
||||
*,
|
||||
bridge_id: int | None = None,
|
||||
trade_id: int | None = None,
|
||||
) -> str | None:
|
||||
try:
|
||||
if trade_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT underlying FROM options_trades WHERE id=?",
|
||||
(int(trade_id),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row["underlying"] if hasattr(row, "keys") else row[0] or "").strip().upper() or None
|
||||
if bridge_id is not None:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT underlying FROM options_trades
|
||||
WHERE bridge_id=? AND status='closed'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row["underlying"] if hasattr(row, "keys") else row[0] or "").strip().upper() or None
|
||||
brow = conn.execute(
|
||||
"SELECT underlying FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if brow:
|
||||
return str(brow["underlying"] if hasattr(brow, "keys") else brow[0] or "").strip().upper() or None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def try_settle_after_sellback(
|
||||
get_db: Any,
|
||||
ex: Any,
|
||||
*,
|
||||
bridge_id: int | None = None,
|
||||
cfg: dict[str, Any] | None = None,
|
||||
underlying: str | None = None,
|
||||
sell_complete: bool | None = None,
|
||||
) -> int:
|
||||
"""卖回路径通用结算:读交易账户 after 并冻结未结算单.
|
||||
|
||||
门禁:sell_complete 显式 False 时不结算;仍有可卖标的币时不结算.
|
||||
"""
|
||||
if sell_complete is False:
|
||||
return 0
|
||||
pack = cfg if isinstance(cfg, dict) else {"fetch_options_balances": None}
|
||||
import sqlite3
|
||||
|
||||
# Windows 上 sqlite3.Connection 的 callable() 可能为 True,不能据此判断 get_db
|
||||
if isinstance(get_db, sqlite3.Connection):
|
||||
conn = get_db
|
||||
close_conn = False
|
||||
elif callable(get_db):
|
||||
conn = get_db()
|
||||
close_conn = True
|
||||
else:
|
||||
conn = get_db
|
||||
close_conn = False
|
||||
try:
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
init_options_tables(conn)
|
||||
uly = (underlying or "").strip().upper() or None
|
||||
if not uly:
|
||||
uly = _underlying_for_settle(conn, bridge_id=bridge_id)
|
||||
block = sellable_coin_blocks_settle(ex, uly)
|
||||
if block:
|
||||
return 0
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
|
||||
|
||||
invalidate_options_balance_cache()
|
||||
except Exception:
|
||||
pass
|
||||
after = fetch_trading_usdt(pack, ex)
|
||||
n = 0
|
||||
if bridge_id is not None:
|
||||
n = settle_closed_trades_from_trading_account(
|
||||
conn, trading_usdt_after=after, bridge_id=int(bridge_id)
|
||||
)
|
||||
if n < 1:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id FROM options_trades
|
||||
WHERE status='closed' AND COALESCE(pnl_settled,0)=0
|
||||
AND trading_usdt_before IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if row:
|
||||
tid = int(row["id"] if hasattr(row, "keys") else row[0])
|
||||
if not uly:
|
||||
uly = _underlying_for_settle(conn, trade_id=tid)
|
||||
block2 = sellable_coin_blocks_settle(ex, uly)
|
||||
if block2:
|
||||
return 0
|
||||
n = settle_closed_trades_from_trading_account(
|
||||
conn,
|
||||
trading_usdt_after=after,
|
||||
trade_id=tid,
|
||||
)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return n
|
||||
finally:
|
||||
if close_conn:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def settle_closed_trades_from_trading_account(
|
||||
conn,
|
||||
*,
|
||||
trading_usdt_after: float | None = None,
|
||||
bridge_id: int | None = None,
|
||||
trade_id: int | None = None,
|
||||
) -> int:
|
||||
"""卖回后:拍 after 快照,并冻结复盘真盈亏.
|
||||
|
||||
realized_pnl_usdt = 期权币盈亏×平仓价(卖回VWAP优先);
|
||||
不含现货多买.账户 after 仅审计.已 pnl_settled=1 的行永不改写.
|
||||
"""
|
||||
from lib.options.options_pnl_settle_lib import resolve_true_pnl_usdt_from_trade
|
||||
|
||||
after = _safe_float(trading_usdt_after)
|
||||
if after is not None:
|
||||
after = round(float(after), 8)
|
||||
clauses = ["status='closed'", "COALESCE(pnl_settled, 0)=0"]
|
||||
params: list[Any] = []
|
||||
if trade_id is not None:
|
||||
clauses.append("id=?")
|
||||
params.append(int(trade_id))
|
||||
elif bridge_id is not None:
|
||||
clauses.append("bridge_id=?")
|
||||
params.append(int(bridge_id))
|
||||
else:
|
||||
return 0
|
||||
from lib.options.options_pnl_settle_lib import resolve_spot_vwap_prices
|
||||
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT id, trading_usdt_before, premium_paid, premium_received, realized_pnl,
|
||||
open_index_px, close_index_px, open_fee, close_fee, premium_ccy, fee_ccy,
|
||||
bridge_id, initial_premium_usdt
|
||||
FROM options_trades
|
||||
WHERE {" AND ".join(clauses)}
|
||||
""",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
updated = 0
|
||||
for row in rows:
|
||||
tid = int(row["id"] if hasattr(row, "keys") else row[0])
|
||||
before = _safe_float(
|
||||
row["trading_usdt_before"] if hasattr(row, "keys") else row[1]
|
||||
)
|
||||
after_use = after
|
||||
if after_use is None and before is not None:
|
||||
after_use = None
|
||||
row_bid = row["bridge_id"] if hasattr(row, "keys") else row[11]
|
||||
if row_bid is None and bridge_id is not None:
|
||||
row_bid = bridge_id
|
||||
initial_u = (
|
||||
row["initial_premium_usdt"] if hasattr(row, "keys") else row[12]
|
||||
)
|
||||
open_px, close_px, _gate = resolve_spot_vwap_prices(
|
||||
conn,
|
||||
bridge_id=row_bid,
|
||||
open_index_px=row["open_index_px"] if hasattr(row, "keys") else row[5],
|
||||
close_index_px=row["close_index_px"] if hasattr(row, "keys") else row[6],
|
||||
)
|
||||
pnl_u, src = resolve_true_pnl_usdt_from_trade(
|
||||
premium_paid=row["premium_paid"] if hasattr(row, "keys") else row[2],
|
||||
premium_received=row["premium_received"] if hasattr(row, "keys") else row[3],
|
||||
realized_pnl=row["realized_pnl"] if hasattr(row, "keys") else row[4],
|
||||
open_index_px=open_px,
|
||||
close_index_px=close_px,
|
||||
initial_premium_usdt=initial_u,
|
||||
open_fee=row["open_fee"] if hasattr(row, "keys") else row[7],
|
||||
close_fee=row["close_fee"] if hasattr(row, "keys") else row[8],
|
||||
premium_ccy=row["premium_ccy"] if hasattr(row, "keys") else row[9],
|
||||
fee_ccy=row["fee_ccy"] if hasattr(row, "keys") else row[10],
|
||||
conn=conn,
|
||||
bridge_id=row_bid,
|
||||
)
|
||||
if pnl_u is None or src in ("none", "pending_sell"):
|
||||
continue
|
||||
# 卖回价冻为卖回 VWAP;开仓价/初始权利金已在开仓时冻住,仅缺省时补
|
||||
paid = _safe_float(row["premium_paid"] if hasattr(row, "keys") else row[2])
|
||||
heal_initial = _safe_float(initial_u)
|
||||
if heal_initial is None and paid is not None and open_px is not None and open_px > 0:
|
||||
heal_initial = round(float(paid) * float(open_px), 4)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET trading_usdt_after = COALESCE(?, trading_usdt_after),
|
||||
realized_pnl_usdt = ?,
|
||||
open_index_px = COALESCE(open_index_px, ?),
|
||||
close_index_px = COALESCE(?, close_index_px),
|
||||
initial_premium_usdt = COALESCE(initial_premium_usdt, ?),
|
||||
pnl_settled = 1
|
||||
WHERE id = ? AND COALESCE(pnl_settled, 0)=0
|
||||
""",
|
||||
(after_use, pnl_u, open_px, close_px, heal_initial, tid),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
@@ -11,9 +11,11 @@ from lib.options.options_close_gate_lib import (
|
||||
update_close_gate,
|
||||
)
|
||||
from lib.options.options_pricing_lib import (
|
||||
PREMIUM_DECIMALS_COIN,
|
||||
estimate_close_by_bids,
|
||||
fetch_option_mark_px,
|
||||
is_stub_bid_px,
|
||||
round_premium_amount,
|
||||
total_premium,
|
||||
)
|
||||
|
||||
@@ -306,7 +308,10 @@ def close_option_by_bid1(
|
||||
|
||||
px = float(order.get("px", locked_bid_px))
|
||||
oid = str((order.get("data") or {}).get("ordId") or "")
|
||||
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4)
|
||||
# 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计
|
||||
prem_recv_submit = round_premium_amount(
|
||||
total_premium(px, level_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN
|
||||
)
|
||||
time.sleep(0.6)
|
||||
invalidate_option_positions_cache()
|
||||
raw2 = cfg["fetch_option_positions"](ex)
|
||||
@@ -325,37 +330,211 @@ def close_option_by_bid1(
|
||||
reduced = max(0, before_avail - after_avail)
|
||||
remaining_pos = after_avail
|
||||
fully_closed = remaining_pos < 1
|
||||
filled_sheets = min(reduced, level_sheets) if reduced > 0 else 0
|
||||
# 全平但减仓读数异常时回退挂单张数,避免漏记本批
|
||||
if fully_closed and filled_sheets < 1:
|
||||
filled_sheets = level_sheets
|
||||
batch_recv = (
|
||||
round_premium_amount(total_premium(px, filled_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN)
|
||||
if filled_sheets > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
total_recv = batch_recv
|
||||
close_px = px
|
||||
merged_ord = oid or None
|
||||
|
||||
if fully_closed:
|
||||
clear_close_gate(inst_id)
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_db import (
|
||||
add_open_close_accum,
|
||||
get_open_close_accum,
|
||||
init_options_tables,
|
||||
)
|
||||
|
||||
init_options_tables(conn)
|
||||
# 先并入本批,再读累计(含此前分批)
|
||||
if filled_sheets > 0:
|
||||
add_open_close_accum(
|
||||
conn,
|
||||
inst_id,
|
||||
recv=batch_recv,
|
||||
sheets=filled_sheets,
|
||||
px=px,
|
||||
ord_id=oid or None,
|
||||
)
|
||||
accum = get_open_close_accum(conn, inst_id)
|
||||
total_recv = round_premium_amount(
|
||||
float(accum.get("recv") or 0), decimals=PREMIUM_DECIMALS_COIN
|
||||
) or 0.0
|
||||
accum_sheets = int(accum.get("sheets") or 0)
|
||||
accum_notional = float(accum.get("notional") or 0)
|
||||
if accum_sheets > 0 and accum_notional > 0:
|
||||
close_px = round(accum_notional / accum_sheets, 8)
|
||||
merged_ord = str(accum.get("close_ord_id") or "").strip() or (oid or None)
|
||||
|
||||
open_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, premium_paid FROM options_trades
|
||||
SELECT id, premium_paid, open_fee, premium_ccy, underlying,
|
||||
exchange_ord_id, budget_usdt, open_index_px, fee_ccy,
|
||||
eth_amount, open_quote, trading_usdt_before
|
||||
FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchall()
|
||||
from lib.options.options_pnl_settle_lib import (
|
||||
extract_order_fee,
|
||||
fee_to_premium_ccy,
|
||||
resolve_close_index_px,
|
||||
settle_closed_pnl,
|
||||
sum_option_order_fees_in_premium,
|
||||
)
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
row_ccy = premium_ccy
|
||||
if open_rows:
|
||||
row_ccy = str(open_rows[0]["premium_ccy"] or premium_ccy or "").strip() or premium_ccy
|
||||
underly = str((open_rows[0]["underlying"] if open_rows else "") or "").strip()
|
||||
close_idx = resolve_close_index_px(
|
||||
ex, inst_id=inst_id, underlying=underly, hint=index_px
|
||||
)
|
||||
# 平仓手续费:优先累计订单号查单;查不到再用本笔下单 info
|
||||
close_fee_total = 0.0
|
||||
try:
|
||||
close_fee_total = sum_option_order_fees_in_premium(
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
ord_ids=merged_ord,
|
||||
premium_ccy=row_ccy,
|
||||
index_px=close_idx,
|
||||
fetch_order_fn=cfg.get("fetch_option_order"),
|
||||
)
|
||||
except Exception:
|
||||
close_fee_total = 0.0
|
||||
if close_fee_total <= 0:
|
||||
fee_hint, fee_hint_ccy = extract_order_fee(
|
||||
order if isinstance(order, dict) else None
|
||||
)
|
||||
if fee_hint > 0:
|
||||
close_fee_total = fee_to_premium_ccy(
|
||||
fee_hint,
|
||||
fee_hint_ccy or row_ccy,
|
||||
premium_ccy=row_ccy,
|
||||
index_px=close_idx,
|
||||
)
|
||||
|
||||
total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows)
|
||||
total_open_fee = sum(float(r["open_fee"] or 0) for r in open_rows)
|
||||
# 开仓费未落库时按开仓单号补查
|
||||
if total_open_fee <= 0:
|
||||
open_oids = [
|
||||
str(r["exchange_ord_id"] or "").strip()
|
||||
for r in open_rows
|
||||
if str(r["exchange_ord_id"] or "").strip()
|
||||
]
|
||||
try:
|
||||
total_open_fee = sum_option_order_fees_in_premium(
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
ord_ids=open_oids,
|
||||
premium_ccy=row_ccy,
|
||||
index_px=close_idx,
|
||||
fetch_order_fn=cfg.get("fetch_option_order"),
|
||||
)
|
||||
except Exception:
|
||||
total_open_fee = 0.0
|
||||
|
||||
allocated = 0.0
|
||||
allocated_cfee = 0.0
|
||||
allocated_ofee = 0.0
|
||||
for i, row in enumerate(open_rows):
|
||||
paid = float(row["premium_paid"] or 0)
|
||||
if i == len(open_rows) - 1:
|
||||
recv = round(prem_recv - allocated, 4)
|
||||
recv = round_premium_amount(
|
||||
total_recv - allocated, decimals=PREMIUM_DECIMALS_COIN
|
||||
) or 0.0
|
||||
row_cfee = round(close_fee_total - allocated_cfee, 8)
|
||||
stored_ofee = float(row["open_fee"] or 0)
|
||||
if stored_ofee > 0:
|
||||
row_ofee = stored_ofee
|
||||
elif total_paid > 0 and total_open_fee > 0:
|
||||
row_ofee = round(total_open_fee - allocated_ofee, 8)
|
||||
else:
|
||||
row_ofee = round(total_open_fee - allocated_ofee, 8) if total_open_fee > 0 else 0.0
|
||||
elif total_paid > 0:
|
||||
recv = round(prem_recv * (paid / total_paid), 4)
|
||||
share = paid / total_paid
|
||||
recv = round_premium_amount(
|
||||
total_recv * share, decimals=PREMIUM_DECIMALS_COIN
|
||||
) or 0.0
|
||||
allocated += recv
|
||||
row_cfee = round(close_fee_total * share, 8)
|
||||
allocated_cfee += row_cfee
|
||||
stored_ofee = float(row["open_fee"] or 0)
|
||||
if stored_ofee > 0:
|
||||
row_ofee = stored_ofee
|
||||
else:
|
||||
row_ofee = round(total_open_fee * share, 8)
|
||||
allocated_ofee += row_ofee
|
||||
else:
|
||||
recv = round(prem_recv / len(open_rows), 4)
|
||||
recv = round_premium_amount(
|
||||
total_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN
|
||||
) or 0.0
|
||||
allocated += recv
|
||||
pnl = round(recv - paid, 4)
|
||||
row_cfee = round(close_fee_total / len(open_rows), 8)
|
||||
allocated_cfee += row_cfee
|
||||
stored_ofee = float(row["open_fee"] or 0)
|
||||
row_ofee = stored_ofee if stored_ofee > 0 else round(
|
||||
total_open_fee / len(open_rows), 8
|
||||
)
|
||||
if stored_ofee <= 0:
|
||||
allocated_ofee += row_ofee
|
||||
|
||||
row_budget = _safe_float(row["budget_usdt"])
|
||||
row_open_idx = _safe_float(row["open_index_px"])
|
||||
# 禁止 budget/premium 反推开仓价(会把缓冲算进均价,开仓价被抬飞)
|
||||
if (row_open_idx is None or row_open_idx <= 0) and row.get("bridge_id") is not None:
|
||||
try:
|
||||
from lib.options.options_spot_bridge_lib import bridge_buy_vwap
|
||||
|
||||
bv = bridge_buy_vwap(conn, int(row["bridge_id"]))
|
||||
if bv is not None and float(bv) > 0:
|
||||
row_open_idx = float(bv)
|
||||
except Exception:
|
||||
pass
|
||||
settled = settle_closed_pnl(
|
||||
premium_paid=paid,
|
||||
premium_received=recv,
|
||||
open_fee=row_ofee,
|
||||
close_fee=row_cfee,
|
||||
premium_ccy=str(row["premium_ccy"] or row_ccy or premium_ccy),
|
||||
index_px=close_idx,
|
||||
open_index_px=row_open_idx,
|
||||
budget_usdt=row_budget,
|
||||
fee_ccy=str(row["fee_ccy"] or row["premium_ccy"] or row_ccy or ""),
|
||||
)
|
||||
pnl = settled.get("realized_pnl")
|
||||
# 真盈亏U=期权币盈亏×平仓价
|
||||
pnl_usdt = settled.get("realized_pnl_usdt")
|
||||
note_sql = ""
|
||||
params: list[Any] = [px, recv, pnl, oid or None]
|
||||
|
||||
# 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/close_idx/open_idx/pnl_u/[note]/id
|
||||
params: list[Any] = [
|
||||
close_px,
|
||||
recv,
|
||||
pnl,
|
||||
merged_ord,
|
||||
options_app_now_str(),
|
||||
row_ofee,
|
||||
row_cfee,
|
||||
str(row["premium_ccy"] or row_ccy or premium_ccy or ""),
|
||||
settled.get("close_index_px"),
|
||||
settled.get("open_index_px") or row_open_idx,
|
||||
pnl_usdt,
|
||||
]
|
||||
if signal_note and i == len(open_rows) - 1:
|
||||
note_sql = """,
|
||||
signal_note = CASE
|
||||
@@ -368,7 +547,12 @@ def close_option_by_bid1(
|
||||
f"""
|
||||
UPDATE options_trades
|
||||
SET status = 'closed', close_quote = ?, premium_received = ?,
|
||||
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
|
||||
realized_pnl = ?, close_ord_id = ?, closed_at = ?,
|
||||
open_fee = ?, close_fee = ?, fee_ccy = ?,
|
||||
close_index_px = ?, open_index_px = COALESCE(open_index_px, ?),
|
||||
realized_pnl_usdt = ?,
|
||||
close_recv_accum = 0, close_sheets_accum = 0,
|
||||
close_notional_accum = 0
|
||||
{note_sql}
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -377,6 +561,27 @@ def close_option_by_bid1(
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
elif filled_sheets > 0:
|
||||
# 部分成交:持久化本批收回,下次全平合计
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_db import add_open_close_accum, init_options_tables
|
||||
|
||||
init_options_tables(conn)
|
||||
add_open_close_accum(
|
||||
conn,
|
||||
inst_id,
|
||||
recv=batch_recv,
|
||||
sheets=filled_sheets,
|
||||
px=px,
|
||||
ord_id=oid or None,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if require_recycle_gate:
|
||||
# 自动平已挂过单:同仓续批只验流动性
|
||||
mark_close_gate_passed(inst_id)
|
||||
elif require_recycle_gate:
|
||||
# 自动平已挂过单:同仓续批只验流动性
|
||||
mark_close_gate_passed(inst_id)
|
||||
@@ -388,11 +593,12 @@ def close_option_by_bid1(
|
||||
"bid": px,
|
||||
"locked_bid_px": locked_bid_px,
|
||||
"submitted_sheets": level_sheets,
|
||||
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0,
|
||||
"filled_or_reduced_sheets": filled_sheets,
|
||||
"remaining_sheets": remaining_pos,
|
||||
"premium_received": prem_recv,
|
||||
"premium_received": total_recv if fully_closed else (batch_recv or prem_recv_submit),
|
||||
"batch_premium_received": batch_recv,
|
||||
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
|
||||
"close_ord_id": oid or None,
|
||||
"close_ord_id": merged_ord if fully_closed else (oid or None),
|
||||
"fully_closed": fully_closed,
|
||||
"msg": (
|
||||
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
|
||||
|
||||
@@ -27,7 +27,7 @@ def _close_recycle_mult(premium_ccy: str) -> float:
|
||||
|
||||
|
||||
def _close_hold_seconds() -> float:
|
||||
return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
|
||||
return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 20.0)
|
||||
|
||||
|
||||
def _close_net_pnl_min_usdt() -> float:
|
||||
@@ -36,7 +36,7 @@ def _close_net_pnl_min_usdt() -> float:
|
||||
|
||||
# 兼容旧引用
|
||||
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0)
|
||||
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
|
||||
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 20.0)
|
||||
|
||||
_lock = threading.Lock()
|
||||
# inst_id -> gate state
|
||||
|
||||
@@ -27,6 +27,55 @@ from lib.options.options_spot_bridge_lib import (
|
||||
)
|
||||
|
||||
|
||||
def _sell_excess_bridge_coin(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
conn: Any,
|
||||
*,
|
||||
bridge_id: int,
|
||||
underlying: str,
|
||||
keep_coin: float,
|
||||
coin_bought: float,
|
||||
) -> dict[str, Any] | None:
|
||||
"""开仓后卖掉多买的币:卖量不超过 bridge 买入量,并保留权利金所需 keep_coin."""
|
||||
try:
|
||||
keep = max(0.0, float(keep_coin))
|
||||
except (TypeError, ValueError):
|
||||
keep = 0.0
|
||||
try:
|
||||
bought = max(0.0, float(coin_bought))
|
||||
except (TypeError, ValueError):
|
||||
bought = 0.0
|
||||
excess_cap = max(0.0, bought - keep)
|
||||
if excess_cap <= 0:
|
||||
return None
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
avail = _spot_bridge.fetch_trading_coin_available(ex, underlying)
|
||||
if avail is None:
|
||||
return None
|
||||
sell_amt = min(float(avail), excess_cap)
|
||||
if not _spot_bridge.is_sellable_spot_coin(ex, underlying, sell_amt):
|
||||
return None
|
||||
sell = _spot_bridge.spot_market_sell_coin_to_usdt(
|
||||
ex, underlying=underlying, coin_amount=sell_amt
|
||||
)
|
||||
if sell and sell.get("ok"):
|
||||
try:
|
||||
recovered = float(sell.get("usdt_recovered") or 0)
|
||||
except (TypeError, ValueError):
|
||||
recovered = 0.0
|
||||
if recovered > 0:
|
||||
try:
|
||||
_spot_bridge.add_bridge_usdt_recovered(conn, int(bridge_id), recovered)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return sell
|
||||
|
||||
|
||||
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
|
||||
from lib.exchange.okx_options_lib import fetch_options_balances
|
||||
|
||||
@@ -38,7 +87,7 @@ def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
|
||||
trading_f = float(trading or 0)
|
||||
except (TypeError, ValueError):
|
||||
trading_f = 0.0
|
||||
buf = float(cfg.get("budget_buffer") or 0.95)
|
||||
buf = float(cfg.get("budget_buffer") or 1.0)
|
||||
return compute_coin_budget_usdt(trading_f, buffer=buf)
|
||||
|
||||
|
||||
@@ -69,13 +118,33 @@ def open_coin_option_buy_full(
|
||||
parts = inst_id.split("-")
|
||||
underlying = (parts[0] if parts else "ETH").upper()
|
||||
|
||||
# 先清粉尘桥 + 空仓扫尾卖回(短连接),再开业务连接
|
||||
try:
|
||||
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], ex)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sweep_spot_after_options_flat(cfg, ex)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn: Any = None
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
block = _spot_bridge.bridge_blocks_new_open_msg(conn)
|
||||
if block:
|
||||
return {"ok": False, "msg": block, "can_open": False}
|
||||
|
||||
# 网络检查前再提交,避免握锁拉持仓/行情
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
compound_block = compound_full_single_position_block_msg(
|
||||
ex, fetch_positions=cfg.get("fetch_option_positions")
|
||||
)
|
||||
@@ -142,6 +211,21 @@ def open_coin_option_buy_full(
|
||||
buy_usdt = float(plan["buy_usdt"])
|
||||
sheets = int(plan["sheets"])
|
||||
|
||||
# 买币/读余额(模拟盘写钱包)前必须提交释放锁
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# 开仓前有可卖标的币 → 拒开(规格:先卖回)
|
||||
from lib.options.options_account_pnl_lib import (
|
||||
fetch_trading_usdt,
|
||||
sellable_coin_blocks_open_msg,
|
||||
)
|
||||
|
||||
coin_block = sellable_coin_blocks_open_msg(ex, underlying)
|
||||
if coin_block:
|
||||
return {"ok": False, "msg": coin_block, "can_open": False}
|
||||
trading_usdt_before = fetch_trading_usdt(cfg, ex)
|
||||
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
|
||||
coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0
|
||||
buy = _spot_bridge.spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
|
||||
@@ -161,6 +245,10 @@ def open_coin_option_buy_full(
|
||||
inst_id=inst_id,
|
||||
message="已买币,待开期权",
|
||||
)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# 等余额落账
|
||||
time.sleep(1.5)
|
||||
try:
|
||||
@@ -183,14 +271,59 @@ def open_coin_option_buy_full(
|
||||
}
|
||||
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
|
||||
if coin_bought <= 0:
|
||||
# 落账延迟时退化为用当前可用,但仍写入上限提示
|
||||
coin_bought = float(coin_after)
|
||||
if coin_bought <= 0:
|
||||
rb = _spot_bridge.rollback_bought_coin_to_usdt(
|
||||
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
|
||||
)
|
||||
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
|
||||
# 落账延迟:短重试取增量;绝不把整户余额当成「本次买入」
|
||||
for _ in range(3):
|
||||
time.sleep(0.6)
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
|
||||
|
||||
invalidate_options_balance_cache()
|
||||
except Exception:
|
||||
pass
|
||||
again = _spot_bridge.fetch_trading_coin_available(ex, underlying)
|
||||
if again is None:
|
||||
continue
|
||||
coin_bought = max(0.0, float(again) - float(coin_before or 0))
|
||||
if coin_bought > 0:
|
||||
coin_after = again
|
||||
break
|
||||
if coin_bought <= 0:
|
||||
# 订单回执有数量则用之,否则回滚
|
||||
try:
|
||||
hinted = float(buy.get("coin_bought") or 0)
|
||||
except (TypeError, ValueError):
|
||||
hinted = 0.0
|
||||
if hinted > 0:
|
||||
coin_bought = hinted
|
||||
else:
|
||||
rb = _spot_bridge.rollback_bought_coin_to_usdt(
|
||||
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "买币后可用增量无效",
|
||||
"rollback": rb,
|
||||
"budget": budget_info,
|
||||
}
|
||||
_spot_bridge.update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
|
||||
# 买侧 VWAP 累加(多笔买同理);USDT 优先成交回报,否则用计划买额
|
||||
try:
|
||||
usdt_spent = float(buy.get("usdt_spent") or buy_usdt)
|
||||
except (TypeError, ValueError):
|
||||
usdt_spent = float(buy_usdt)
|
||||
try:
|
||||
_spot_bridge.add_bridge_buy_fill(
|
||||
conn,
|
||||
bridge_id,
|
||||
usdt_spent=usdt_spent,
|
||||
coin_bought=float(coin_bought),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sizing = calc_sheets_from_coin_balance(
|
||||
quote_per_unit=float(ask),
|
||||
@@ -288,6 +421,17 @@ def open_coin_option_buy_full(
|
||||
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
|
||||
except (TypeError, ValueError):
|
||||
fill_timeout = 12.0
|
||||
# 等待成交可能十余秒:先提交并释放业务连接,避免锁死中控/卖币
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = None
|
||||
|
||||
fill = wait_option_order_full_fill(
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
@@ -296,6 +440,9 @@ def open_coin_option_buy_full(
|
||||
timeout_sec=fill_timeout,
|
||||
cancel_on_timeout=True,
|
||||
)
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
init_options_tables(conn)
|
||||
if not fill.get("ok"):
|
||||
filled_n = int(fill.get("filled_sheets") or 0)
|
||||
if filled_n <= 0:
|
||||
@@ -313,11 +460,73 @@ def open_coin_option_buy_full(
|
||||
premium_paid = round(float(ask) * eth_amount, 8)
|
||||
premium_ccy = premium_ccy_for_mode("coin", underlying)
|
||||
|
||||
# 开仓手续费(折权利金单位);优先成交回包,否则查单
|
||||
open_fee = 0.0
|
||||
idx_open = None
|
||||
try:
|
||||
from lib.options.options_pnl_settle_lib import (
|
||||
extract_order_fee,
|
||||
fee_to_premium_ccy,
|
||||
resolve_close_index_px,
|
||||
sum_option_order_fees_in_premium,
|
||||
)
|
||||
|
||||
idx_open = resolve_close_index_px(
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
underlying=underlying,
|
||||
hint=(q.get("index_px") if isinstance(q, dict) else None) or idx,
|
||||
)
|
||||
fee_hint, fee_ccy_hint = extract_order_fee(order if isinstance(order, dict) else None)
|
||||
if fee_hint <= 0 and isinstance(fill, dict):
|
||||
fee_hint, fee_ccy_hint = extract_order_fee(fill.get("order") if isinstance(fill.get("order"), dict) else fill)
|
||||
if fee_hint > 0:
|
||||
open_fee = fee_to_premium_ccy(
|
||||
fee_hint, fee_ccy_hint or premium_ccy, premium_ccy=premium_ccy, index_px=idx_open
|
||||
)
|
||||
if open_fee <= 0:
|
||||
open_fee = sum_option_order_fees_in_premium(
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
ord_ids=[ord_id],
|
||||
premium_ccy=premium_ccy,
|
||||
index_px=idx_open,
|
||||
fetch_order_fn=cfg.get("fetch_option_order"),
|
||||
)
|
||||
except Exception:
|
||||
open_fee = 0.0
|
||||
|
||||
# 部分成交/减半张数后卖掉多余买币(按 bridge coin_bought 封顶)
|
||||
need_coin = float(premium_paid)
|
||||
try:
|
||||
_sell_excess_bridge_coin(
|
||||
cfg,
|
||||
ex,
|
||||
conn,
|
||||
bridge_id=bridge_id,
|
||||
underlying=underlying,
|
||||
keep_coin=need_coin,
|
||||
coin_bought=float(coin_bought),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 开仓价必须在缩减 coin_bought 之前按买币 VWAP 冻住
|
||||
# (buy_usdt_spent/buy_coin_accum;禁止事后 budget/缩水权利金量反推)
|
||||
open_px_freeze = idx_open
|
||||
try:
|
||||
bv = _spot_bridge.bridge_buy_vwap(conn, bridge_id)
|
||||
if bv is not None and float(bv) > 0:
|
||||
open_px_freeze = float(bv)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_spot_bridge.update_bridge(
|
||||
conn,
|
||||
bridge_id,
|
||||
status=BRIDGE_HOLDING,
|
||||
inst_id=inst_id,
|
||||
coin_bought=float(need_coin),
|
||||
message="期权持仓中",
|
||||
)
|
||||
|
||||
@@ -337,6 +546,9 @@ def open_coin_option_buy_full(
|
||||
bridge_id=bridge_id,
|
||||
budget_usdt=buy_usdt,
|
||||
premium_ccy=premium_ccy,
|
||||
open_fee=open_fee,
|
||||
open_index_px=open_px_freeze,
|
||||
trading_usdt_before=trading_usdt_before,
|
||||
profit_exit_enabled=profit_exit_enabled,
|
||||
profit_exit_mult=profit_exit_mult,
|
||||
)
|
||||
@@ -393,24 +605,53 @@ def open_coin_option_buy_full(
|
||||
"fill": fill,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
pe = 1 if kwargs.get("profit_exit_enabled") else 0
|
||||
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
|
||||
pe_state = "active" if pe else "idle"
|
||||
open_fee = float(kwargs.get("open_fee") or 0.0)
|
||||
if open_fee < 0:
|
||||
open_fee = abs(open_fee)
|
||||
fee_ccy = str(kwargs.get("premium_ccy") or "ETH")
|
||||
open_index_px = kwargs.get("open_index_px")
|
||||
try:
|
||||
open_index_px = float(open_index_px) if open_index_px is not None else None
|
||||
if open_index_px is not None and open_index_px <= 0:
|
||||
open_index_px = None
|
||||
except (TypeError, ValueError):
|
||||
open_index_px = None
|
||||
tusdt_before = kwargs.get("trading_usdt_before")
|
||||
try:
|
||||
tusdt_before = float(tusdt_before) if tusdt_before is not None else None
|
||||
except (TypeError, ValueError):
|
||||
tusdt_before = None
|
||||
paid = kwargs.get("premium_paid")
|
||||
try:
|
||||
paid_f = float(paid) if paid is not None else None
|
||||
except (TypeError, ValueError):
|
||||
paid_f = None
|
||||
initial_premium_usdt = None
|
||||
if open_index_px is not None and paid_f is not None and paid_f > 0:
|
||||
initial_premium_usdt = round(float(open_index_px) * float(paid_f), 4)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades(
|
||||
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, signal_note, exchange_ord_id,
|
||||
margin_mode, premium_ccy, bridge_id, budget_usdt,
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?)
|
||||
open_fee, fee_ccy, open_index_px, trading_usdt_before, pnl_settled,
|
||||
initial_premium_usdt,
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
kwargs["inst_id"],
|
||||
@@ -427,9 +668,15 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
|
||||
kwargs.get("premium_ccy") or "ETH",
|
||||
kwargs.get("bridge_id"),
|
||||
kwargs.get("budget_usdt"),
|
||||
open_fee,
|
||||
fee_ccy,
|
||||
open_index_px,
|
||||
tusdt_before,
|
||||
initial_premium_usdt,
|
||||
pe,
|
||||
pe_mult,
|
||||
pe_state,
|
||||
options_app_now_str(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -443,32 +690,136 @@ def maybe_sell_spot_after_close(
|
||||
inst_id: str,
|
||||
close_result: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
|
||||
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.
|
||||
|
||||
持仓查询失败/空响应时不卖,等下次监控再查(与 sweep_spot_after_options_flat 一致).
|
||||
"""
|
||||
del close_result # 预留字段;卖回只认交易所持仓实况
|
||||
if margin_mode_from_inst_id(inst_id) != "coin":
|
||||
return None
|
||||
# 仍有仓则不卖
|
||||
try:
|
||||
rows = cfg["fetch_option_positions"](ex) or []
|
||||
for p in rows:
|
||||
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
|
||||
continue
|
||||
try:
|
||||
if abs(float(p.get("pos") or 0)) > 1e-12:
|
||||
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
rows = cfg["fetch_option_positions"](ex)
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"持仓查询失败,暂不卖币: {e}", "skipped": True}
|
||||
if rows is None:
|
||||
return {"ok": False, "msg": "持仓查询失败: 空响应,暂不卖币", "skipped": True}
|
||||
for p in rows or []:
|
||||
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
|
||||
continue
|
||||
try:
|
||||
if abs(float(p.get("pos") or 0)) > 1e-12:
|
||||
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
parts = inst_id.split("-")
|
||||
underlying = (parts[0] if parts else "ETH").upper()
|
||||
conn = cfg["get_db"]()
|
||||
result = _spot_bridge.sell_residual_after_option_flat(
|
||||
cfg["get_db"],
|
||||
ex,
|
||||
underlying=underlying,
|
||||
inst_id=inst_id,
|
||||
)
|
||||
# 卖回完成(sell_complete):交易账户 after − 开仓 before → 冻结本单盈亏
|
||||
try:
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_account_pnl_lib import try_settle_after_sellback
|
||||
|
||||
init_options_tables(conn)
|
||||
return _spot_bridge.sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
|
||||
finally:
|
||||
res = result or {}
|
||||
if res.get("ok") and res.get("sell_complete", True) and not res.get("incomplete"):
|
||||
bid = res.get("bridge_id")
|
||||
try_settle_after_sellback(
|
||||
cfg["get_db"],
|
||||
ex,
|
||||
bridge_id=int(bid) if bid is not None else None,
|
||||
cfg=cfg,
|
||||
underlying=underlying,
|
||||
sell_complete=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def sweep_spot_after_options_flat(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""币本位:某标的已无期权持仓时,把交易户该标的残留币卖回 USDT.
|
||||
|
||||
覆盖到期结算、限价平仓后同步关仓等未走买一平仓卖回路径的情况。
|
||||
"""
|
||||
if not is_coin_margin_mode():
|
||||
return {"ok": True, "skipped": True, "msg": "非币本位"}
|
||||
exchange = ex if ex is not None else cfg.get("exchange_options")
|
||||
if exchange is None:
|
||||
return {"ok": False, "msg": "exchange 未就绪"}
|
||||
live_uly: set[str] = set()
|
||||
try:
|
||||
rows = cfg["fetch_option_positions"](exchange)
|
||||
except Exception as e:
|
||||
# 持仓查询失败:只清 pending 粉尘桥,绝不卖币
|
||||
try:
|
||||
conn.close()
|
||||
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "msg": f"持仓查询失败: {e}"}
|
||||
if rows is None:
|
||||
try:
|
||||
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "msg": "持仓查询失败: 空响应"}
|
||||
for p in rows or []:
|
||||
try:
|
||||
if abs(float(p.get("pos") or 0)) <= 1e-12:
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
|
||||
if not inst:
|
||||
continue
|
||||
live_uly.add(inst.split("-")[0].upper())
|
||||
|
||||
# 先关掉只剩粉尘的 pending 桥,避免拦开仓又看不到重试按钮
|
||||
try:
|
||||
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sold: list[dict[str, Any]] = []
|
||||
for uly in ("BTC", "ETH"):
|
||||
if uly in live_uly:
|
||||
continue
|
||||
try:
|
||||
avail = _spot_bridge.fetch_trading_coin_available(exchange, uly)
|
||||
# 低于现货 minSz 的粉尘不扫、不重试
|
||||
if avail is None or not _spot_bridge.is_sellable_spot_coin(exchange, uly, avail):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
out = _spot_bridge.sell_residual_after_option_flat(
|
||||
cfg["get_db"],
|
||||
exchange,
|
||||
underlying=uly,
|
||||
inst_id=None,
|
||||
)
|
||||
out = out or {}
|
||||
if out.get("ok") and out.get("sell_complete", True) and not out.get("incomplete"):
|
||||
try:
|
||||
from lib.options.options_account_pnl_lib import try_settle_after_sellback
|
||||
|
||||
bid = out.get("bridge_id")
|
||||
try_settle_after_sellback(
|
||||
cfg["get_db"],
|
||||
exchange,
|
||||
bridge_id=int(bid) if bid is not None else None,
|
||||
cfg=cfg,
|
||||
underlying=uly,
|
||||
sell_complete=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
sold.append({"underlying": uly, **out})
|
||||
except Exception as e:
|
||||
sold.append({"underlying": uly, "ok": False, "msg": str(e)})
|
||||
return {"ok": True, "results": sold, "skipped": not sold}
|
||||
|
||||
@@ -0,0 +1,999 @@
|
||||
"""中控条件开仓单:客户端本地监控/开仓/出场(中控只下发不盯盘)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
# armed:待命穿越; triggering:已穿越等偏离区内开仓; open:持仓中;
|
||||
# done:终态(leave_up/leave_down/cancel/timeout/closed)
|
||||
ACTIVE_STATUSES = ("armed", "triggering", "open")
|
||||
|
||||
|
||||
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_cond_order_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_cond_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hub_order_id TEXT NOT NULL UNIQUE,
|
||||
underlying TEXT NOT NULL,
|
||||
opt_type TEXT NOT NULL,
|
||||
trigger_side TEXT NOT NULL,
|
||||
trigger_px REAL NOT NULL,
|
||||
deviation_pct REAL NOT NULL,
|
||||
min_expiry_hours REAL NOT NULL,
|
||||
target_index REAL,
|
||||
profit_exit_enabled INTEGER DEFAULT 0,
|
||||
profit_exit_mult REAL DEFAULT 1.0,
|
||||
pending_target_index REAL,
|
||||
pending_profit_exit_enabled INTEGER,
|
||||
pending_profit_exit_mult REAL,
|
||||
exit_update_pending INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'armed',
|
||||
end_reason TEXT,
|
||||
last_index REAL,
|
||||
crossed_at TEXT,
|
||||
opened_inst_id TEXT,
|
||||
opened_trade_id INTEGER,
|
||||
opened_at TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
ended_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE options_cond_orders ADD COLUMN opened_at TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_options_cond_orders_status
|
||||
ON options_cond_orders(status)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_cond_orders
|
||||
SET opened_at = updated_at
|
||||
WHERE opened_at IS NULL
|
||||
AND status = 'open'
|
||||
AND updated_at IS NOT NULL
|
||||
AND TRIM(updated_at) != ''
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
try:
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
return options_app_now_str()
|
||||
except Exception:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _notify_cond(
|
||||
send_wechat: Any,
|
||||
*,
|
||||
title: str,
|
||||
order: dict[str, Any],
|
||||
account_label: str = "",
|
||||
extra: list[str] | None = None,
|
||||
) -> None:
|
||||
if not callable(send_wechat) or not order:
|
||||
return
|
||||
try:
|
||||
lines = [
|
||||
title,
|
||||
f"账户:{account_label or 'OKX期权'}",
|
||||
f"单号:{order.get('hub_order_id') or '—'}",
|
||||
f"标的:{order.get('underlying') or '—'} {order.get('opt_type') or '—'}",
|
||||
f"触发价:{order.get('trigger_px') if order.get('trigger_px') is not None else '—'}",
|
||||
]
|
||||
for line in extra or []:
|
||||
if line:
|
||||
lines.append(line)
|
||||
send_wechat("\n".join(lines))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def next_thursday_2300(created_at: Any) -> datetime | None:
|
||||
"""下发后第一个周四 23:00(业务时区).周四 23:00 之后下发的顺延到下周四."""
|
||||
from lib.options.options_time_lib import options_app_tz, options_wall_clock_to_ms
|
||||
|
||||
ms = options_wall_clock_to_ms(created_at)
|
||||
if ms is None:
|
||||
return None
|
||||
tz = options_app_tz()
|
||||
dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(tz)
|
||||
days = (3 - dt.weekday()) % 7
|
||||
cand = dt.replace(hour=23, minute=0, second=0, microsecond=0) + timedelta(days=days)
|
||||
if cand <= dt:
|
||||
cand += timedelta(days=7)
|
||||
return cand
|
||||
|
||||
|
||||
def expire_untriggered_cond_orders(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
send_wechat: Any = None,
|
||||
account_label: str = "",
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""仅取消仍待命(未触发)的条件单;持仓/触发中不动."""
|
||||
from lib.options.options_time_lib import options_app_tz
|
||||
|
||||
now_dt = now or datetime.now(options_app_tz())
|
||||
n = 0
|
||||
for order in list_cond_orders(conn, active_only=True):
|
||||
if str(order.get("status") or "") != "armed":
|
||||
continue
|
||||
deadline = next_thursday_2300(order.get("created_at"))
|
||||
if deadline is None or now_dt < deadline:
|
||||
continue
|
||||
_end_order(
|
||||
conn,
|
||||
order,
|
||||
end_reason="timeout",
|
||||
message="周四23:00未触发自动取消",
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["profit_exit_enabled"] = bool(int(d.get("profit_exit_enabled") or 0))
|
||||
d["exit_update_pending"] = bool(int(d.get("exit_update_pending") or 0))
|
||||
if d.get("pending_profit_exit_enabled") is not None:
|
||||
d["pending_profit_exit_enabled"] = bool(int(d["pending_profit_exit_enabled"]))
|
||||
return d
|
||||
|
||||
|
||||
def list_cond_orders(conn: sqlite3.Connection, *, active_only: bool = False) -> list[dict[str, Any]]:
|
||||
ensure_cond_order_table(conn)
|
||||
if active_only:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM options_cond_orders
|
||||
WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))})
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
ACTIVE_STATUSES,
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM options_cond_orders ORDER BY id DESC LIMIT 100"
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows if r]
|
||||
|
||||
|
||||
def get_cond_order_by_hub_id(conn: sqlite3.Connection, hub_order_id: str) -> dict[str, Any] | None:
|
||||
ensure_cond_order_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM options_cond_orders WHERE hub_order_id = ?",
|
||||
((hub_order_id or "").strip(),),
|
||||
).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def has_active_cond_order(conn: sqlite3.Connection) -> bool:
|
||||
ensure_cond_order_table(conn)
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT 1 FROM options_cond_orders
|
||||
WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))})
|
||||
LIMIT 1
|
||||
""",
|
||||
ACTIVE_STATUSES,
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def cond_order_blocks_manual_open_msg(conn: sqlite3.Connection) -> str | None:
|
||||
if has_active_cond_order(conn):
|
||||
return "中控条件单占用中,禁止手动开仓"
|
||||
return None
|
||||
|
||||
|
||||
def machine_has_option_position(cfg: dict[str, Any], ex: Any) -> bool:
|
||||
try:
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
except Exception:
|
||||
return False
|
||||
if not raw:
|
||||
return False
|
||||
from lib.options.options_position_limit_lib import count_live_option_positions
|
||||
|
||||
return count_live_option_positions(raw) > 0
|
||||
|
||||
|
||||
def create_cond_order(
|
||||
conn: sqlite3.Connection,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
send_wechat: Any = None,
|
||||
account_label: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""接收中控下发;同机仅允许一张活跃条件单."""
|
||||
ensure_cond_order_table(conn)
|
||||
hub_order_id = (payload.get("hub_order_id") or "").strip()
|
||||
if not hub_order_id:
|
||||
return {"ok": False, "msg": "缺少 hub_order_id"}
|
||||
existing = get_cond_order_by_hub_id(conn, hub_order_id)
|
||||
if existing:
|
||||
return {"ok": True, "msg": "条件单已存在", "order": existing, "deduped": True}
|
||||
if has_active_cond_order(conn):
|
||||
return {"ok": False, "msg": "本机已有活跃条件单"}
|
||||
|
||||
underlying = (payload.get("underlying") or "ETH").strip().upper() or "ETH"
|
||||
try:
|
||||
from lib.options.options_underly_lib import resolve_default_underly, underly_not_allowed_msg
|
||||
|
||||
if not (payload.get("underlying") or "").strip():
|
||||
underlying = resolve_default_underly()
|
||||
uly_block = underly_not_allowed_msg(underlying)
|
||||
if uly_block:
|
||||
return {"ok": False, "msg": uly_block}
|
||||
except Exception:
|
||||
pass
|
||||
opt_type = (payload.get("opt_type") or "P").strip().upper()
|
||||
if opt_type not in ("P", "C"):
|
||||
return {"ok": False, "msg": "opt_type 须为 P 或 C"}
|
||||
# 左侧单:看跌等上穿、看涨等下穿;忽略中控传入的 trigger_side
|
||||
trigger_side = "gte" if opt_type == "P" else "lte"
|
||||
trigger_px = _safe_float(payload.get("trigger_px"))
|
||||
if trigger_px is None or trigger_px <= 0:
|
||||
return {"ok": False, "msg": "trigger_px 无效"}
|
||||
deviation_pct = _safe_float(payload.get("deviation_pct"))
|
||||
if deviation_pct is None or deviation_pct <= 0:
|
||||
return {"ok": False, "msg": "deviation_pct 须 > 0"}
|
||||
min_expiry_hours = _safe_float(payload.get("min_expiry_hours"))
|
||||
if min_expiry_hours is None or min_expiry_hours <= 0:
|
||||
return {"ok": False, "msg": "min_expiry_hours 须 > 0"}
|
||||
|
||||
exit_cfg = payload.get("exit") if isinstance(payload.get("exit"), dict) else {}
|
||||
target_index = _safe_float(exit_cfg.get("target_index") or payload.get("target_index"))
|
||||
pe_en = bool(exit_cfg.get("profit_exit_enabled") or payload.get("profit_exit_enabled"))
|
||||
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
|
||||
|
||||
pe_mult = normalize_profit_exit_mult(
|
||||
exit_cfg.get("profit_exit_mult", payload.get("profit_exit_mult")), default=1.0
|
||||
)
|
||||
if target_index is not None and target_index <= 0:
|
||||
return {"ok": False, "msg": "target_index 无效"}
|
||||
if not pe_en and target_index is None:
|
||||
return {"ok": False, "msg": "请至少配置目标位或翻倍出场"}
|
||||
|
||||
now = _now()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_cond_orders (
|
||||
hub_order_id, underlying, opt_type, trigger_side, trigger_px, deviation_pct,
|
||||
min_expiry_hours, target_index, profit_exit_enabled, profit_exit_mult,
|
||||
status, message, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?, 'armed', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
hub_order_id,
|
||||
underlying,
|
||||
opt_type,
|
||||
trigger_side,
|
||||
float(trigger_px),
|
||||
float(deviation_pct),
|
||||
float(min_expiry_hours),
|
||||
float(target_index) if target_index is not None else None,
|
||||
1 if pe_en else 0,
|
||||
float(pe_mult),
|
||||
"已接收,待命穿越",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
order = get_cond_order_by_hub_id(conn, hub_order_id)
|
||||
_notify_cond(
|
||||
send_wechat,
|
||||
title="【OKX期权·条件单下发】",
|
||||
order=order or {},
|
||||
account_label=account_label,
|
||||
extra=[
|
||||
f"方向:{'Put 上穿' if opt_type == 'P' else 'Call 下穿'}",
|
||||
f"偏离:{deviation_pct:g}%",
|
||||
f"最短到期:{float(min_expiry_hours):g}h",
|
||||
"状态:待命",
|
||||
],
|
||||
)
|
||||
return {"ok": True, "msg": "条件单已 armed", "order": order}
|
||||
|
||||
|
||||
def cancel_cond_order(
|
||||
conn: sqlite3.Connection,
|
||||
hub_order_id: str,
|
||||
*,
|
||||
send_wechat: Any = None,
|
||||
account_label: str = "",
|
||||
) -> dict[str, Any]:
|
||||
order = get_cond_order_by_hub_id(conn, hub_order_id)
|
||||
if not order:
|
||||
return {"ok": False, "msg": "条件单不存在"}
|
||||
if order.get("status") not in ACTIVE_STATUSES:
|
||||
return {"ok": True, "msg": "条件单已结束", "order": order}
|
||||
if order.get("status") == "open":
|
||||
return {"ok": False, "msg": "已开仓,请用平仓/出场结束;取消仅适用于未成交条件单"}
|
||||
_end_order(
|
||||
conn,
|
||||
order,
|
||||
end_reason="cancel",
|
||||
message="中控取消",
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
)
|
||||
return {"ok": True, "msg": "已取消", "order": get_cond_order_by_hub_id(conn, hub_order_id)}
|
||||
|
||||
|
||||
def update_cond_order_exit(
|
||||
conn: sqlite3.Connection,
|
||||
hub_order_id: str,
|
||||
*,
|
||||
target_index: Any = None,
|
||||
profit_exit_enabled: Any = None,
|
||||
profit_exit_mult: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""成交后改出场;若正在平仓挂单则先记 pending,本轮结束后再套用."""
|
||||
order = get_cond_order_by_hub_id(conn, hub_order_id)
|
||||
if not order:
|
||||
return {"ok": False, "msg": "条件单不存在"}
|
||||
if order.get("status") != "open":
|
||||
return {"ok": False, "msg": "仅持仓中的条件单可改出场"}
|
||||
|
||||
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
|
||||
|
||||
new_target = _safe_float(target_index) if target_index is not None else order.get("target_index")
|
||||
if target_index is not None and str(target_index).strip() == "":
|
||||
new_target = None
|
||||
if new_target is not None and new_target <= 0:
|
||||
return {"ok": False, "msg": "target_index 无效"}
|
||||
if profit_exit_enabled is None:
|
||||
pe_en = bool(order.get("profit_exit_enabled"))
|
||||
else:
|
||||
pe_en = bool(profit_exit_enabled)
|
||||
pe_mult = normalize_profit_exit_mult(
|
||||
profit_exit_mult if profit_exit_mult is not None else order.get("profit_exit_mult"),
|
||||
default=1.0,
|
||||
)
|
||||
if not pe_en and new_target is None:
|
||||
return {"ok": False, "msg": "请至少配置目标位或翻倍出场"}
|
||||
|
||||
closing = _exit_close_in_progress(conn, str(order.get("opened_inst_id") or ""))
|
||||
now = _now()
|
||||
if closing:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_cond_orders SET
|
||||
pending_target_index = ?,
|
||||
pending_profit_exit_enabled = ?,
|
||||
pending_profit_exit_mult = ?,
|
||||
exit_update_pending = 1,
|
||||
message = ?,
|
||||
updated_at = ?
|
||||
WHERE hub_order_id = ?
|
||||
""",
|
||||
(
|
||||
float(new_target) if new_target is not None else None,
|
||||
1 if pe_en else 0,
|
||||
float(pe_mult),
|
||||
"出场已更新,等待本轮平仓挂单结束后生效",
|
||||
now,
|
||||
hub_order_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "已记录,本轮平仓结束后生效",
|
||||
"deferred": True,
|
||||
"order": get_cond_order_by_hub_id(conn, hub_order_id),
|
||||
}
|
||||
|
||||
_apply_exit_to_position(
|
||||
conn,
|
||||
order,
|
||||
target_index=new_target,
|
||||
profit_exit_enabled=pe_en,
|
||||
profit_exit_mult=pe_mult,
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_cond_orders SET
|
||||
target_index = ?,
|
||||
profit_exit_enabled = ?,
|
||||
profit_exit_mult = ?,
|
||||
exit_update_pending = 0,
|
||||
pending_target_index = NULL,
|
||||
pending_profit_exit_enabled = NULL,
|
||||
pending_profit_exit_mult = NULL,
|
||||
message = ?,
|
||||
updated_at = ?
|
||||
WHERE hub_order_id = ?
|
||||
""",
|
||||
(
|
||||
float(new_target) if new_target is not None else None,
|
||||
1 if pe_en else 0,
|
||||
float(pe_mult),
|
||||
"出场已更新并生效",
|
||||
now,
|
||||
hub_order_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "msg": "出场已更新", "order": get_cond_order_by_hub_id(conn, hub_order_id)}
|
||||
|
||||
|
||||
def _exit_close_in_progress(conn: sqlite3.Connection, inst_id: str) -> bool:
|
||||
if not inst_id:
|
||||
return False
|
||||
try:
|
||||
from lib.options.options_target_lib import list_closing_targets
|
||||
|
||||
for t in list_closing_targets(conn):
|
||||
if str(t.get("inst_id") or "") == inst_id:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT profit_exit_state FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
ORDER BY id ASC LIMIT 1
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if row and str(row["profit_exit_state"] or "") == "closing":
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _apply_exit_to_position(
|
||||
conn: sqlite3.Connection,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
target_index: float | None,
|
||||
profit_exit_enabled: bool,
|
||||
profit_exit_mult: float,
|
||||
) -> None:
|
||||
inst_id = str(order.get("opened_inst_id") or "").strip()
|
||||
if not inst_id:
|
||||
return
|
||||
from lib.options.options_profit_exit_lib import set_profit_exit
|
||||
from lib.options.options_target_lib import cancel_target_monitor, upsert_target_monitor
|
||||
|
||||
set_profit_exit(conn, inst_id=inst_id, enabled=profit_exit_enabled, mult=profit_exit_mult)
|
||||
if target_index is not None:
|
||||
upsert_target_monitor(
|
||||
conn,
|
||||
inst_id=inst_id,
|
||||
target_index=float(target_index),
|
||||
underlying=str(order.get("underlying") or ""),
|
||||
opt_type=str(order.get("opt_type") or ""),
|
||||
)
|
||||
else:
|
||||
cancel_target_monitor(conn, inst_id=inst_id)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _end_order(
|
||||
conn: sqlite3.Connection,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
end_reason: str,
|
||||
message: str,
|
||||
send_wechat: Any = None,
|
||||
account_label: str = "",
|
||||
index_px: float | None = None,
|
||||
) -> None:
|
||||
now = _now()
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_cond_orders SET
|
||||
status = 'done',
|
||||
end_reason = ?,
|
||||
message = ?,
|
||||
updated_at = ?,
|
||||
ended_at = ?
|
||||
WHERE hub_order_id = ?
|
||||
""",
|
||||
(end_reason, message, now, now, order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
if callable(send_wechat):
|
||||
try:
|
||||
reason_map = {
|
||||
"leave_up": "上破偏离区结束",
|
||||
"leave_down": "下破偏离区结束",
|
||||
"cancel": "中控取消",
|
||||
"timeout": "周四23:00未触发自动取消",
|
||||
"closed": "出场完成",
|
||||
}
|
||||
_notify_cond(
|
||||
send_wechat,
|
||||
title="【OKX期权·条件单结束】",
|
||||
order=order,
|
||||
account_label=account_label,
|
||||
extra=[
|
||||
f"原因:{reason_map.get(end_reason, end_reason)}",
|
||||
f"说明:{message}",
|
||||
]
|
||||
+ ([f"当前指数:{index_px:g}"] if index_px is not None else []),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def band_bounds(trigger_px: float, deviation_pct: float) -> tuple[float, float]:
|
||||
d = max(0.0, float(deviation_pct)) / 100.0
|
||||
lo = float(trigger_px) * (1.0 - d)
|
||||
hi = float(trigger_px) * (1.0 + d)
|
||||
return lo, hi
|
||||
|
||||
|
||||
def pick_nearest_itm_contract(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
*,
|
||||
underlying: str,
|
||||
opt_type: str,
|
||||
index_px: float,
|
||||
min_expiry_hours: float,
|
||||
) -> dict[str, Any] | None:
|
||||
"""按当前指数选最近一档实值;到期 ≥ min_expiry_hours."""
|
||||
from lib.exchange.okx_options_lib import build_option_chain, option_buy_liquidity_ok
|
||||
from lib.options.options_pricing_lib import option_moneyness
|
||||
|
||||
max_dte_days = max(float(min_expiry_hours) / 24.0 + 14.0, 14.0)
|
||||
# 放宽实值距离:取全部 ITM,再按距离排序
|
||||
chain = build_option_chain(
|
||||
ex,
|
||||
underlying,
|
||||
max_dte_days=max_dte_days,
|
||||
itm_only=False,
|
||||
itm_max_dist_usd=1e12,
|
||||
index_px=index_px,
|
||||
)
|
||||
min_ms = time.time() * 1000 + float(min_expiry_hours) * 3600 * 1000
|
||||
want = (opt_type or "P").upper()
|
||||
candidates: list[tuple[float, dict[str, Any]]] = []
|
||||
expiries = chain.get("expiries") or []
|
||||
# build_option_chain 返回 list[{exp_time, contracts}]
|
||||
if isinstance(expiries, dict):
|
||||
exp_iter = [{"exp_time": k, "contracts": v} for k, v in expiries.items()]
|
||||
else:
|
||||
exp_iter = list(expiries)
|
||||
for bucket in exp_iter:
|
||||
try:
|
||||
exp_ms = int(bucket.get("exp_time") or 0)
|
||||
except (TypeError, ValueError):
|
||||
exp_ms = 0
|
||||
if exp_ms and exp_ms < min_ms:
|
||||
continue
|
||||
for c in bucket.get("contracts") or []:
|
||||
if str(c.get("opt_type") or "").upper() != want:
|
||||
continue
|
||||
strike = _safe_float(c.get("strike"))
|
||||
if strike is None:
|
||||
continue
|
||||
mny = option_moneyness(opt_type=want, strike=strike, index_px=index_px)
|
||||
if mny != "itm":
|
||||
continue
|
||||
dist = abs(float(strike) - float(index_px))
|
||||
candidates.append((dist, c))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda x: (x[0], str(x[1].get("inst_id") or "")))
|
||||
# 优先有卖一深度的最近档
|
||||
for _dist, c in candidates[:12]:
|
||||
inst = str(c.get("inst_id") or "")
|
||||
if not inst:
|
||||
continue
|
||||
try:
|
||||
q = cfg["quote_option_contract"](ex, inst)
|
||||
except Exception:
|
||||
continue
|
||||
ask = q.get("ask")
|
||||
ask_sz = q.get("ask_sz")
|
||||
ok, _ = option_buy_liquidity_ok(ask, ask_sz)
|
||||
if ok:
|
||||
out = dict(c)
|
||||
out["quote"] = q
|
||||
return out
|
||||
# 深度都不够时仍返回最近档,由上层继续等
|
||||
best = dict(candidates[0][1])
|
||||
return best
|
||||
|
||||
|
||||
def _fetch_index(cfg: dict[str, Any], ex: Any, underlying: str) -> float | None:
|
||||
from lib.exchange.okx_options_lib import fetch_index_price
|
||||
|
||||
uly = f"{(underlying or 'ETH').upper()}-USD"
|
||||
try:
|
||||
return fetch_index_price(ex, uly)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _try_open_full(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
*,
|
||||
inst_id: str,
|
||||
order: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
from lib.options.options_coin_open_lib import open_coin_option_buy_full
|
||||
|
||||
note = f"中控条件单:{order.get('hub_order_id')}"
|
||||
target_index = _safe_float(order.get("target_index"))
|
||||
pe_en = bool(order.get("profit_exit_enabled"))
|
||||
pe_mult = float(order.get("profit_exit_mult") or 1.0)
|
||||
# USDC 期权开仓已下线,条件单仅走币本位
|
||||
return open_coin_option_buy_full(
|
||||
cfg,
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
signal_note=note,
|
||||
target_index=target_index,
|
||||
profit_exit_enabled=pe_en,
|
||||
profit_exit_mult=pe_mult,
|
||||
target_sheets=None,
|
||||
)
|
||||
|
||||
|
||||
def run_cond_orders_tick(
|
||||
conn_or_get_db: Any,
|
||||
cfg: dict[str, Any],
|
||||
*,
|
||||
send_wechat: Any = None,
|
||||
account_label: str = "",
|
||||
) -> None:
|
||||
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾.
|
||||
|
||||
推荐传 get_db:开仓前会关闭连接,避免与买币/等待成交嵌套锁库。
|
||||
"""
|
||||
get_db = conn_or_get_db if callable(conn_or_get_db) else None
|
||||
if get_db is None:
|
||||
conn = conn_or_get_db
|
||||
ensure_cond_order_table(conn)
|
||||
expire_untriggered_cond_orders(
|
||||
conn, send_wechat=send_wechat, account_label=account_label
|
||||
)
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is None:
|
||||
return
|
||||
for order in list_cond_orders(conn, active_only=True):
|
||||
try:
|
||||
_tick_one(
|
||||
conn,
|
||||
cfg,
|
||||
ex,
|
||||
order,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
get_db=None,
|
||||
)
|
||||
except Exception as e:
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
|
||||
(f"监控异常: {e}"[:200], _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
ex: Any = None
|
||||
orders: list[dict[str, Any]] = []
|
||||
conn = get_db()
|
||||
try:
|
||||
ensure_cond_order_table(conn)
|
||||
expire_untriggered_cond_orders(
|
||||
conn, send_wechat=send_wechat, account_label=account_label
|
||||
)
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is not None:
|
||||
orders = list_cond_orders(conn, active_only=True)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if ex is None or not orders:
|
||||
return
|
||||
for order in orders:
|
||||
conn = get_db()
|
||||
try:
|
||||
_tick_one(
|
||||
conn,
|
||||
cfg,
|
||||
ex,
|
||||
order,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
get_db=get_db,
|
||||
)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
|
||||
(f"监控异常: {e}"[:200], _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _tick_one(
|
||||
conn: sqlite3.Connection,
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
send_wechat: Any,
|
||||
account_label: str,
|
||||
get_db: Any = None,
|
||||
) -> None:
|
||||
status = str(order.get("status") or "")
|
||||
underlying = str(order.get("underlying") or "ETH")
|
||||
idx = _fetch_index(cfg, ex, underlying)
|
||||
if idx is None or idx <= 0:
|
||||
return
|
||||
last = _safe_float(order.get("last_index"))
|
||||
conn.execute(
|
||||
"UPDATE options_cond_orders SET last_index=?, updated_at=? WHERE hub_order_id=?",
|
||||
(float(idx), _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
trigger_px = float(order["trigger_px"])
|
||||
lo, hi = band_bounds(trigger_px, float(order["deviation_pct"]))
|
||||
side = str(order.get("trigger_side") or "gte")
|
||||
|
||||
if status == "armed":
|
||||
crossed = False
|
||||
if side == "gte" and last is not None and last < trigger_px and idx >= trigger_px:
|
||||
crossed = True
|
||||
elif side == "lte" and last is not None and last > trigger_px and idx <= trigger_px:
|
||||
crossed = True
|
||||
# 首次无 last:只记录,等下一跳再判穿越(避免挂单时已在上方立刻开)
|
||||
if last is None:
|
||||
return
|
||||
if crossed:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_cond_orders SET status='triggering', crossed_at=?, message=?, updated_at=?
|
||||
WHERE hub_order_id=?
|
||||
""",
|
||||
(_now(), f"已穿越 {trigger_px:g},等待偏离区内开仓", _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
order["status"] = "triggering"
|
||||
status = "triggering"
|
||||
else:
|
||||
return
|
||||
|
||||
if status == "triggering":
|
||||
if idx > hi:
|
||||
_end_order(
|
||||
conn,
|
||||
order,
|
||||
end_reason="leave_up",
|
||||
message=f"指数 {idx:g} 上破偏离上沿 {hi:g}",
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
index_px=idx,
|
||||
)
|
||||
return
|
||||
if idx < lo:
|
||||
_end_order(
|
||||
conn,
|
||||
order,
|
||||
end_reason="leave_down",
|
||||
message=f"指数 {idx:g} 下破偏离下沿 {lo:g}",
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
index_px=idx,
|
||||
)
|
||||
return
|
||||
# 区内:有仓则不再开
|
||||
if machine_has_option_position(cfg, ex):
|
||||
conn.execute(
|
||||
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
|
||||
("偏离区内但已有持仓,等待", _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
picked = pick_nearest_itm_contract(
|
||||
cfg,
|
||||
ex,
|
||||
underlying=underlying,
|
||||
opt_type=str(order.get("opt_type") or "P"),
|
||||
index_px=idx,
|
||||
min_expiry_hours=float(order.get("min_expiry_hours") or 24),
|
||||
)
|
||||
if not picked or not picked.get("inst_id"):
|
||||
conn.execute(
|
||||
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
|
||||
("偏离区内暂无合适实值合约", _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
inst_id = str(picked["inst_id"])
|
||||
# 深度检查
|
||||
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
|
||||
|
||||
q = picked.get("quote") or cfg["quote_option_contract"](ex, inst_id)
|
||||
ok_liq, msg_liq = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
|
||||
if not ok_liq:
|
||||
conn.execute(
|
||||
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
|
||||
(f"深度不足继续等待: {msg_liq or '无卖一'}", _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
# 开仓(买币/等成交)前释放本连接,避免嵌套锁库
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
hub_id = order["hub_order_id"]
|
||||
if get_db is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = None
|
||||
result = _try_open_full(cfg, ex, inst_id=inst_id, order=order)
|
||||
if get_db is not None:
|
||||
conn = get_db()
|
||||
ensure_cond_order_table(conn)
|
||||
assert conn is not None
|
||||
if not result.get("ok"):
|
||||
conn.execute(
|
||||
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
|
||||
(f"开仓未成继续等: {result.get('msg') or '失败'}"[:180], _now(), hub_id),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
trade_id = result.get("trade_id")
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_cond_orders SET
|
||||
status='open', opened_inst_id=?, opened_trade_id=?,
|
||||
opened_at=COALESCE(opened_at, ?),
|
||||
message=?, updated_at=?
|
||||
WHERE hub_order_id=?
|
||||
""",
|
||||
(
|
||||
inst_id,
|
||||
int(trade_id) if trade_id is not None else None,
|
||||
_now(),
|
||||
f"已开仓 {inst_id}",
|
||||
_now(),
|
||||
hub_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
opened = get_cond_order_by_hub_id(conn, order["hub_order_id"]) or order
|
||||
_notify_cond(
|
||||
send_wechat,
|
||||
title="【OKX期权·条件单开仓】",
|
||||
order=opened,
|
||||
account_label=account_label,
|
||||
extra=[
|
||||
f"合约:{inst_id}",
|
||||
f"说明:已开仓 {inst_id}",
|
||||
],
|
||||
)
|
||||
order2 = opened
|
||||
order2["opened_inst_id"] = inst_id
|
||||
_apply_exit_to_position(
|
||||
conn,
|
||||
order2,
|
||||
target_index=_safe_float(order2.get("target_index")),
|
||||
profit_exit_enabled=bool(order2.get("profit_exit_enabled")),
|
||||
profit_exit_mult=float(order2.get("profit_exit_mult") or 1.0),
|
||||
)
|
||||
return
|
||||
|
||||
if status == "open":
|
||||
# 延迟出场更新
|
||||
if order.get("exit_update_pending") and not _exit_close_in_progress(
|
||||
conn, str(order.get("opened_inst_id") or "")
|
||||
):
|
||||
pe_en = order.get("pending_profit_exit_enabled")
|
||||
if pe_en is None:
|
||||
pe_en = order.get("profit_exit_enabled")
|
||||
_apply_exit_to_position(
|
||||
conn,
|
||||
order,
|
||||
target_index=_safe_float(order.get("pending_target_index")),
|
||||
profit_exit_enabled=bool(pe_en),
|
||||
profit_exit_mult=float(
|
||||
order.get("pending_profit_exit_mult")
|
||||
if order.get("pending_profit_exit_mult") is not None
|
||||
else order.get("profit_exit_mult")
|
||||
or 1.0
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_cond_orders SET
|
||||
target_index = pending_target_index,
|
||||
profit_exit_enabled = COALESCE(pending_profit_exit_enabled, profit_exit_enabled),
|
||||
profit_exit_mult = COALESCE(pending_profit_exit_mult, profit_exit_mult),
|
||||
exit_update_pending = 0,
|
||||
pending_target_index = NULL,
|
||||
pending_profit_exit_enabled = NULL,
|
||||
pending_profit_exit_mult = NULL,
|
||||
message = ?,
|
||||
updated_at = ?
|
||||
WHERE hub_order_id = ?
|
||||
""",
|
||||
("延迟出场已生效", _now(), order["hub_order_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
inst_id = str(order.get("opened_inst_id") or "")
|
||||
if not inst_id:
|
||||
return
|
||||
if not machine_has_option_position(cfg, ex):
|
||||
# 确认该合约已平
|
||||
raw = cfg["fetch_option_positions"](ex) or []
|
||||
still = any(str(p.get("instId") or "") == inst_id for p in raw)
|
||||
if still:
|
||||
return
|
||||
_end_order(
|
||||
conn,
|
||||
order,
|
||||
end_reason="closed",
|
||||
message="持仓已平,条件单完成",
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
index_px=idx,
|
||||
)
|
||||
@@ -1,85 +1,77 @@
|
||||
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
|
||||
不走 options dashboard snapshot(避免余额/历史).
|
||||
"""
|
||||
if not cfg.get("enabled"):
|
||||
return []
|
||||
ex = cfg.get("exchange_options")
|
||||
ready_fn = cfg.get("options_api_ready")
|
||||
if not callable(ready_fn):
|
||||
return []
|
||||
ok, _reason = ready_fn(ex)
|
||||
if not ok:
|
||||
return []
|
||||
fetch_fn = cfg.get("fetch_option_positions")
|
||||
if not callable(fetch_fn):
|
||||
return []
|
||||
raw = fetch_fn(ex)
|
||||
if raw is None:
|
||||
return []
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||
from lib.options.options_history_lib import enrich_position_row_display
|
||||
from lib.options.options_positions_lib import attach_close_preview
|
||||
|
||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
get_db = cfg.get("get_db")
|
||||
if not callable(get_db):
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
|
||||
attach_close_preview(cfg, ex, row)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
||||
from lib.options.options_target_lib import targets_by_inst
|
||||
|
||||
tgt_map = targets_by_inst(conn)
|
||||
hedge_target_map = active_options_targets_by_inst(conn)
|
||||
except Exception:
|
||||
tgt_map = {}
|
||||
hedge_target_map = {}
|
||||
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
inst = str(p.get("instId") or "").strip()
|
||||
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
||||
row = enrich_position_row_display(
|
||||
cfg,
|
||||
ex,
|
||||
p,
|
||||
meta_cache=meta_cache,
|
||||
premium_override=premium_override,
|
||||
)
|
||||
attach_close_preview(cfg, ex, row, premium_paid=premium_override)
|
||||
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
||||
if mon:
|
||||
row["target_index"] = mon.get("target_index")
|
||||
row["target_monitor_id"] = mon.get("id")
|
||||
row["target_monitor"] = mon
|
||||
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
||||
if hedge_target:
|
||||
row["hedge_plan_target"] = hedge_target
|
||||
if not mon:
|
||||
row["target_index"] = hedge_target.get("target_index")
|
||||
rows.append(row)
|
||||
finally:
|
||||
conn.close()
|
||||
return rows
|
||||
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
|
||||
不走 options dashboard snapshot(避免余额/历史).
|
||||
"""
|
||||
if not cfg.get("enabled"):
|
||||
return []
|
||||
ex = cfg.get("exchange_options")
|
||||
ready_fn = cfg.get("options_api_ready")
|
||||
if not callable(ready_fn):
|
||||
return []
|
||||
ok, _reason = ready_fn(ex)
|
||||
if not ok:
|
||||
return []
|
||||
fetch_fn = cfg.get("fetch_option_positions")
|
||||
if not callable(fetch_fn):
|
||||
return []
|
||||
raw = fetch_fn(ex)
|
||||
if raw is None:
|
||||
return []
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||
from lib.options.options_history_lib import enrich_position_row_display
|
||||
from lib.options.options_positions_lib import attach_close_preview
|
||||
|
||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
get_db = cfg.get("get_db")
|
||||
if not callable(get_db):
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
|
||||
attach_close_preview(cfg, ex, row)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
try:
|
||||
from lib.options.options_target_lib import targets_by_inst
|
||||
|
||||
tgt_map = targets_by_inst(conn)
|
||||
except Exception:
|
||||
tgt_map = {}
|
||||
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
inst = str(p.get("instId") or "").strip()
|
||||
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
||||
row = enrich_position_row_display(
|
||||
cfg,
|
||||
ex,
|
||||
p,
|
||||
meta_cache=meta_cache,
|
||||
premium_override=premium_override,
|
||||
)
|
||||
attach_close_preview(cfg, ex, row, premium_paid=premium_override)
|
||||
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
||||
if mon:
|
||||
row["target_index"] = mon.get("target_index")
|
||||
row["target_monitor_id"] = mon.get("id")
|
||||
row["target_monitor"] = mon
|
||||
rows.append(row)
|
||||
finally:
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
@@ -95,6 +95,19 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
ON options_target_monitors(status)
|
||||
"""
|
||||
)
|
||||
# 实盘统计:按期权历史 history_key 冻结交易所盈亏折 U,避免跟实时指数漂移
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_exchange_pnl_freeze (
|
||||
history_key TEXT PRIMARY KEY,
|
||||
realized_pnl REAL,
|
||||
realized_pnl_usdt REAL,
|
||||
close_index_px REAL,
|
||||
premium_ccy TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
for ddl in (
|
||||
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
|
||||
@@ -105,6 +118,23 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
|
||||
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
|
||||
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
|
||||
# 分批平仓累计:全平前写在最早一笔 open 上,全平后并入 premium_received
|
||||
"ALTER TABLE options_trades ADD COLUMN close_recv_accum REAL DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN close_sheets_accum INTEGER DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN close_notional_accum REAL DEFAULT 0",
|
||||
# 手续费(权利金单位)与平仓冻结 U 口径
|
||||
"ALTER TABLE options_trades ADD COLUMN open_fee REAL DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN close_fee REAL DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN fee_ccy TEXT",
|
||||
"ALTER TABLE options_trades ADD COLUMN close_index_px REAL",
|
||||
"ALTER TABLE options_trades ADD COLUMN open_index_px REAL",
|
||||
"ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt REAL",
|
||||
# 交易账户快照盈亏(一次一仓):before→after 差分冻结
|
||||
"ALTER TABLE options_trades ADD COLUMN trading_usdt_before REAL",
|
||||
"ALTER TABLE options_trades ADD COLUMN trading_usdt_after REAL",
|
||||
"ALTER TABLE options_trades ADD COLUMN pnl_settled INTEGER DEFAULT 0",
|
||||
# 初始权利金(U)=开仓指数×权利金币量,不含多买缓冲
|
||||
"ALTER TABLE options_trades ADD COLUMN initial_premium_usdt REAL",
|
||||
):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
@@ -116,6 +146,12 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
ensure_bridge_table(conn)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.options.options_cond_order_lib import ensure_cond_order_table
|
||||
|
||||
ensure_cond_order_table(conn)
|
||||
except Exception:
|
||||
pass
|
||||
init_options_review_tables(conn)
|
||||
|
||||
|
||||
@@ -138,6 +174,27 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
|
||||
return round(float(row["total"] or 0), 8)
|
||||
|
||||
|
||||
def sum_open_budget_usdt(conn: sqlite3.Connection, inst_id: str) -> float | None:
|
||||
"""同合约 open 腿开仓预算/兑换占用 USDT 合计(权利金折 U 展示用,不随币价变)."""
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT SUM(budget_usdt) AS total, COUNT(*) AS n
|
||||
FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open' AND budget_usdt IS NOT NULL
|
||||
""",
|
||||
(inst,),
|
||||
).fetchone()
|
||||
if not row or int(row["n"] or 0) < 1:
|
||||
return None
|
||||
try:
|
||||
return round(float(row["total"] or 0), 4)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
|
||||
"""同合约所有 open 腿张数合计."""
|
||||
inst = (inst_id or "").strip()
|
||||
@@ -154,3 +211,86 @@ def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
|
||||
if not row or int(row["n"] or 0) < 1:
|
||||
return None
|
||||
return int(row["total"] or 0)
|
||||
|
||||
|
||||
def _earliest_open_trade_id(conn: sqlite3.Connection, inst_id: str) -> int | None:
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(inst,),
|
||||
).fetchone()
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
|
||||
def get_open_close_accum(conn: sqlite3.Connection, inst_id: str) -> dict[str, float | int | str | None]:
|
||||
"""读取分批平仓累计(挂在最早一笔 open 上)."""
|
||||
tid = _earliest_open_trade_id(conn, inst_id)
|
||||
if tid is None:
|
||||
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT close_recv_accum, close_sheets_accum, close_notional_accum, close_ord_id
|
||||
FROM options_trades WHERE id = ?
|
||||
""",
|
||||
(tid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
|
||||
return {
|
||||
"recv": float(row["close_recv_accum"] or 0),
|
||||
"sheets": int(row["close_sheets_accum"] or 0),
|
||||
"notional": float(row["close_notional_accum"] or 0),
|
||||
"close_ord_id": str(row["close_ord_id"] or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def add_open_close_accum(
|
||||
conn: sqlite3.Connection,
|
||||
inst_id: str,
|
||||
*,
|
||||
recv: float,
|
||||
sheets: int,
|
||||
px: float,
|
||||
ord_id: str | None = None,
|
||||
) -> bool:
|
||||
"""分批平仓成交后累加收回权利金/张数/名义(供全平 VWAP 与总盈亏)."""
|
||||
tid = _earliest_open_trade_id(conn, inst_id)
|
||||
if tid is None:
|
||||
return False
|
||||
sheets_i = max(0, int(sheets or 0))
|
||||
recv_f = float(recv or 0)
|
||||
px_f = float(px or 0)
|
||||
if sheets_i < 1 and abs(recv_f) < 1e-15:
|
||||
return False
|
||||
notional_add = px_f * sheets_i if sheets_i > 0 and px_f > 0 else 0.0
|
||||
oid = str(ord_id or "").strip()
|
||||
prev = conn.execute(
|
||||
"SELECT close_ord_id FROM options_trades WHERE id = ?", (tid,)
|
||||
).fetchone()
|
||||
prev_oids = str((prev["close_ord_id"] if prev else None) or "").strip()
|
||||
if oid:
|
||||
parts = [p for p in prev_oids.split(",") if p] if prev_oids else []
|
||||
if oid not in parts:
|
||||
parts.append(oid)
|
||||
merged_oid = ",".join(parts)
|
||||
else:
|
||||
merged_oid = prev_oids or None
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET close_recv_accum = COALESCE(close_recv_accum, 0) + ?,
|
||||
close_sheets_accum = COALESCE(close_sheets_accum, 0) + ?,
|
||||
close_notional_accum = COALESCE(close_notional_accum, 0) + ?,
|
||||
close_ord_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(recv_f, sheets_i, notional_add, merged_oid, tid),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -29,6 +29,25 @@ def enrich_position_row_display(
|
||||
if premium_override is not None:
|
||||
row["premium_paid"] = premium_override
|
||||
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
|
||||
try:
|
||||
from lib.options.options_pricing_lib import equivalent_contract_leverage
|
||||
|
||||
def _sf(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
row["contract_leverage"] = equivalent_contract_leverage(
|
||||
index_px=_sf(row.get("idx_px")),
|
||||
eth_amount=_sf(row.get("eth_amount")),
|
||||
total_premium=_sf(row.get("premium_paid")),
|
||||
margin_mode=row_mode,
|
||||
)
|
||||
except Exception:
|
||||
row.setdefault("contract_leverage", None)
|
||||
return row
|
||||
|
||||
|
||||
@@ -48,6 +67,8 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if raw_live is None:
|
||||
return []
|
||||
|
||||
prem_by_inst: dict[str, float | None] = {}
|
||||
hidden_keys: set[str] = set()
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
@@ -57,25 +78,34 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
}
|
||||
for p in raw_live:
|
||||
inst = str(p.get("instId") or "").strip()
|
||||
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
||||
row = enrich_position_row_display(
|
||||
cfg,
|
||||
ex,
|
||||
p,
|
||||
meta_cache=meta_cache,
|
||||
premium_override=premium_override,
|
||||
)
|
||||
open_ms = None
|
||||
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
|
||||
try:
|
||||
if ctime is not None and str(ctime).strip():
|
||||
open_ms = int(float(ctime))
|
||||
except (TypeError, ValueError):
|
||||
open_ms = None
|
||||
items.append(format_live_option_history_row(row, open_ms=open_ms))
|
||||
if inst:
|
||||
prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
for p in raw_live:
|
||||
inst = str(p.get("instId") or "").strip()
|
||||
premium_override = prem_by_inst.get(inst)
|
||||
row = enrich_position_row_display(
|
||||
cfg,
|
||||
ex,
|
||||
p,
|
||||
meta_cache=meta_cache,
|
||||
premium_override=premium_override,
|
||||
)
|
||||
open_ms = None
|
||||
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
|
||||
try:
|
||||
if ctime is not None and str(ctime).strip():
|
||||
open_ms = int(float(ctime))
|
||||
except (TypeError, ValueError):
|
||||
open_ms = None
|
||||
items.append(format_live_option_history_row(row, open_ms=open_ms))
|
||||
|
||||
hist_raw = fetch_all_option_positions_history(ex, limit=200)
|
||||
for raw in hist_raw:
|
||||
inst_id = str(raw.get("instId") or "").strip()
|
||||
|
||||
@@ -23,20 +23,20 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
if raw is None:
|
||||
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
|
||||
positions = build_display_option_positions(cfg, ex, raw)
|
||||
# 中控需同步实例买一/流动性;盘口在 DB 已释放后拉取(见 build_display_option_positions)
|
||||
positions = build_display_option_positions(
|
||||
cfg, ex, raw, include_close_preview=True
|
||||
)
|
||||
target_monitors: list[dict[str, Any]] = []
|
||||
try:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
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
|
||||
|
||||
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
|
||||
tgt_map = 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())
|
||||
for pe in profit_exit_map.values():
|
||||
if pe.get("profit_exit_enabled"):
|
||||
target_monitors.append(
|
||||
@@ -59,12 +59,6 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
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 ""))
|
||||
if hedge_target:
|
||||
p["hedge_plan_target"] = hedge_target
|
||||
if not mon:
|
||||
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
|
||||
p["target_index"] = hedge_target.get("target_index")
|
||||
try:
|
||||
from lib.instance.instance_dashboard_lib import (
|
||||
_format_options_target,
|
||||
@@ -85,13 +79,14 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
conn.close()
|
||||
except Exception:
|
||||
target_monitors = []
|
||||
from lib.options.options_positions_lib import display_pnl_from_option_row
|
||||
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
|
||||
|
||||
upl_total = 0.0
|
||||
has_upl = False
|
||||
for p in positions:
|
||||
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
|
||||
pnl = display_pnl_from_option_row(p)
|
||||
# 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
|
||||
pnl = display_pnl_usdt_from_option_row(p)
|
||||
p["display_pnl_usdt"] = pnl
|
||||
if pnl is None:
|
||||
continue
|
||||
has_upl = True
|
||||
@@ -138,7 +133,10 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
except Exception:
|
||||
open_bridges = []
|
||||
|
||||
return {
|
||||
from lib.options.options_underly_lib import underly_cfg_snapshot
|
||||
|
||||
_uly_snap = underly_cfg_snapshot()
|
||||
out = {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"positions": positions,
|
||||
@@ -157,10 +155,43 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"max_active_positions": options_max_active_positions(),
|
||||
"options_margin_mode": margin_mode,
|
||||
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
|
||||
"options_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH",
|
||||
"options_underly": _uly_snap["default_underly"],
|
||||
"options_allowed_underlys": _uly_snap["allowed_underlys"],
|
||||
"coin_budget": coin_budget,
|
||||
"bridge_status": bridge_status,
|
||||
"open_bridges": open_bridges,
|
||||
"cond_order_locked": False,
|
||||
"cond_orders_active": [],
|
||||
"transfer_blocked": False,
|
||||
"transfer_block_msg": "",
|
||||
}
|
||||
try:
|
||||
from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders
|
||||
|
||||
conn_c = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
init_options_tables(conn_c)
|
||||
out["cond_order_locked"] = has_active_cond_order(conn_c)
|
||||
out["cond_orders_active"] = list_cond_orders(conn_c, active_only=True)
|
||||
finally:
|
||||
conn_c.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg
|
||||
|
||||
conn_tb = cfg["get_db"]()
|
||||
try:
|
||||
tmsg = transfer_blocked_by_position_msg(conn_tb, ex)
|
||||
if tmsg:
|
||||
out["transfer_blocked"] = True
|
||||
out["transfer_block_msg"] = tmsg
|
||||
finally:
|
||||
conn_tb.close()
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
except Exception as e:
|
||||
return {"ok": False, "enabled": True, "msg": str(e)}
|
||||
|
||||
@@ -24,30 +24,21 @@ def _env_float(name: str, default: float) -> float:
|
||||
|
||||
|
||||
def normalize_options_margin_mode(raw: Any = None) -> str:
|
||||
"""返回 usdc | coin;未配置时默认币本位."""
|
||||
if raw is None:
|
||||
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
|
||||
v = str(raw or MODE_COIN).strip().lower()
|
||||
if v in ("usdc", "usdc_margin", "usd_margin", "u本位", "u"):
|
||||
return MODE_USDC
|
||||
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
|
||||
return MODE_COIN
|
||||
# 空串或未知值:默认币本位
|
||||
if not v:
|
||||
return MODE_COIN
|
||||
"""币本位专用机:始终 coin;历史 usdc 配置读取时亦归一为 coin."""
|
||||
del raw
|
||||
return MODE_COIN
|
||||
|
||||
|
||||
def is_coin_margin_mode(raw: Any = None) -> bool:
|
||||
return normalize_options_margin_mode(raw) == MODE_COIN
|
||||
del raw
|
||||
return True
|
||||
|
||||
|
||||
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
|
||||
"""仅币本位期权族 {U}-USD;USD_UM 已下线."""
|
||||
del margin_mode
|
||||
u = (underlying or "ETH").strip().upper() or "ETH"
|
||||
mode = normalize_options_margin_mode(margin_mode)
|
||||
if mode == MODE_COIN:
|
||||
return f"{u}-USD"
|
||||
return f"{u}-USD_UM"
|
||||
return f"{u}-USD"
|
||||
|
||||
|
||||
def margin_mode_from_inst_id(inst_id: str) -> str:
|
||||
@@ -63,9 +54,10 @@ def margin_mode_from_inst_id(inst_id: str) -> str:
|
||||
|
||||
|
||||
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
|
||||
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
|
||||
return (underlying or "ETH").strip().upper() or "ETH"
|
||||
return "USDC"
|
||||
"""币本位权利金为标的币;历史 USDC 行(margin_mode=usdc)仍标 USDC."""
|
||||
if str(margin_mode or "").strip().lower() == MODE_USDC:
|
||||
return "USDC"
|
||||
return (underlying or "ETH").strip().upper() or "ETH"
|
||||
|
||||
|
||||
def spot_quote_inst_id(underlying: str) -> str:
|
||||
@@ -90,9 +82,9 @@ def compute_coin_budget_usdt(
|
||||
"""
|
||||
bal = max(0.0, float(trading_usdt or 0))
|
||||
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
|
||||
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
|
||||
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 1.0)
|
||||
if buf <= 0:
|
||||
buf = 0.95
|
||||
buf = 1.0
|
||||
fixed = (
|
||||
float(fixed_budget_usdt)
|
||||
if fixed_budget_usdt is not None
|
||||
@@ -137,18 +129,18 @@ def compute_coin_budget_usdt(
|
||||
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
|
||||
"""
|
||||
现货买入相对权利金的倍数缓冲.
|
||||
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
|
||||
也可写 0.10 表示 +10%.
|
||||
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.05(=多买 5%).
|
||||
也可写 0.05 表示 +5%.
|
||||
"""
|
||||
if raw is None:
|
||||
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
|
||||
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.05)
|
||||
else:
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
v = 1.10
|
||||
v = 1.05
|
||||
if v <= 0:
|
||||
return 1.10
|
||||
return 1.05
|
||||
if v < 1.0:
|
||||
return 1.0 + v
|
||||
return v
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
|
||||
|
||||
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
|
||||
from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
@@ -133,12 +129,16 @@ def run_options_profit_alerts(
|
||||
premium_ccy=str(bucket.get("premium_ccy") or pos.get("premium_ccy") or ""),
|
||||
)
|
||||
try:
|
||||
# 推送前先提交,避免握锁等 webhook
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
send_wechat(msg)
|
||||
conn.execute(
|
||||
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
|
||||
tuple(bucket["ids"]),
|
||||
)
|
||||
# 立即提交,避免后续目标/翻倍/同步异常回滚后每轮重推
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
@@ -151,18 +151,7 @@ def run_options_profit_alerts(
|
||||
|
||||
def _created_at_ms(created_at: Any) -> int | None:
|
||||
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
|
||||
if not created_at:
|
||||
return None
|
||||
raw = str(created_at).strip()
|
||||
if not raw:
|
||||
return None
|
||||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
|
||||
try:
|
||||
dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
return options_wall_clock_to_ms(created_at)
|
||||
|
||||
|
||||
def _group_key_for_closed_trade(row: Any) -> str:
|
||||
@@ -198,7 +187,8 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote,
|
||||
created_at, closed_at, close_ord_id
|
||||
created_at, closed_at, close_ord_id, realized_pnl_usdt,
|
||||
open_fee, close_fee, premium_ccy, underlying
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
ORDER BY id DESC
|
||||
@@ -209,8 +199,16 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
from lib.options.options_pnl_settle_lib import (
|
||||
resolve_close_index_px,
|
||||
settle_exchange_realized_pnl,
|
||||
)
|
||||
|
||||
groups: dict[str, list[Any]] = {}
|
||||
for row in rows:
|
||||
# 已冻结 U 的本地结算不再被交易所历史覆盖
|
||||
if _safe_float(row["realized_pnl_usdt"]) is not None:
|
||||
continue
|
||||
inst = str(row["inst_id"] or "").strip()
|
||||
if not inst or inst not in by_inst:
|
||||
continue
|
||||
@@ -244,6 +242,8 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
total_paid = 0.0
|
||||
for r in group:
|
||||
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
|
||||
underly = str(group[0]["underlying"] or "").strip()
|
||||
close_idx = resolve_close_index_px(None, inst_id=inst, underlying=underly)
|
||||
allocated = 0.0
|
||||
for i, r in enumerate(group):
|
||||
paid = float(_safe_float(r["premium_paid"]) or 0.0)
|
||||
@@ -265,17 +265,37 @@ def backfill_closed_options_realized_pnl_from_history(
|
||||
ord_ok = (not matched_pos) or (local_ord == matched_pos)
|
||||
if pnl_ok and quote_ok and ord_ok:
|
||||
continue
|
||||
prem_recv = round(paid + share, 4)
|
||||
# OKX realizedPnl 已含手续费,只冻结折 U,禁止再减 open_fee/close_fee
|
||||
prem_ccy = str(r["premium_ccy"] or "").strip() or "ETH"
|
||||
settled = settle_exchange_realized_pnl(
|
||||
realized_pnl=share,
|
||||
premium_ccy=prem_ccy,
|
||||
index_px=close_idx,
|
||||
)
|
||||
# 展示用收回≈已付+净盈亏+已记手续费(毛额近似,不参与再扣费)
|
||||
ofee = float(_safe_float(r["open_fee"]) or 0.0)
|
||||
cfee = float(_safe_float(r["close_fee"]) or 0.0)
|
||||
prem_recv = round(paid + share + ofee + cfee, 4)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET realized_pnl = ?,
|
||||
premium_received = ?,
|
||||
close_quote = COALESCE(?, close_quote),
|
||||
close_ord_id = COALESCE(?, close_ord_id)
|
||||
WHERE id = ?
|
||||
close_ord_id = COALESCE(?, close_ord_id),
|
||||
close_index_px = COALESCE(?, close_index_px),
|
||||
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt)
|
||||
WHERE id = ? AND realized_pnl_usdt IS NULL
|
||||
""",
|
||||
(share, prem_recv, close_quote, matched_pos, int(r["id"])),
|
||||
(
|
||||
settled.get("realized_pnl"),
|
||||
prem_recv,
|
||||
close_quote,
|
||||
matched_pos,
|
||||
settled.get("close_index_px"),
|
||||
settled.get("realized_pnl_usdt"),
|
||||
int(r["id"]),
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
@@ -294,13 +314,21 @@ def sync_open_options_trades(
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, premium_paid, exp_time, created_at
|
||||
SELECT id, inst_id, premium_paid, exp_time, created_at,
|
||||
open_fee, premium_ccy, underlying, exchange_ord_id,
|
||||
budget_usdt, open_index_px, fee_ccy
|
||||
FROM options_trades
|
||||
WHERE status = 'open'
|
||||
"""
|
||||
).fetchall()
|
||||
updated = 0
|
||||
closed_inst_ids: list[str] = []
|
||||
now_ms = int(time.time() * 1000)
|
||||
from lib.options.options_pnl_settle_lib import (
|
||||
resolve_close_index_px,
|
||||
settle_closed_pnl,
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
inst_id = str(row["inst_id"] or "")
|
||||
if not inst_id or inst_id in live_inst_ids:
|
||||
@@ -323,25 +351,60 @@ def sync_open_options_trades(
|
||||
close_quote = close_info.get("close_quote")
|
||||
realized_pnl = close_info.get("realized_pnl")
|
||||
close_ord_id = close_info.get("pos_id")
|
||||
if realized_pnl is not None:
|
||||
prem_recv = round(paid + float(realized_pnl), 4)
|
||||
close_ms = close_info.get("close_ms")
|
||||
if close_ms:
|
||||
closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
closed_at = ms_to_options_app_str(close_ms)
|
||||
elif exp_ms is not None and now_ms >= int(exp_ms):
|
||||
close_reason = "expired"
|
||||
close_quote = 0.0
|
||||
prem_recv = 0.0
|
||||
realized_pnl = round(-paid, 4)
|
||||
if exp_ms:
|
||||
closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
closed_at = ms_to_options_app_str(exp_ms)
|
||||
else:
|
||||
continue
|
||||
|
||||
if not closed_at:
|
||||
closed_at = options_app_now_str()
|
||||
|
||||
open_fee = float(_safe_float(row["open_fee"]) or 0.0)
|
||||
prem_ccy = str(row["premium_ccy"] or "").strip() or "ETH"
|
||||
underly = str(row["underlying"] or "").strip()
|
||||
close_idx = resolve_close_index_px(None, inst_id=inst_id, underlying=underly)
|
||||
budget = _safe_float(row["budget_usdt"])
|
||||
open_idx = _safe_float(row["open_index_px"])
|
||||
fee_ccy = str(row["fee_ccy"] or prem_ccy or "")
|
||||
if close_reason == "exchange" and realized_pnl is not None:
|
||||
# OKX realizedPnl 已含费 → 反推毛收回,再按开仓成本折 U(不再二次扣费)
|
||||
prem_recv = round(paid + float(realized_pnl) + open_fee, 4)
|
||||
settled = settle_closed_pnl(
|
||||
premium_paid=paid,
|
||||
premium_received=prem_recv,
|
||||
open_fee=open_fee,
|
||||
close_fee=0,
|
||||
premium_ccy=prem_ccy,
|
||||
index_px=close_idx,
|
||||
open_index_px=open_idx,
|
||||
budget_usdt=budget,
|
||||
fee_ccy=fee_ccy,
|
||||
)
|
||||
else:
|
||||
if prem_recv is None:
|
||||
prem_recv = 0.0
|
||||
settled = settle_closed_pnl(
|
||||
premium_paid=paid,
|
||||
premium_received=prem_recv,
|
||||
open_fee=open_fee,
|
||||
close_fee=0,
|
||||
premium_ccy=prem_ccy,
|
||||
index_px=close_idx,
|
||||
open_index_px=open_idx,
|
||||
budget_usdt=budget,
|
||||
fee_ccy=fee_ccy,
|
||||
)
|
||||
realized_pnl = settled.get("realized_pnl")
|
||||
pnl_usdt = settled.get("realized_pnl_usdt")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
@@ -350,7 +413,11 @@ def sync_open_options_trades(
|
||||
premium_received = ?,
|
||||
realized_pnl = ?,
|
||||
close_ord_id = COALESCE(?, close_ord_id),
|
||||
closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP),
|
||||
closed_at = COALESCE(?, closed_at),
|
||||
close_index_px = COALESCE(?, close_index_px),
|
||||
open_index_px = COALESCE(open_index_px, ?),
|
||||
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt),
|
||||
fee_ccy = COALESCE(fee_ccy, ?),
|
||||
signal_note = CASE
|
||||
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
|
||||
THEN '到期结算'
|
||||
@@ -364,11 +431,16 @@ def sync_open_options_trades(
|
||||
realized_pnl,
|
||||
close_ord_id,
|
||||
closed_at,
|
||||
settled.get("close_index_px"),
|
||||
settled.get("open_index_px"),
|
||||
pnl_usdt,
|
||||
prem_ccy,
|
||||
close_reason,
|
||||
int(row["id"]),
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
closed_inst_ids.append(inst_id)
|
||||
if notify_cfg is not None:
|
||||
try:
|
||||
from lib.options.options_notify_lib import notify_options_close
|
||||
@@ -387,9 +459,80 @@ def sync_open_options_trades(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if notify_cfg is not None:
|
||||
_notify_recent_unsent_expiry_closes(conn, notify_cfg)
|
||||
# 卖币不在此处做:调用方须先 commit/close 本连接,再调 sell_spot_after_options_sync,
|
||||
# 否则监控线程握锁时中控快照会超时,表现为全部离线。
|
||||
return updated
|
||||
|
||||
|
||||
def sell_spot_after_options_sync(
|
||||
cfg: dict[str, Any] | None,
|
||||
closed_inst_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
"""在释放业务 DB 连接后调用:同步关仓/到期后的币本位卖回."""
|
||||
if not isinstance(cfg, dict):
|
||||
return
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is None:
|
||||
return
|
||||
try:
|
||||
from lib.options.options_coin_open_lib import (
|
||||
maybe_sell_spot_after_close,
|
||||
sweep_spot_after_options_flat,
|
||||
)
|
||||
|
||||
seen: set[str] = set()
|
||||
for inst_id in closed_inst_ids or []:
|
||||
iid = str(inst_id or "").strip()
|
||||
if not iid or iid in seen:
|
||||
continue
|
||||
seen.add(iid)
|
||||
try:
|
||||
maybe_sell_spot_after_close(cfg, ex, inst_id=iid)
|
||||
except Exception:
|
||||
pass
|
||||
sweep_spot_after_options_flat(cfg, ex)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _notify_recent_unsent_expiry_closes(
|
||||
conn: sqlite3.Connection,
|
||||
notify_cfg: dict[str, Any],
|
||||
*,
|
||||
within_hours: float = 48.0,
|
||||
) -> None:
|
||||
"""页面同步曾漏推时,补发近期到期结算."""
|
||||
try:
|
||||
from lib.options.options_notify_lib import notify_options_close_trade_ids
|
||||
from lib.options.options_time_lib import options_wall_clock_to_ms
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
by_inst: dict[str, list[int]] = {}
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, closed_at
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
AND COALESCE(wechat_close_sent, 0) = 0
|
||||
AND COALESCE(signal_note, '') = '到期结算'
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
closed_ms = options_wall_clock_to_ms(r["closed_at"])
|
||||
if closed_ms is None or now_ms - int(closed_ms) > float(within_hours) * 3600 * 1000:
|
||||
continue
|
||||
inst = str(r["inst_id"] or "")
|
||||
by_inst.setdefault(inst, []).append(int(r["id"]))
|
||||
for ids in by_inst.values():
|
||||
notify_options_close_trade_ids(notify_cfg, conn, ids, reason="到期结算")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def reconcile_live_open_trades(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
@@ -456,6 +599,7 @@ def options_monitor_loop(
|
||||
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,
|
||||
cond_order_tick_fn: Callable[[sqlite3.Connection], None] | None = None,
|
||||
stop_event: Any = None,
|
||||
) -> None:
|
||||
if not enabled:
|
||||
@@ -464,9 +608,25 @@ def options_monitor_loop(
|
||||
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
|
||||
break
|
||||
try:
|
||||
conn = get_db()
|
||||
# 持仓拉取不握 DB,避免与模拟盘钱包写互相锁
|
||||
try:
|
||||
positions = fetch_positions()
|
||||
except Exception:
|
||||
positions = []
|
||||
|
||||
pe_cfg = dict(profit_exit_cfg or {})
|
||||
pe_cfg.setdefault("send_wechat", send_wechat)
|
||||
pe_cfg.setdefault("account_label", account_label)
|
||||
|
||||
# 条件单自管连接(开仓前会释放)
|
||||
if cond_order_tick_fn is not None:
|
||||
try:
|
||||
cond_order_tick_fn(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
run_options_profit_alerts(
|
||||
conn,
|
||||
positions,
|
||||
@@ -475,25 +635,37 @@ def options_monitor_loop(
|
||||
account_label=account_label,
|
||||
ticker_bid_fn=ticker_bid_fn,
|
||||
)
|
||||
pe_cfg = dict(profit_exit_cfg or {})
|
||||
pe_cfg.setdefault("send_wechat", send_wechat)
|
||||
pe_cfg.setdefault("account_label", account_label)
|
||||
if target_close_fn is not None:
|
||||
from lib.options.options_target_lib import run_options_target_closes
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 目标/翻倍出场:传 get_db,平仓时不握业务连接
|
||||
if target_close_fn is not None:
|
||||
from lib.options.options_target_lib import run_options_target_closes
|
||||
|
||||
try:
|
||||
run_options_target_closes(
|
||||
conn,
|
||||
get_db,
|
||||
positions,
|
||||
close_fn=target_close_fn,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
cfg=pe_cfg,
|
||||
)
|
||||
if profit_exit_close_fn is not None:
|
||||
from lib.options.options_profit_exit_lib import run_options_profit_exits
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if profit_exit_close_fn is not None:
|
||||
from lib.options.options_profit_exit_lib import run_options_profit_exits
|
||||
|
||||
try:
|
||||
run_options_profit_exits(
|
||||
conn,
|
||||
get_db,
|
||||
positions,
|
||||
close_fn=profit_exit_close_fn,
|
||||
send_wechat=send_wechat,
|
||||
@@ -501,12 +673,18 @@ def options_monitor_loop(
|
||||
cfg=pe_cfg,
|
||||
ex=pe_cfg.get("exchange_options"),
|
||||
)
|
||||
if sync_trades_fn is not None:
|
||||
sync_trades_fn(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
# 平仓限价挂单超时撤单(独立于 DB 事务)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if sync_trades_fn is not None:
|
||||
try:
|
||||
sync_trades_fn(None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sell_spot_after_options_sync(pe_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
if stale_pending_fn is not None:
|
||||
try:
|
||||
stale_pending_fn()
|
||||
|
||||
@@ -58,6 +58,38 @@ def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
|
||||
pass
|
||||
|
||||
|
||||
_BOUND_NOTIFY_CFG: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def bind_options_notify_cfg(cfg: dict[str, Any] | None) -> None:
|
||||
global _BOUND_NOTIFY_CFG
|
||||
_BOUND_NOTIFY_CFG = cfg
|
||||
|
||||
|
||||
def active_options_notify_cfg(cfg: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if cfg is not None and callable(cfg.get("send_wechat")):
|
||||
return cfg
|
||||
if _BOUND_NOTIFY_CFG is not None and callable(_BOUND_NOTIFY_CFG.get("send_wechat")):
|
||||
return _BOUND_NOTIFY_CFG
|
||||
import os
|
||||
|
||||
from lib.common.wechat_notify_lib import send_wechat_webhook
|
||||
|
||||
webhook = (os.getenv("WECHAT_WEBHOOK") or "").strip()
|
||||
try:
|
||||
timeout = int(float(os.getenv("WECHAT_TIMEOUT_SECONDS") or "10"))
|
||||
except (TypeError, ValueError):
|
||||
timeout = 10
|
||||
label = (
|
||||
os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or os.getenv("OKX_ACCOUNT_LABEL") or "OKX期权"
|
||||
).strip()
|
||||
|
||||
def send(content: str) -> None:
|
||||
send_wechat_webhook(webhook, content, timeout=timeout)
|
||||
|
||||
return {"send_wechat": send, "account_label": label}
|
||||
|
||||
|
||||
def notify_options_send(cfg: dict[str, Any], content: str) -> bool:
|
||||
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
|
||||
if not callable(send):
|
||||
@@ -200,6 +232,12 @@ def notify_options_open(
|
||||
trade_id=trade_id,
|
||||
premium_ccy=ccy,
|
||||
)
|
||||
# 推送前先提交,避免握锁等 webhook 导致模拟盘 database is locked
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
ok = notify_options_send(cfg, msg)
|
||||
if ok and conn is not None and trade_id is not None:
|
||||
conn.execute(
|
||||
@@ -304,6 +342,12 @@ def notify_options_close(
|
||||
trade_id=head.get("id") if len(rows) == 1 else None,
|
||||
premium_ccy=ccy,
|
||||
)
|
||||
# 推送企业微信前先提交,避免握锁等待 webhook(可达数秒)导致卖币/快照 database is locked
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
ok = notify_options_send(cfg, msg)
|
||||
if ok and conn is not None:
|
||||
for r in pending or rows:
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
"""已实现盈亏结算:扣手续费,并在平仓时按指数冻结 U 口径."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
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 fee_amount_abs(fee: Any) -> float:
|
||||
"""OKX 扣费多为负;统一为正数「已付手续费」."""
|
||||
f = _safe_float(fee)
|
||||
if f is None:
|
||||
return 0.0
|
||||
return abs(float(f))
|
||||
|
||||
|
||||
def fee_to_premium_ccy(
|
||||
fee_amt: Any,
|
||||
fee_ccy: Any,
|
||||
*,
|
||||
premium_ccy: str,
|
||||
index_px: float | None,
|
||||
) -> float:
|
||||
"""把手续费折成权利金币种数量(与 premium_paid/received 同单位)."""
|
||||
amt = fee_amount_abs(fee_amt)
|
||||
if amt <= 0:
|
||||
return 0.0
|
||||
fc = str(fee_ccy or "").strip().upper()
|
||||
pc = str(premium_ccy or "").strip().upper()
|
||||
if not fc or not pc or fc == pc:
|
||||
return round(amt, 8)
|
||||
# 稳定币互兑近似 1:1
|
||||
stables = {"USDT", "USDC", "USD"}
|
||||
if fc in stables and pc in stables:
|
||||
return round(amt, 8)
|
||||
idx = _safe_float(index_px)
|
||||
if idx is None or idx <= 0:
|
||||
return 0.0
|
||||
if fc in stables and pc in ("ETH", "BTC"):
|
||||
return round(amt / float(idx), 8)
|
||||
if fc in ("ETH", "BTC") and pc in stables:
|
||||
return round(amt * float(idx), 8)
|
||||
if fc in ("ETH", "BTC") and pc in ("ETH", "BTC") and fc != pc:
|
||||
# 跨币种无可靠汇率时放弃折算,避免脏数据
|
||||
return 0.0
|
||||
return round(amt, 8)
|
||||
|
||||
|
||||
def extract_order_fee(order: dict[str, Any] | None) -> tuple[float, str]:
|
||||
"""从下单/查单结果取手续费(原始币种,正数)."""
|
||||
if not isinstance(order, dict):
|
||||
return 0.0, ""
|
||||
raw = order.get("raw") if isinstance(order.get("raw"), dict) else {}
|
||||
info = order.get("info") if isinstance(order.get("info"), dict) else {}
|
||||
fee = order.get("fee")
|
||||
if fee is None:
|
||||
fee = raw.get("fee")
|
||||
if fee is None:
|
||||
fee = info.get("fee")
|
||||
ccy = (
|
||||
order.get("fee_ccy")
|
||||
or raw.get("feeCcy")
|
||||
or raw.get("fee_ccy")
|
||||
or info.get("fee_ccy")
|
||||
or info.get("feeCcy")
|
||||
or ""
|
||||
)
|
||||
return fee_amount_abs(fee), str(ccy or "").strip().upper()
|
||||
|
||||
|
||||
def sum_option_order_fees_in_premium(
|
||||
ex: Any,
|
||||
*,
|
||||
inst_id: str,
|
||||
ord_ids: list[str] | str | None,
|
||||
premium_ccy: str,
|
||||
index_px: float | None,
|
||||
fetch_order_fn: Any = None,
|
||||
) -> float:
|
||||
"""按订单号汇总手续费并折成权利金单位."""
|
||||
if isinstance(ord_ids, str):
|
||||
ids = [p.strip() for p in ord_ids.split(",") if p.strip()]
|
||||
else:
|
||||
ids = [str(x).strip() for x in (ord_ids or []) if str(x).strip()]
|
||||
if not ids:
|
||||
return 0.0
|
||||
if fetch_order_fn is None:
|
||||
from lib.exchange.okx_options_lib import fetch_option_order
|
||||
|
||||
fetch_order_fn = fetch_option_order
|
||||
total = 0.0
|
||||
seen: set[str] = set()
|
||||
for oid in ids:
|
||||
if oid in seen:
|
||||
continue
|
||||
seen.add(oid)
|
||||
try:
|
||||
info = fetch_order_fn(ex, inst_id=inst_id, ord_id=oid)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(info, dict) or not info.get("ok"):
|
||||
continue
|
||||
fee_amt, fee_ccy = extract_order_fee(info)
|
||||
total += fee_to_premium_ccy(
|
||||
fee_amt, fee_ccy, premium_ccy=premium_ccy, index_px=index_px
|
||||
)
|
||||
return round(total, 8)
|
||||
|
||||
|
||||
def freeze_pnl_to_usdt(
|
||||
net_pnl: float,
|
||||
*,
|
||||
premium_ccy: str,
|
||||
index_px: float | None,
|
||||
total_fee: float = 0.0,
|
||||
) -> dict[str, float | None]:
|
||||
"""把已是「净额」的权利金盈亏冻结为 U(不再扣费)."""
|
||||
net = round(float(net_pnl), 8)
|
||||
pc = str(premium_ccy or "").strip().upper()
|
||||
idx = _safe_float(index_px)
|
||||
fee_total = abs(float(total_fee or 0.0))
|
||||
if pc in ("ETH", "BTC"):
|
||||
if idx is None or idx <= 0:
|
||||
return {
|
||||
"realized_pnl": net,
|
||||
"close_index_px": None,
|
||||
"realized_pnl_usdt": None,
|
||||
"total_fee": round(fee_total, 8),
|
||||
}
|
||||
return {
|
||||
"realized_pnl": net,
|
||||
"close_index_px": float(idx),
|
||||
"realized_pnl_usdt": round(net * float(idx), 4),
|
||||
"total_fee": round(fee_total, 8),
|
||||
}
|
||||
return {
|
||||
"realized_pnl": net,
|
||||
"close_index_px": float(idx) if idx is not None and idx > 0 else None,
|
||||
"realized_pnl_usdt": round(net, 4),
|
||||
"total_fee": round(fee_total, 8),
|
||||
}
|
||||
|
||||
|
||||
def fee_to_usdt(
|
||||
fee_amt: Any,
|
||||
fee_ccy: Any,
|
||||
*,
|
||||
index_px: float | None,
|
||||
) -> float:
|
||||
"""手续费折 USDT."""
|
||||
amt = fee_amount_abs(fee_amt)
|
||||
if amt <= 0:
|
||||
return 0.0
|
||||
fc = str(fee_ccy or "").strip().upper()
|
||||
if fc in ("USDT", "USDC", "USD", ""):
|
||||
# 空币种时按稳定币近似,避免把小数额 ETH 费当成整 U
|
||||
if fc == "" and amt < 0.05:
|
||||
idx = _safe_float(index_px)
|
||||
if idx is not None and idx > 100:
|
||||
return round(amt * float(idx), 4)
|
||||
return round(amt, 4)
|
||||
if fc in ("ETH", "BTC"):
|
||||
idx = _safe_float(index_px)
|
||||
if idx is None or idx <= 0:
|
||||
return 0.0
|
||||
return round(amt * float(idx), 4)
|
||||
return round(amt, 4)
|
||||
|
||||
|
||||
def true_pnl_usdt_premium_move(
|
||||
*,
|
||||
coin_pnl: Any,
|
||||
premium_paid: Any = None,
|
||||
sell_back_px: Any = None,
|
||||
initial_premium_usdt: Any = None,
|
||||
open_index_px: Any = None,
|
||||
) -> float | None:
|
||||
"""复盘真盈亏(U)=期权历史币盈亏×平仓价.
|
||||
|
||||
平仓价优先卖回 VWAP,否则用冻结/传入的 close 价.
|
||||
premium_paid / initial_premium_usdt / open_index_px 仅兼容旧调用,不参与计算.
|
||||
"""
|
||||
del premium_paid, initial_premium_usdt, open_index_px
|
||||
pnl = _safe_float(coin_pnl)
|
||||
close_px = _safe_float(sell_back_px)
|
||||
if pnl is None or close_px is None or close_px <= 0:
|
||||
return None
|
||||
return round(float(pnl) * float(close_px), 4)
|
||||
|
||||
|
||||
def resolve_spot_vwap_prices(
|
||||
conn: Any = None,
|
||||
*,
|
||||
bridge_id: Any = None,
|
||||
open_index_px: Any = None,
|
||||
close_index_px: Any = None,
|
||||
) -> tuple[float | None, float | None, str]:
|
||||
"""开仓价/平仓价解析(期权盈亏折U用).
|
||||
|
||||
平仓价:桥上卖回 VWAP > 冻结 close_index_px > 开仓价.
|
||||
桥未关不再阻断折U(现货卖回与期权盈亏脱钩).
|
||||
|
||||
返回 (open_px, close_px, gate):
|
||||
gate='' 可算; 'missing' 缺价
|
||||
"""
|
||||
open_px = _safe_float(open_index_px)
|
||||
close_hint = _safe_float(close_index_px)
|
||||
close_px = close_hint
|
||||
bid = None
|
||||
try:
|
||||
if bridge_id is not None and str(bridge_id).strip() != "":
|
||||
bid = int(bridge_id)
|
||||
except (TypeError, ValueError):
|
||||
bid = None
|
||||
if conn is not None and bid is not None:
|
||||
try:
|
||||
from lib.options.options_spot_bridge_lib import (
|
||||
bridge_buy_vwap,
|
||||
bridge_sell_vwap,
|
||||
ensure_bridge_table,
|
||||
)
|
||||
|
||||
ensure_bridge_table(conn)
|
||||
if open_px is None or open_px <= 0:
|
||||
bv = bridge_buy_vwap(conn, bid)
|
||||
if bv is not None and bv > 0:
|
||||
open_px = bv
|
||||
sv = bridge_sell_vwap(conn, bid)
|
||||
if sv is not None and sv > 0:
|
||||
close_px = sv
|
||||
except Exception:
|
||||
pass
|
||||
if close_px is None or close_px <= 0:
|
||||
close_px = open_px
|
||||
if close_px is None or close_px <= 0:
|
||||
return open_px, close_px, "missing"
|
||||
return open_px, close_px, ""
|
||||
|
||||
|
||||
def resolve_true_pnl_usdt_from_trade(
|
||||
*,
|
||||
premium_paid: Any = None,
|
||||
premium_received: Any = None,
|
||||
realized_pnl: Any = None,
|
||||
open_index_px: Any = None,
|
||||
close_index_px: Any = None,
|
||||
initial_premium_usdt: Any = None,
|
||||
open_fee: Any = 0,
|
||||
close_fee: Any = 0,
|
||||
premium_ccy: str | None = None,
|
||||
fee_ccy: str | None = None,
|
||||
conn: Any = None,
|
||||
bridge_id: Any = None,
|
||||
) -> tuple[float | None, str]:
|
||||
"""由成交字段算复盘真盈亏: 期权币盈亏×平仓价.
|
||||
|
||||
返回 (pnl_u, source): premium_move | stable | none
|
||||
"""
|
||||
del fee_ccy, initial_premium_usdt
|
||||
pc = str(premium_ccy or "").strip().upper()
|
||||
paid = _safe_float(premium_paid)
|
||||
recv = _safe_float(premium_received)
|
||||
ofee = abs(float(_safe_float(open_fee) or 0.0))
|
||||
cfee = abs(float(_safe_float(close_fee) or 0.0))
|
||||
# 期权历史币盈亏已含开/平手续费;真盈亏不再另扣费
|
||||
coin = _safe_float(realized_pnl)
|
||||
if coin is None and paid is not None and recv is not None:
|
||||
coin = round(float(recv) - float(paid) - ofee - cfee, 8)
|
||||
|
||||
if pc not in ("ETH", "BTC"):
|
||||
if coin is not None:
|
||||
return round(float(coin), 4), "stable"
|
||||
return None, "none"
|
||||
|
||||
_open_px, close_px, gate = resolve_spot_vwap_prices(
|
||||
conn,
|
||||
bridge_id=bridge_id,
|
||||
open_index_px=open_index_px,
|
||||
close_index_px=close_index_px,
|
||||
)
|
||||
if gate == "missing":
|
||||
return None, "none"
|
||||
usdt = true_pnl_usdt_premium_move(
|
||||
coin_pnl=coin,
|
||||
sell_back_px=close_px,
|
||||
)
|
||||
if usdt is not None:
|
||||
return usdt, "premium_move"
|
||||
return None, "none"
|
||||
|
||||
|
||||
def settle_sim_coin_pnl_usdt(
|
||||
*,
|
||||
premium_paid: Any,
|
||||
premium_received: Any,
|
||||
open_fee: Any = 0,
|
||||
close_fee: Any = 0,
|
||||
open_index_px: float | None,
|
||||
sell_back_px: float | None,
|
||||
fee_ccy: str | None = None,
|
||||
premium_ccy: str = "ETH",
|
||||
) -> dict[str, float | None]:
|
||||
"""由毛权利金生成净币盈亏,再按期权盈亏折 U(费只扣进币盈亏一次).
|
||||
|
||||
币盈亏 = 收回 − 已付 − 开/平手续费
|
||||
真盈亏U = 币盈亏 × 平仓价
|
||||
"""
|
||||
del fee_ccy
|
||||
paid = float(_safe_float(premium_paid) or 0.0)
|
||||
recv = float(_safe_float(premium_received) or 0.0)
|
||||
ofee = abs(float(_safe_float(open_fee) or 0.0))
|
||||
cfee = abs(float(_safe_float(close_fee) or 0.0))
|
||||
open_idx = _safe_float(open_index_px)
|
||||
sell_px = _safe_float(sell_back_px)
|
||||
net_coin = round(recv - paid - ofee - cfee, 8)
|
||||
fee_u = None
|
||||
if open_idx is not None and open_idx > 0 and sell_px is not None and sell_px > 0:
|
||||
fee_u = round(ofee * float(open_idx) + cfee * float(sell_px), 4)
|
||||
elif sell_px is not None and sell_px > 0 and (ofee > 0 or cfee > 0):
|
||||
fee_u = round((ofee + cfee) * float(sell_px), 4)
|
||||
if sell_px is None or sell_px <= 0:
|
||||
return {
|
||||
"realized_pnl": net_coin,
|
||||
"close_index_px": None,
|
||||
"open_index_px": float(open_idx) if open_idx is not None and open_idx > 0 else None,
|
||||
"realized_pnl_usdt": None,
|
||||
"total_fee": round(ofee + cfee, 8),
|
||||
"initial_premium_usdt": (
|
||||
round(float(paid) * float(open_idx), 4)
|
||||
if open_idx is not None and open_idx > 0 and paid > 0
|
||||
else None
|
||||
),
|
||||
"proceeds_usdt": None,
|
||||
"fee_usdt": fee_u,
|
||||
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
|
||||
}
|
||||
initial_u = (
|
||||
float(paid) * float(open_idx)
|
||||
if open_idx is not None and open_idx > 0
|
||||
else None
|
||||
)
|
||||
proceeds_u = float(recv) * float(sell_px)
|
||||
pnl_u = true_pnl_usdt_premium_move(
|
||||
coin_pnl=net_coin,
|
||||
sell_back_px=sell_px,
|
||||
)
|
||||
return {
|
||||
"realized_pnl": net_coin,
|
||||
"close_index_px": float(sell_px),
|
||||
"open_index_px": float(open_idx) if open_idx is not None and open_idx > 0 else None,
|
||||
"realized_pnl_usdt": pnl_u,
|
||||
"total_fee": round(ofee + cfee, 8),
|
||||
"initial_premium_usdt": round(initial_u, 4) if initial_u is not None else None,
|
||||
"proceeds_usdt": round(proceeds_u, 4),
|
||||
"fee_usdt": fee_u,
|
||||
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
|
||||
}
|
||||
|
||||
|
||||
def settle_closed_pnl(
|
||||
*,
|
||||
premium_paid: Any,
|
||||
premium_received: Any,
|
||||
open_fee: Any = 0,
|
||||
close_fee: Any = 0,
|
||||
premium_ccy: str,
|
||||
index_px: float | None,
|
||||
open_index_px: float | None = None,
|
||||
budget_usdt: float | None = None,
|
||||
fee_ccy: str | None = None,
|
||||
) -> dict[str, float | None]:
|
||||
"""
|
||||
由「毛权利金」结算.
|
||||
币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次).
|
||||
|
||||
币本位 U: 净币盈亏×平仓价(币量不含现货多余仓)
|
||||
USDC: 净额原样为 U.
|
||||
|
||||
注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲.
|
||||
不要对 OKX realizedPnl 再调此函数扣费.
|
||||
"""
|
||||
del budget_usdt # 买币预算不是权利金成本
|
||||
paid = float(_safe_float(premium_paid) or 0.0)
|
||||
recv = float(_safe_float(premium_received) or 0.0)
|
||||
ofee = float(_safe_float(open_fee) or 0.0)
|
||||
cfee = float(_safe_float(close_fee) or 0.0)
|
||||
if ofee < 0:
|
||||
ofee = abs(ofee)
|
||||
if cfee < 0:
|
||||
cfee = abs(cfee)
|
||||
net = round(recv - paid - ofee - cfee, 8)
|
||||
pc = str(premium_ccy or "").strip().upper()
|
||||
if pc in ("ETH", "BTC"):
|
||||
open_idx = _safe_float(open_index_px)
|
||||
sell_px = _safe_float(index_px)
|
||||
if sell_px is not None and sell_px > 0:
|
||||
return settle_sim_coin_pnl_usdt(
|
||||
premium_paid=paid,
|
||||
premium_received=recv,
|
||||
open_fee=ofee,
|
||||
close_fee=cfee,
|
||||
open_index_px=open_idx,
|
||||
sell_back_px=sell_px,
|
||||
fee_ccy=fee_ccy,
|
||||
premium_ccy=pc,
|
||||
)
|
||||
return freeze_pnl_to_usdt(
|
||||
net,
|
||||
premium_ccy=pc,
|
||||
index_px=index_px,
|
||||
total_fee=ofee + cfee,
|
||||
)
|
||||
out = freeze_pnl_to_usdt(
|
||||
net,
|
||||
premium_ccy=premium_ccy,
|
||||
index_px=index_px,
|
||||
total_fee=ofee + cfee,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def settle_exchange_realized_pnl(
|
||||
*,
|
||||
realized_pnl: Any,
|
||||
premium_ccy: str,
|
||||
index_px: float | None,
|
||||
fee_hint: Any = None,
|
||||
) -> dict[str, float | None]:
|
||||
"""
|
||||
冻结交易所 positions-history 的 realizedPnl.
|
||||
该字段已含手续费,此处只折 U,不再减 fee.
|
||||
"""
|
||||
net = _safe_float(realized_pnl)
|
||||
if net is None:
|
||||
return {
|
||||
"realized_pnl": None,
|
||||
"close_index_px": None,
|
||||
"realized_pnl_usdt": None,
|
||||
"total_fee": fee_amount_abs(fee_hint),
|
||||
}
|
||||
return freeze_pnl_to_usdt(
|
||||
float(net),
|
||||
premium_ccy=premium_ccy,
|
||||
index_px=index_px,
|
||||
total_fee=fee_amount_abs(fee_hint),
|
||||
)
|
||||
|
||||
|
||||
def resolve_close_index_px(
|
||||
ex: Any,
|
||||
*,
|
||||
inst_id: str = "",
|
||||
underlying: str = "",
|
||||
hint: Any = None,
|
||||
) -> float | None:
|
||||
"""平仓冻结用指数:优先调用方传入,否则拉标的近似价."""
|
||||
h = _safe_float(hint)
|
||||
if h is not None and h > 0:
|
||||
return float(h)
|
||||
underly = (underlying or "").strip().upper()
|
||||
if not underly and inst_id:
|
||||
underly = str(inst_id).split("-")[0].strip().upper() or "ETH"
|
||||
if not underly:
|
||||
underly = "ETH"
|
||||
try:
|
||||
from lib.options.options_stats_lib import _underlying_index_usdt
|
||||
|
||||
return _underlying_index_usdt(ex, underly)
|
||||
except Exception:
|
||||
return None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user