Compare commits
112 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 |
+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
|
||||
@@ -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()}"
|
||||
)
|
||||
@@ -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>" +
|
||||
@@ -2243,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) {
|
||||
@@ -2287,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();
|
||||
@@ -2412,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) {
|
||||
@@ -2757,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
-1484
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
+76
-291
@@ -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.options.okx_trade_mode_lib import normalize_okx_trade_mode
|
||||
|
||||
return normalize_okx_trade_mode(file_val) or file_val
|
||||
try:
|
||||
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.options.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.options.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
|
||||
@@ -710,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")),
|
||||
@@ -794,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,
|
||||
@@ -814,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
|
||||
@@ -842,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:
|
||||
@@ -867,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,
|
||||
@@ -879,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,
|
||||
@@ -899,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),
|
||||
@@ -1147,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),
|
||||
@@ -1157,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:
|
||||
@@ -1502,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",
|
||||
@@ -1806,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,
|
||||
@@ -1831,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
|
||||
|
||||
@@ -1891,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
|
||||
@@ -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()
|
||||
@@ -237,12 +237,8 @@ def _table_exists(conn, name: str) -> bool:
|
||||
|
||||
|
||||
def collect_orders(conn) -> list[dict[str, Any]]:
|
||||
if not _table_exists(conn, "order_monitors"):
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
return [_format_order_item(_row_dict(r)) for r in rows]
|
||||
del conn
|
||||
return []
|
||||
|
||||
|
||||
def collect_keys(conn) -> list[dict[str, Any]]:
|
||||
@@ -265,17 +261,8 @@ def collect_trends(conn) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def collect_rolls(conn) -> list[dict[str, Any]]:
|
||||
if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"):
|
||||
return []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT g.* FROM roll_groups g
|
||||
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
|
||||
WHERE g.status='active' ORDER BY g.id DESC"""
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
return [_format_roll_item(_row_dict(r)) for r in rows]
|
||||
del conn
|
||||
return []
|
||||
|
||||
|
||||
def collect_options_items(
|
||||
|
||||
@@ -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()
|
||||
@@ -10,22 +10,22 @@ 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_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_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_nav_hedge_plan": False,
|
||||
"show_settings_transfer": False, # 永续资金划转默认隐藏
|
||||
"show_settings_transfer": False, # 永续资金划转已下线
|
||||
"show_settings_export": True,
|
||||
"show_settings_password": True,
|
||||
"show_settings_options_swap": True,
|
||||
"show_settings_options_swap": False, # USDC 币种兑换已下线
|
||||
"show_settings_options_transfer": True,
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ DISPLAY_LABELS: dict[str, str] = {
|
||||
"show_nav_strategy": "策略交易",
|
||||
"show_nav_strategy_records": "策略交易记录",
|
||||
"show_nav_records": "交易记录与复盘",
|
||||
"show_nav_stats": "统计分析",
|
||||
"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_nav_hedge_plan": "对冲计划",
|
||||
"show_settings_transfer": "永续资金划转",
|
||||
"show_settings_export": "数据导出",
|
||||
"show_settings_password": "账户密码修改",
|
||||
@@ -61,11 +61,11 @@ NAV_TAB_ALLOWED: dict[str, str] = {
|
||||
"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",
|
||||
"hedge_plan": "show_nav_hedge_plan",
|
||||
}
|
||||
|
||||
|
||||
@@ -111,8 +111,8 @@ def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
|
||||
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 # 实盘下单 / 交易记录复盘 / 统计分析 已移除
|
||||
if t in ("trade", "records", "hedge_plan"):
|
||||
return False # 永续下单/记录已下线;关键位监控保留
|
||||
key = NAV_TAB_ALLOWED.get(t)
|
||||
if not key:
|
||||
return True
|
||||
@@ -124,18 +124,17 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||
"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",
|
||||
"show_nav_hedge_plan",
|
||||
]
|
||||
settings_keys = [
|
||||
"show_settings_transfer",
|
||||
"show_settings_export",
|
||||
"show_settings_password",
|
||||
"show_settings_options_swap",
|
||||
"show_settings_options_transfer",
|
||||
]
|
||||
return [
|
||||
|
||||
@@ -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,6 +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",
|
||||
"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,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,
|
||||
)
|
||||
|
||||
@@ -307,7 +309,9 @@ def close_option_by_bid1(
|
||||
px = float(order.get("px", locked_bid_px))
|
||||
oid = str((order.get("data") or {}).get("ordId") or "")
|
||||
# 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计
|
||||
prem_recv_submit = 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)
|
||||
@@ -331,7 +335,9 @@ def close_option_by_bid1(
|
||||
if fully_closed and filled_sheets < 1:
|
||||
filled_sheets = level_sheets
|
||||
batch_recv = (
|
||||
round(total_premium(px, filled_sheets * ct_mult), 4) if filled_sheets > 0 else 0.0
|
||||
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
|
||||
@@ -360,7 +366,9 @@ def close_option_by_bid1(
|
||||
ord_id=oid or None,
|
||||
)
|
||||
accum = get_open_close_accum(conn, inst_id)
|
||||
total_recv = round(float(accum.get("recv") or 0), 4)
|
||||
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:
|
||||
@@ -369,35 +377,163 @@ def close_option_by_bid1(
|
||||
|
||||
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(total_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(total_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(total_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)
|
||||
note_sql = ""
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
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
|
||||
|
||||
# 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id
|
||||
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 = ""
|
||||
|
||||
# 占位: 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 = """,
|
||||
@@ -412,6 +548,9 @@ def close_option_by_bid1(
|
||||
UPDATE options_trades
|
||||
SET status = 'closed', close_quote = ?, premium_received = ?,
|
||||
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}
|
||||
|
||||
@@ -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,10 +605,11 @@ 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:
|
||||
@@ -405,14 +618,40 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
|
||||
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,
|
||||
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', ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
kwargs["inst_id"],
|
||||
@@ -429,6 +668,11 @@ 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,
|
||||
@@ -446,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,
|
||||
)
|
||||
@@ -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",
|
||||
@@ -109,6 +122,19 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
"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)
|
||||
@@ -120,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)
|
||||
|
||||
|
||||
@@ -142,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()
|
||||
|
||||
@@ -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,7 +23,10 @@ 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"]()
|
||||
@@ -83,6 +86,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
for p in positions:
|
||||
# 合计一律折 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
|
||||
@@ -129,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,
|
||||
@@ -148,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
|
||||
|
||||
@@ -129,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:
|
||||
@@ -183,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
|
||||
@@ -194,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
|
||||
@@ -229,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)
|
||||
@@ -250,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
|
||||
@@ -279,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:
|
||||
@@ -308,8 +351,6 @@ 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 = ms_to_options_app_str(close_ms)
|
||||
@@ -326,6 +367,44 @@ def sync_open_options_trades(
|
||||
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
|
||||
@@ -335,6 +414,10 @@ def sync_open_options_trades(
|
||||
realized_pnl = ?,
|
||||
close_ord_id = COALESCE(?, close_ord_id),
|
||||
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 '到期结算'
|
||||
@@ -348,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
|
||||
@@ -371,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,
|
||||
*,
|
||||
@@ -440,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:
|
||||
@@ -448,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,
|
||||
@@ -459,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,
|
||||
@@ -485,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
|
||||
@@ -6,12 +6,12 @@ from typing import Any, Optional, Sequence
|
||||
|
||||
|
||||
def options_max_active_positions() -> int:
|
||||
"""同时持有的期权合约笔数上限;0=不限制.热更读 env."""
|
||||
raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "0")
|
||||
"""同时持有的期权合约笔数上限;0=不限制.默认 1(一次一仓).热更读 env."""
|
||||
raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "1")
|
||||
try:
|
||||
v = int(float(str(raw).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return 1
|
||||
return max(0, v)
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ def forget_close_gate_for_inst(inst_id: str) -> None:
|
||||
|
||||
def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
|
||||
"""与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值."""
|
||||
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
|
||||
|
||||
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
|
||||
if preview.get("bid_invalid"):
|
||||
return None
|
||||
@@ -112,7 +114,9 @@ def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
|
||||
recv = _safe_float(preview.get("total_received"))
|
||||
paid = _safe_float(row.get("premium_paid"))
|
||||
if covered > 0 and recv is not None and paid is not None:
|
||||
return round(recv - paid, 4)
|
||||
ccy = str(row.get("premium_ccy") or "").strip().upper()
|
||||
decimals = PREMIUM_DECIMALS_COIN if ccy in ("ETH", "BTC") else None
|
||||
return round_premium_amount(recv - paid, ccy=ccy or None, decimals=decimals)
|
||||
return None
|
||||
|
||||
|
||||
@@ -132,9 +136,43 @@ def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
|
||||
pnl = display_pnl_from_option_row(row)
|
||||
if pnl is None:
|
||||
return None
|
||||
return _coin_amount_to_usdt(pnl, row)
|
||||
|
||||
|
||||
def premium_paid_usdt_from_option_row(row: dict[str, Any]) -> float | None:
|
||||
"""开仓权利金折 USDT.
|
||||
|
||||
用权利金币数量 × 开仓/标记指数;绝不用 budget_usdt(那是买币缓冲预算,不是权利金).
|
||||
"""
|
||||
entry = _safe_float(row.get("premium_entry_usdt"))
|
||||
if entry is not None:
|
||||
return round(float(entry), 4)
|
||||
paid = _safe_float(row.get("premium_paid"))
|
||||
if paid is None:
|
||||
return None
|
||||
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
|
||||
if ccy in ("ETH", "BTC"):
|
||||
idx = _safe_float(
|
||||
row.get("open_index_px")
|
||||
or row.get("idx_px")
|
||||
or row.get("idxPx")
|
||||
or row.get("index_px")
|
||||
)
|
||||
if idx is None or idx <= 0:
|
||||
return None
|
||||
return round(float(paid) * float(idx), 4)
|
||||
return _coin_amount_to_usdt(paid, row)
|
||||
|
||||
|
||||
def _coin_amount_to_usdt(amount: float, row: dict[str, Any]) -> float | None:
|
||||
"""币本位金额按指数折 U;USDC 原样."""
|
||||
try:
|
||||
n = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
|
||||
if not ccy or ccy == "USDC":
|
||||
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U
|
||||
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 数量当 U
|
||||
inst = str(row.get("inst_id") or row.get("instId") or "")
|
||||
mode = str(row.get("margin_mode") or "").strip().lower()
|
||||
if mode == "coin" or (
|
||||
@@ -146,10 +184,9 @@ def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
|
||||
if ccy in ("ETH", "BTC"):
|
||||
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
|
||||
if idx is None or idx <= 0:
|
||||
# 无指数时无法折算,跳过该仓以免把「币数量」当成 U
|
||||
return None
|
||||
return float(pnl) * float(idx)
|
||||
return float(pnl)
|
||||
return float(n) * float(idx)
|
||||
return float(n)
|
||||
|
||||
|
||||
def sum_options_net_pnl_usdc(
|
||||
@@ -185,25 +222,53 @@ def build_display_option_positions(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
raw_positions: list[dict[str, Any]],
|
||||
*,
|
||||
include_close_preview: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""与实例 /api/options/positions 相同 enrichment + close_preview."""
|
||||
"""与实例 /api/options/positions 相同 enrichment;可选 close_preview.
|
||||
|
||||
DB 读写与交易所拉盘口分离,避免握库期间网络导致 database is locked / 中控超时。
|
||||
"""
|
||||
from lib.options.options_db import init_options_tables, sum_open_budget_usdt, sum_open_premium_paid
|
||||
|
||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
prem_by_inst: dict[str, float | None] = {}
|
||||
budget_by_inst: dict[str, float | None] = {}
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
for p in raw_positions:
|
||||
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=_safe_float(row.get("premium_paid")))
|
||||
rows.append(row)
|
||||
if not inst:
|
||||
continue
|
||||
prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
|
||||
budget_by_inst[inst] = sum_open_budget_usdt(conn, inst)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for p in raw_positions:
|
||||
inst = str(p.get("instId") or "").strip()
|
||||
premium_override = prem_by_inst.get(inst)
|
||||
budget_usdt = budget_by_inst.get(inst)
|
||||
row = enrich_position_row_display(
|
||||
cfg,
|
||||
ex,
|
||||
p,
|
||||
meta_cache=meta_cache,
|
||||
premium_override=premium_override,
|
||||
)
|
||||
if budget_usdt is not None:
|
||||
row["budget_usdt"] = round(float(budget_usdt), 4)
|
||||
# 权利金入口 U:币数量×指数,不用买币预算
|
||||
prem_u = premium_paid_usdt_from_option_row(row)
|
||||
if prem_u is not None:
|
||||
row["premium_entry_usdt"] = round(float(prem_u), 4)
|
||||
if include_close_preview:
|
||||
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
@@ -4,6 +4,24 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
# 币本位权利金常为 1e-4~1e-5 量级(尤其 BTC);round(...,4) 会把净盈亏抹成 0
|
||||
PREMIUM_DECIMALS_COIN = 8
|
||||
PREMIUM_DECIMALS_FIAT = 4
|
||||
|
||||
|
||||
def round_premium_amount(v: float | None, *, ccy: str | None = None, decimals: int | None = None) -> float | None:
|
||||
"""权利金/回收/币盈亏入账精度:ETH/BTC 用 8 位,USDC 等用法币 4 位."""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if decimals is None:
|
||||
unit = (ccy or "").strip().upper()
|
||||
decimals = PREMIUM_DECIMALS_COIN if unit in ("ETH", "BTC") else PREMIUM_DECIMALS_FIAT
|
||||
return round(n, int(decimals))
|
||||
|
||||
|
||||
def ct_mult_from_meta(meta: dict[str, Any] | None) -> float:
|
||||
if not meta:
|
||||
@@ -298,7 +316,7 @@ def estimate_close_by_bids(
|
||||
"available_sheets": sz,
|
||||
"sheets": take,
|
||||
"eth_amount": eth_amount,
|
||||
"received": round(received, 4),
|
||||
"received": round_premium_amount(received, decimals=PREMIUM_DECIMALS_COIN),
|
||||
}
|
||||
)
|
||||
total_received += received
|
||||
@@ -310,15 +328,16 @@ def estimate_close_by_bids(
|
||||
estimated_pnl_ratio_pct = None
|
||||
if premium_paid is not None and covered > 0:
|
||||
paid = float(premium_paid)
|
||||
estimated_pnl = round(total_received - paid, 4)
|
||||
if paid > 0:
|
||||
# 必须用 8 位:BTC 权利金常约 1e-4,round(...,4) 会把盈亏抹成 0
|
||||
estimated_pnl = round_premium_amount(total_received - paid, decimals=PREMIUM_DECIMALS_COIN)
|
||||
if paid > 0 and estimated_pnl is not None:
|
||||
estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2)
|
||||
return {
|
||||
"levels": levels,
|
||||
"covered_sheets": covered,
|
||||
"uncovered_sheets": remaining,
|
||||
"total_received": round(total_received, 4),
|
||||
"avg_px": round(avg_px, 4) if avg_px is not None else None,
|
||||
"total_received": round_premium_amount(total_received, decimals=PREMIUM_DECIMALS_COIN),
|
||||
"avg_px": round(avg_px, 8) if avg_px is not None else None,
|
||||
"estimated_pnl": estimated_pnl,
|
||||
"estimated_pnl_ratio_pct": estimated_pnl_ratio_pct,
|
||||
"bid_invalid": False,
|
||||
@@ -368,7 +387,7 @@ def calc_order_size(
|
||||
ct_mult: float,
|
||||
min_sz: int,
|
||||
budget_usdc: float | None = None,
|
||||
budget_buffer: float = 0.95,
|
||||
budget_buffer: float = 1.0,
|
||||
eth_amount: float | None = None,
|
||||
sheets: int | None = None,
|
||||
budget_cap: float | None = None,
|
||||
|
||||
@@ -59,11 +59,14 @@ def profit_exit_hit(
|
||||
|
||||
|
||||
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
|
||||
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
|
||||
|
||||
prem = float(premium_paid or 0)
|
||||
m = float(mult or 0)
|
||||
if prem <= 0 or m <= 0:
|
||||
return None
|
||||
return round(prem * (1.0 + m), 4)
|
||||
# 币本位权利金很小,必须用 8 位,否则翻倍门槛会被 round4 抹歪
|
||||
return round_premium_amount(prem * (1.0 + m), decimals=PREMIUM_DECIMALS_COIN)
|
||||
|
||||
|
||||
def set_profit_exit(
|
||||
@@ -297,7 +300,7 @@ def _notify_profit_exit_close(
|
||||
|
||||
|
||||
def run_options_profit_exits(
|
||||
conn: sqlite3.Connection,
|
||||
conn_or_get_db: Any,
|
||||
positions: list[dict[str, Any]],
|
||||
*,
|
||||
close_fn: Callable[[str], dict[str, Any]],
|
||||
@@ -307,54 +310,77 @@ def run_options_profit_exits(
|
||||
cfg: dict[str, Any] | None = None,
|
||||
ex: Any = None,
|
||||
) -> int:
|
||||
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
|
||||
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数.
|
||||
|
||||
可传 get_db:拉盘口/平仓时不握业务连接。
|
||||
"""
|
||||
get_db = conn_or_get_db if callable(conn_or_get_db) else None
|
||||
conn = conn_or_get_db() if get_db else conn_or_get_db
|
||||
ensure_profit_exit_columns(conn)
|
||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||
rules = profit_exit_by_inst(conn)
|
||||
triggered = 0
|
||||
|
||||
closing_ids: list[str] = []
|
||||
scan_jobs: list[tuple[str, dict[str, Any], float, float]] = []
|
||||
for inst_id, info in list(rules.items()):
|
||||
if not inst_id:
|
||||
continue
|
||||
pos = pos_by_inst.get(inst_id)
|
||||
if not pos:
|
||||
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
|
||||
continue
|
||||
|
||||
state = str(info.get("profit_exit_state") or "active")
|
||||
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
|
||||
prem = sum_open_premium_paid(conn, inst_id)
|
||||
if prem is None or prem <= 0:
|
||||
continue
|
||||
|
||||
# done/idle 不再扫描(查询层已过滤;此处双保险)
|
||||
if state in ("done", "idle"):
|
||||
continue
|
||||
|
||||
if state == "closing":
|
||||
result = close_fn(inst_id)
|
||||
if result.get("already_flat") or _result_fully_done(result):
|
||||
_mark_state(conn, inst_id, "done")
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_enabled = 0
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(inst_id,),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_commit(conn)
|
||||
else:
|
||||
_mark_state(conn, inst_id, "closing")
|
||||
_commit(conn)
|
||||
closing_ids.append(inst_id)
|
||||
continue
|
||||
|
||||
if not info.get("profit_exit_enabled"):
|
||||
continue
|
||||
scan_jobs.append((inst_id, pos, float(prem), float(mult)))
|
||||
_commit(conn)
|
||||
if get_db is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = None
|
||||
|
||||
def _ensure_conn() -> sqlite3.Connection:
|
||||
nonlocal conn
|
||||
if conn is not None:
|
||||
return conn
|
||||
assert get_db is not None
|
||||
conn = get_db()
|
||||
ensure_profit_exit_columns(conn)
|
||||
return conn
|
||||
|
||||
for inst_id in closing_ids:
|
||||
result = close_fn(inst_id)
|
||||
c = _ensure_conn()
|
||||
if result.get("already_flat") or _result_fully_done(result):
|
||||
_mark_state(c, inst_id, "done")
|
||||
try:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_enabled = 0
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(inst_id,),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_commit(c)
|
||||
else:
|
||||
_mark_state(c, inst_id, "closing")
|
||||
_commit(c)
|
||||
|
||||
for inst_id, pos, prem, mult in scan_jobs:
|
||||
if recycle_fn is not None:
|
||||
recycle = recycle_fn(pos, prem)
|
||||
elif cfg is not None and ex is not None:
|
||||
@@ -367,10 +393,11 @@ def run_options_profit_exits(
|
||||
continue
|
||||
|
||||
result = close_fn(inst_id)
|
||||
c = _ensure_conn()
|
||||
if result.get("already_flat"):
|
||||
_mark_state(conn, inst_id, "done")
|
||||
_mark_state(c, inst_id, "done")
|
||||
try:
|
||||
conn.execute(
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_enabled = 0
|
||||
@@ -380,18 +407,18 @@ def run_options_profit_exits(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_commit(conn)
|
||||
_commit(c)
|
||||
continue
|
||||
if not result.get("ok"):
|
||||
_mark_state(conn, inst_id, "active")
|
||||
_commit(conn)
|
||||
_mark_state(c, inst_id, "active")
|
||||
_commit(c)
|
||||
continue
|
||||
|
||||
done = _result_fully_done(result)
|
||||
_mark_state(conn, inst_id, "done" if done else "closing")
|
||||
_mark_state(c, inst_id, "done" if done else "closing")
|
||||
if done:
|
||||
try:
|
||||
conn.execute(
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET profit_exit_enabled = 0
|
||||
@@ -401,8 +428,7 @@ def run_options_profit_exits(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 先落库再推送:同一笔只推一次;closing 重试不再进本分支
|
||||
_commit(conn)
|
||||
_commit(c)
|
||||
triggered += 1
|
||||
_notify_profit_exit_close(
|
||||
cfg,
|
||||
@@ -413,6 +439,11 @@ def run_options_profit_exits(
|
||||
premium_paid=prem,
|
||||
recycle=recycle,
|
||||
result=result,
|
||||
conn=conn,
|
||||
conn=c,
|
||||
)
|
||||
if get_db is not None and conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return triggered
|
||||
|
||||
+1579
-1997
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,7 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
|
||||
strategy_tag TEXT,
|
||||
direction_view TEXT,
|
||||
entry_logic TEXT,
|
||||
prev_entry_tf TEXT,
|
||||
exit_reason TEXT,
|
||||
followed_plan TEXT,
|
||||
mistake_tags TEXT,
|
||||
@@ -130,6 +131,11 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
|
||||
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
|
||||
_ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
|
||||
_ensure_column(conn, "options_review_trades", "profit_rr", "REAL")
|
||||
_ensure_column(conn, "options_review_trades", "initial_premium_usdt", "REAL")
|
||||
_ensure_column(conn, "options_review_trades", "profit_rate", "REAL")
|
||||
# 仅期权开/平手续费折 U(不含现货买币/卖回)
|
||||
_ensure_column(conn, "options_review_trades", "option_fee_usdt", "REAL")
|
||||
_ensure_column(conn, "options_review_entries", "prev_entry_tf", "TEXT")
|
||||
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||||
@@ -140,5 +146,13 @@ def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str)
|
||||
names.add(str(r["name"]))
|
||||
except (TypeError, KeyError, IndexError):
|
||||
names.add(str(r[1]))
|
||||
if col not in names:
|
||||
if col in names:
|
||||
return
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||||
except sqlite3.OperationalError as e:
|
||||
# 并发首启时多个请求同时 ADD,后者报 duplicate column — 可忽略
|
||||
msg = str(e).lower()
|
||||
if "duplicate column" in msg or "already exists" in msg:
|
||||
return
|
||||
raise
|
||||
|
||||
@@ -66,21 +66,28 @@ def _safe_float(v: Any) -> Optional[float]:
|
||||
|
||||
|
||||
def get_sync_state(conn: sqlite3.Connection, key: str) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM options_review_sync_state WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM options_review_sync_state WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
return str(row["value"]) if row and row["value"] is not None else None
|
||||
|
||||
|
||||
def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_review_sync_state(key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, value, _now_str()),
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_review_sync_state(key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, value, _now_str()),
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
# 列表/统计并发刷新时可能撞锁;标记失败不阻断读列表
|
||||
pass
|
||||
|
||||
|
||||
def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bool:
|
||||
@@ -139,6 +146,9 @@ def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) ->
|
||||
"close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")),
|
||||
"premium_paid": _safe_float(row.get("premium_paid")),
|
||||
"realized_pnl": pnl,
|
||||
"initial_premium_usdt": _safe_float(row.get("initial_premium_usdt")),
|
||||
"profit_rate": _safe_float(row.get("profit_rate")),
|
||||
"option_fee_usdt": _safe_float(row.get("option_fee_usdt")),
|
||||
}
|
||||
cols = list(fields.keys())
|
||||
if existing:
|
||||
@@ -270,6 +280,148 @@ def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]
|
||||
return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")}
|
||||
|
||||
|
||||
def _sum_sim_order_fees(conn: sqlite3.Connection, ord_ids: list[str]) -> float | None:
|
||||
"""从 sim_option_orders 汇总手续费(权利金币种,正数)."""
|
||||
ids = [str(x).strip() for x in (ord_ids or []) if str(x).strip()]
|
||||
if not ids:
|
||||
return None
|
||||
try:
|
||||
conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
total = 0.0
|
||||
n = 0
|
||||
for oid in ids:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT fee FROM sim_option_orders WHERE ord_id=?",
|
||||
(oid,),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
continue
|
||||
if not row:
|
||||
continue
|
||||
fee = _safe_float(row["fee"] if hasattr(row, "keys") else row[0])
|
||||
if fee is None:
|
||||
continue
|
||||
total += abs(float(fee))
|
||||
n += 1
|
||||
return round(total, 8) if n else None
|
||||
|
||||
|
||||
def _estimate_okx_option_fee_coin(
|
||||
*,
|
||||
underlying_coin: float | None,
|
||||
premium_coin: float | None,
|
||||
) -> float:
|
||||
"""按 OKX 币本位公式估算期权手续费(权利金币种)."""
|
||||
try:
|
||||
from lib.sim.pricing_lib import option_fee_coin
|
||||
|
||||
return float(
|
||||
option_fee_coin(
|
||||
underlying_coin=float(underlying_coin or 0.0),
|
||||
premium_coin=float(premium_coin or 0.0),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fee_looks_like_legacy_premium_rate(
|
||||
fee_coin: float,
|
||||
*,
|
||||
premium_coin: float | None,
|
||||
underlying_coin: float | None,
|
||||
) -> bool:
|
||||
"""旧模拟盘把手续费算成权利金×费率,约为正确值的 premium/underlying 倍."""
|
||||
if fee_coin <= 0:
|
||||
return True
|
||||
prem = abs(float(premium_coin or 0.0))
|
||||
uly = abs(float(underlying_coin or 0.0))
|
||||
if prem <= 0 or uly <= 0:
|
||||
return False
|
||||
# 正确量级 ~ uly * fee_rate; 错误量级 ~ prem * fee_rate
|
||||
# 若 fee << uly*1e-4 且接近 prem*5e-4,视为旧算法
|
||||
try:
|
||||
from lib.sim.pricing_lib import sim_fee_rate
|
||||
|
||||
fr = float(sim_fee_rate())
|
||||
except Exception:
|
||||
fr = 0.0005
|
||||
expect_okx = uly * fr
|
||||
expect_legacy = prem * fr
|
||||
if expect_okx <= 0:
|
||||
return False
|
||||
# 比 OKX 估算小一个数量级以上,且靠近旧公式
|
||||
if fee_coin < expect_okx * 0.25 and abs(fee_coin - expect_legacy) <= max(expect_legacy * 0.5, 1e-10):
|
||||
return True
|
||||
if fee_coin < expect_okx * 0.05:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_option_fees_coin(
|
||||
conn: sqlite3.Connection,
|
||||
row: Any,
|
||||
*,
|
||||
open_fee: float,
|
||||
close_fee: float,
|
||||
premium_ccy: str,
|
||||
) -> tuple[float, float]:
|
||||
"""解析期权开/平手续费(权利金币种);修正模拟盘旧算法偏小值."""
|
||||
ccy = (premium_ccy or "").strip().upper()
|
||||
paid = _safe_float(row["premium_paid"] if hasattr(row, "keys") else None)
|
||||
recv = _safe_float(row["premium_received"] if hasattr(row, "keys") else None)
|
||||
uly = _safe_float(row["eth_amount"] if hasattr(row, "keys") else None)
|
||||
if uly is None or uly <= 0:
|
||||
sheets = _safe_float(row["sheets"] if hasattr(row, "keys") else None) or 0.0
|
||||
# BTC 期权约 0.01,ETH 约 0.1
|
||||
ct = 0.01 if ccy == "BTC" else 0.1
|
||||
uly = float(sheets) * ct if sheets > 0 else None
|
||||
|
||||
open_oids = [
|
||||
p.strip()
|
||||
for p in str(row["exchange_ord_id"] if hasattr(row, "keys") else "").split(",")
|
||||
if p.strip()
|
||||
]
|
||||
close_oids = [
|
||||
p.strip()
|
||||
for p in str(row["close_ord_id"] if hasattr(row, "keys") else "").split(",")
|
||||
if p.strip()
|
||||
]
|
||||
|
||||
ofee = abs(float(open_fee or 0.0))
|
||||
cfee = abs(float(close_fee or 0.0))
|
||||
|
||||
# 优先用订单表(若订单本身仍是旧算法,下面会再纠正)
|
||||
sim_open = _sum_sim_order_fees(conn, open_oids)
|
||||
sim_close = _sum_sim_order_fees(conn, close_oids)
|
||||
if sim_open is not None and sim_open > 0:
|
||||
ofee = sim_open
|
||||
if sim_close is not None and sim_close > 0:
|
||||
cfee = sim_close
|
||||
|
||||
if ccy in ("ETH", "BTC"):
|
||||
if _fee_looks_like_legacy_premium_rate(ofee, premium_coin=paid, underlying_coin=uly):
|
||||
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=paid)
|
||||
if est > 0:
|
||||
ofee = est
|
||||
if _fee_looks_like_legacy_premium_rate(cfee, premium_coin=recv, underlying_coin=uly):
|
||||
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=recv)
|
||||
if est > 0:
|
||||
cfee = est
|
||||
elif cfee <= 0 and recv is not None and recv > 0:
|
||||
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=recv)
|
||||
if est > 0:
|
||||
cfee = est
|
||||
if ofee <= 0 and paid is not None and paid > 0:
|
||||
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=paid)
|
||||
if est > 0:
|
||||
ofee = est
|
||||
|
||||
return round(ofee, 8), round(cfee, 8)
|
||||
|
||||
|
||||
def sync_options_from_local_trades(
|
||||
conn: sqlite3.Connection,
|
||||
@@ -277,24 +429,52 @@ def sync_options_from_local_trades(
|
||||
) -> dict[str, Any]:
|
||||
"""从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).
|
||||
|
||||
币本位权利金/盈亏按指数折算成 USDT(U) 写入,复盘页统一按 U 展示.
|
||||
真盈亏=历史币盈亏×卖回价 − (权利金×开仓价 − 权利金×卖回价);
|
||||
计入权利金 USDT 波动,忽略现货多买.
|
||||
"""
|
||||
init_options_review_tables(conn)
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
|
||||
|
||||
init_options_tables(conn)
|
||||
# 模拟盘先把现金盈亏写进 realized_pnl_usdt,复盘与持仓统计同一口径.
|
||||
# 不可 is_sim_mode(lambda: conn): with_db 会 close 掉调用方连接.
|
||||
try:
|
||||
from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_get
|
||||
from lib.sim.mode_lib import MODE_SIM, default_trading_mode
|
||||
|
||||
ensure_runtime_settings_table(conn)
|
||||
mode = runtime_get(conn, "trading.mode")
|
||||
mode_s = str(mode or "").strip().lower() or default_trading_mode()
|
||||
if mode_s == MODE_SIM:
|
||||
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
|
||||
|
||||
sync_sim_cash_realized_pnl_usdt(conn, ex)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets,
|
||||
open_quote, close_quote, premium_paid, premium_received, realized_pnl,
|
||||
premium_ccy, created_at, closed_at, signal_note, status
|
||||
eth_amount, open_quote, close_quote, premium_paid, premium_received,
|
||||
realized_pnl, realized_pnl_usdt, close_index_px, open_index_px,
|
||||
budget_usdt, premium_ccy, created_at, closed_at, signal_note, status,
|
||||
trading_usdt_before, trading_usdt_after, bridge_id, pnl_settled,
|
||||
initial_premium_usdt, open_fee, close_fee, exchange_ord_id, close_ord_id
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
"""
|
||||
).fetchall()
|
||||
# 读完先提交,后续拉指数不与写事务叠在一起
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _index_px(underly: str) -> float | None:
|
||||
u = (underly or "ETH").strip().upper() or "ETH"
|
||||
@@ -313,9 +493,9 @@ def sync_options_from_local_trades(
|
||||
for pub in pubs:
|
||||
try:
|
||||
if hasattr(pub, "public_get_market_ticker"):
|
||||
rows = (pub.public_get_market_ticker({"instId": inst}) or {}).get("data") or []
|
||||
if rows:
|
||||
last = rows[0].get("last") or rows[0].get("lastPx")
|
||||
tick_rows = (pub.public_get_market_ticker({"instId": inst}) or {}).get("data") or []
|
||||
if tick_rows:
|
||||
last = tick_rows[0].get("last") or tick_rows[0].get("lastPx")
|
||||
if last is not None and float(last) > 0:
|
||||
return float(last)
|
||||
except Exception:
|
||||
@@ -349,6 +529,24 @@ def sync_options_from_local_trades(
|
||||
|
||||
inserted = updated = skipped = 0
|
||||
idx_cache: dict[str, float | None] = {}
|
||||
need_idx: set[str] = set()
|
||||
for r in rows:
|
||||
# 已有冻结 U 或平仓指数时不再打实时行情
|
||||
if _safe_float(r["realized_pnl_usdt"]) is not None:
|
||||
continue
|
||||
frozen_idx = _safe_float(r["close_index_px"]) or _safe_float(r["open_index_px"])
|
||||
if frozen_idx is not None and frozen_idx > 0:
|
||||
continue
|
||||
inst0 = str(r["inst_id"] or "")
|
||||
underly0 = str(r["underlying"] or (inst0.split("-")[0] if inst0 else "ETH") or "ETH")
|
||||
ccy0 = str(r["premium_ccy"] or "").strip().upper()
|
||||
if not ccy0:
|
||||
ccy0 = premium_ccy_for_mode(margin_mode_from_inst_id(inst0), underly0)
|
||||
if ccy0 in ("ETH", "BTC"):
|
||||
need_idx.add(underly0)
|
||||
for underly0 in need_idx:
|
||||
idx_cache[underly0] = _index_px(underly0)
|
||||
|
||||
for r in rows:
|
||||
trade_id = int(r["id"])
|
||||
history_key = f"local_opt:{trade_id}"
|
||||
@@ -357,21 +555,112 @@ def sync_options_from_local_trades(
|
||||
ccy = str(r["premium_ccy"] or "").strip().upper()
|
||||
if not ccy:
|
||||
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
|
||||
if underly not in idx_cache:
|
||||
idx_cache[underly] = _index_px(underly)
|
||||
idx = idx_cache.get(underly)
|
||||
pnl = _safe_float(r["realized_pnl"])
|
||||
if pnl is None:
|
||||
paid0 = _safe_float(r["premium_paid"])
|
||||
recv0 = _safe_float(r["premium_received"])
|
||||
if paid0 is not None and recv0 is not None:
|
||||
pnl = recv0 - paid0
|
||||
close_idx = _safe_float(r["close_index_px"])
|
||||
open_idx = _safe_float(r["open_index_px"])
|
||||
idx = None
|
||||
if close_idx is not None and close_idx > 0:
|
||||
idx = close_idx
|
||||
elif open_idx is not None and open_idx > 0:
|
||||
idx = open_idx
|
||||
else:
|
||||
idx = idx_cache.get(underly)
|
||||
prem = _safe_float(r["premium_paid"])
|
||||
pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx)
|
||||
prem_u = _to_usdt(prem, ccy=ccy, idx=idx)
|
||||
if ccy in ("ETH", "BTC") and idx is None:
|
||||
pnl_u = pnl
|
||||
# 仅期权开/平手续费折 U(不含现货);先解析再算真盈亏
|
||||
ofee = abs(_safe_float(r["open_fee"]) or 0.0)
|
||||
cfee = abs(_safe_float(r["close_fee"]) or 0.0)
|
||||
ofee, cfee = _resolve_option_fees_coin(
|
||||
conn,
|
||||
r,
|
||||
open_fee=ofee,
|
||||
close_fee=cfee,
|
||||
premium_ccy=ccy,
|
||||
)
|
||||
# 币本位/初始权利金U 冻结不变;刷新只按卖回价重算真盈亏U
|
||||
stored_initial = _safe_float(r["initial_premium_usdt"])
|
||||
open_idx_for_prem = open_idx if (open_idx is not None and open_idx > 0) else idx
|
||||
sell_idx = close_idx if (close_idx is not None and close_idx > 0) else None
|
||||
from lib.options.options_pnl_settle_lib import (
|
||||
resolve_spot_vwap_prices,
|
||||
resolve_true_pnl_usdt_from_trade,
|
||||
)
|
||||
|
||||
status_label = "已平"
|
||||
pnl_u = None
|
||||
# 有桥时卖回价只认 close_sell VWAP;开仓价不覆盖已冻值
|
||||
opx, cpx, _gate = resolve_spot_vwap_prices(
|
||||
conn,
|
||||
bridge_id=r["bridge_id"],
|
||||
open_index_px=open_idx,
|
||||
close_index_px=sell_idx,
|
||||
)
|
||||
pnl_u, pnl_src = resolve_true_pnl_usdt_from_trade(
|
||||
premium_paid=prem,
|
||||
premium_received=r["premium_received"],
|
||||
realized_pnl=r["realized_pnl"],
|
||||
open_index_px=opx,
|
||||
close_index_px=cpx,
|
||||
initial_premium_usdt=stored_initial,
|
||||
open_fee=ofee,
|
||||
close_fee=cfee,
|
||||
premium_ccy=ccy,
|
||||
fee_ccy=str(r["premium_ccy"] or ccy or ""),
|
||||
conn=conn,
|
||||
bridge_id=r["bridge_id"],
|
||||
)
|
||||
if pnl_u is not None and pnl_src not in ("none", "pending_sell"):
|
||||
try:
|
||||
# 卖回 VWAP 写入 close;开仓价/初始权利金仅缺省时补
|
||||
heal_initial = stored_initial
|
||||
if heal_initial is None and prem is not None and opx is not None and opx > 0:
|
||||
heal_initial = round(float(prem) * float(opx), 4)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET 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 = ?
|
||||
""",
|
||||
(pnl_u, opx, cpx, heal_initial, trade_id),
|
||||
)
|
||||
if (open_idx is None or open_idx <= 0) and opx is not None and opx > 0:
|
||||
open_idx = opx
|
||||
open_idx_for_prem = opx
|
||||
if cpx is not None and cpx > 0:
|
||||
sell_idx = cpx
|
||||
if stored_initial is None and heal_initial is not None:
|
||||
stored_initial = heal_initial
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
status_label = "缺价未核"
|
||||
pnl_u = None
|
||||
# 初始权利金U:优先开仓冻结值
|
||||
prem_u = stored_initial
|
||||
if prem_u is None:
|
||||
prem_u = _to_usdt(prem, ccy=ccy, idx=open_idx_for_prem)
|
||||
if ccy in ("ETH", "BTC") and prem_u is None:
|
||||
prem_u = prem
|
||||
# 手续费列仅展示(已含在历史币盈亏内,真盈亏不再扣)
|
||||
fee_u = None
|
||||
if ccy in ("ETH", "BTC"):
|
||||
fee_parts = 0.0
|
||||
has_fee = False
|
||||
if ofee > 0 and open_idx_for_prem is not None and open_idx_for_prem > 0:
|
||||
fee_parts += ofee * float(open_idx_for_prem)
|
||||
has_fee = True
|
||||
if cfee > 0 and sell_idx is not None and sell_idx > 0:
|
||||
fee_parts += cfee * float(sell_idx)
|
||||
has_fee = True
|
||||
if has_fee:
|
||||
fee_u = round(fee_parts, 4)
|
||||
elif ofee > 0 or cfee > 0:
|
||||
fee_u = round(ofee + cfee, 4)
|
||||
profit_rate = None
|
||||
if pnl_u is not None and prem_u is not None and abs(float(prem_u)) > 1e-12:
|
||||
profit_rate = round(float(pnl_u) / float(prem_u), 6)
|
||||
opened_at = r["created_at"]
|
||||
closed_at = r["closed_at"]
|
||||
action = upsert_option_history_row(
|
||||
@@ -388,10 +677,13 @@ def sync_options_from_local_trades(
|
||||
"open_avg_px": r["open_quote"],
|
||||
"close_avg_px": r["close_quote"],
|
||||
"premium_paid": prem_u if prem_u is not None else prem,
|
||||
"realized_pnl": pnl_u if pnl_u is not None else pnl,
|
||||
"realized_pnl": pnl_u,
|
||||
"initial_premium_usdt": prem_u,
|
||||
"profit_rate": profit_rate,
|
||||
"option_fee_usdt": fee_u,
|
||||
"created_at": opened_at,
|
||||
"closed_at": closed_at,
|
||||
"status_label": "已平",
|
||||
"status_label": status_label,
|
||||
},
|
||||
)
|
||||
if action == "inserted":
|
||||
@@ -627,9 +919,33 @@ def ensure_local_review_synced(
|
||||
repair_swapped_closed_at_signal_note,
|
||||
)
|
||||
|
||||
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
|
||||
note_swap = repair_swapped_closed_at_signal_note(conn)
|
||||
if backfill_exchange_pnl and ex is not None:
|
||||
ts_migrate: dict[str, Any] = {}
|
||||
note_swap: dict[str, Any] = {}
|
||||
try:
|
||||
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
|
||||
except Exception as e:
|
||||
ts_migrate = {"ok": False, "msg": str(e)}
|
||||
try:
|
||||
note_swap = repair_swapped_closed_at_signal_note(conn)
|
||||
except Exception as e:
|
||||
note_swap = {"ok": False, "msg": str(e)}
|
||||
# 提交后再拉历史/行情,避免模拟盘 broker 二次开库与本连接互锁
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# 模拟盘用现金冻结,勿再用交易所历史+实时指数覆盖
|
||||
sim_mode = False
|
||||
try:
|
||||
from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_get
|
||||
from lib.sim.mode_lib import MODE_SIM, default_trading_mode
|
||||
|
||||
ensure_runtime_settings_table(conn)
|
||||
mode = runtime_get(conn, "trading.mode")
|
||||
sim_mode = (str(mode or "").strip().lower() or default_trading_mode()) == MODE_SIM
|
||||
except Exception:
|
||||
sim_mode = False
|
||||
if backfill_exchange_pnl and ex is not None and not sim_mode:
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
|
||||
from lib.options.options_monitor_lib import (
|
||||
@@ -638,12 +954,21 @@ def ensure_local_review_synced(
|
||||
|
||||
hist = fetch_all_option_positions_history(ex, limit=200)
|
||||
backfill_closed_options_realized_pnl_from_history(conn, hist)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
|
||||
except sqlite3.OperationalError as e:
|
||||
out = {"ok": False, "msg": f"sync locked: {e}"}
|
||||
if isinstance(out, dict):
|
||||
out["ts_migrate"] = ts_migrate
|
||||
out["note_swap"] = note_swap
|
||||
if out.get("ok") is not False:
|
||||
out["ok"] = True
|
||||
return out
|
||||
|
||||
|
||||
@@ -653,6 +978,12 @@ def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
|
||||
def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
out = dict(row)
|
||||
# 盈利率缺失时按 复盘盈亏/初始权利金 补算
|
||||
if _safe_float(out.get("profit_rate")) is None:
|
||||
pnl = _safe_float(out.get("realized_pnl_total"))
|
||||
prem = _safe_float(out.get("initial_premium_usdt"))
|
||||
if pnl is not None and prem is not None and abs(prem) > 1e-12:
|
||||
out["profit_rate"] = round(pnl / prem, 6)
|
||||
out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type"))
|
||||
out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS)
|
||||
legs = []
|
||||
@@ -669,6 +1000,7 @@ def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -
|
||||
out["strategy_tag"] = entry.get("strategy_tag")
|
||||
out["direction_view"] = entry.get("direction_view")
|
||||
out["entry_logic"] = entry.get("entry_logic")
|
||||
out["prev_entry_tf"] = entry.get("prev_entry_tf")
|
||||
out["result_tag"] = entry.get("result_tag")
|
||||
out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at")
|
||||
else:
|
||||
@@ -676,6 +1008,7 @@ def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -
|
||||
out["strategy_tag"] = None
|
||||
out["direction_view"] = None
|
||||
out["entry_logic"] = None
|
||||
out["prev_entry_tf"] = None
|
||||
out["result_tag"] = None
|
||||
out["reviewed_at"] = None
|
||||
return out
|
||||
@@ -837,7 +1170,7 @@ def list_review_trades(
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT t.*, e.id AS entry_id, e.strategy_tag AS e_strategy_tag,
|
||||
e.direction_view, e.entry_logic, e.exit_reason, e.followed_plan,
|
||||
e.direction_view, e.entry_logic, e.prev_entry_tf, e.exit_reason, e.followed_plan,
|
||||
e.mistake_tags, e.result_tag, e.note, e.images_json, e.image,
|
||||
e.reviewed_at, e.updated_at
|
||||
FROM options_review_trades t
|
||||
@@ -858,6 +1191,7 @@ def list_review_trades(
|
||||
"strategy_tag": d.pop("e_strategy_tag", None),
|
||||
"direction_view": d.pop("direction_view", None),
|
||||
"entry_logic": d.pop("entry_logic", None),
|
||||
"prev_entry_tf": d.pop("prev_entry_tf", None),
|
||||
"exit_reason": d.pop("exit_reason", None),
|
||||
"followed_plan": d.pop("followed_plan", None),
|
||||
"mistake_tags": d.pop("mistake_tags", None),
|
||||
@@ -874,6 +1208,7 @@ def list_review_trades(
|
||||
"e_strategy_tag",
|
||||
"direction_view",
|
||||
"entry_logic",
|
||||
"prev_entry_tf",
|
||||
"exit_reason",
|
||||
"followed_plan",
|
||||
"mistake_tags",
|
||||
@@ -924,10 +1259,14 @@ def save_review_entry(
|
||||
primary = None
|
||||
if images:
|
||||
primary = str(images[0].get("file") or "").strip() or None
|
||||
prev_tf = str(payload.get("prev_entry_tf") or "").strip().lower()
|
||||
if prev_tf and prev_tf not in ("15m", "1h", "2h", "4h", "8h", "12h"):
|
||||
prev_tf = ""
|
||||
fields = {
|
||||
"strategy_tag": str(payload.get("strategy_tag") or "").strip() or None,
|
||||
"direction_view": str(payload.get("direction_view") or "").strip() or None,
|
||||
"entry_logic": str(payload.get("entry_logic") or "").strip() or None,
|
||||
"prev_entry_tf": prev_tf or None,
|
||||
"exit_reason": str(payload.get("exit_reason") or "").strip() or None,
|
||||
"followed_plan": str(payload.get("followed_plan") or "").strip() or None,
|
||||
"mistake_tags": str(payload.get("mistake_tags") or "").strip() or None,
|
||||
|
||||
@@ -275,7 +275,16 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
ex, _err = _require_ex(cfg)
|
||||
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||
conn.commit()
|
||||
return jsonify(result)
|
||||
if isinstance(result, dict) and result.get("ok") is not False:
|
||||
result["ok"] = True
|
||||
return jsonify(result if isinstance(result, dict) else {"ok": True, "result": result})
|
||||
except Exception as e:
|
||||
print(f"[options_review_sync] {e}", flush=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"ok": False, "msg": f"同步失败: {e}"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -284,16 +293,32 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
def api_options_review_trades():
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
# 翻页可跳过同步,仅刷新当前卡片列表
|
||||
do_sync = (request.args.get("sync") or "1").strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
# 默认不同步:列表先读缓存;完整同步走 POST /api/options/review/sync
|
||||
do_sync = (request.args.get("sync") or "0").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
if do_sync:
|
||||
ex, _err = _require_ex(cfg)
|
||||
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||
conn.commit()
|
||||
try:
|
||||
ex, _err = _require_ex(cfg)
|
||||
# 列表路径不做历史回填,避免模拟盘握库二次开连接卡死
|
||||
ensure_local_review_synced(
|
||||
conn,
|
||||
ex=ex if ex is not None else None,
|
||||
backfill_exchange_pnl=False,
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as sync_err:
|
||||
# 同步撞锁时仍返回已有列表,避免整页 500
|
||||
print(f"[options_review_trades] sync skipped: {sync_err}", flush=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
init_options_review_tables(conn)
|
||||
else:
|
||||
init_options_review_tables(conn)
|
||||
filt = dict(
|
||||
source_type=_review_source_for_mode(request.args.get("source_type")),
|
||||
underlying=(request.args.get("underlying") or "").strip() or None,
|
||||
@@ -326,6 +351,9 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
"pages": pages,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[options_review_trades] {e}", flush=True)
|
||||
return jsonify({"ok": False, "msg": f"加载交易记录失败: {e}"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -431,9 +459,26 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
def api_options_review_stats():
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
ex, _err = _require_ex(cfg)
|
||||
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||
conn.commit()
|
||||
# 默认不同步:与 trades?sync=1 并行时避免双写撞锁;需要时再 sync=1
|
||||
do_sync = (request.args.get("sync") or "0").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
if do_sync:
|
||||
try:
|
||||
ex, _err = _require_ex(cfg)
|
||||
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||
conn.commit()
|
||||
except Exception as sync_err:
|
||||
print(f"[options_review_stats] sync skipped: {sync_err}", flush=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
init_options_review_tables(conn)
|
||||
else:
|
||||
init_options_review_tables(conn)
|
||||
stats = compute_review_stats(
|
||||
conn,
|
||||
source_type=_review_source_for_mode(request.args.get("source_type")),
|
||||
@@ -447,6 +492,10 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
)
|
||||
stats["source_types"] = list(SOURCE_TYPES)
|
||||
stats["source_labels"] = SOURCE_LABELS
|
||||
stats["ok"] = True
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
print(f"[options_review_stats] {e}", flush=True)
|
||||
return jsonify({"ok": False, "msg": f"加载统计失败: {e}"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -13,6 +13,61 @@ BRIDGE_HOLDING = "holding"
|
||||
BRIDGE_PENDING_SELL = "pending_sell_spot"
|
||||
BRIDGE_CLOSED = "closed"
|
||||
|
||||
# 拉取 instruments 失败时的兜底(与 OKX 现货常见 minSz 对齐)
|
||||
_SPOT_MIN_SZ_FALLBACK: dict[str, float] = {"ETH": 0.0001, "BTC": 0.00001}
|
||||
_SPOT_MIN_SZ_CACHE: dict[str, tuple[float, float]] = {} # ccy -> (min_sz, mono_ts)
|
||||
_SPOT_MIN_SZ_TTL_SEC = 3600.0
|
||||
|
||||
|
||||
def spot_min_sell_sz(ex: Any | None, underlying: str) -> float:
|
||||
"""ETH/BTC-USDT 现货最小下单量(base ccy);粉尘低于此值不卖、不展示."""
|
||||
ccy = (underlying or "ETH").strip().upper() or "ETH"
|
||||
fb = float(_SPOT_MIN_SZ_FALLBACK.get(ccy) or _SPOT_MIN_SZ_FALLBACK["ETH"])
|
||||
now = time.monotonic()
|
||||
cached = _SPOT_MIN_SZ_CACHE.get(ccy)
|
||||
if cached and (now - cached[1]) < _SPOT_MIN_SZ_TTL_SEC:
|
||||
return float(cached[0])
|
||||
min_sz = fb
|
||||
if ex is not None:
|
||||
try:
|
||||
inst_id = spot_quote_inst_id(ccy)
|
||||
rows = ex.public_get_public_instruments({"instType": "SPOT", "instId": inst_id}) or {}
|
||||
data = rows.get("data") if isinstance(rows, dict) else None
|
||||
if not data and hasattr(ex, "publicGetPublicInstruments"):
|
||||
rows = ex.publicGetPublicInstruments({"instType": "SPOT", "instId": inst_id}) or {}
|
||||
data = rows.get("data") if isinstance(rows, dict) else None
|
||||
row = (data or [None])[0] if data else None
|
||||
if isinstance(row, dict):
|
||||
raw = _safe_float(row.get("minSz"))
|
||||
if raw is not None and raw > 0:
|
||||
min_sz = float(raw)
|
||||
except Exception:
|
||||
pass
|
||||
_SPOT_MIN_SZ_CACHE[ccy] = (min_sz, now)
|
||||
return min_sz
|
||||
|
||||
|
||||
def is_sellable_spot_coin(ex: Any | None, underlying: str, amount: Any) -> bool:
|
||||
"""数量是否达到现货最小可卖(达到才重试/展示)."""
|
||||
try:
|
||||
amt = float(amount or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if amt <= 0:
|
||||
return False
|
||||
return amt + 1e-15 >= spot_min_sell_sz(ex, underlying)
|
||||
|
||||
|
||||
def display_sellable_coin(ex: Any | None, underlying: str, amount: Any) -> float:
|
||||
"""展示用:不够 minSz 的粉尘归零."""
|
||||
try:
|
||||
amt = float(amount or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if not is_sellable_spot_coin(ex, underlying, amt):
|
||||
return 0.0
|
||||
return amt
|
||||
|
||||
|
||||
def ensure_bridge_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
@@ -41,6 +96,148 @@ def ensure_bridge_table(conn: sqlite3.Connection) -> None:
|
||||
ON options_spot_bridge(status)
|
||||
"""
|
||||
)
|
||||
# 多笔买卖 VWAP:买侧累加(含缓冲);平仓后卖回单独累加(不含开仓多余卖回)
|
||||
for ddl in (
|
||||
"ALTER TABLE options_spot_bridge ADD COLUMN buy_usdt_spent REAL",
|
||||
"ALTER TABLE options_spot_bridge ADD COLUMN buy_coin_accum REAL",
|
||||
"ALTER TABLE options_spot_bridge ADD COLUMN close_sell_coin REAL",
|
||||
"ALTER TABLE options_spot_bridge ADD COLUMN close_sell_usdt REAL",
|
||||
):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def add_bridge_buy_fill(
|
||||
conn: sqlite3.Connection,
|
||||
bridge_id: int,
|
||||
*,
|
||||
usdt_spent: float | None,
|
||||
coin_bought: float | None,
|
||||
) -> None:
|
||||
"""累加开仓买币成交,供买侧 VWAP=ΣUSDT/Σ币."""
|
||||
ensure_bridge_table(conn)
|
||||
u = _safe_float(usdt_spent)
|
||||
c = _safe_float(coin_bought)
|
||||
if bridge_id is None or u is None or c is None or u <= 0 or c <= 0:
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT buy_usdt_spent, buy_coin_accum FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
prev_u = _safe_float(row["buy_usdt_spent"] if hasattr(row, "keys") else row[0]) or 0.0
|
||||
prev_c = _safe_float(row["buy_coin_accum"] if hasattr(row, "keys") else row[1]) or 0.0
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_spot_bridge
|
||||
SET buy_usdt_spent = ?, buy_coin_accum = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(round(prev_u + float(u), 8), round(prev_c + float(c), 8), int(bridge_id)),
|
||||
)
|
||||
|
||||
|
||||
def add_bridge_close_sell_fill(
|
||||
conn: sqlite3.Connection,
|
||||
bridge_id: int,
|
||||
*,
|
||||
usdt_recovered: float | None,
|
||||
coin_sold: float | None,
|
||||
) -> None:
|
||||
"""累加平仓后卖回成交(多笔),供卖回 VWAP=ΣUSDT/Σ币;同时累加 usdt_recovered."""
|
||||
ensure_bridge_table(conn)
|
||||
u = _safe_float(usdt_recovered)
|
||||
c = _safe_float(coin_sold)
|
||||
if bridge_id is None:
|
||||
return
|
||||
if u is not None and u > 0:
|
||||
add_bridge_usdt_recovered(conn, int(bridge_id), u)
|
||||
if (u is None or u <= 0) and (c is None or c <= 0):
|
||||
return
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT close_sell_usdt, close_sell_coin, coin_sold
|
||||
FROM options_spot_bridge WHERE id=?
|
||||
""",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
prev_u = _safe_float(row["close_sell_usdt"] if hasattr(row, "keys") else row[0]) or 0.0
|
||||
prev_c = _safe_float(row["close_sell_coin"] if hasattr(row, "keys") else row[1]) or 0.0
|
||||
prev_sold = _safe_float(row["coin_sold"] if hasattr(row, "keys") else row[2]) or 0.0
|
||||
new_u = prev_u + (float(u) if u is not None and u > 0 else 0.0)
|
||||
new_c = prev_c + (float(c) if c is not None and c > 0 else 0.0)
|
||||
new_sold = prev_sold + (float(c) if c is not None and c > 0 else 0.0)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_spot_bridge
|
||||
SET close_sell_usdt = ?, close_sell_coin = ?, coin_sold = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(round(new_u, 8), round(new_c, 8), round(new_sold, 8), int(bridge_id)),
|
||||
)
|
||||
|
||||
|
||||
def bridge_buy_vwap(conn: sqlite3.Connection, bridge_id: int | None) -> float | None:
|
||||
"""开仓买币 VWAP(USDT/币);多笔加权.
|
||||
|
||||
只用 buy_usdt_spent/buy_coin_accum.禁止 budget/coin_bought:
|
||||
开仓成功后 coin_bought 会被改成权利金量,用 budget/缩水量会把开仓价抬飞.
|
||||
"""
|
||||
if bridge_id is None:
|
||||
return None
|
||||
ensure_bridge_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT buy_usdt_spent, buy_coin_accum FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
spent = _safe_float(row["buy_usdt_spent"] if hasattr(row, "keys") else row[0])
|
||||
coins = _safe_float(row["buy_coin_accum"] if hasattr(row, "keys") else row[1])
|
||||
if spent is not None and coins is not None and spent > 0 and coins > 0:
|
||||
return round(float(spent) / float(coins), 8)
|
||||
return None
|
||||
|
||||
|
||||
def bridge_sell_vwap(conn: sqlite3.Connection, bridge_id: int | None) -> float | None:
|
||||
"""平仓后卖回 VWAP(USDT/币);仅 close_sell_* 多笔加权.
|
||||
|
||||
禁止回退 usdt_recovered/coin_sold:后者常混有开仓多余卖回,会污染卖回均价.
|
||||
"""
|
||||
if bridge_id is None:
|
||||
return None
|
||||
ensure_bridge_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT close_sell_usdt, close_sell_coin FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
u = _safe_float(row["close_sell_usdt"] if hasattr(row, "keys") else row[0])
|
||||
c = _safe_float(row["close_sell_coin"] if hasattr(row, "keys") else row[1])
|
||||
if u is not None and c is not None and u > 0 and c > 0:
|
||||
return round(float(u) / float(c), 8)
|
||||
return None
|
||||
|
||||
|
||||
def bridge_sell_complete(conn: sqlite3.Connection, bridge_id: int | None) -> bool:
|
||||
"""平仓卖回是否已完成(桥已关闭)."""
|
||||
if bridge_id is None:
|
||||
return False
|
||||
ensure_bridge_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT status FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
return str(row["status"] if hasattr(row, "keys") else row[0] or "") == BRIDGE_CLOSED
|
||||
|
||||
|
||||
def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
@@ -96,6 +293,63 @@ def insert_bridge(
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def add_bridge_usdt_recovered(
|
||||
conn: sqlite3.Connection,
|
||||
bridge_id: int,
|
||||
delta_usdt: float | None,
|
||||
) -> float | None:
|
||||
"""累加桥上卖币收回的 USDT(开仓多余卖回 + 平仓后卖回)."""
|
||||
ensure_bridge_table(conn)
|
||||
try:
|
||||
d = float(delta_usdt or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if d <= 0 or bridge_id is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT usdt_recovered FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
prev = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[0]) or 0.0
|
||||
new_v = round(float(prev) + d, 8)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_spot_bridge
|
||||
SET usdt_recovered = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(new_v, int(bridge_id)),
|
||||
)
|
||||
return new_v
|
||||
|
||||
|
||||
def cash_pnl_usdt_from_bridge(conn: sqlite3.Connection, bridge_id: int | None) -> float | None:
|
||||
"""桥上真盈亏(U)=累计卖回 USDT − 开仓买币预算.
|
||||
|
||||
与账户 after−before 等价(一次一仓、无中途划转时);可作口径 A 回退.
|
||||
不是期权腿币盈亏(口径 B).
|
||||
"""
|
||||
if bridge_id is None:
|
||||
return None
|
||||
ensure_bridge_table(conn)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT budget_usdt, usdt_recovered
|
||||
FROM options_spot_bridge WHERE id = ?
|
||||
""",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
spent = _safe_float(row["budget_usdt"] if hasattr(row, "keys") else row[0])
|
||||
recovered = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[1])
|
||||
if spent is None or recovered is None:
|
||||
return None
|
||||
return round(float(recovered) - float(spent), 4)
|
||||
|
||||
|
||||
def update_bridge(
|
||||
conn: sqlite3.Connection,
|
||||
bridge_id: int,
|
||||
@@ -221,16 +475,32 @@ def spot_market_sell_coin_to_usdt(
|
||||
underlying: str,
|
||||
coin_amount: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""交易账户:市价卖出标的币换 USDT.始终卖光交易户可用余额(coin_amount 仅兼容旧调用,不参与定量)."""
|
||||
"""交易账户:市价卖出标的币换 USDT.
|
||||
|
||||
coin_amount 有值时按 min(可用, coin_amount) 卖,避免误卖无关余额;
|
||||
未传时卖光该币可用(空仓扫尾).
|
||||
"""
|
||||
ccy = (underlying or "ETH").upper()
|
||||
avail = fetch_trading_coin_available(ex, ccy)
|
||||
if avail is None or float(avail) <= 0:
|
||||
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
|
||||
amt = float(avail)
|
||||
if coin_amount is not None:
|
||||
try:
|
||||
lim = float(coin_amount)
|
||||
except (TypeError, ValueError):
|
||||
lim = 0.0
|
||||
if lim > 0:
|
||||
amt = min(amt, lim)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
|
||||
min_sz = spot_min_sell_sz(ex, ccy)
|
||||
if amt + 1e-15 < min_sz:
|
||||
return {"ok": False, "msg": f"{ccy} 可卖数量过小(低于现货最小 {min_sz:g})"}
|
||||
# 留一点粉尘避免精度拒单
|
||||
sell_sz = max(0.0, amt * 0.999)
|
||||
if sell_sz + 1e-15 < min_sz:
|
||||
return {"ok": False, "msg": f"{ccy} 可卖数量过小(低于现货最小 {min_sz:g})"}
|
||||
inst_id = spot_quote_inst_id(ccy)
|
||||
try:
|
||||
# 现货卖出数量精度:截到 8 位
|
||||
@@ -274,9 +544,29 @@ def rollback_bought_coin_to_usdt(
|
||||
reason: str = "",
|
||||
coin_amount: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""买币后开期权失败:卖回 USDT 并关闭桥.卖光交易户可用标的币."""
|
||||
_ = coin_amount # 兼容旧签名;定量以交易户可用为准
|
||||
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying)
|
||||
"""买币后开期权失败:按本桥买入量封顶卖回 USDT 并关闭桥."""
|
||||
ensure_bridge_table(conn)
|
||||
cap = coin_amount
|
||||
if cap is None:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT coin_bought FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
cap = _safe_float(row["coin_bought"] if hasattr(row, "keys") else row[0])
|
||||
except Exception:
|
||||
cap = None
|
||||
# 卖币(模拟盘)会另开连接写钱包,须先释放本连接事务锁
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
sell = spot_market_sell_coin_to_usdt(
|
||||
ex,
|
||||
underlying=underlying,
|
||||
coin_amount=float(cap) if cap is not None and float(cap) > 0 else None,
|
||||
)
|
||||
if not sell.get("ok"):
|
||||
update_bridge(
|
||||
conn,
|
||||
@@ -298,58 +588,397 @@ def rollback_bought_coin_to_usdt(
|
||||
|
||||
|
||||
def sell_residual_after_option_flat(
|
||||
conn: sqlite3.Connection,
|
||||
conn_or_get_db: Any,
|
||||
ex: Any,
|
||||
*,
|
||||
underlying: str,
|
||||
inst_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥."""
|
||||
ensure_bridge_table(conn)
|
||||
bridges = list_open_bridges(conn)
|
||||
target = None
|
||||
for b in bridges:
|
||||
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
|
||||
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
|
||||
target = b
|
||||
break
|
||||
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying)
|
||||
if target is None:
|
||||
"""期权已平:卖光交易户该标的可卖币(一次一仓,含盈利币).
|
||||
|
||||
规格:平仓卖回禁止按开仓权利金 coin_bought 封顶;仍有可卖则不关桥、不结算.
|
||||
可传 get_db 可调用对象(推荐)或已打开的 Connection。
|
||||
卖币(尤其模拟盘写钱包)期间绝不握着业务连接,否则会 database is locked。
|
||||
"""
|
||||
get_db = conn_or_get_db if callable(conn_or_get_db) else None
|
||||
bid: int | None = None
|
||||
want_inst = str(inst_id or "").strip()
|
||||
|
||||
def _pick_bridge(bridges: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
uly_u = (underlying or "").upper()
|
||||
matched: list[dict[str, Any]] = []
|
||||
for b in bridges:
|
||||
if str(b.get("status")) not in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
|
||||
continue
|
||||
if uly_u and str(b.get("underlying") or "").upper() != uly_u:
|
||||
continue
|
||||
matched.append(b)
|
||||
if not matched:
|
||||
return None
|
||||
if want_inst:
|
||||
for b in matched:
|
||||
if str(b.get("inst_id") or "").strip() == want_inst:
|
||||
return b
|
||||
return matched[0]
|
||||
|
||||
if get_db is not None:
|
||||
conn = get_db()
|
||||
try:
|
||||
ensure_bridge_table(conn)
|
||||
target = _pick_bridge(list_open_bridges(conn))
|
||||
if target is not None:
|
||||
bid = int(target["id"])
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
conn = conn_or_get_db
|
||||
ensure_bridge_table(conn)
|
||||
target = _pick_bridge(list_open_bridges(conn))
|
||||
if target is not None:
|
||||
bid = int(target["id"])
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 粉尘:不够现货最小下单量则忽略,不再重试;有桥则直接关闭
|
||||
try:
|
||||
avail_chk = fetch_trading_coin_available(ex, underlying)
|
||||
except Exception:
|
||||
avail_chk = None
|
||||
if avail_chk is not None and not is_sellable_spot_coin(ex, underlying, avail_chk):
|
||||
dust_msg = "粉尘低于现货最小可卖,已忽略"
|
||||
if bid is None:
|
||||
return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True, "sell_complete": True}
|
||||
def _close_dust(c: sqlite3.Connection) -> dict[str, Any]:
|
||||
update_bridge(
|
||||
c,
|
||||
bid,
|
||||
status=BRIDGE_CLOSED,
|
||||
inst_id=inst_id,
|
||||
message=dust_msg,
|
||||
close=True,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": dust_msg,
|
||||
"skipped": True,
|
||||
"dust": True,
|
||||
"sell_complete": True,
|
||||
"bridge_id": bid,
|
||||
"bridge_status": BRIDGE_CLOSED,
|
||||
}
|
||||
if get_db is not None:
|
||||
conn_d = get_db()
|
||||
try:
|
||||
return _close_dust(conn_d)
|
||||
finally:
|
||||
try:
|
||||
conn_d.close()
|
||||
except Exception:
|
||||
pass
|
||||
return _close_dust(conn_or_get_db)
|
||||
|
||||
# 一次一仓空仓后卖光可用(含盈利币);禁止用 coin_bought/权利金封顶
|
||||
# 此处无业务连接:模拟盘 convert 可安全另开写钱包
|
||||
usdt_before_sell = None
|
||||
try:
|
||||
from lib.options.options_account_pnl_lib import fetch_trading_usdt
|
||||
|
||||
usdt_before_sell = fetch_trading_usdt({"fetch_options_balances": None}, ex)
|
||||
except Exception:
|
||||
usdt_before_sell = None
|
||||
sell = spot_market_sell_coin_to_usdt(
|
||||
ex,
|
||||
underlying=underlying,
|
||||
coin_amount=None,
|
||||
)
|
||||
# 实盘订单回报常无 usdt_recovered:用交易账户 USDT 增量补
|
||||
if sell.get("ok") and _safe_float(sell.get("usdt_recovered")) is None:
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
|
||||
from lib.options.options_account_pnl_lib import fetch_trading_usdt
|
||||
|
||||
invalidate_options_balance_cache()
|
||||
usdt_after_sell = fetch_trading_usdt({"fetch_options_balances": None}, ex)
|
||||
if (
|
||||
usdt_before_sell is not None
|
||||
and usdt_after_sell is not None
|
||||
and float(usdt_after_sell) > float(usdt_before_sell)
|
||||
):
|
||||
sell = dict(sell)
|
||||
sell["usdt_recovered"] = round(
|
||||
float(usdt_after_sell) - float(usdt_before_sell), 8
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if bid is None:
|
||||
if not sell.get("ok"):
|
||||
msg = str(sell.get("msg") or "")
|
||||
if "无可用" in msg or "过小" in msg:
|
||||
return {"ok": True, "msg": "无残留币需卖回", "skipped": True}
|
||||
return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL}
|
||||
return {"ok": True, "sell": sell, "bridge_status": None}
|
||||
bid = int(target["id"])
|
||||
if not sell.get("ok"):
|
||||
update_bridge(
|
||||
conn,
|
||||
return {"ok": True, "msg": "无残留币需卖回", "skipped": True, "sell_complete": True}
|
||||
return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL, "sell_complete": False}
|
||||
# 再确认无可卖残留
|
||||
try:
|
||||
left = fetch_trading_coin_available(ex, underlying)
|
||||
except Exception:
|
||||
left = None
|
||||
if left is not None and is_sellable_spot_coin(ex, underlying, left):
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"卖回未完成,仍有可卖 {str(underlying or '').upper()}",
|
||||
"incomplete": True,
|
||||
"sell_complete": False,
|
||||
"sell": sell,
|
||||
}
|
||||
return {"ok": True, "sell": sell, "bridge_status": None, "sell_complete": True}
|
||||
|
||||
def _apply_bridge(c: sqlite3.Connection) -> dict[str, Any]:
|
||||
if not sell.get("ok"):
|
||||
msg = str(sell.get("msg") or "卖回 USDT 失败")
|
||||
# 交易所拒单「过小」也按粉尘关闭桥,避免一直 pending 重试
|
||||
if "过小" in msg or "minimum" in msg.lower() or "min sz" in msg.lower():
|
||||
update_bridge(
|
||||
c,
|
||||
bid,
|
||||
status=BRIDGE_CLOSED,
|
||||
inst_id=inst_id,
|
||||
message="粉尘低于现货最小可卖,已忽略",
|
||||
close=True,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "粉尘低于现货最小可卖,已忽略",
|
||||
"skipped": True,
|
||||
"dust": True,
|
||||
"sell_complete": True,
|
||||
"bridge_id": bid,
|
||||
"bridge_status": BRIDGE_CLOSED,
|
||||
}
|
||||
update_bridge(
|
||||
c,
|
||||
bid,
|
||||
status=BRIDGE_PENDING_SELL,
|
||||
inst_id=inst_id,
|
||||
message=msg,
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": msg,
|
||||
"bridge_id": bid,
|
||||
"bridge_status": BRIDGE_PENDING_SELL,
|
||||
"sell_complete": False,
|
||||
}
|
||||
recovered = _safe_float(sell.get("usdt_recovered"))
|
||||
sold_amt = _safe_float(sell.get("coin_sold"))
|
||||
# 缺 USDT 回报时用成交价×币量补,保证 close_sell VWAP 能落库
|
||||
if (recovered is None or recovered <= 0) and sold_amt is not None and sold_amt > 0:
|
||||
fill_px = _safe_float(sell.get("fill_px"))
|
||||
if fill_px is not None and fill_px > 0:
|
||||
recovered = round(float(sold_amt) * float(fill_px), 8)
|
||||
sell = dict(sell)
|
||||
sell["usdt_recovered"] = recovered
|
||||
# 平仓后卖回(可多笔):累加 close_sell_* 供卖回 VWAP;开仓多余卖回不走这里
|
||||
add_bridge_close_sell_fill(
|
||||
c,
|
||||
bid,
|
||||
status=BRIDGE_PENDING_SELL,
|
||||
inst_id=inst_id,
|
||||
message=str(sell.get("msg") or "卖回 USDT 失败"),
|
||||
usdt_recovered=recovered,
|
||||
coin_sold=sold_amt,
|
||||
)
|
||||
# 卖后仍有可卖 → 不关桥、不结算(盈利币未卖回时曾误冻亏损)
|
||||
try:
|
||||
left = fetch_trading_coin_available(ex, underlying)
|
||||
except Exception:
|
||||
left = None
|
||||
if left is not None and is_sellable_spot_coin(ex, underlying, left):
|
||||
left_msg = f"卖回未完成,仍有可卖 {str(underlying or '').upper()}({float(left):g})"
|
||||
update_bridge(
|
||||
c,
|
||||
bid,
|
||||
status=BRIDGE_PENDING_SELL,
|
||||
sell_ord_id=str(sell.get("ord_id") or ""),
|
||||
inst_id=inst_id,
|
||||
message=left_msg,
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": left_msg,
|
||||
"incomplete": True,
|
||||
"sell_complete": False,
|
||||
"sell": sell,
|
||||
"bridge_id": bid,
|
||||
"bridge_status": BRIDGE_PENDING_SELL,
|
||||
}
|
||||
sell_vwap = bridge_sell_vwap(c, bid)
|
||||
update_bridge(
|
||||
c,
|
||||
bid,
|
||||
status=BRIDGE_CLOSED,
|
||||
sell_ord_id=str(sell.get("ord_id") or ""),
|
||||
inst_id=inst_id,
|
||||
message="期权已平,币已卖回 USDT",
|
||||
close=True,
|
||||
)
|
||||
# 卖回 VWAP 写入关联已平仓(覆盖平仓时指数占位价)
|
||||
if sell_vwap is not None and float(sell_vwap) > 0:
|
||||
try:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET close_index_px = ?
|
||||
WHERE bridge_id = ? AND status = 'closed'
|
||||
""",
|
||||
(float(sell_vwap), int(bid)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": sell.get("msg") or "卖回 USDT 失败",
|
||||
"ok": True,
|
||||
"sell": sell,
|
||||
"bridge_id": bid,
|
||||
"bridge_status": BRIDGE_PENDING_SELL,
|
||||
"bridge_status": BRIDGE_CLOSED,
|
||||
"sell_complete": True,
|
||||
"sell_vwap": sell_vwap,
|
||||
}
|
||||
update_bridge(
|
||||
conn,
|
||||
bid,
|
||||
status=BRIDGE_CLOSED,
|
||||
sell_ord_id=str(sell.get("ord_id") or ""),
|
||||
coin_sold=_safe_float(sell.get("coin_sold")),
|
||||
inst_id=inst_id,
|
||||
message="期权已平,币已卖回 USDT",
|
||||
close=True,
|
||||
)
|
||||
return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED}
|
||||
|
||||
if get_db is not None:
|
||||
conn2 = get_db()
|
||||
try:
|
||||
return _apply_bridge(conn2)
|
||||
finally:
|
||||
try:
|
||||
conn2.close()
|
||||
except Exception:
|
||||
pass
|
||||
return _apply_bridge(conn_or_get_db)
|
||||
|
||||
|
||||
def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
|
||||
def clear_dust_pending_sell_bridges(
|
||||
conn_or_get_db: Any,
|
||||
ex: Any | None,
|
||||
) -> int:
|
||||
"""待卖回桥若交易户只剩粉尘(或已空),关闭桥并返回关闭条数.
|
||||
|
||||
推荐传 get_db:余额查询(模拟盘会另开连接)绝不能套在未关闭的业务连接上。
|
||||
"""
|
||||
if ex is None:
|
||||
return 0
|
||||
get_db = conn_or_get_db if callable(conn_or_get_db) else None
|
||||
|
||||
def _pending(c: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
# pending_sell 始终可清粉尘;holding 仅在无对应标的期权仓时清(避免误关持仓中桥)
|
||||
return [
|
||||
b
|
||||
for b in list_open_bridges(c)
|
||||
if str(b.get("status") or "") in (BRIDGE_PENDING_SELL, BRIDGE_HOLDING)
|
||||
]
|
||||
|
||||
live_uly: set[str] = set()
|
||||
positions_ok = False
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import fetch_option_positions
|
||||
|
||||
raw_pos = fetch_option_positions(ex)
|
||||
if raw_pos is not None:
|
||||
positions_ok = True
|
||||
for p in raw_pos 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 inst:
|
||||
live_uly.add(inst.split("-")[0].upper())
|
||||
except Exception:
|
||||
positions_ok = False
|
||||
live_uly = set()
|
||||
|
||||
if get_db is not None:
|
||||
conn = get_db()
|
||||
try:
|
||||
pending = _pending(conn)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
conn = conn_or_get_db
|
||||
pending = _pending(conn)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
to_close: list[dict[str, Any]] = []
|
||||
for b in pending:
|
||||
st = str(b.get("status") or "")
|
||||
uly = str(b.get("underlying") or "ETH").upper() or "ETH"
|
||||
# holding 仅在「确认无该标的期权仓」时才可按粉尘关闭;持仓查询失败绝不关 holding
|
||||
if st == BRIDGE_HOLDING and (not positions_ok or uly in live_uly):
|
||||
continue
|
||||
try:
|
||||
avail = fetch_trading_coin_available(ex, uly)
|
||||
except Exception:
|
||||
continue
|
||||
# 余额查不到时:用桥上记录的买入量判断粉尘,避免永久拦开仓
|
||||
if avail is None:
|
||||
cb = _safe_float(b.get("coin_bought"))
|
||||
if cb is None or is_sellable_spot_coin(ex, uly, cb):
|
||||
continue
|
||||
to_close.append(b)
|
||||
continue
|
||||
if is_sellable_spot_coin(ex, uly, avail):
|
||||
continue
|
||||
to_close.append(b)
|
||||
if not to_close:
|
||||
return 0
|
||||
|
||||
def _close_all(c: sqlite3.Connection) -> int:
|
||||
n = 0
|
||||
for b in to_close:
|
||||
try:
|
||||
update_bridge(
|
||||
c,
|
||||
int(b["id"]),
|
||||
status=BRIDGE_CLOSED,
|
||||
message="粉尘低于现货最小可卖,已忽略",
|
||||
close=True,
|
||||
)
|
||||
n += 1
|
||||
except Exception:
|
||||
pass
|
||||
return n
|
||||
|
||||
if get_db is not None:
|
||||
conn2 = get_db()
|
||||
try:
|
||||
return _close_all(conn2)
|
||||
finally:
|
||||
try:
|
||||
conn2.close()
|
||||
except Exception:
|
||||
pass
|
||||
return _close_all(conn_or_get_db)
|
||||
|
||||
|
||||
def bridge_blocks_new_open_msg(
|
||||
conn: sqlite3.Connection,
|
||||
ex: Any | None = None,
|
||||
) -> str | None:
|
||||
_ = ex # 清粉尘请调用方先 clear_dust_pending_sell_bridges(get_db, ex)
|
||||
bridges = list_open_bridges(conn)
|
||||
if not bridges:
|
||||
return None
|
||||
@@ -363,8 +992,18 @@ def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
|
||||
return "存在未完成的币本位资金桥,暂不可开仓"
|
||||
|
||||
|
||||
def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None:
|
||||
def mode_switch_block_msg(
|
||||
conn: sqlite3.Connection,
|
||||
ex: Any | None = None,
|
||||
*,
|
||||
get_db: Any | None = None,
|
||||
) -> str | None:
|
||||
"""有单笔期权仓或未完成桥时禁止切换本位."""
|
||||
try:
|
||||
if get_db is not None:
|
||||
clear_dust_pending_sell_bridges(get_db, ex)
|
||||
except Exception:
|
||||
pass
|
||||
if has_unfinished_bridge(conn):
|
||||
return "存在未完成的币本位资金桥,禁止切换期权本位模式"
|
||||
if ex is not None:
|
||||
|
||||
@@ -59,10 +59,16 @@ def _underlying_index_usdt(ex: Any, underly: str) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
统计用:币本位 realized_pnl(ETH/BTC) 按指数折成 U;USDC 原样.
|
||||
折算失败的币仓剔除盈亏字段,避免把「币数量」当成 U.
|
||||
def history_pnl_to_usdt(
|
||||
history: list[dict[str, Any]],
|
||||
ex: Any = None,
|
||||
*,
|
||||
prefer_frozen_usdt: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""折 U.
|
||||
|
||||
prefer_frozen_usdt=True:优先行内 realized_pnl_usdt(实盘 exchange freeze / 旧兼容).
|
||||
prefer_frozen_usdt=False:口径 B — 币盈亏 × 平仓卖回价(close_index_px),忽略账户差分 A.
|
||||
"""
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
|
||||
|
||||
@@ -73,6 +79,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
|
||||
if r.get("status") == "open":
|
||||
out.append(r)
|
||||
continue
|
||||
frozen = _safe_float(r.get("realized_pnl_usdt"))
|
||||
if prefer_frozen_usdt and frozen is not None:
|
||||
r["realized_pnl"] = round(float(frozen), 4)
|
||||
out.append(r)
|
||||
continue
|
||||
pnl = _safe_float(r.get("realized_pnl"))
|
||||
if pnl is None:
|
||||
out.append(r)
|
||||
@@ -83,9 +94,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
|
||||
if not ccy:
|
||||
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
|
||||
if ccy in ("ETH", "BTC"):
|
||||
if underly not in idx_cache:
|
||||
idx_cache[underly] = _underlying_index_usdt(ex, underly)
|
||||
idx = idx_cache.get(underly)
|
||||
idx = _safe_float(r.get("close_index_px"))
|
||||
if idx is None or idx <= 0:
|
||||
if underly not in idx_cache:
|
||||
idx_cache[underly] = _underlying_index_usdt(ex, underly)
|
||||
idx = idx_cache.get(underly)
|
||||
if idx is None or idx <= 0:
|
||||
r["realized_pnl"] = None
|
||||
else:
|
||||
@@ -96,6 +109,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
|
||||
return out
|
||||
|
||||
|
||||
def history_pnl_to_usdt_stats(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]:
|
||||
"""数据统计口径 B:期权历史币盈亏 × 平仓卖回价."""
|
||||
return history_pnl_to_usdt(history, ex, prefer_frozen_usdt=False)
|
||||
|
||||
|
||||
def _parse_ts(raw: Any) -> datetime | None:
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
@@ -185,87 +203,635 @@ def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[st
|
||||
}
|
||||
|
||||
|
||||
def compute_options_stats(get_db) -> dict[str, Any]:
|
||||
_STATS_WINDOWS = ("today", "7d", "30d", "all")
|
||||
|
||||
|
||||
def normalize_stats_window(raw: Any) -> str:
|
||||
w = str(raw or "7d").strip().lower()
|
||||
if w in ("1d", "day", "今日"):
|
||||
return "today"
|
||||
if w in ("7", "week"):
|
||||
return "7d"
|
||||
if w in ("30", "month"):
|
||||
return "30d"
|
||||
if w in ("0", "全部", "total"):
|
||||
return "all"
|
||||
return w if w in _STATS_WINDOWS else "7d"
|
||||
|
||||
|
||||
def stats_window_bounds(window: str, *, now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
|
||||
"""返回闭区间起止(本地/业务时区近似用本地 now).all → (None, None)."""
|
||||
w = normalize_stats_window(window)
|
||||
end = now or datetime.now()
|
||||
if w == "all":
|
||||
return None, None
|
||||
if w == "today":
|
||||
start = end.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start, end
|
||||
days = 7 if w == "7d" else 30
|
||||
from datetime import timedelta
|
||||
|
||||
start = end - timedelta(days=days)
|
||||
return start, end
|
||||
|
||||
|
||||
def filter_history_for_stats_window(
|
||||
history: list[dict[str, Any]],
|
||||
window: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""窗口内已平仓 + 当前持仓(open)."""
|
||||
start, end = stats_window_bounds(window, now=now)
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in history or []:
|
||||
if row.get("status") == "open":
|
||||
out.append(row)
|
||||
continue
|
||||
if start is None:
|
||||
out.append(row)
|
||||
continue
|
||||
ts = _parse_ts(row.get("closed_at")) or _parse_ts(row.get("created_at"))
|
||||
if ts is None:
|
||||
continue
|
||||
if ts < start:
|
||||
continue
|
||||
if end is not None and ts > end:
|
||||
continue
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def compute_options_stats_for_window(
|
||||
history: list[dict[str, Any]],
|
||||
window: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
w = normalize_stats_window(window)
|
||||
filtered = filter_history_for_stats_window(history, w, now=now)
|
||||
stats = compute_options_stats_from_history(filtered)
|
||||
start, end = stats_window_bounds(w, now=now)
|
||||
stats["window"] = w
|
||||
stats["window_start"] = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
|
||||
stats["window_end"] = end.strftime("%Y-%m-%d %H:%M:%S") if end else None
|
||||
return stats
|
||||
|
||||
|
||||
def _row_get(row: Any, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
if hasattr(row, "keys") and key in row.keys():
|
||||
return row[key]
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
return default
|
||||
|
||||
|
||||
def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in closed_rows or []:
|
||||
out.append(
|
||||
{
|
||||
"status": "closed",
|
||||
"realized_pnl": _row_get(row, "realized_pnl"),
|
||||
# 口径 B 不读账户 realized_pnl_usdt;保留键位兼容旧调用
|
||||
"realized_pnl_usdt": None,
|
||||
"close_index_px": _row_get(row, "close_index_px"),
|
||||
"created_at": _row_get(row, "created_at"),
|
||||
"closed_at": _row_get(row, "closed_at"),
|
||||
"inst_id": _row_get(row, "inst_id") or "",
|
||||
"underlying": _row_get(row, "underlying") or "",
|
||||
"premium_ccy": _row_get(row, "premium_ccy") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# 口径 B:凡有币盈亏的已平均计入;U 由 history_pnl_to_usdt_stats 按卖回价折算
|
||||
_CLOSED_STATS_SELECT = """
|
||||
SELECT realized_pnl, close_index_px,
|
||||
created_at, closed_at, inst_id, underlying, premium_ccy
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
AND realized_pnl IS NOT NULL
|
||||
"""
|
||||
|
||||
|
||||
def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int:
|
||||
"""旧已平仓缺冻结 U 时按 close_index/当前指数写一次,之后不再跟盘变.
|
||||
|
||||
注意:已有 realized_pnl 视为净额直接冻结,不再二次扣 open_fee/close_fee
|
||||
(避免对 OKX realizedPnl 或已扣费本地净额重复减费).
|
||||
"""
|
||||
init_options_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, realized_pnl, realized_pnl_usdt, close_index_px,
|
||||
inst_id, underlying, premium_ccy
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
AND realized_pnl_usdt IS NULL
|
||||
AND realized_pnl IS NOT NULL
|
||||
AND trading_usdt_before IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 300
|
||||
"""
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
from lib.options.options_pnl_settle_lib import (
|
||||
freeze_pnl_to_usdt,
|
||||
resolve_close_index_px,
|
||||
)
|
||||
|
||||
updated = 0
|
||||
for row in rows:
|
||||
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper()
|
||||
idx = _safe_float(_row_get(row, "close_index_px"))
|
||||
if idx is None or idx <= 0:
|
||||
idx = resolve_close_index_px(
|
||||
ex,
|
||||
inst_id=str(_row_get(row, "inst_id") or ""),
|
||||
underlying=str(_row_get(row, "underlying") or ""),
|
||||
)
|
||||
pnl = _safe_float(_row_get(row, "realized_pnl"))
|
||||
if pnl is None:
|
||||
continue
|
||||
settled = freeze_pnl_to_usdt(pnl, premium_ccy=ccy or "USDC", index_px=idx)
|
||||
usdt = settled.get("realized_pnl_usdt")
|
||||
close_idx = settled.get("close_index_px")
|
||||
if usdt is None:
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET realized_pnl_usdt = ?,
|
||||
close_index_px = COALESCE(close_index_px, ?)
|
||||
WHERE id = ? AND realized_pnl_usdt IS NULL
|
||||
""",
|
||||
(float(usdt), close_idx, int(row["id"])),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def recompute_closed_realized_pnl_usdt(conn, ex: Any = None) -> int:
|
||||
"""强制按「平仓收回×平仓指数 − 开仓成本U − 手续费」重算已平冻结 U.
|
||||
|
||||
旧逻辑用 (收回−已付)×平仓指数,等于把开仓成本也按平仓价重估,ETH 上涨时会偏亏,
|
||||
导致合计盈亏与余额对不上.有 budget_usdt/open_index_px 时优先用真实开仓成本.
|
||||
"""
|
||||
init_options_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, realized_pnl, realized_pnl_usdt, close_index_px, open_index_px,
|
||||
inst_id, underlying, premium_ccy, premium_paid, premium_received,
|
||||
open_fee, close_fee, fee_ccy, budget_usdt, close_quote, eth_amount,
|
||||
open_quote
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
AND premium_paid IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 300
|
||||
"""
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl
|
||||
from lib.options.options_pricing_lib import total_premium
|
||||
|
||||
updated = 0
|
||||
for row in rows:
|
||||
paid = _safe_float(_row_get(row, "premium_paid"))
|
||||
recv = _safe_float(_row_get(row, "premium_received"))
|
||||
if recv is None:
|
||||
cq = _safe_float(_row_get(row, "close_quote"))
|
||||
eth = _safe_float(_row_get(row, "eth_amount"))
|
||||
if cq is not None and eth is not None and eth > 0:
|
||||
recv = float(total_premium(cq, eth))
|
||||
if paid is None or recv is None:
|
||||
continue
|
||||
ofee = abs(_safe_float(_row_get(row, "open_fee")) or 0.0)
|
||||
cfee = abs(_safe_float(_row_get(row, "close_fee")) or 0.0)
|
||||
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper() or "ETH"
|
||||
close_idx = _safe_float(_row_get(row, "close_index_px"))
|
||||
open_idx = _safe_float(_row_get(row, "open_index_px"))
|
||||
budget = _safe_float(_row_get(row, "budget_usdt"))
|
||||
if close_idx is None or close_idx <= 0:
|
||||
close_idx = resolve_close_index_px(
|
||||
ex,
|
||||
inst_id=str(_row_get(row, "inst_id") or ""),
|
||||
underlying=str(_row_get(row, "underlying") or ""),
|
||||
)
|
||||
if (open_idx is None or open_idx <= 0) and budget and paid > 1e-12:
|
||||
open_idx = float(budget) / float(paid)
|
||||
settled = settle_closed_pnl(
|
||||
premium_paid=paid,
|
||||
premium_received=recv,
|
||||
open_fee=ofee,
|
||||
close_fee=cfee,
|
||||
premium_ccy=ccy,
|
||||
index_px=close_idx,
|
||||
open_index_px=open_idx,
|
||||
budget_usdt=budget,
|
||||
fee_ccy=str(_row_get(row, "fee_ccy") or ccy),
|
||||
)
|
||||
new_usdt = settled.get("realized_pnl_usdt")
|
||||
if new_usdt is None:
|
||||
continue
|
||||
old_usdt = _safe_float(_row_get(row, "realized_pnl_usdt"))
|
||||
new_pnl = settled.get("realized_pnl")
|
||||
# 有变化才写,避免无意义刷库
|
||||
if (
|
||||
old_usdt is not None
|
||||
and abs(float(old_usdt) - float(new_usdt)) < 1e-6
|
||||
and _safe_float(_row_get(row, "realized_pnl")) is not None
|
||||
and new_pnl is not None
|
||||
and abs(float(_row_get(row, "realized_pnl")) - float(new_pnl)) < 1e-10
|
||||
):
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET realized_pnl = COALESCE(?, realized_pnl),
|
||||
close_index_px = COALESCE(?, close_index_px),
|
||||
open_index_px = COALESCE(open_index_px, ?),
|
||||
realized_pnl_usdt = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
new_pnl,
|
||||
settled.get("close_index_px"),
|
||||
settled.get("open_index_px"),
|
||||
float(new_usdt),
|
||||
int(row["id"]),
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int:
|
||||
"""兼容旧名:改为强制按开仓成本重算冻结 U."""
|
||||
return recompute_closed_realized_pnl_usdt(conn, ex)
|
||||
|
||||
|
||||
def _sim_net_usdt_converts_for_trade(
|
||||
conn,
|
||||
*,
|
||||
underlying: str,
|
||||
start_ts: str,
|
||||
end_ts: str,
|
||||
) -> float | None:
|
||||
"""从 sim_ledger 汇总该标的现货兑换的 USDT 净变动(买币为负、卖回为正)."""
|
||||
coin = (underlying or "").strip().upper()
|
||||
if coin not in ("ETH", "BTC") or not start_ts or not end_ts:
|
||||
return None
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT amount, note FROM sim_ledger_entries
|
||||
WHERE kind = 'convert'
|
||||
AND UPPER(ccy) = 'USDT'
|
||||
AND account = 'trading'
|
||||
AND ts >= ?
|
||||
AND ts <= ?
|
||||
""",
|
||||
(str(start_ts), str(end_ts)),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return None
|
||||
total = 0.0
|
||||
n = 0
|
||||
for row in rows or []:
|
||||
note = str(_row_get(row, "note") or "").upper()
|
||||
amt = _safe_float(_row_get(row, "amount"))
|
||||
if amt is None:
|
||||
continue
|
||||
related = (
|
||||
f"{coin}/USDT" in note
|
||||
or f"{coin}-USDT" in note
|
||||
or note.strip() == f"FROM {coin}"
|
||||
)
|
||||
if not related:
|
||||
continue
|
||||
total += float(amt)
|
||||
n += 1
|
||||
if n < 1:
|
||||
return None
|
||||
return round(total, 4)
|
||||
|
||||
|
||||
def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
|
||||
"""兼容入口:已按交易账户结算(pnl_settled=1)的永不改写.
|
||||
|
||||
有 trading_usdt_before 的待结算单留给卖回结算;
|
||||
仅无账户快照的旧单才回退权利金×指数冻结.
|
||||
"""
|
||||
del ex
|
||||
init_options_tables(conn)
|
||||
# 账户快照路径由 sell_residual / maybe_sell 结算,此处不覆盖
|
||||
return 0
|
||||
|
||||
|
||||
def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]:
|
||||
"""本地已平(模拟盘口径) + 当前 open."""
|
||||
init_options_tables(conn)
|
||||
try:
|
||||
# 权利金净盈亏冻结 U(纠正旧版把买币预算差额当盈利的数据)
|
||||
sync_sim_cash_realized_pnl_usdt(conn, ex)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
freeze_missing_realized_pnl_usdt(conn, ex)
|
||||
except Exception:
|
||||
pass
|
||||
closed_rows = conn.execute(_CLOSED_STATS_SELECT).fetchall()
|
||||
open_rows = conn.execute(
|
||||
"SELECT created_at FROM options_trades WHERE status = 'open'"
|
||||
).fetchall()
|
||||
history = _closed_trade_dicts(closed_rows)
|
||||
for row in open_rows:
|
||||
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
|
||||
return history
|
||||
|
||||
|
||||
def _load_exchange_pnl_freeze_map(conn) -> dict[str, dict[str, Any]]:
|
||||
init_options_tables(conn)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT history_key, realized_pnl_usdt, close_index_px, realized_pnl
|
||||
FROM options_exchange_pnl_freeze
|
||||
"""
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return out
|
||||
for row in rows or []:
|
||||
key = str(_row_get(row, "history_key") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
out[key] = {
|
||||
"realized_pnl_usdt": _row_get(row, "realized_pnl_usdt"),
|
||||
"close_index_px": _row_get(row, "close_index_px"),
|
||||
"realized_pnl": _row_get(row, "realized_pnl"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def apply_exchange_pnl_freeze(
|
||||
conn,
|
||||
history: list[dict[str, Any]],
|
||||
ex: Any = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""写冻结表.行内已有 close_index_px / _freeze_index_hint 时不打网.
|
||||
|
||||
若仍缺指数且传了 ex,会在持连接时补拉(仅兼容旧单测).生产请用
|
||||
load_live_exchange_stats_history(先网后短事务).
|
||||
"""
|
||||
freeze_map = _load_exchange_pnl_freeze_map(conn)
|
||||
enriched: list[dict[str, Any]] = []
|
||||
need_idx: dict[str, str] = {}
|
||||
for row in history or []:
|
||||
r = dict(row)
|
||||
if r.get("status") == "open":
|
||||
enriched.append(r)
|
||||
continue
|
||||
key = str(r.get("history_key") or "").strip()
|
||||
pnl = _safe_float(r.get("realized_pnl"))
|
||||
if (
|
||||
key
|
||||
and pnl is not None
|
||||
and not (
|
||||
key in freeze_map
|
||||
and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None
|
||||
)
|
||||
and _safe_float(r.get("close_index_px")) is None
|
||||
and _safe_float(r.get("_freeze_index_hint")) is None
|
||||
):
|
||||
underly = str(r.get("underlying") or "").strip().upper()
|
||||
inst = str(r.get("inst_id") or "")
|
||||
if not underly and inst:
|
||||
underly = inst.split("-")[0].strip().upper()
|
||||
if underly:
|
||||
need_idx[underly] = inst
|
||||
enriched.append(r)
|
||||
|
||||
if need_idx and ex is not None:
|
||||
from lib.options.options_pnl_settle_lib import resolve_close_index_px
|
||||
|
||||
idx_cache: dict[str, float | None] = {}
|
||||
for underly, inst in need_idx.items():
|
||||
idx_cache[underly] = resolve_close_index_px(
|
||||
ex, inst_id=inst, underlying=underly
|
||||
)
|
||||
for r in enriched:
|
||||
if r.get("status") == "open":
|
||||
continue
|
||||
underly = str(r.get("underlying") or "").strip().upper()
|
||||
inst = str(r.get("inst_id") or "")
|
||||
if not underly and inst:
|
||||
underly = inst.split("-")[0].strip().upper()
|
||||
if underly in idx_cache and _safe_float(r.get("close_index_px")) is None:
|
||||
r["_freeze_index_hint"] = idx_cache.get(underly)
|
||||
|
||||
return _apply_exchange_pnl_freeze_with_hints(conn, enriched, freeze_map)
|
||||
|
||||
|
||||
def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]:
|
||||
"""模拟盘数据统计:口径 B(历史币盈亏×卖回价)."""
|
||||
conn = get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
closed_rows = conn.execute(
|
||||
"""
|
||||
SELECT realized_pnl, created_at, closed_at
|
||||
FROM options_trades
|
||||
WHERE status = 'closed' AND realized_pnl IS NOT NULL
|
||||
"""
|
||||
).fetchall()
|
||||
open_rows = conn.execute(
|
||||
"""
|
||||
SELECT created_at FROM options_trades WHERE status = 'open'
|
||||
"""
|
||||
).fetchall()
|
||||
return _stats_from_option_trade_rows(closed_rows, open_rows)
|
||||
history = load_local_options_stats_history(conn, ex)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _stats_from_option_trade_rows(closed_rows, open_rows) -> dict[str, Any]:
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
win_holds: list[float] = []
|
||||
loss_holds: list[float] = []
|
||||
all_holds: list[float] = []
|
||||
now = datetime.now()
|
||||
def load_live_exchange_stats_history(cfg: dict[str, Any], ex: Any) -> list[dict[str, Any]]:
|
||||
"""实盘统计用历史:先拉交易所,再无连接时取指数,最后短事务写冻结.
|
||||
|
||||
for row in closed_rows:
|
||||
pnl = float(row["realized_pnl"])
|
||||
hold = _hold_seconds(row["created_at"], row["closed_at"])
|
||||
if hold is not None:
|
||||
all_holds.append(hold)
|
||||
if pnl > 0:
|
||||
wins.append(pnl)
|
||||
if hold is not None:
|
||||
win_holds.append(hold)
|
||||
elif pnl < 0:
|
||||
losses.append(pnl)
|
||||
if hold is not None:
|
||||
loss_holds.append(hold)
|
||||
禁止在持有 DB 连接时打行情/历史网,否则会拖死持仓与中控轮询.
|
||||
"""
|
||||
from lib.options.options_history_lib import load_options_history
|
||||
from lib.options.options_pnl_settle_lib import resolve_close_index_px
|
||||
|
||||
open_holds: list[float] = []
|
||||
for row in open_rows:
|
||||
start = _parse_ts(row["created_at"])
|
||||
if start is None:
|
||||
history = load_options_history(ex, cfg) or []
|
||||
get_db = cfg["get_db"]
|
||||
conn = get_db()
|
||||
try:
|
||||
freeze_map = _load_exchange_pnl_freeze_map(conn)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
need_idx: dict[str, str] = {}
|
||||
for row in history:
|
||||
if row.get("status") == "open":
|
||||
continue
|
||||
sec = (now - start).total_seconds()
|
||||
if sec >= 0:
|
||||
open_holds.append(sec)
|
||||
key = str(row.get("history_key") or "").strip()
|
||||
if not key or _safe_float(row.get("realized_pnl")) is None:
|
||||
continue
|
||||
if key in freeze_map and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None:
|
||||
continue
|
||||
underly = str(row.get("underlying") or "").strip().upper()
|
||||
inst = str(row.get("inst_id") or "")
|
||||
if not underly and inst:
|
||||
underly = inst.split("-")[0].strip().upper()
|
||||
if underly and _safe_float(row.get("close_index_px")) is None:
|
||||
need_idx[underly] = inst
|
||||
|
||||
total_closed = len(wins) + len(losses)
|
||||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
idx_cache: dict[str, float | None] = {}
|
||||
for underly, inst in need_idx.items():
|
||||
idx_cache[underly] = resolve_close_index_px(ex, inst_id=inst, underlying=underly)
|
||||
|
||||
total_profit = round(sum(wins), 4) if wins else 0.0
|
||||
total_loss = round(abs(sum(losses)), 4) if losses else 0.0
|
||||
net_realized = round(sum(wins) + sum(losses), 4)
|
||||
return {
|
||||
"total_closed": total_closed,
|
||||
"win_count": len(wins),
|
||||
"loss_count": len(losses),
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
"avg_win": round(avg_win, 4) if avg_win is not None else None,
|
||||
"avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None,
|
||||
"total_profit": total_profit,
|
||||
"total_loss": total_loss,
|
||||
"net_realized_pnl": net_realized,
|
||||
"avg_hold_sec": _avg_seconds(all_holds),
|
||||
"avg_win_hold_sec": _avg_seconds(win_holds),
|
||||
"avg_loss_hold_sec": _avg_seconds(loss_holds),
|
||||
"open_count": len(open_holds),
|
||||
"avg_open_hold_sec": _avg_seconds(open_holds),
|
||||
}
|
||||
enriched: list[dict[str, Any]] = []
|
||||
for row in history:
|
||||
r = dict(row)
|
||||
if r.get("status") != "open":
|
||||
underly = str(r.get("underlying") or "").strip().upper()
|
||||
inst = str(r.get("inst_id") or "")
|
||||
if not underly and inst:
|
||||
underly = inst.split("-")[0].strip().upper()
|
||||
key = str(r.get("history_key") or "").strip()
|
||||
if underly in idx_cache and _safe_float(r.get("close_index_px")) is None:
|
||||
if not (
|
||||
key in freeze_map
|
||||
and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None
|
||||
):
|
||||
r["_freeze_index_hint"] = idx_cache.get(underly)
|
||||
enriched.append(r)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
out = _apply_exchange_pnl_freeze_with_hints(conn, enriched, freeze_map)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]:
|
||||
"""顶栏总交易/胜率/盈亏比:纯期权(options_trades),全站导航共用,按列表窗过滤."""
|
||||
def compute_options_stats_live_exchange(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
|
||||
"""实盘:与「期权历史」同一数据源;口径 B=币盈亏×卖回价(freeze)."""
|
||||
history = load_live_exchange_stats_history(cfg, ex)
|
||||
return compute_options_stats_from_history(
|
||||
history_pnl_to_usdt(history, ex, prefer_frozen_usdt=True)
|
||||
)
|
||||
|
||||
|
||||
def _apply_exchange_pnl_freeze_with_hints(
|
||||
conn,
|
||||
history: list[dict[str, Any]],
|
||||
freeze_map: dict[str, dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""短事务写冻结;指数只用来自行内 _freeze_index_hint / 已有 close_index_px."""
|
||||
from lib.options.options_pnl_settle_lib import settle_exchange_realized_pnl
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
init_options_tables(conn)
|
||||
fmap = freeze_map if freeze_map is not None else _load_exchange_pnl_freeze_map(conn)
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in history or []:
|
||||
r = dict(row)
|
||||
hint = r.pop("_freeze_index_hint", None)
|
||||
if r.get("status") == "open":
|
||||
out.append(r)
|
||||
continue
|
||||
key = str(r.get("history_key") or "").strip()
|
||||
pnl = _safe_float(r.get("realized_pnl"))
|
||||
if not key or pnl is None:
|
||||
out.append(r)
|
||||
continue
|
||||
frozen = fmap.get(key)
|
||||
if frozen is not None and _safe_float(frozen.get("realized_pnl_usdt")) is not None:
|
||||
r["realized_pnl_usdt"] = frozen.get("realized_pnl_usdt")
|
||||
r["close_index_px"] = frozen.get("close_index_px")
|
||||
out.append(r)
|
||||
continue
|
||||
ccy = str(r.get("premium_ccy") or "").strip().upper()
|
||||
idx = _safe_float(r.get("close_index_px")) or _safe_float(hint)
|
||||
settled = settle_exchange_realized_pnl(
|
||||
realized_pnl=pnl,
|
||||
premium_ccy=ccy or "ETH",
|
||||
index_px=idx,
|
||||
)
|
||||
usdt = settled.get("realized_pnl_usdt")
|
||||
close_idx = settled.get("close_index_px")
|
||||
if usdt is not None:
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_exchange_pnl_freeze(
|
||||
history_key, realized_pnl, realized_pnl_usdt, close_index_px,
|
||||
premium_ccy, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(history_key) DO UPDATE SET
|
||||
realized_pnl = excluded.realized_pnl,
|
||||
realized_pnl_usdt = excluded.realized_pnl_usdt,
|
||||
close_index_px = excluded.close_index_px,
|
||||
premium_ccy = excluded.premium_ccy,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
key,
|
||||
float(pnl),
|
||||
float(usdt),
|
||||
close_idx,
|
||||
ccy or "",
|
||||
options_app_now_str(),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
r["realized_pnl_usdt"] = float(usdt)
|
||||
r["close_index_px"] = close_idx
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def compute_options_stats_auto(cfg: dict[str, Any], ex: Any = None) -> dict[str, Any]:
|
||||
"""模拟盘走本地结算;实盘走交易所期权历史原生盈亏."""
|
||||
get_db = cfg.get("get_db")
|
||||
try:
|
||||
from lib.sim.mode_lib import is_sim_mode
|
||||
|
||||
if callable(get_db) and is_sim_mode(get_db):
|
||||
return compute_options_stats(get_db, ex)
|
||||
except Exception:
|
||||
pass
|
||||
if ex is None:
|
||||
ex = cfg.get("exchange_options")
|
||||
return compute_options_stats_live_exchange(cfg, ex)
|
||||
|
||||
|
||||
def _stats_from_option_trade_rows(closed_rows, open_rows, *, ex: Any = None) -> dict[str, Any]:
|
||||
"""兼容旧调用;内部统一折 U 后走 compute_options_stats_from_history."""
|
||||
history = _closed_trade_dicts(closed_rows)
|
||||
for row in open_rows or []:
|
||||
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
|
||||
return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
|
||||
|
||||
|
||||
def header_options_stats_for_window(
|
||||
conn,
|
||||
list_window: dict[str, Any],
|
||||
app_tz,
|
||||
ex: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""顶栏总交易/胜率/盈亏比:口径 B(历史币盈亏×卖回价)."""
|
||||
from lib.common.history_window_lib import utc_window_to_bj_sql_strings
|
||||
|
||||
init_options_tables(conn)
|
||||
@@ -274,7 +840,8 @@ def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) -
|
||||
)
|
||||
closed_rows = conn.execute(
|
||||
"""
|
||||
SELECT realized_pnl, created_at, closed_at
|
||||
SELECT realized_pnl, close_index_px,
|
||||
created_at, closed_at, inst_id, underlying, premium_ccy
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
AND realized_pnl IS NOT NULL
|
||||
@@ -286,7 +853,10 @@ def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) -
|
||||
open_rows = conn.execute(
|
||||
"SELECT created_at FROM options_trades WHERE status = 'open'"
|
||||
).fetchall()
|
||||
stats = _stats_from_option_trade_rows(closed_rows, open_rows)
|
||||
history = _closed_trade_dicts(closed_rows)
|
||||
for row in open_rows:
|
||||
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
|
||||
stats = compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
|
||||
return {
|
||||
"total": int(stats.get("total_closed") or 0),
|
||||
"rate": float(stats.get("win_rate") or 0),
|
||||
|
||||
@@ -360,7 +360,7 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def run_options_target_closes(
|
||||
conn: sqlite3.Connection,
|
||||
conn_or_get_db: Any,
|
||||
positions: list[dict[str, Any]],
|
||||
*,
|
||||
close_fn: Callable[[str], dict[str, Any]],
|
||||
@@ -371,17 +371,17 @@ def run_options_target_closes(
|
||||
) -> int:
|
||||
"""
|
||||
扫描 active 目标委托;指数到位后限价平仓.
|
||||
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
||||
未完全成交进入 closing,仅重试平仓不再推送.
|
||||
返回本次新触发(并推送)的条数.
|
||||
可传 get_db:平仓在无业务连接时执行,避免嵌套锁库。
|
||||
"""
|
||||
get_db = conn_or_get_db if callable(conn_or_get_db) else None
|
||||
conn = conn_or_get_db() if get_db else conn_or_get_db
|
||||
ensure_target_tables(conn)
|
||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||
live_ids = {k for k in pos_by_inst if k}
|
||||
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
|
||||
_commit_monitor(conn)
|
||||
|
||||
# 先处理已挂单等待成交的,绝不再发微信
|
||||
retry_jobs: list[dict[str, Any]] = []
|
||||
for mon in list_closing_targets(conn):
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
if not inst_id:
|
||||
@@ -390,30 +390,9 @@ def run_options_target_closes(
|
||||
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
result = close_fn(inst_id)
|
||||
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
|
||||
if result.get("already_flat") or _result_fully_done(result):
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status="triggered",
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message="目标位限价平仓完成",
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status="closing",
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
retry_jobs.append(dict(mon))
|
||||
|
||||
triggered = 0
|
||||
trigger_jobs: list[tuple[dict[str, Any], float, float]] = []
|
||||
for mon in list_active_targets(conn):
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
target = _safe_float(mon.get("target_index"))
|
||||
@@ -431,35 +410,80 @@ def run_options_target_closes(
|
||||
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
||||
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
|
||||
continue
|
||||
trigger_jobs.append((dict(mon), float(idx), float(target)))
|
||||
_commit_monitor(conn)
|
||||
if get_db is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = None
|
||||
|
||||
def _ensure_conn() -> sqlite3.Connection:
|
||||
nonlocal conn
|
||||
if conn is not None:
|
||||
return conn
|
||||
assert get_db is not None
|
||||
conn = get_db()
|
||||
ensure_target_tables(conn)
|
||||
return conn
|
||||
|
||||
for mon in retry_jobs:
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
result = close_fn(inst_id)
|
||||
c = _ensure_conn()
|
||||
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
|
||||
if result.get("already_flat") or _result_fully_done(result):
|
||||
mark_monitor(
|
||||
c,
|
||||
int(mon["id"]),
|
||||
status="triggered",
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message="目标位限价平仓完成",
|
||||
)
|
||||
else:
|
||||
mark_monitor(
|
||||
c,
|
||||
int(mon["id"]),
|
||||
status="closing",
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
|
||||
)
|
||||
_commit_monitor(c)
|
||||
|
||||
triggered = 0
|
||||
for mon, idx, target in trigger_jobs:
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
result = close_fn(inst_id)
|
||||
c = _ensure_conn()
|
||||
if result.get("already_flat"):
|
||||
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
||||
_commit_monitor(conn)
|
||||
mark_monitor(c, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
||||
_commit_monitor(c)
|
||||
continue
|
||||
if not result.get("ok"):
|
||||
mark_monitor(
|
||||
conn,
|
||||
c,
|
||||
int(mon["id"]),
|
||||
status="active",
|
||||
trigger_idx=idx,
|
||||
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
_commit_monitor(c)
|
||||
continue
|
||||
|
||||
done = _result_fully_done(result)
|
||||
status = "triggered" if done else "closing"
|
||||
mark_monitor(
|
||||
conn,
|
||||
c,
|
||||
int(mon["id"]),
|
||||
status=status,
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
|
||||
)
|
||||
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
|
||||
_commit_monitor(conn)
|
||||
_commit_monitor(c)
|
||||
triggered += 1
|
||||
_notify_target_close(
|
||||
cfg,
|
||||
@@ -469,6 +493,11 @@ def run_options_target_closes(
|
||||
target=target,
|
||||
idx=idx,
|
||||
result=result,
|
||||
conn=conn,
|
||||
conn=c,
|
||||
)
|
||||
if get_db is not None and conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return triggered
|
||||
|
||||
@@ -112,11 +112,16 @@ def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str,
|
||||
|
||||
init_options_tables(conn)
|
||||
trades = _shift_table("options_trades", ("created_at", "closed_at"))
|
||||
except sqlite3.OperationalError:
|
||||
return {"skipped": 0, "trades": 0, "review": 0, "hidden": 0, "locked": 1}
|
||||
except Exception:
|
||||
trades = 0
|
||||
|
||||
review = _shift_table("options_review_trades", ("opened_at", "closed_at"))
|
||||
hidden = _shift_table("options_review_hidden", ("closed_at",))
|
||||
try:
|
||||
review = _shift_table("options_review_trades", ("opened_at", "closed_at"))
|
||||
hidden = _shift_table("options_review_hidden", ("closed_at",))
|
||||
except sqlite3.OperationalError:
|
||||
return {"skipped": 0, "trades": trades, "review": 0, "hidden": 0, "locked": 1}
|
||||
|
||||
set_sync_state(conn, _MIGRATE_FLAG, "1")
|
||||
return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden}
|
||||
@@ -129,7 +134,7 @@ def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str,
|
||||
幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修).
|
||||
"""
|
||||
from lib.options.options_review_db import init_options_review_tables
|
||||
from lib.options.options_review_lib import set_sync_state
|
||||
from lib.options.options_review_lib import get_sync_state, set_sync_state
|
||||
|
||||
init_options_review_tables(conn)
|
||||
trades_fixed = 0
|
||||
@@ -174,9 +179,13 @@ def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str,
|
||||
(note, hk, closed),
|
||||
)
|
||||
review_fixed += int(cur.rowcount or 0)
|
||||
except sqlite3.OperationalError:
|
||||
# 并发刷新撞锁时跳过本轮修复,不影响列表读取
|
||||
return {"trades": 0, "review": 0, "locked": 1}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 复盘表里仍残留非时间戳 closed_at 的,若能从 local_opt 源修好则已处理;否则仅标记完成
|
||||
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
|
||||
# 已标记过则不再写同步状态,减少并发写冲突
|
||||
if get_sync_state(conn, _SWAP_REPAIR_FLAG) != "1":
|
||||
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
|
||||
return {"trades": trades_fixed, "review": review_fixed}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""期权账户内划转执行(本机设置 / 中控 Bridge 共用)."""
|
||||
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 _pick_stable(bal: dict[str, Any] | None, *, account: str, ccy: str) -> float | None:
|
||||
if not isinstance(bal, dict):
|
||||
return None
|
||||
acct = "trading" if account == "trading" else "funding"
|
||||
ccy_l = (ccy or "usdt").strip().lower() or "usdt"
|
||||
avail = _safe_float(bal.get(f"{acct}_{ccy_l}_avail"))
|
||||
if avail is not None:
|
||||
return round(float(avail), 8)
|
||||
total = _safe_float(bal.get(f"{acct}_{ccy_l}"))
|
||||
if total is not None:
|
||||
return round(float(total), 8)
|
||||
return None
|
||||
|
||||
|
||||
def execute_options_internal_transfer(
|
||||
cfg: dict[str, Any],
|
||||
*,
|
||||
amount: Any,
|
||||
from_acct: str = "funding",
|
||||
to_acct: str = "trading",
|
||||
ccy: str = "USDT",
|
||||
) -> dict[str, Any]:
|
||||
"""资金户↔交易户划转;持仓/待卖回拒绝.成功后附带最新 USDT 余额."""
|
||||
from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
ex = cfg.get("exchange_options")
|
||||
ready_fn = cfg.get("options_api_ready")
|
||||
if callable(ready_fn):
|
||||
ok, reason = ready_fn(ex)
|
||||
if not ok:
|
||||
return {"ok": False, "msg": reason or "期权 API 未配置"}
|
||||
if ex is None:
|
||||
return {"ok": False, "msg": "交易所未就绪"}
|
||||
|
||||
src = (from_acct or "funding").strip().lower()
|
||||
dst = (to_acct or "trading").strip().lower()
|
||||
if src not in ("funding", "trading") or dst not in ("funding", "trading"):
|
||||
return {"ok": False, "msg": "仅支持 funding/trading"}
|
||||
if src == dst:
|
||||
return {"ok": False, "msg": "转出与转入账户不能相同"}
|
||||
ccy_u = (ccy or "USDT").strip().upper() or "USDT"
|
||||
if ccy_u not in ("USDT", "USDC"):
|
||||
return {"ok": False, "msg": "仅支持 USDT/USDC"}
|
||||
amt = _safe_float(amount)
|
||||
if amt is None or amt <= 0:
|
||||
return {"ok": False, "msg": "数量无效"}
|
||||
|
||||
get_db = cfg.get("get_db")
|
||||
if not callable(get_db):
|
||||
return {"ok": False, "msg": "数据库未就绪"}
|
||||
conn = get_db()
|
||||
try:
|
||||
block = transfer_blocked_by_position_msg(conn, ex)
|
||||
if block:
|
||||
return {"ok": False, "msg": block, "transfer_blocked": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
xfer = cfg.get("transfer_ccy")
|
||||
if not callable(xfer):
|
||||
try:
|
||||
import lib.exchange.okx_options_lib as _opt_lib
|
||||
|
||||
xfer = getattr(_opt_lib, "transfer_ccy", None)
|
||||
except Exception:
|
||||
xfer = None
|
||||
if not callable(xfer):
|
||||
return {"ok": False, "msg": "划转不可用"}
|
||||
|
||||
result = xfer(ex, ccy_u, float(amt), src, dst)
|
||||
if not isinstance(result, dict):
|
||||
return {"ok": False, "msg": "划转返回异常"}
|
||||
out = dict(result)
|
||||
if not out.get("ok"):
|
||||
out.setdefault("msg", "划转失败")
|
||||
return out
|
||||
|
||||
conn2 = get_db()
|
||||
try:
|
||||
init_options_tables(conn2)
|
||||
conn2.execute(
|
||||
"""
|
||||
INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
|
||||
VALUES (?, ?, ?, ?, 'ok', ?)
|
||||
""",
|
||||
(ccy_u, float(amt), src, dst, ""),
|
||||
)
|
||||
conn2.commit()
|
||||
except Exception:
|
||||
try:
|
||||
conn2.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn2.close()
|
||||
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
|
||||
from lib.instance.instance_live_push_lib import notify_instance_balance_changed
|
||||
|
||||
invalidate_options_balance_cache()
|
||||
app_mod = cfg.get("app_module")
|
||||
if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"):
|
||||
app_mod.invalidate_account_balance_cache()
|
||||
notify_instance_balance_changed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bal = {}
|
||||
try:
|
||||
fetch_bal = cfg.get("fetch_options_balances")
|
||||
if callable(fetch_bal):
|
||||
bal = fetch_bal(ex) or {}
|
||||
except Exception:
|
||||
bal = {}
|
||||
trading_usdt = _pick_stable(bal, account="trading", ccy="USDT")
|
||||
funding_usdt = _pick_stable(bal, account="funding", ccy="USDT")
|
||||
out.update(
|
||||
{
|
||||
"ok": True,
|
||||
"msg": out.get("msg") or "划转成功",
|
||||
"ccy": ccy_u,
|
||||
"amount": round(float(amt), 8),
|
||||
"from": src,
|
||||
"to": dst,
|
||||
"trading_usdt": trading_usdt,
|
||||
"funding_usdt": funding_usdt,
|
||||
"transfer_blocked": False,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,56 @@
|
||||
"""期权标的白名单:OKX_OPTIONS_UNDERLYINGS + DEFAULT_UNDERLY."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
_ALLOWED = ("ETH", "BTC")
|
||||
|
||||
|
||||
def parse_options_underlyings() -> list[str]:
|
||||
"""env 白名单;空或缺省为 ETH,BTC.仅认 ETH/BTC."""
|
||||
raw = (os.getenv("OKX_OPTIONS_UNDERLYINGS") or "").strip()
|
||||
if not raw:
|
||||
return ["ETH", "BTC"]
|
||||
out: list[str] = []
|
||||
for part in raw.replace(";", ",").split(","):
|
||||
u = part.strip().upper()
|
||||
if u in _ALLOWED and u not in out:
|
||||
out.append(u)
|
||||
return out or ["ETH", "BTC"]
|
||||
|
||||
|
||||
def resolve_default_underly(allowed: list[str] | None = None) -> str:
|
||||
"""DEFAULT 落在白名单内;否则取白名单第一项."""
|
||||
allow = list(allowed) if allowed is not None else parse_options_underlyings()
|
||||
if not allow:
|
||||
allow = ["ETH", "BTC"]
|
||||
d = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
|
||||
if d in allow:
|
||||
return d
|
||||
return allow[0]
|
||||
|
||||
|
||||
def underly_from_inst_id(inst_id: str) -> str:
|
||||
return (str(inst_id or "").split("-")[0] or "").strip().upper()
|
||||
|
||||
|
||||
def underly_not_allowed_msg(underlying: str, allowed: list[str] | None = None) -> str | None:
|
||||
"""白名单外返回拒因;允许则 None."""
|
||||
allow = list(allowed) if allowed is not None else parse_options_underlyings()
|
||||
u = (underlying or "").strip().upper()
|
||||
if u in allow:
|
||||
return None
|
||||
return f"标的 {u or '—'} 不在本机白名单({','.join(allow) or '—'})"
|
||||
|
||||
|
||||
def inst_underly_not_allowed_msg(inst_id: str, allowed: list[str] | None = None) -> str | None:
|
||||
return underly_not_allowed_msg(underly_from_inst_id(inst_id), allowed)
|
||||
|
||||
|
||||
def underly_cfg_snapshot() -> dict[str, Any]:
|
||||
allow = parse_options_underlyings()
|
||||
return {
|
||||
"allowed_underlys": allow,
|
||||
"default_underly": resolve_default_underly(allow),
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
{% set _uly_allowed = options_allowed_underlys if options_allowed_underlys is defined and options_allowed_underlys else ['ETH', 'BTC'] %}
|
||||
{% set _uly_default = options_default_underly | default(_uly_allowed[0] if _uly_allowed else 'ETH') %}
|
||||
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
||||
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
|
||||
data-default-underly="{{ _uly_default }}"
|
||||
data-allowed-underlys="{{ _uly_allowed | join(',') }}"
|
||||
data-budget-buffer="{{ options_budget_buffer | default(1) }}"
|
||||
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
|
||||
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
|
||||
@@ -13,6 +16,15 @@
|
||||
{% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
|
||||
<div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div>
|
||||
{% endif %}
|
||||
<div class="opt-cond-order-card" id="opt-cond-order-card" hidden>
|
||||
<div class="opt-cond-order-head">
|
||||
<strong>中控条件单</strong>
|
||||
<span class="opt-cond-st" id="opt-cond-order-st"></span>
|
||||
<span class="opt-cond-lock-note">已接收 · 禁止手动开仓</span>
|
||||
</div>
|
||||
<div class="opt-cond-order-grid" id="opt-cond-order-grid"></div>
|
||||
<div class="opt-cond-order-msg" id="opt-cond-order-msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="options-dual-grid">
|
||||
<div class="card options-order-card"{% if options_open_allowed is defined and not options_open_allowed %} style="opacity:.72"{% endif %}>
|
||||
@@ -27,9 +39,9 @@
|
||||
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
|
||||
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
|
||||
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
|
||||
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
|
||||
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(1)|float) }}</strong> 算张数(env 可改)。默认选中打满。</li>
|
||||
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
|
||||
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code> 默认):按最大可开张数×卖一权利金×现货缓冲(<code>OKX_OPTIONS_COIN_SPOT_BUY_BUFFER</code> 默认1.10)买 ETH/BTC,不全额兑换;平仓后卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。</li>
|
||||
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code> 默认):按最大可开张数×卖一权利金×现货缓冲(<code>OKX_OPTIONS_COIN_SPOT_BUY_BUFFER</code> 默认1.05)买 ETH/BTC,不全额兑换;平仓后卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。</li>
|
||||
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
|
||||
</ul>
|
||||
<p class="options-settings-subtitle" style="margin:10px 0 4px">买一平仓</p>
|
||||
@@ -45,8 +57,9 @@
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row options-chain-toolbar">
|
||||
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
||||
{% for u in _uly_allowed %}
|
||||
<button type="button" class="btn-secondary opt-uly-btn{% if u == _uly_default %} active{% endif %}" data-uly="{{ u }}">{{ u }}</button>
|
||||
{% endfor %}
|
||||
<select id="opt-exp-select"><option value="">选择到期日</option></select>
|
||||
<span class="opt-chain-view-group">
|
||||
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
|
||||
@@ -74,6 +87,7 @@
|
||||
<th>合约</th>
|
||||
<th>卖一/张</th>
|
||||
<th title="USDC:指数÷卖一;币本位:1÷卖一(卖一为币报价)">杠杆</th>
|
||||
<th title="OKX 行情 Delta(约 -1~1)">Delta</th>
|
||||
<th>买一/张</th>
|
||||
<th>到期平衡</th>
|
||||
<th>平衡价差</th>
|
||||
@@ -97,7 +111,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-strike-tbody">
|
||||
<tr><td colspan="9" class="muted">请选择到期日</td></tr>
|
||||
<tr><td colspan="10" class="muted">请选择到期日</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -150,13 +164,13 @@
|
||||
<div class="form-row options-order-mode-row">
|
||||
<div class="opt-size-mode-bar">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
|
||||
<input type="radio" name="opt-size-mode" value="sheets">
|
||||
<span>指定张数</span>
|
||||
</label>
|
||||
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
|
||||
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}>
|
||||
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
|
||||
<input type="radio" name="opt-size-mode" value="budget_full"{% if not compound_on %} checked{% endif %}{% if compound_on %} disabled{% endif %}>
|
||||
<span>按可用余额打满</span>
|
||||
</label>
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}>
|
||||
@@ -197,7 +211,7 @@
|
||||
<div class="options-pos-head-actions">
|
||||
<div id="opt-spot-sell-head" class="opt-spot-sell-head" hidden>
|
||||
<span class="opt-spot-sell-avail" id="opt-spot-sell-avail-text"></span>
|
||||
<button type="button" class="btn-primary opt-spot-sell-retry-btn" id="opt-spot-sell-retry-btn">重试卖回 ETH</button>
|
||||
<button type="button" class="btn-primary opt-spot-sell-retry-btn" id="opt-spot-sell-retry-btn">重试卖回</button>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary opt-pos-refresh-btn" id="opt-refresh-positions" title="刷新" aria-label="刷新">↻</button>
|
||||
</div>
|
||||
@@ -336,7 +350,7 @@
|
||||
<th>张数</th>
|
||||
<th>权利金</th>
|
||||
<th>状态</th>
|
||||
<th>盈亏</th>
|
||||
<th title="仅期权腿币收益,不含现货买/卖回">盈亏</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -355,4 +369,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/options_panel.js?v=65"></script>
|
||||
<script src="/static/options_panel.js?v=73"></script>
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
}
|
||||
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
|
||||
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem;overflow-x:auto}
|
||||
.or-reviewed-table{min-width:980px}
|
||||
.or-reviewed-table{min-width:1180px}
|
||||
.or-trades-table{min-width:1080px}
|
||||
.or-kpi-row{
|
||||
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
|
||||
gap:8px;margin-bottom:12px;
|
||||
@@ -228,7 +229,7 @@
|
||||
<span class="or-step" aria-hidden="true">1</span>
|
||||
<div>
|
||||
<div class="or-section-title" id="or-list-title">期权交易记录</div>
|
||||
<p class="or-section-desc">点「复盘」填写表单;已复盘仍保留在此,也可在下方查看详情。</p>
|
||||
<p class="or-section-desc">期权盈亏=期权历史币盈亏×平仓价(有卖回 VWAP 优先,否则用冻结平仓价)。缺价标「缺价未核」。期权历史仅记币收益。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-strike-table-wrap or-trades-table-wrap" id="or-trades-wrap">
|
||||
@@ -237,7 +238,10 @@
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>标的/合约</th>
|
||||
<th>盈亏</th>
|
||||
<th>初始权利金</th>
|
||||
<th title="期权历史币盈亏×平仓价">期权盈亏</th>
|
||||
<th>盈利率</th>
|
||||
<th title="仅期权开/平手续费">手续费</th>
|
||||
<th>开仓时间</th>
|
||||
<th>平仓时间</th>
|
||||
<th>持有</th>
|
||||
@@ -245,7 +249,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="or-trades-tbody">
|
||||
<tr><td colspan="7" class="muted">加载中…</td></tr>
|
||||
<tr><td colspan="10" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -280,6 +284,9 @@
|
||||
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
<input type="text" id="or-f-pnl" name="or_f_pnl" placeholder="盈亏(U)"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
<input type="text" id="or-f-initial-prem" name="or_f_initial_prem" placeholder="初始权利金(U)" readonly autocomplete="off">
|
||||
<input type="text" id="or-f-profit-rate" name="or_f_profit_rate" placeholder="盈利率" readonly autocomplete="off">
|
||||
<input type="text" id="or-f-option-fee" name="or_f_option_fee" placeholder="期权手续费(U)" readonly autocomplete="off" title="仅期权开/平手续费,不含现货">
|
||||
<input type="text" id="or-f-hold" name="or_f_hold" placeholder="持有时长" readonly autocomplete="off">
|
||||
</div>
|
||||
<div class="or-form-grid2">
|
||||
@@ -314,6 +321,15 @@
|
||||
<select id="or-f-entry" title="入场逻辑" style="width:100%;margin-bottom:6px;box-sizing:border-box">
|
||||
<option value="">入场逻辑</option>
|
||||
</select>
|
||||
<select id="or-f-prev-tf" title="入场周期" style="width:100%;margin-bottom:6px;box-sizing:border-box">
|
||||
<option value="">入场周期</option>
|
||||
<option value="15m">15m</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="2h">2h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="8h">8h</option>
|
||||
<option value="12h">12h</option>
|
||||
</select>
|
||||
|
||||
<div class="journal-upload-slots" id="or-upload-slots">
|
||||
{% for tf in ['5m', '15m', '1h', '4h'] %}
|
||||
@@ -329,7 +345,7 @@
|
||||
|
||||
<div class="form-row or-chart-options journal-chart-options hidden" id="or-chart-options" style="flex-wrap:wrap;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem">
|
||||
<input type="checkbox" id="or-exchange-chart" value="true">
|
||||
<input type="checkbox" id="or-exchange-chart" value="true" checked>
|
||||
保存后后台生成 K 线图并作为截图(不阻塞保存)
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
@@ -394,7 +410,9 @@
|
||||
<th>类型</th>
|
||||
<th>标的/合约</th>
|
||||
<th>方向</th>
|
||||
<th>初始权利金</th>
|
||||
<th>盈亏</th>
|
||||
<th>盈利率</th>
|
||||
<th>开仓时间</th>
|
||||
<th>平仓时间</th>
|
||||
<th>持仓时长</th>
|
||||
@@ -405,7 +423,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="or-reviewed-tbody">
|
||||
<tr><td colspan="11" class="muted">加载中…</td></tr>
|
||||
<tr><td colspan="13" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -447,4 +465,4 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/static/options_review.js?v=23"></script>
|
||||
<script src="/static/options_review.js?v=30"></script>
|
||||
|
||||
+162
-37
@@ -273,6 +273,8 @@ class SimBroker:
|
||||
side = (direction or "").lower().strip()
|
||||
if side not in ("long", "short"):
|
||||
raise ValueError("direction 须为 long 或 short")
|
||||
# 行情先拉,再开库,避免握锁等网络
|
||||
bid, ask = _ticker_bid_ask(exchange, symbol)
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
@@ -289,7 +291,6 @@ class SimBroker:
|
||||
if close_c <= 0:
|
||||
raise ValueError("平仓张数无效")
|
||||
fr = sim_fee_rate(fee_rate)
|
||||
bid, ask = _ticker_bid_ask(exchange, symbol)
|
||||
qty = close_c * ct_sz
|
||||
pr = perp_fill(side=side, action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr)
|
||||
if side == "long":
|
||||
@@ -358,16 +359,30 @@ class SimBroker:
|
||||
side: str,
|
||||
sheets: float,
|
||||
avg_px: float,
|
||||
fee: float = 0.0,
|
||||
fee_ccy: str | None = None,
|
||||
) -> None:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
ccy = str(fee_ccy or _premium_ccy_for_inst(inst_id) or "").strip().upper()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO sim_option_orders(
|
||||
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
|
||||
) VALUES (?,?,?,?,?,'filled',?,?)
|
||||
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz,
|
||||
fee, fee_ccy, created_at
|
||||
) VALUES (?,?,?,?,?,'filled',?,?,?,?)
|
||||
""",
|
||||
(ord_id, inst_id, side, float(sheets), float(avg_px), float(sheets), _now()),
|
||||
(
|
||||
ord_id,
|
||||
inst_id,
|
||||
side,
|
||||
float(sheets),
|
||||
float(avg_px),
|
||||
float(sheets),
|
||||
float(fee or 0),
|
||||
ccy,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -382,6 +397,11 @@ class SimBroker:
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
keys = set(row.keys()) if hasattr(row, "keys") else set()
|
||||
fee = float(row["fee"]) if "fee" in keys and row["fee"] is not None else 0.0
|
||||
fee_ccy = str(row["fee_ccy"] or "").strip().upper() if "fee_ccy" in keys else ""
|
||||
if not fee_ccy:
|
||||
fee_ccy = _premium_ccy_for_inst(row["inst_id"])
|
||||
return {
|
||||
"ok": True,
|
||||
"ord_id": row["ord_id"],
|
||||
@@ -390,7 +410,10 @@ class SimBroker:
|
||||
"state": row["state"],
|
||||
"acc_fill_sz": float(row["acc_fill_sz"]),
|
||||
"avg_px": float(row["avg_px"]),
|
||||
"fee": -abs(fee) if fee else 0.0,
|
||||
"fee_ccy": fee_ccy,
|
||||
"sim": True,
|
||||
"raw": {"fee": -abs(fee) if fee else 0.0, "feeCcy": fee_ccy, "sim": True},
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -468,15 +491,23 @@ class SimBroker:
|
||||
|
||||
ord_id = f"sim-opt-{uuid.uuid4().hex[:16]}"
|
||||
self._store_option_order(
|
||||
ord_id=ord_id, inst_id=inst_id, side="buy", sheets=n, avg_px=pr.fill_px
|
||||
ord_id=ord_id,
|
||||
inst_id=inst_id,
|
||||
side="buy",
|
||||
sheets=n,
|
||||
avg_px=pr.fill_px,
|
||||
fee=float(pr.fee or 0),
|
||||
fee_ccy=prem_ccy,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
|
||||
"raw": {"sim": True},
|
||||
"raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy},
|
||||
"px": pr.fill_px,
|
||||
"ord_type": "ioc",
|
||||
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
|
||||
"fee": float(pr.fee or 0),
|
||||
"fee_ccy": prem_ccy,
|
||||
"info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "fill": pr.to_dict()},
|
||||
}
|
||||
|
||||
def sell_option_close(
|
||||
@@ -492,6 +523,10 @@ class SimBroker:
|
||||
n = int(sheets)
|
||||
if n < 1:
|
||||
return {"ok": False, "msg": "张数至少为 1"}
|
||||
# 行情先拉,再开库,避免握锁等网络/嵌套写钱包
|
||||
bid, ask, _ = _option_bid_ask(exchange_options, inst_id)
|
||||
if price is not None and float(price) > 0:
|
||||
bid = max(float(bid), float(price)) if float(price) > 0 else float(bid)
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
@@ -506,9 +541,6 @@ class SimBroker:
|
||||
if close_n <= 0:
|
||||
return {"ok": False, "msg": "平仓张数无效"}
|
||||
fr = sim_fee_rate(fee_rate)
|
||||
bid, ask, _ = _option_bid_ask(exchange_options, inst_id)
|
||||
if price is not None and float(price) > 0:
|
||||
bid = max(float(bid), float(price)) if float(price) > 0 else float(bid)
|
||||
qty = close_n * ct_mult
|
||||
pr = option_fill(action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr)
|
||||
credit = pr.notional - pr.fee
|
||||
@@ -538,16 +570,25 @@ class SimBroker:
|
||||
note=f"sell {inst_id} x{close_n}@{pr.fill_px}",
|
||||
)
|
||||
ord_id = f"sim-opt-close-{uuid.uuid4().hex[:16]}"
|
||||
prem_ccy = _premium_ccy_for_inst(inst_id)
|
||||
self._store_option_order(
|
||||
ord_id=ord_id, inst_id=inst_id, side="sell", sheets=close_n, avg_px=pr.fill_px
|
||||
ord_id=ord_id,
|
||||
inst_id=inst_id,
|
||||
side="sell",
|
||||
sheets=close_n,
|
||||
avg_px=pr.fill_px,
|
||||
fee=float(pr.fee or 0),
|
||||
fee_ccy=prem_ccy,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
|
||||
"raw": {"sim": True},
|
||||
"raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy},
|
||||
"px": pr.fill_px,
|
||||
"ord_type": "ioc",
|
||||
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
|
||||
"fee": float(pr.fee or 0),
|
||||
"fee_ccy": prem_ccy,
|
||||
"info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "fill": pr.to_dict()},
|
||||
}
|
||||
|
||||
def convert_usdt_usdc(
|
||||
@@ -749,40 +790,100 @@ class SimBroker:
|
||||
|
||||
prem_ccy = _premium_ccy_for_inst(inst_id)
|
||||
# 币本位到期兑付用币数量: 实值/指数 × 张数 × 乘数
|
||||
# 注意: option_expiry_pnl 的 intrinsic 是 U,减权利金时币本位不能直接用
|
||||
if prem_ccy in ("ETH", "BTC") and float(spot) > 0:
|
||||
settle_recv = round(
|
||||
max(0.0, (intrinsic_u / float(spot)) * sheets * ct_mult),
|
||||
8,
|
||||
)
|
||||
# 币盈亏 = 兑付币 − 仓位权利金币(与 options_trades.premium_paid 同口径)
|
||||
pnl = round(float(settle_recv) - float(prem), 8)
|
||||
else:
|
||||
settle_recv = round(max(0.0, prem + pnl), 4)
|
||||
pnl = round(float(pnl), 4)
|
||||
|
||||
conn = self.get_db()
|
||||
try:
|
||||
conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,))
|
||||
try:
|
||||
closed_at = _now()
|
||||
conn.execute(
|
||||
open_rows = conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET status = 'closed',
|
||||
close_quote = ?,
|
||||
premium_received = ?,
|
||||
realized_pnl = ?,
|
||||
closed_at = COALESCE(closed_at, ?),
|
||||
signal_note = CASE
|
||||
WHEN signal_note IS NULL OR TRIM(signal_note) = ''
|
||||
THEN '到期结算'
|
||||
ELSE signal_note
|
||||
END
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
SELECT id, premium_paid, open_fee, close_fee
|
||||
FROM options_trades
|
||||
WHERE inst_id=? AND status='open'
|
||||
""",
|
||||
(
|
||||
round(intrinsic_u, 6),
|
||||
settle_recv,
|
||||
round(pnl, 4),
|
||||
closed_at,
|
||||
inst_id,
|
||||
),
|
||||
)
|
||||
(inst_id,),
|
||||
).fetchall()
|
||||
open_ids = [int(r["id"]) for r in open_rows]
|
||||
total_paid = 0.0
|
||||
for r in open_rows:
|
||||
try:
|
||||
total_paid += float(r["premium_paid"] or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
n_open = len(open_rows) or 1
|
||||
for r in open_rows:
|
||||
paid_i = 0.0
|
||||
try:
|
||||
paid_i = float(r["premium_paid"] or 0)
|
||||
except (TypeError, ValueError):
|
||||
paid_i = 0.0
|
||||
ofee_i = 0.0
|
||||
try:
|
||||
ofee_i = abs(float(r["open_fee"] or 0))
|
||||
except (TypeError, ValueError):
|
||||
ofee_i = 0.0
|
||||
# 多笔开仓按权利金占比拆兑付;单笔拿全部
|
||||
if n_open == 1:
|
||||
recv_i = float(settle_recv)
|
||||
elif total_paid > 1e-12:
|
||||
recv_i = float(settle_recv) * (paid_i / total_paid)
|
||||
else:
|
||||
recv_i = float(settle_recv) / float(n_open)
|
||||
if prem_ccy in ("ETH", "BTC"):
|
||||
recv_i = round(recv_i, 8)
|
||||
pnl_i = round(recv_i - paid_i - ofee_i, 8)
|
||||
else:
|
||||
recv_i = round(recv_i, 4)
|
||||
pnl_i = round(recv_i - paid_i - ofee_i, 4)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET status = 'closed',
|
||||
close_quote = ?,
|
||||
premium_received = ?,
|
||||
realized_pnl = ?,
|
||||
close_index_px = COALESCE(close_index_px, ?),
|
||||
closed_at = COALESCE(closed_at, ?),
|
||||
signal_note = CASE
|
||||
WHEN signal_note IS NULL OR TRIM(signal_note) = ''
|
||||
THEN '到期结算'
|
||||
ELSE signal_note
|
||||
END
|
||||
WHERE id = ? AND status = 'open'
|
||||
""",
|
||||
(
|
||||
round(intrinsic_u, 6),
|
||||
recv_i,
|
||||
pnl_i,
|
||||
float(spot),
|
||||
closed_at,
|
||||
int(r["id"]),
|
||||
),
|
||||
)
|
||||
if open_ids:
|
||||
from lib.options.options_notify_lib import (
|
||||
active_options_notify_cfg,
|
||||
notify_options_close_trade_ids,
|
||||
)
|
||||
|
||||
notify_options_close_trade_ids(
|
||||
active_options_notify_cfg(),
|
||||
conn,
|
||||
open_ids,
|
||||
reason="到期结算",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
@@ -810,6 +911,20 @@ class SimBroker:
|
||||
forget_close_gate_for_inst(inst_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
|
||||
from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat
|
||||
|
||||
if margin_mode_from_inst_id(inst_id) == "coin":
|
||||
uly = inst_id.split("-")[0].upper() if "-" in inst_id else "ETH"
|
||||
sell_residual_after_option_flat(
|
||||
self.get_db,
|
||||
exchange,
|
||||
underlying=uly,
|
||||
inst_id=inst_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[sim_expiry_sell] {inst_id}: {e}", flush=True)
|
||||
settled.append(
|
||||
{
|
||||
"inst_id": inst_id,
|
||||
@@ -1146,10 +1261,17 @@ class SimBroker:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
try:
|
||||
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
|
||||
|
||||
sync_sim_cash_realized_pnl_usdt(conn, exchange)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
sql = """
|
||||
SELECT id, inst_id, underlying, sheets, open_quote, close_quote,
|
||||
premium_paid, premium_received, realized_pnl, premium_ccy,
|
||||
created_at, closed_at
|
||||
premium_paid, premium_received, realized_pnl, realized_pnl_usdt,
|
||||
premium_ccy, created_at, closed_at
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
"""
|
||||
@@ -1176,6 +1298,7 @@ class SimBroker:
|
||||
paid = float(r["premium_paid"] or 0) if r["premium_paid"] is not None else None
|
||||
recv = float(r["premium_received"] or 0) if r["premium_received"] is not None else None
|
||||
pnl = float(r["realized_pnl"]) if r["realized_pnl"] is not None else None
|
||||
pnl_usdt = float(r["realized_pnl_usdt"]) if r["realized_pnl_usdt"] is not None else None
|
||||
if pnl is None and paid is not None and recv is not None:
|
||||
pnl = round(recv - paid, 8)
|
||||
if pnl is None:
|
||||
@@ -1209,6 +1332,7 @@ class SimBroker:
|
||||
"closeAvgPx": str(close_px) if close_px is not None else "",
|
||||
"closeTotalPos": str(sheets),
|
||||
"openMaxPos": str(sheets),
|
||||
# 期权历史展示币数量盈亏;U 口径只写 _sim_realized_pnl_usdt 供统计
|
||||
"realizedPnl": str(round(pnl, 8)),
|
||||
"pnl": str(round(pnl, 8)),
|
||||
"pnlRatio": str(round(pnl_ratio, 8)) if pnl_ratio is not None else "",
|
||||
@@ -1217,8 +1341,9 @@ class SimBroker:
|
||||
"type": "2",
|
||||
"optType": opt_type or "",
|
||||
"stk": str(strike) if strike is not None else "",
|
||||
"_sim_premium_ccy": ccy,
|
||||
"_sim_premium_paid": paid,
|
||||
"_sim_premium_ccy": ccy,
|
||||
"_sim_realized_pnl_usdt": pnl_usdt,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user