Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 890659f173 | |||
| ca499c6104 | |||
| 54f1857fa2 | |||
| 6f1ae14b3d | |||
| 29d59d6a53 | |||
| 58a4dafe9a | |||
| 9e0591c676 | |||
| ed3033d793 | |||
| b6156e0049 | |||
| 8e3c00641f | |||
| f11f89e760 | |||
| 0096467d14 | |||
| 8a9dee267f | |||
| 910c938d0a | |||
| 0a9e3aa95c | |||
| b64c742fc9 | |||
| 789ab43dbe | |||
| 61e8da1e8b |
@@ -1,18 +0,0 @@
|
||||
---
|
||||
description: After each completed code change, commit, push origin/main, and deploy to zk.hyf2.cc
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Auto push & deploy
|
||||
|
||||
When a user-facing code change is **finished** (not mid-debug / not "先不要改代码"):
|
||||
|
||||
1. Commit only the relevant files (skip unrelated CRLF-only docs noise).
|
||||
2. `git push origin main` to `https://git.bz121.com/dekun/crypto_monitor.git`.
|
||||
3. Deploy to production `zk.hyf2.cc`:`cd /opt/crypto_monitor && git pull && bash deploy/pull_and_restart.sh`.
|
||||
4. Confirm PM2 processes are online; briefly report commit hash + deploy status.
|
||||
|
||||
Do **not** wait for the user to say "推送并部署" again unless they cancel this habit.
|
||||
|
||||
SSH: Prefer key auth; if BatchMode fails, use existing Paramiko root login path used in this project.
|
||||
Do not print or put passwords in user-facing replies.
|
||||
@@ -15,12 +15,17 @@
|
||||
**/.env.backup*
|
||||
**/.env.bak
|
||||
**/.env.local
|
||||
|
||||
# Cursor 本机规则/配置(勿提交;只留本地)
|
||||
.cursor/
|
||||
|
||||
manual_trading_hub/hub_settings.json
|
||||
manual_trading_hub/hub_backup_state.json
|
||||
manual_trading_hub/hub_fund_history.json
|
||||
manual_trading_hub/hub_supervisor_state.json
|
||||
manual_trading_hub/hub_ai_summaries.json
|
||||
manual_trading_hub/hub_ai_chat.json
|
||||
manual_trading_hub/amp_stats_history.json
|
||||
manual_trading_hub/hub_ai_fund_history.json
|
||||
manual_trading_hub/data/
|
||||
backups/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> 状态:**方案冻结**(按本文实现;改需求先改本文).
|
||||
> 范围:**中控**新增只读统计工具;不改开平仓、不接 AI 教练(首版).
|
||||
> 数据源:**仅 OKX**.
|
||||
> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)(实现后用户说明)
|
||||
> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**.左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易.
|
||||
|
||||
顶栏有 **永续 / 期权** 品种切换:
|
||||
|
||||
| 品种 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| **永续** | 三所 `trade_records` → `archive_trade_cache` | 含犯病标签、K 线 |
|
||||
| **期权** | OKX `options_review_trades` → `archive_options_trade_cache` | 独立 Tab;同步进中控库后离线可看;默认排除对冲腿 |
|
||||
|
||||
同步:「同步」按钮与后台 4h 任务会同时拉永续与期权(仅 `capabilities` 含 `options` 的账户).
|
||||
|
||||
与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留.
|
||||
|
||||
## 页面布局
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
| 文档 | 实例 | 状态 |
|
||||
|------|------|------|
|
||||
| [交易执行手册-期权与Gate.md](../交易执行手册-期权与Gate.md) | 中控「策略说明」·执行手册 | 个人开单纪律 |
|
||||
| [交易执行手册-v2-期权与合约.md](../交易执行手册-v2-期权与合约.md) | 中控「策略说明」·执行手册v2 | **现行**:无对冲;1H→空间→结构→定损盈→期权/合约 |
|
||||
| [交易执行手册-期权与Gate.md](../交易执行手册-期权与Gate.md) | 中控「策略说明」·执行手册v1 | 含对冲;历史对照 |
|
||||
| [交易行为准则-开单三检.md](../交易行为准则-开单三检.md) | 中控「策略说明」·行为准则 | 开单前信号/流程/情绪三检 |
|
||||
| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 |
|
||||
| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 |
|
||||
| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"exchange": "behavior",
|
||||
"title": "开单三检清单",
|
||||
"version": "v0.1",
|
||||
"groups": [
|
||||
{
|
||||
"title": "信号判断",
|
||||
"items": [
|
||||
"最核心、最明确的一个点位/结构确认已写清",
|
||||
"该确认本身足够清晰(不是靠一长串宏大叙事)",
|
||||
"已过主链条:1H方向 → 空间 → 结构 → 定损盈 → 选工具(期权/合约,无对冲);不够格则空仓"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "流程确认",
|
||||
"items": [
|
||||
"账户资金与当日额度符合要求",
|
||||
"单笔风险 / 组合敞口在手册预算内",
|
||||
"无跳步;超限则暂停开单"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "情绪自检",
|
||||
"items": [
|
||||
"心态是「符合系统所以做」,不是「证明自己」",
|
||||
"无怕踏空 → 否则放弃",
|
||||
"无回本 / 报复交易念头 → 否则放弃",
|
||||
"不需要再找更多开单理由"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"exchange": "playbook_v2",
|
||||
"title": "执行手册 v2 开仓清单(无对冲)",
|
||||
"version": "v0.1",
|
||||
"groups": [
|
||||
{
|
||||
"title": "主链条",
|
||||
"items": [
|
||||
"1H 方向清楚(含明显 N 字);跟的是 1H 波段",
|
||||
"空间足够(支撑/阻力;至少约 ≥2%)",
|
||||
"结构已出现且量级够(约 8h+ / 48 根 15m)",
|
||||
"止损按模型:结构突破=外沿;假突破=针尖;止盈与 RR 已接受",
|
||||
"工具只在「期权 / 合约」中选择;未开对冲"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "账户与仓位",
|
||||
"items": [
|
||||
"只动 OKX 期权或 Gate 合约;其它账户零操作",
|
||||
"期权:约 10U、一次一仓;合约:止损约 5U、本位置次数未超两次",
|
||||
"合计最坏风险可接受(约 ≤20U 量级)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "离场与心态",
|
||||
"items": [
|
||||
"期权离场只认规则止盈或到期;开仓后中间不手平",
|
||||
"不是「今天也要开点期权」;过检才开,不过则空仓",
|
||||
"已过开单三检(信号 / 流程 / 情绪)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
# 交易执行手册 v2(期权 / 合约 · 无对冲)
|
||||
|
||||
> 个人开单纪律第二版(2026-07-24 起)。
|
||||
> **相对 v1:去掉期期对冲 / 偏置对冲;工具只留期权与合约。**
|
||||
> 目标:少而精、珍惜机会、样本干净;**不保证收益**。
|
||||
> 旧版(含对冲)见 [交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)。
|
||||
> **开单前先过** [交易行为准则-开单三检.md](./交易行为准则-开单三检.md);本手册管怎么做单。
|
||||
|
||||
---
|
||||
|
||||
## 1. 主链条(强制)
|
||||
|
||||
```
|
||||
1H 方向 → 空间 → 结构 → 定损盈 → 选工具(期权 / 合约)
|
||||
```
|
||||
|
||||
任一步不过 → **空仓等待**,不为开单找理由。
|
||||
|
||||
| 步骤 | 做什么 | 否决 |
|
||||
|------|--------|------|
|
||||
| **1H 方向** | 趋势周期以 **1H** 为准;1H 上要有明显 **N 字**。跟 1H 波段,不跟 4H 打架硬做。例:4H 多、1H 空 → 做 1H 空头波段 | 1H 方向不清、无 N 字 |
|
||||
| **空间** | 做空看下方支撑,做多看上方阻力;至少约 **≥2%** 才值得谈(常期望更大空间,如 ~5%) | 空间不够、贴着墙 |
|
||||
| **结构** | 方向与空间过关后,在 **15m / 5m** 等结构;结构量级至少约 **8h+**(约 **48 根 15m**)。形态:收敛 / 两段式回调 / 箱体 / 假突破等 | 结构未出现、磨不够就抢跑 |
|
||||
| **定损盈** | 结构出现后定义止损、止盈,算盈亏比。结构突破 → 止损在 **结构外沿**;假突破 → 止损在 **假突破针尖** | 损盈说不清、RR 不接受 |
|
||||
| **选工具** | 只在上四步都齐之后选:**期权** 或 **合约**。波段有足够时间考虑,不急着下手 | 用对冲、或「每天都要开点期权」 |
|
||||
|
||||
**丢掉对冲。** 对冲易带来「有保护就能多做」的幻觉;本版不做期期对冲、不做偏置对冲壳。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总原则
|
||||
|
||||
1. **工具只有期权与合约**;同一时段尽量只让一边「说话」。
|
||||
2. **看不懂不做**;过滤比频率重要。日更不是目标,过检才是。
|
||||
3. 动手前先过 **开单三检**(信号 → 流程 → 情绪);不过 → 空仓。
|
||||
4. 玩法必须走完主链条;不够格 → 空仓。
|
||||
5. 期权离场只认:**系统/规则止盈** 与 **到期**;**开仓后中间不手动平仓**(紧急例外不进策略样本)。
|
||||
6. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满。
|
||||
|
||||
---
|
||||
|
||||
## 3. 账户与分工
|
||||
|
||||
| 账户 | 角色 | 说明 |
|
||||
|------|------|------|
|
||||
| OKX 期权 | **主业之一** | 方向单(虚值等);**不做对冲腿** |
|
||||
| Gate 合约 | **主业之一** | 结构清楚时的波段;与期权尽量错开 |
|
||||
| 其它 | 暂不做 | 减少分心与样本污染 |
|
||||
|
||||
**到期选择(期权)**
|
||||
|
||||
- 方向单默认 **一天期**。
|
||||
- 尽量在 **北京时间下午 4 点后** 开 **次日到期**,覆盖较完整的美盘 + 亚盘 + 欧盘窗口。
|
||||
- 更长故事优先考虑合约,不强行拉长期权。
|
||||
|
||||
---
|
||||
|
||||
## 4. 入场逻辑(两类工具)
|
||||
|
||||
开仓前先判断:当前是 **买方向的期权表达**,还是 **合约波段**。
|
||||
|
||||
### 4.1 方向明确 · 结构到位 → 期权
|
||||
|
||||
- **条件**:主链条全部过关;常用结构突破或假突破模型在 15m/5m 成立。
|
||||
- **工具**:**一天期期权方向单**(空间够时优先考虑 **虚值**:同止损口径下盈亏比往往更高)。
|
||||
- **离场**:规则止盈或到期;不手平。
|
||||
- **默认**:先只开期权,不上合约。
|
||||
|
||||
### 4.2 结构到位 · 更适合合约 → 合约
|
||||
|
||||
- **条件**:主链条过关;位置极明确;同一位置机会计数见 Gate 纪律。
|
||||
- **工具**:Gate 合约波段;止损挂在模型对应位置(外沿 / 针尖)。
|
||||
- **独立假突破**(没有先开突破期权时):优先 **只做合约** 或 **空仓**,勿与「突破期权后再加仓」混用同一套仓。
|
||||
|
||||
### 4.3 明确不做
|
||||
|
||||
- 横盘「买波动」的 **期期对冲**(Call+Put)。
|
||||
- 任何「对冲壳 + 偏置」伪装成单边。
|
||||
- 为了「今天也开点期权」而破主链条。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓位与风险预算
|
||||
|
||||
**总资金参考:约 800U。**
|
||||
|
||||
| 项目 | 规则 |
|
||||
|------|------|
|
||||
| 单笔期权 | 约 **10U** 权利金预算;**一次只持有一个期权仓位** |
|
||||
| Gate 合约 | 日内保证金约 **50U**、约 **10 倍**;有单才用,无单为 0 |
|
||||
| 合约止损 | 一般约 **5U**;单笔最大亏损不超过约 **10U** |
|
||||
| 日损失心理框 | 期权+合约若都错:合计大约 **≤20U**;都对时期望可到 **40U+**(理想情形,非每日目标) |
|
||||
|
||||
相对 800U:单笔约 **1.25%** 量级;全错一天约 **2.5%** 量级——防守优先。
|
||||
|
||||
**叠加红线**
|
||||
|
||||
- 期权一仓 + 合约同日存在时,按合计风险接受最坏约 20U,且尽量少「同向双开」。
|
||||
- 不为「好像有保护」放大仓位(本版已无对冲保护叙事)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 合约日纪律(Gate)
|
||||
|
||||
1. 只做 **很明确的位置**;不明确基本不做。
|
||||
2. 动手前想清:**如何进场**(假突破 / 结构突破)。
|
||||
3. **同一位置最多两次机会**:结构突破、假突破。
|
||||
4. **两次都错 → 当日不再做单**(即使后面更「看起来清楚」也留到明天)。
|
||||
5. 止损约 **5U**;波段规则开仓前想清。
|
||||
6. 离场以结构止盈/止损为准。
|
||||
|
||||
---
|
||||
|
||||
## 7. 期权日纪律(OKX)
|
||||
|
||||
1. **不手动平仓**;只等规则止盈或到期(紧急手平标记为非策略样本)。
|
||||
2. 一次一仓;约 10U 权利金。
|
||||
3. **不做对冲**;不做「每天默认开期权」。
|
||||
4. 结构突破 / 假突破用期权表达时,损位跟模型:外沿 / 针尖。
|
||||
5. 默认一天期;优先完整会话窗口再开。
|
||||
|
||||
---
|
||||
|
||||
## 8. 开仓前自检清单
|
||||
|
||||
- [ ] 今天是否只动「期权 / 合约」,其它账户零操作?是否 **未开对冲**?
|
||||
- [ ] **1H 方向**是否清楚(含 N 字)?
|
||||
- [ ] **空间**是否足够(支撑/阻力,至少约 ≥2%)?
|
||||
- [ ] **结构**是否出现且量级够(约 8h+ / 48×15m)?
|
||||
- [ ] **止损 / 止盈**是否按模型定好(外沿或针尖)?RR 是否接受?
|
||||
- [ ] **工具**选的是期权还是合约?理由是否写清?
|
||||
- [ ] 期权:止盈条件与「接受到期」是否写清?
|
||||
- [ ] 合约:本位置第几次机会?止损约 5U 设好了吗?今日两次是否已用完?
|
||||
|
||||
---
|
||||
|
||||
## 9. 一句话版本
|
||||
|
||||
> **1H 定方向 → 量空间 → 等够级别的结构 → 按模型定损盈 → 只在期权与合约里选工具;不对冲;期权不手平;一位置两次,错完收工;珍惜机会,日更不是目标。**
|
||||
|
||||
---
|
||||
|
||||
## 10. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-24 | v2 初版:去掉对冲;主链条 1H→空间→结构→定损盈→期权/合约;吸收假突破针尖 / 结构外沿止损口径 |
|
||||
+11
-7
@@ -1,18 +1,20 @@
|
||||
# 交易执行手册(期权为主 · Gate 为辅)
|
||||
# 交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)
|
||||
|
||||
> 个人开单纪律与仓位规则(2026-07 起)。
|
||||
> 个人开单纪律与仓位规则(2026-07 起)。**本版保留对冲,仅作历史/对照。**
|
||||
> **现行主版本请用** [交易执行手册-v2-期权与合约.md](./交易执行手册-v2-期权与合约.md)(无对冲:1H→空间→结构→定损盈→期权/合约)。
|
||||
> 目标:少而精、可控回撤、样本干净;**不保证收益**。
|
||||
> 工具:OKX 期权(主)+ Gate 合约(辅);其它账户暂不做。
|
||||
|
||||
> 工具:OKX 期权(主)+ Gate 合约(辅);其它账户暂不做。
|
||||
> **开单前先过** [交易行为准则-开单三检.md](./交易行为准则-开单三检.md)(信号 / 流程 / 情绪);本手册管怎么做单。
|
||||
---
|
||||
|
||||
## 1. 总原则
|
||||
|
||||
1. **主做期权,合约为辅**;同一时段尽量只让一边「说话」。
|
||||
2. **看不懂不做**;过滤比频率重要。
|
||||
3. 开仓前先过三关:**方向 → 空间 → 值不值得**。不够格 → 空仓。
|
||||
4. 期权离场只认:**止盈(规则触发)** 与 **到期**;**不手动平仓**(紧急例外单不算策略样本)。
|
||||
5. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满理想上限。
|
||||
3. 动手前先过 **开单三检**(信号判断 → 流程确认 → 情绪自检);不过 → 空仓。详见 [行为准则](./交易行为准则-开单三检.md)。
|
||||
4. 开仓前再过玩法三关:**方向 → 空间 → 值不值得**。不够格 → 空仓。
|
||||
5. 期权离场只认:**止盈(规则触发)** 与 **到期**;**不手动平仓**(紧急例外单不算策略样本)。
|
||||
6. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满理想上限。
|
||||
|
||||
---
|
||||
|
||||
@@ -143,3 +145,5 @@
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-21 | 初版:根据实盘讨论整理(期权为主、Gate 为辅、仓位与日停手规则) |
|
||||
| 2026-07-23 | 挂钩开单三检行为准则 |
|
||||
| 2026-07-24 | 标注为 v1(含对冲);现行纪律迁至执行手册 v2 |
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 交易行为准则(开单三检)
|
||||
|
||||
> 个人强制思维动作 · 初级版(2026-07)。
|
||||
> **不是策略**,是开单前的「交易防火墙」:保证动作在可控轨道上,**不判断这笔会不会赚钱**。
|
||||
> 来源:中控 AI 复盘对话(2026-07-22)与本人归纳。
|
||||
> 仓位与玩法细则见 [交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话
|
||||
|
||||
> **信号够不够清晰?流程有没有跑通?情绪是不是在证明自己?三检不过 → 不开。**
|
||||
|
||||
复盘成败的第一标准:**三检是否完整完成**,而不是这笔盈亏。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总循环
|
||||
|
||||
```
|
||||
信号判断 → 流程确认 → 情绪自检 → 全部通过
|
||||
→ 开仓 → 等待系统结果(止盈 / 止损 / 到期)
|
||||
→ 本次结束 → 复盘整环 → 等待下一个信号
|
||||
```
|
||||
|
||||
任一步否决 → **空仓离开**,不找补丁理由硬开。
|
||||
|
||||
---
|
||||
|
||||
## 3. 开单前:三秒停顿
|
||||
|
||||
手要动之前,强制停顿,把注意力从宏大叙事拉回内部三点:
|
||||
|
||||
1. 我的**核心信号**是什么?
|
||||
2. **安全流程**跑通了吗?
|
||||
3. 我现在是冷静执行,还是急着证明 / 怕踏空 / 想回本?
|
||||
|
||||
---
|
||||
|
||||
## 4. 三检细则
|
||||
|
||||
### 4.1 信号判断(Signal Judgment)
|
||||
|
||||
**问:** 这次入场,最核心、最明确的那一个点位 / 结构确认是什么?它本身够不够清晰?
|
||||
|
||||
| 通过 | 否决 |
|
||||
|------|------|
|
||||
| 能用一句话说清「唯一核心确认」 | 说不清、要靠一长串宏观故事才能自圆其说 |
|
||||
| 点位 / 结构本身已经够清楚 | 「好像有戏」但确认点模糊 |
|
||||
| 只描述事实与系统条件 | 堆细节证明自己分析很厉害 |
|
||||
|
||||
对照执行手册时:先过 **1H 方向 → 空间 → 结构 → 定损盈 → 选工具(期权/合约)**;不够格 → 空仓(见手册 v2)。
|
||||
|
||||
### 4.2 流程确认(Process Confirmation)
|
||||
|
||||
**问:** 决定执行前,有没有按设定步骤检查资金与风险敞口?内部安全流程跑通了吗?
|
||||
|
||||
| 通过 | 否决 / 暂停 |
|
||||
|------|-------------|
|
||||
| 账户资金与当日额度符合要求 | 资金或次数已触限 |
|
||||
| 单笔风险 / 组合敞口在手册预算内 | 单笔或日最坏超限 → **暂停开单** |
|
||||
| 该走的检查项没有跳步 | 「先开了再说」 |
|
||||
|
||||
细则数字以执行手册仓位章为准(单笔期权、对冲总权利金、Gate 止损与日停手等)。
|
||||
|
||||
### 4.3 情绪自检(Emotional Self-Check)
|
||||
|
||||
**问:** 看到复杂结构与逻辑时,内心是什么?是「必须证明分析是对的」,还是「符合系统要求,所以做」?
|
||||
|
||||
| 通过 | 否决(果断放弃) |
|
||||
|------|------------------|
|
||||
| 「符合系统信号 + 账户没问题 → 开」 | 「怕踏空」 |
|
||||
| 不需要再找更多开单理由 | 「上回亏了,这单要回本」 |
|
||||
| 旁观者视角、可接受空仓 | 「必须证明我是对的」 |
|
||||
|
||||
**原则:** 不为开单找理由;情绪红灯亮了,信号再好看也不开。
|
||||
|
||||
---
|
||||
|
||||
## 5. 开仓后纪律(与手册一致)
|
||||
|
||||
- 开仓后:**等待系统结果**(规则止盈 / 止损 / 到期),不靠情绪手平(紧急例外不算策略样本)。
|
||||
- 持仓期盯的是「程序与纪律是否正常」,不是浮盈浮亏数字本身。
|
||||
- 无信号时的空档也算训练:反复在脑子里空跑三检,比硬找单更重要。
|
||||
|
||||
---
|
||||
|
||||
## 6. 复盘只记什么
|
||||
|
||||
每次交易(含未开成的冲动)建议只记:
|
||||
|
||||
1. 信号判断:做了吗?核心确认写了什么?是否清晰?
|
||||
2. 流程确认:资金 / 敞口是否过关?有无跳步?
|
||||
3. 情绪自检:当时心态是哪一类?有无怕踏空 / 回本?
|
||||
4. 结果:止盈 / 止损 / 到期 / 未开 — **结果不推翻「三检是否完成」这一评分。**
|
||||
|
||||
---
|
||||
|
||||
## 7. 与执行手册的分工
|
||||
|
||||
| 文档 | 管什么 |
|
||||
|------|--------|
|
||||
| **本准则** | 能不能动手(防火墙 / 操作系统) |
|
||||
| **执行手册** | 怎么做单(期权 / Gate、仓位、离场) |
|
||||
|
||||
先过本准则三检,再谈手册里的玩法与仓位。
|
||||
|
||||
---
|
||||
|
||||
## 8. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-23 | 初级版:三检 + 总循环 + 红线;对齐 AI 复盘与本人总结 |
|
||||
| 2026-07-24 | 信号检对齐执行手册 v2 主链条(1H→空间→结构→定损盈→期权/合约) |
|
||||
+3
-2
@@ -6,7 +6,8 @@
|
||||
|
||||
| 标签 | 指向提交 | 说明 |
|
||||
|------|----------|------|
|
||||
| `snapshot/20260723-pre-amp-stats` | 4ccfb83 | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 |
|
||||
| `snapshot/20260723-2` | `9e0591c` | 2026-07-23:策略对比页(合约/单期权/期期7:3)、监控与看板隐藏浮盈偏好、对比页卡片内边距等 |
|
||||
| `snapshot/20260723-pre-amp-stats` | `40be3a5` | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 |
|
||||
| `snapshot/20260721-2` | `a721642` | 2026-07-21 晚:日亏损次数冻结、交易执行手册入中控策略说明、期权/Gate 执行手册文档等 |
|
||||
| `snapshot/20260721` | `1a163c0` | 2026-07-21:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 |
|
||||
|
||||
@@ -27,7 +28,7 @@
|
||||
git tag -l 'snapshot/*'
|
||||
|
||||
# 检出快照(只读查看,勿在此分支直接开发)
|
||||
git checkout snapshot/20260723-pre-amp-stats
|
||||
git checkout snapshot/20260723-2
|
||||
|
||||
# 回到主线
|
||||
git checkout main
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# 振幅统计(中控)
|
||||
|
||||
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
|
||||
|
||||
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
|
||||
> **不改下单链路**;不算 IV / 权利金。
|
||||
|
||||
---
|
||||
|
||||
## 入口
|
||||
|
||||
- 顶栏 **振幅统计**(`/amp-stats`)
|
||||
- 手机端:**更多 → 振幅统计**
|
||||
- 可在系统设置里隐藏该导航
|
||||
|
||||
---
|
||||
|
||||
## 怎么用
|
||||
|
||||
1. 打开 **统计** Tab
|
||||
2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
|
||||
3. **起点整点**(00–23);终点固定 **16:00**
|
||||
4. **周期**:1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
|
||||
5. 点 **计算** → 下方看汇总 + 分页日表
|
||||
6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
|
||||
|
||||
**跨天例子**
|
||||
|
||||
| 起点 | 含义(结算日 D) |
|
||||
|------|------------------|
|
||||
| 22:00 | 昨天 22:00 → 今天 16:00 |
|
||||
| 16:00 | 昨天 16:00 → 今天 16:00 |
|
||||
| 08:00 | 今天 08:00 → 今天 16:00 |
|
||||
|
||||
未到当日 16:00 的「今天」不入样本。
|
||||
|
||||
---
|
||||
|
||||
## 指标(点数)
|
||||
|
||||
设开盘 O、最高 H、最低 L、收盘 C:
|
||||
|
||||
| 字段 | 算法 |
|
||||
|------|------|
|
||||
| 开→高 | `H − O` |
|
||||
| 开→低 | `O − L` |
|
||||
| **振幅** | `H − L`(= 开→高 + 开→低) |
|
||||
| 涨跌值 | `C − O` |
|
||||
|
||||
例:O=2000,H=2500,L=1800 → 开→高 500,开→低 200,振幅 **700**。
|
||||
|
||||
汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。
|
||||
|
||||
K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。
|
||||
近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`。
|
||||
分页带间隔,遇 OKX **429** 会自动退避重试(长周期首次会慢一些)。
|
||||
|
||||
---
|
||||
|
||||
## 买跨对照(赌波动)
|
||||
|
||||
表单可填 **双边权利金(点)**,例如 `30`;旁边可填 **止盈点**(可空):
|
||||
|
||||
| 汇总项 | 口径 |
|
||||
|--------|------|
|
||||
| 开→高超过权利金 | `H−O > 权利金` 的天数与占比 |
|
||||
| 开→低超过权利金 | `O−L > 权利金` 的天数与占比 |
|
||||
| \|涨跌\|超过权利金 | `\|C−O\| > 权利金` 的天数与占比 |
|
||||
| 有效波动 | 若设止盈且 `开→高≥止盈` 或 `开→低≥止盈` → 用止盈点;否则用 `\|C−O\|` |
|
||||
| 买跨收益 | `有效波动 − 权利金`(日表「收益」列同口径) |
|
||||
|
||||
- 方向:**买跨**
|
||||
- 权利金越过:严格 **`>`**;止盈触达:**`≥`**
|
||||
- 止盈留空 / ≤0:有效波动一律按 `|涨跌|`
|
||||
- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线)
|
||||
|
||||
### 周末
|
||||
|
||||
- 下拉:**全部**(默认)/ **排除周末** / **仅周末**
|
||||
- 按 **结算日** 北京时间星期判断;表中六、日带标注并高亮
|
||||
|
||||
---
|
||||
|
||||
## 历史 Tab
|
||||
|
||||
- 仅 **保存到历史** 后出现(不会一算就自动入库)
|
||||
- 可查看、再下载、删除
|
||||
- 数据文件:`manual_trading_hub/amp_stats_history.json`(勿当密钥提交)
|
||||
|
||||
---
|
||||
|
||||
## 相关代码
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `lib/hub/amp_stats_lib.py` | 切窗、汇总、OKX 拉取、CSV |
|
||||
| `manual_trading_hub/amp_stats_routes.py` | API |
|
||||
| `manual_trading_hub/amp_stats_store.py` | 历史 JSON |
|
||||
| `manual_trading_hub/static/amp_stats.js` | 前端 |
|
||||
| `tests/test_amp_stats_lib.py` | 单元测试 |
|
||||
|
||||
---
|
||||
|
||||
## 修订
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-23 | 首版上线说明 |
|
||||
| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 |
|
||||
| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 |
|
||||
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 |
|
||||
+7
-2
@@ -74,10 +74,15 @@ OKX_OPTIONS_API_PASSPHRASE=...
|
||||
|
||||
## 5. 微信提醒
|
||||
|
||||
当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次).
|
||||
|
||||
需已配置 `WECHAT_WEBHOOK`.
|
||||
|
||||
| 场景 | 标题 | 说明 |
|
||||
|------|------|------|
|
||||
| **开仓** | 【OKX期权·开仓】 | 下单成功并写入本地后必发(幂等) |
|
||||
| **平仓** | 【OKX期权·平仓】 | 手动全平 / 目标位全平 / 到期或交易所平仓同步后必发(幂等) |
|
||||
| 浮盈翻倍 | 【OKX期权·翻倍提醒】 | 未实现盈亏 ≥ 已付权利金约 100%,同一笔只提醒一次 |
|
||||
| 挂单超时撤销 | 【OKX期权·挂单超时撤销】 | 平仓挂单超时被系统撤销 |
|
||||
|
||||
## 6. 与永续 / 对冲计划的关系
|
||||
|
||||
| | 永续(子账户) | 期权(主账户) |
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 策略对比说明
|
||||
|
||||
中控独立页 **策略对比**(`/compare`):在同一风险额 `R` 下,对比三种工具的止盈能力与止损/踏空路径。
|
||||
|
||||
## 用途
|
||||
|
||||
回答两件事:
|
||||
|
||||
1. **盈利时谁更厉害**:干净止盈路径下各赚多少 U
|
||||
2. **谁更易亏 / 更易踏空**:合约止损后踏空;期权/对冲最坏亏满权利金,但踏空路径下常仍可持有到目标
|
||||
|
||||
不是精确概率模型。到期「小盈/小亏」与 4 点收盘相关,**未纳入主表与推荐**。
|
||||
|
||||
## 入口
|
||||
|
||||
- 顶栏「策略对比」;设置 → 显示与导航可隐藏(`show_nav_compare`)
|
||||
- API:`POST /api/compare/calc`(页面即时调用,价格均为手填)
|
||||
|
||||
## 输入
|
||||
|
||||
| 区块 | 字段 |
|
||||
|------|------|
|
||||
| 公共 | 标的 ETH/BTC、方向、入场价、风险 R、统一止损、止盈 |
|
||||
| 单期权 | Call/Put、行权价、卖一(每币)、可选目标价 |
|
||||
| 期期 | 主腿/次腿 各自行权与卖一;预算固定 **7:3** |
|
||||
|
||||
卖一口径与对冲计划一致:`单张成本 = 卖一 × ct_mult`(默认 `ct_mult=0.01`)。
|
||||
|
||||
## 仓位
|
||||
|
||||
- **合约**:`张数 = floor(R / (|入场−止损| × 面值))`,默认面值 0.01
|
||||
- **单期权**:`张数 = floor(R / 单张成本)`
|
||||
- **期期**:主预算 `0.7R`、次预算 `0.3R`,各自 `floor(预算/单张成本)`
|
||||
|
||||
## 主情景(A/B/C)
|
||||
|
||||
| 路径 | 合约 | 单期权 / 期期 |
|
||||
|------|------|----------------|
|
||||
| A 干净止盈 | 入场→止盈盈亏 | 目标价内在价值 − 已付权利金(近似) |
|
||||
| B 打止损 | −实际止损额(≈R) | 止损价处内在−权利金;并注最坏 −权利金 |
|
||||
| C 先止损再去止盈 | **本单仍为止损亏损**;旁注踏空未拿到的原止盈空间 | **仍持有**至目标价,结果同 A(抗踏空对照) |
|
||||
|
||||
期权止盈按**内在价值近似**,不是盘口卖出价。
|
||||
|
||||
## 推荐规则(可解释)
|
||||
|
||||
1. 比较三者 A / R
|
||||
2. 若合约止盈明显高于另两者(≥1.15×)→ 倾向合约,并提示踏空
|
||||
3. 否则若存在踏空对照(合约亏、期权类 C 仍为正)→ 倾向单期权或期期(期期与单腿接近时优先期期)
|
||||
4. 平局:抗踏空优先期权类,赔付碾压则合约
|
||||
|
||||
## 手测示例
|
||||
|
||||
`ETH` 做多,入场 3500,止损 3400,止盈 3700,R=10;单 Call 行权 3600 卖一 50;对冲主 Call 3600/50、次 Put 3400/30:
|
||||
|
||||
- 合约约 10 张,止损 −10U,止盈约 +20U,踏空未拿到约 +20U
|
||||
- 单期权约 20 张,权利金 10U,止盈约 +10U,最坏 −10U
|
||||
- 期期主 14 / 次 10 张
|
||||
|
||||
## 不做
|
||||
|
||||
实盘下单、拉交易所卖一(二期可选)、历史回测入库。
|
||||
@@ -147,9 +147,10 @@
|
||||
opts = opts || {};
|
||||
const hub = !!opts.hub;
|
||||
const readOnly = !!opts.readOnly;
|
||||
const net = netPnlFromPos(p);
|
||||
const roi = netRoiFromPos(p, net);
|
||||
const uplCls = pnlCls(net, hub);
|
||||
const hidePnl = !!opts.hidePnl;
|
||||
const net = hidePnl ? null : netPnlFromPos(p);
|
||||
const roi = hidePnl ? null : netRoiFromPos(p, net);
|
||||
const uplCls = hidePnl ? "" : pnlCls(net, hub);
|
||||
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
|
||||
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
|
||||
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||
@@ -166,6 +167,12 @@
|
||||
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
|
||||
"</div>";
|
||||
}
|
||||
const pnlCells = hidePnl
|
||||
? ""
|
||||
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
|
||||
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||||
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
|
||||
return (
|
||||
'<div class="pos-card-head">' +
|
||||
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
|
||||
@@ -189,15 +196,12 @@
|
||||
'<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>" +
|
||||
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
|
||||
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||||
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
|
||||
pnlCells +
|
||||
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
|
||||
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
|
||||
(closePreview.bid_invalid
|
||||
? '<span class="muted">暂无有效买盘</span>'
|
||||
: fmtClosePreview(closePreview, p.premium_paid, hub)) + "</span></div>" +
|
||||
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" +
|
||||
"</div>" +
|
||||
(function () {
|
||||
const hint = closeGateHint(closePreview);
|
||||
@@ -217,19 +221,22 @@
|
||||
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
|
||||
if (intrinsic != null) {
|
||||
value = Math.round(intrinsic * eth * 100) / 100;
|
||||
if (Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
|
||||
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
|
||||
}
|
||||
}
|
||||
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
|
||||
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
|
||||
const hedgeTarget = p.hedge_plan_target || null;
|
||||
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
||||
const profitSpan = hidePnl
|
||||
? ""
|
||||
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
|
||||
return (
|
||||
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
||||
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
||||
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
|
||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
|
||||
'<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>" +
|
||||
profitSpan +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
||||
"</span></div>"
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径.
|
||||
|
||||
仅只读行情;不触及下单链路.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import statistics
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
APP_TZ = ZoneInfo("Asia/Shanghai")
|
||||
END_HOUR = 16
|
||||
EXCHANGE = "okx"
|
||||
TIMEFRAME = "1H"
|
||||
|
||||
SYMBOLS: dict[str, dict[str, str]] = {
|
||||
"eth": {
|
||||
"label": "ETH",
|
||||
"index_inst": "ETH-USD",
|
||||
"swap_inst": "ETH-USDT-SWAP",
|
||||
},
|
||||
"btc": {
|
||||
"label": "BTC",
|
||||
"index_inst": "BTC-USD",
|
||||
"swap_inst": "BTC-USDT-SWAP",
|
||||
},
|
||||
}
|
||||
|
||||
PERIOD_DAYS: dict[str, int] = {
|
||||
"1m": 30,
|
||||
"2m": 60,
|
||||
"3m": 90,
|
||||
"6m": 180,
|
||||
"1y": 365,
|
||||
}
|
||||
|
||||
OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles"
|
||||
OKX_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles"
|
||||
OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles"
|
||||
OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-candles"
|
||||
|
||||
|
||||
def normalize_symbol(raw: str) -> str:
|
||||
s = (raw or "").strip().lower()
|
||||
if s in ("eth", "ethereum"):
|
||||
return "eth"
|
||||
if s in ("btc", "bitcoin"):
|
||||
return "btc"
|
||||
raise ValueError("symbol 仅支持 eth / btc")
|
||||
|
||||
|
||||
def resolve_sample_days(period: str, custom_days: Any = None) -> int:
|
||||
p = (period or "2m").strip().lower()
|
||||
if p == "custom":
|
||||
try:
|
||||
n = int(custom_days)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("自定义天数无效") from None
|
||||
return max(7, min(400, n))
|
||||
if p not in PERIOD_DAYS:
|
||||
raise ValueError("周期无效")
|
||||
return PERIOD_DAYS[p]
|
||||
|
||||
|
||||
def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]:
|
||||
"""返回 [start, end) 的本地时刻;end 为结算日 16:00."""
|
||||
if not (0 <= int(start_hour) <= 23):
|
||||
raise ValueError("起点须为 0-23 整点")
|
||||
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
||||
sh = int(start_hour)
|
||||
if sh >= END_HOUR:
|
||||
prev = settlement - timedelta(days=1)
|
||||
start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ)
|
||||
else:
|
||||
start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ)
|
||||
return start, end
|
||||
|
||||
|
||||
def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]:
|
||||
"""最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00)."""
|
||||
now = now or datetime.now(APP_TZ)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=APP_TZ)
|
||||
else:
|
||||
now = now.astimezone(APP_TZ)
|
||||
today = now.date()
|
||||
today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
||||
latest = today if now >= today_end else today - timedelta(days=1)
|
||||
return [latest - timedelta(days=i) for i in range(int(sample_days))]
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
|
||||
"""open_time_ms -> {o,h,l,c}."""
|
||||
m: dict[int, dict[str, float]] = {}
|
||||
for b in bars or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
ts = b.get("ts")
|
||||
if ts is None:
|
||||
ts = b.get("open_time_ms")
|
||||
try:
|
||||
ts_i = int(ts)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
o = _safe_float(b.get("o") if "o" in b else b.get("open"))
|
||||
h = _safe_float(b.get("h") if "h" in b else b.get("high"))
|
||||
l = _safe_float(b.get("l") if "l" in b else b.get("low"))
|
||||
c = _safe_float(b.get("c") if "c" in b else b.get("close"))
|
||||
if None in (o, h, l, c):
|
||||
continue
|
||||
m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)}
|
||||
return m
|
||||
|
||||
|
||||
def compute_day_row(
|
||||
settlement: date,
|
||||
start_hour: int,
|
||||
bar_map: dict[int, dict[str, float]],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
start, end = window_bounds_for_settlement(settlement, start_hour)
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
# 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close
|
||||
last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000)
|
||||
if start_ms not in bar_map or last_bar_ms not in bar_map:
|
||||
return None
|
||||
opens = bar_map[start_ms]["o"]
|
||||
close = bar_map[last_bar_ms]["c"]
|
||||
hi = bar_map[start_ms]["h"]
|
||||
lo = bar_map[start_ms]["l"]
|
||||
t = start_ms
|
||||
while t <= last_bar_ms:
|
||||
b = bar_map.get(t)
|
||||
if b:
|
||||
hi = max(hi, b["h"])
|
||||
lo = min(lo, b["l"])
|
||||
t += 3600 * 1000
|
||||
up = hi - opens
|
||||
down = opens - lo
|
||||
amp = hi - lo
|
||||
change = close - opens
|
||||
wd = settlement.weekday() # Mon=0 … Sun=6
|
||||
is_we = wd >= 5
|
||||
return {
|
||||
"settlement_day": settlement.isoformat(),
|
||||
"window_start": start.strftime("%Y-%m-%d %H:%M"),
|
||||
"window_end": end.strftime("%Y-%m-%d %H:%M"),
|
||||
"weekday": wd,
|
||||
"weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""),
|
||||
"is_weekend": is_we,
|
||||
"open": round(opens, 4),
|
||||
"high": round(hi, 4),
|
||||
"low": round(lo, 4),
|
||||
"close": round(close, 4),
|
||||
"up_points": round(up, 4),
|
||||
"down_points": round(down, 4),
|
||||
"amplitude": round(amp, 4),
|
||||
"change": round(change, 4),
|
||||
}
|
||||
|
||||
|
||||
def normalize_straddle_premium(raw: Any) -> Optional[float]:
|
||||
"""双边权利金(点数).空/≤0 表示不做跨式对照."""
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("双边权利金须为数字") from None
|
||||
if v <= 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def normalize_take_profit(raw: Any) -> Optional[float]:
|
||||
"""止盈点.空/≤0 表示不止盈,有效波动用 |涨跌|."""
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("止盈点须为数字") from None
|
||||
if v <= 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def normalize_weekend_filter(raw: Any) -> str:
|
||||
"""all | exclude | only;默认全部."""
|
||||
s = (str(raw) if raw is not None else "all").strip().lower()
|
||||
if s in ("", "all", "全部"):
|
||||
return "all"
|
||||
if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"):
|
||||
return "exclude"
|
||||
if s in ("only", "weekend_only", "only_weekend", "仅周末"):
|
||||
return "only"
|
||||
raise ValueError("周末筛选须为 all / exclude / only")
|
||||
|
||||
|
||||
def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]:
|
||||
mode = normalize_weekend_filter(weekend_filter)
|
||||
if mode == "all":
|
||||
return list(rows or [])
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows or []:
|
||||
is_we = bool(r.get("is_weekend"))
|
||||
if "is_weekend" not in r and r.get("settlement_day"):
|
||||
try:
|
||||
is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5
|
||||
except ValueError:
|
||||
is_we = False
|
||||
if mode == "exclude" and is_we:
|
||||
continue
|
||||
if mode == "only" and not is_we:
|
||||
continue
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def effective_move_points(row: dict[str, Any], take_profit: Optional[float]) -> float:
|
||||
"""触达止盈(≥)用止盈点,否则用 |涨跌|."""
|
||||
abs_chg = abs(float(row.get("change") or 0))
|
||||
if take_profit is None:
|
||||
return abs_chg
|
||||
tp = float(take_profit)
|
||||
up = float(row.get("up_points") or 0)
|
||||
down = float(row.get("down_points") or 0)
|
||||
if up >= tp or down >= tp:
|
||||
return tp
|
||||
return abs_chg
|
||||
|
||||
|
||||
def enrich_rows_pnl(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
straddle_premium: Optional[float] = None,
|
||||
take_profit: Optional[float] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""为日表附加有效波动 / 是否触达止盈 / 收益(有权利金时)."""
|
||||
prem = normalize_straddle_premium(straddle_premium)
|
||||
tp = normalize_take_profit(take_profit)
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows or []:
|
||||
item = dict(r)
|
||||
if "is_weekend" not in item and item.get("settlement_day"):
|
||||
try:
|
||||
wd = date.fromisoformat(str(item["settlement_day"])).weekday()
|
||||
item["weekday"] = wd
|
||||
item["weekday_label"] = "六" if wd == 5 else ("日" if wd == 6 else "")
|
||||
item["is_weekend"] = wd >= 5
|
||||
except ValueError:
|
||||
item.setdefault("weekday_label", "")
|
||||
item.setdefault("is_weekend", False)
|
||||
move = effective_move_points(item, tp)
|
||||
hit = False
|
||||
if tp is not None:
|
||||
hit = float(item.get("up_points") or 0) >= tp or float(item.get("down_points") or 0) >= tp
|
||||
item["effective_move"] = round(move, 4)
|
||||
item["take_profit_hit"] = hit
|
||||
item["profit"] = round(move - prem, 4) if prem is not None else None
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def straddle_long_stats(
|
||||
rows: list[dict[str, Any]],
|
||||
premium: float,
|
||||
*,
|
||||
take_profit: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""买跨:越过权利金用严格 >;收益=有效波动−权利金(止盈≥触达用止盈点,否则|涨跌|)."""
|
||||
prem = float(premium)
|
||||
if prem <= 0:
|
||||
raise ValueError("双边权利金须 > 0")
|
||||
tp = normalize_take_profit(take_profit)
|
||||
enriched = enrich_rows_pnl(rows, straddle_premium=prem, take_profit=tp)
|
||||
if not enriched:
|
||||
return {
|
||||
"side": "long_straddle",
|
||||
"premium": prem,
|
||||
"take_profit": tp,
|
||||
"sample_count": 0,
|
||||
"up_exceed_days": 0,
|
||||
"up_exceed_ratio": None,
|
||||
"down_exceed_days": 0,
|
||||
"down_exceed_ratio": None,
|
||||
"abs_change_exceed_days": 0,
|
||||
"abs_change_exceed_ratio": None,
|
||||
"tp_hit_days": 0,
|
||||
"tp_hit_ratio": None,
|
||||
"pnl_total": None,
|
||||
"pnl_avg": None,
|
||||
"win_days": 0,
|
||||
"win_ratio": None,
|
||||
"pnl_max": None,
|
||||
"pnl_min": None,
|
||||
}
|
||||
n = len(enriched)
|
||||
up_ex = sum(1 for r in enriched if float(r["up_points"]) > prem)
|
||||
down_ex = sum(1 for r in enriched if float(r["down_points"]) > prem)
|
||||
abs_ex = sum(1 for r in enriched if abs(float(r["change"])) > prem)
|
||||
tp_hits = sum(1 for r in enriched if r.get("take_profit_hit"))
|
||||
pnls = [float(r["profit"]) for r in enriched if r.get("profit") is not None]
|
||||
win = sum(1 for p in pnls if p > 0)
|
||||
return {
|
||||
"side": "long_straddle",
|
||||
"premium": round(prem, 4),
|
||||
"take_profit": round(tp, 4) if tp is not None else None,
|
||||
"sample_count": n,
|
||||
"up_exceed_days": up_ex,
|
||||
"up_exceed_ratio": round(up_ex / n, 4),
|
||||
"down_exceed_days": down_ex,
|
||||
"down_exceed_ratio": round(down_ex / n, 4),
|
||||
"abs_change_exceed_days": abs_ex,
|
||||
"abs_change_exceed_ratio": round(abs_ex / n, 4),
|
||||
"tp_hit_days": tp_hits,
|
||||
"tp_hit_ratio": round(tp_hits / n, 4) if tp is not None else None,
|
||||
"pnl_total": round(sum(pnls), 4),
|
||||
"pnl_avg": round(statistics.fmean(pnls), 4),
|
||||
"win_days": win,
|
||||
"win_ratio": round(win / n, 4),
|
||||
"pnl_max": round(max(pnls), 4),
|
||||
"pnl_min": round(min(pnls), 4),
|
||||
}
|
||||
|
||||
|
||||
def summarize_rows(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
straddle_premium: Any = None,
|
||||
take_profit: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
if not rows:
|
||||
out = {
|
||||
"sample_count": 0,
|
||||
"max_amplitude": None,
|
||||
"max_amplitude_day": None,
|
||||
"avg_amplitude": None,
|
||||
"median_amplitude": None,
|
||||
"max_up_points": None,
|
||||
"avg_up_points": None,
|
||||
"max_down_points": None,
|
||||
"avg_down_points": None,
|
||||
"up_day_ratio": None,
|
||||
"down_day_ratio": None,
|
||||
"straddle": None,
|
||||
}
|
||||
prem = normalize_straddle_premium(straddle_premium)
|
||||
if prem is not None:
|
||||
out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit)
|
||||
return out
|
||||
amps = [float(r["amplitude"]) for r in rows]
|
||||
ups = [float(r["up_points"]) for r in rows]
|
||||
downs = [float(r["down_points"]) for r in rows]
|
||||
max_amp = max(amps)
|
||||
max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp)
|
||||
up_days = sum(1 for r in rows if float(r["change"]) > 0)
|
||||
down_days = sum(1 for r in rows if float(r["change"]) < 0)
|
||||
n = len(rows)
|
||||
out: dict[str, Any] = {
|
||||
"sample_count": n,
|
||||
"max_amplitude": round(max_amp, 4),
|
||||
"max_amplitude_day": max_amp_day,
|
||||
"avg_amplitude": round(statistics.fmean(amps), 4),
|
||||
"median_amplitude": round(statistics.median(amps), 4),
|
||||
"max_up_points": round(max(ups), 4),
|
||||
"avg_up_points": round(statistics.fmean(ups), 4),
|
||||
"max_down_points": round(max(downs), 4),
|
||||
"avg_down_points": round(statistics.fmean(downs), 4),
|
||||
"up_day_ratio": round(up_days / n, 4),
|
||||
"down_day_ratio": round(down_days / n, 4),
|
||||
"straddle": None,
|
||||
}
|
||||
prem = normalize_straddle_premium(straddle_premium)
|
||||
if prem is not None:
|
||||
out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]:
|
||||
if not row or len(row) < 5:
|
||||
return None
|
||||
try:
|
||||
ts = int(row[0])
|
||||
o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
return {"ts": ts, "o": o, "h": h, "l": l, "c": c}
|
||||
|
||||
|
||||
def _okx_get_json(
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
params: dict[str, str],
|
||||
*,
|
||||
retries: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""GET OKX 公共行情;遇 429 指数退避重试."""
|
||||
last_err: Optional[BaseException] = None
|
||||
for attempt in range(max(1, int(retries))):
|
||||
try:
|
||||
r = client.get(url, params=params)
|
||||
if r.status_code == 429:
|
||||
wait = min(12.0, 0.7 * (2**attempt))
|
||||
time.sleep(wait)
|
||||
last_err = httpx.HTTPStatusError(
|
||||
f"429 Too Many Requests for url '{r.url}'",
|
||||
request=r.request,
|
||||
response=r,
|
||||
)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError("OKX 返回非对象 JSON")
|
||||
return body
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
if status == 429 and attempt + 1 < retries:
|
||||
wait = min(12.0, 0.7 * (2**attempt))
|
||||
time.sleep(wait)
|
||||
last_err = exc
|
||||
continue
|
||||
raise
|
||||
except httpx.TransportError as exc:
|
||||
if attempt + 1 < retries:
|
||||
time.sleep(min(8.0, 0.5 * (2**attempt)))
|
||||
last_err = exc
|
||||
continue
|
||||
raise
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise RuntimeError("OKX 请求失败")
|
||||
|
||||
|
||||
def fetch_okx_candles(
|
||||
*,
|
||||
url: str,
|
||||
inst_id: str,
|
||||
since_ms: int,
|
||||
until_ms: int,
|
||||
bar: str = "1H",
|
||||
client: Optional[httpx.Client] = None,
|
||||
timeout: float = 30.0,
|
||||
history_url: Optional[str] = None,
|
||||
max_pages: int = 200,
|
||||
page_pause_sec: float = 0.12,
|
||||
history_page_pause_sec: float = 0.22,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).
|
||||
|
||||
OKX 近期接口约仅 1440 根;更早需 history_* 端点续拉.
|
||||
分页带间隔,429 自动退避重试.
|
||||
"""
|
||||
own = client is None
|
||||
client = client or httpx.Client(
|
||||
timeout=timeout,
|
||||
trust_env=False,
|
||||
headers={"User-Agent": "crypto_monitor-amp-stats/1.0"},
|
||||
)
|
||||
try:
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
after: Optional[str] = None
|
||||
active_url = url
|
||||
switched_history = False
|
||||
for page_i in range(max(20, int(max_pages))):
|
||||
if page_i > 0:
|
||||
pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec
|
||||
if pause > 0:
|
||||
time.sleep(pause)
|
||||
params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"}
|
||||
if after:
|
||||
params["after"] = after
|
||||
body = _okx_get_json(client, active_url, params)
|
||||
if str(body.get("code") or "") not in ("0", "0.0", ""):
|
||||
raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}")
|
||||
data = body.get("data") or []
|
||||
if not data:
|
||||
# 近期接口到头 → 切历史端点再试
|
||||
if history_url and not switched_history and after is not None:
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
continue
|
||||
break
|
||||
oldest_ts = None
|
||||
for row in data:
|
||||
parsed = _parse_okx_candle_row(row)
|
||||
if not parsed:
|
||||
continue
|
||||
ts = int(parsed["ts"])
|
||||
oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts)
|
||||
if ts < since_ms - 3600 * 1000:
|
||||
continue
|
||||
if ts > until_ms + 3600 * 1000:
|
||||
continue
|
||||
out[ts] = parsed
|
||||
if oldest_ts is None:
|
||||
break
|
||||
if oldest_ts <= since_ms:
|
||||
break
|
||||
# 无新进度时避免死循环
|
||||
if after is not None and str(oldest_ts) == after:
|
||||
if history_url and not switched_history:
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
continue
|
||||
break
|
||||
after = str(oldest_ts)
|
||||
# 近期接口返回变少且仍未覆盖 since → 切历史
|
||||
if (
|
||||
history_url
|
||||
and not switched_history
|
||||
and len(data) < 100
|
||||
and oldest_ts > since_ms
|
||||
):
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
return [out[k] for k in sorted(out.keys())]
|
||||
finally:
|
||||
if own:
|
||||
client.close()
|
||||
|
||||
|
||||
def fetch_symbol_bars(
|
||||
symbol: str,
|
||||
*,
|
||||
since_ms: int,
|
||||
until_ms: int,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
) -> tuple[list[dict[str, Any]], str, str]:
|
||||
"""返回 (bars, price_source_label, inst_id)."""
|
||||
key = normalize_symbol(symbol)
|
||||
meta = SYMBOLS[key]
|
||||
if fetch_fn:
|
||||
bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms)
|
||||
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
||||
|
||||
index_err: Optional[BaseException] = None
|
||||
try:
|
||||
bars = fetch_okx_candles(
|
||||
url=OKX_INDEX_CANDLES,
|
||||
history_url=OKX_HISTORY_INDEX_CANDLES,
|
||||
inst_id=meta["index_inst"],
|
||||
since_ms=since_ms,
|
||||
until_ms=until_ms,
|
||||
)
|
||||
if bars:
|
||||
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
||||
except Exception as exc:
|
||||
index_err = exc
|
||||
# 指数侧已触发限频时先冷却,再降级永续,避免连环 429
|
||||
time.sleep(1.2)
|
||||
|
||||
try:
|
||||
bars = fetch_okx_candles(
|
||||
url=OKX_SWAP_CANDLES,
|
||||
history_url=OKX_HISTORY_SWAP_CANDLES,
|
||||
inst_id=meta["swap_inst"],
|
||||
since_ms=since_ms,
|
||||
until_ms=until_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = f"index={index_err}; swap={exc}" if index_err else str(exc)
|
||||
raise RuntimeError(f"OKX K线拉取失败({detail})") from exc
|
||||
if not bars:
|
||||
detail = f"index={index_err}" if index_err else "empty"
|
||||
raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})")
|
||||
return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"]
|
||||
|
||||
|
||||
def compute_amp_stats(
|
||||
*,
|
||||
symbol: str = "eth",
|
||||
start_hour: int = 16,
|
||||
period: str = "2m",
|
||||
custom_days: Any = None,
|
||||
straddle_premium: Any = None,
|
||||
take_profit: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
now: Optional[datetime] = None,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
key = normalize_symbol(symbol)
|
||||
sh = int(start_hour)
|
||||
if sh < 0 or sh > 23:
|
||||
raise ValueError("起点须为 0-23 整点")
|
||||
prem = normalize_straddle_premium(straddle_premium)
|
||||
tp = normalize_take_profit(take_profit)
|
||||
we_mode = normalize_weekend_filter(weekend_filter)
|
||||
sample_days = resolve_sample_days(period, custom_days)
|
||||
settlements = list_settlement_dates(sample_days=sample_days, now=now)
|
||||
if not settlements:
|
||||
raise RuntimeError("无可用结算日")
|
||||
# 最远窗起点
|
||||
oldest = settlements[-1]
|
||||
newest = settlements[0]
|
||||
start0, _ = window_bounds_for_settlement(oldest, sh)
|
||||
_, end1 = window_bounds_for_settlement(newest, sh)
|
||||
since_ms = int(start0.timestamp() * 1000)
|
||||
until_ms = int(end1.timestamp() * 1000)
|
||||
bars, price_source, inst_id = fetch_symbol_bars(
|
||||
key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn
|
||||
)
|
||||
bar_map = bars_to_map(bars)
|
||||
rows_all: list[dict[str, Any]] = []
|
||||
missing: list[str] = []
|
||||
for d in settlements:
|
||||
row = compute_day_row(d, sh, bar_map)
|
||||
if row is None:
|
||||
missing.append(d.isoformat())
|
||||
continue
|
||||
rows_all.append(row)
|
||||
return build_amp_result(
|
||||
rows_all=rows_all,
|
||||
symbol_key=key,
|
||||
start_hour=sh,
|
||||
period=period,
|
||||
sample_days=sample_days,
|
||||
straddle_premium=prem,
|
||||
take_profit=tp,
|
||||
weekend_filter=we_mode,
|
||||
price_source=price_source,
|
||||
inst_id=inst_id,
|
||||
missing=missing,
|
||||
)
|
||||
|
||||
|
||||
def build_amp_result(
|
||||
*,
|
||||
rows_all: list[dict[str, Any]],
|
||||
symbol_key: str,
|
||||
start_hour: int,
|
||||
period: str,
|
||||
sample_days: int,
|
||||
straddle_premium: Any = None,
|
||||
take_profit: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
price_source: str = "",
|
||||
inst_id: str = "",
|
||||
missing: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
prem = normalize_straddle_premium(straddle_premium)
|
||||
tp = normalize_take_profit(take_profit)
|
||||
we_mode = normalize_weekend_filter(weekend_filter)
|
||||
filtered = filter_weekend_rows(rows_all, we_mode)
|
||||
rows = enrich_rows_pnl(filtered, straddle_premium=prem, take_profit=tp)
|
||||
summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp)
|
||||
if period == "custom" or str(period).startswith("custom:"):
|
||||
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
|
||||
else:
|
||||
period_label = str(period)
|
||||
miss = missing or []
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange": EXCHANGE,
|
||||
"symbol": symbol_key,
|
||||
"symbol_label": SYMBOLS[symbol_key]["label"],
|
||||
"start_hour": start_hour,
|
||||
"end_hour": END_HOUR,
|
||||
"period": period_label,
|
||||
"sample_days_requested": sample_days,
|
||||
"straddle_premium": prem,
|
||||
"take_profit": tp,
|
||||
"weekend_filter": we_mode,
|
||||
"timeframe": TIMEFRAME,
|
||||
"price_source": price_source,
|
||||
"inst_id": inst_id,
|
||||
"timezone": "Asia/Shanghai",
|
||||
"rows_all": rows_all,
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
"missing_days": miss[:30],
|
||||
"missing_count": len(miss),
|
||||
}
|
||||
|
||||
|
||||
def reframe_amp_stats(
|
||||
*,
|
||||
rows_all: list[dict[str, Any]],
|
||||
symbol: str = "eth",
|
||||
start_hour: int = 16,
|
||||
period: str = "2m",
|
||||
sample_days: int = 60,
|
||||
straddle_premium: Any = None,
|
||||
take_profit: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
price_source: str = "",
|
||||
inst_id: str = "",
|
||||
missing: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""已有日表上改周末/权利金/止盈,不拉 K 线."""
|
||||
key = normalize_symbol(symbol)
|
||||
return build_amp_result(
|
||||
rows_all=list(rows_all or []),
|
||||
symbol_key=key,
|
||||
start_hour=int(start_hour),
|
||||
period=period,
|
||||
sample_days=int(sample_days or 60),
|
||||
straddle_premium=straddle_premium,
|
||||
take_profit=take_profit,
|
||||
weekend_filter=weekend_filter,
|
||||
price_source=price_source,
|
||||
inst_id=inst_id,
|
||||
missing=missing,
|
||||
)
|
||||
|
||||
|
||||
def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(5, min(100, int(page_size or 20)))
|
||||
total = len(rows)
|
||||
start = (page - 1) * page_size
|
||||
chunk = rows[start : start + page_size]
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": max(1, (total + page_size - 1) // page_size) if total else 1,
|
||||
"rows": chunk,
|
||||
}
|
||||
|
||||
|
||||
def build_export_csv(payload: dict[str, Any]) -> str:
|
||||
buf = io.StringIO()
|
||||
# Excel 友好 BOM
|
||||
buf.write("\ufeff")
|
||||
w = csv.writer(buf)
|
||||
s = payload.get("summary") or {}
|
||||
w.writerow(["【统计摘要】"])
|
||||
w.writerow(["交易所", payload.get("exchange")])
|
||||
w.writerow(["标的", payload.get("symbol_label")])
|
||||
w.writerow(["价源", payload.get("price_source")])
|
||||
w.writerow(["起点整点", f"{payload.get('start_hour')}:00"])
|
||||
w.writerow(["终点", f"{payload.get('end_hour')}:00"])
|
||||
w.writerow(["周期", payload.get("period")])
|
||||
w.writerow(["周末筛选", payload.get("weekend_filter")])
|
||||
w.writerow(["样本数", s.get("sample_count")])
|
||||
w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")])
|
||||
w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")])
|
||||
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
|
||||
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")])
|
||||
w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
|
||||
st = s.get("straddle") or {}
|
||||
if st:
|
||||
w.writerow([])
|
||||
w.writerow(["【买跨对照·双边权利金】", st.get("premium"), "止盈点", st.get("take_profit")])
|
||||
w.writerow(["开→高超过权利金", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")])
|
||||
w.writerow(["开→低超过权利金", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")])
|
||||
w.writerow(["|涨跌|超过权利金", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")])
|
||||
if st.get("take_profit") is not None:
|
||||
w.writerow(["触达止盈天数", st.get("tp_hit_days"), "占比", st.get("tp_hit_ratio")])
|
||||
w.writerow(
|
||||
[
|
||||
"买跨点数盈亏合计",
|
||||
st.get("pnl_total"),
|
||||
"日均",
|
||||
st.get("pnl_avg"),
|
||||
"赚钱天数",
|
||||
st.get("win_days"),
|
||||
"胜率",
|
||||
st.get("win_ratio"),
|
||||
]
|
||||
)
|
||||
w.writerow(["单日最大赚", st.get("pnl_max"), "单日最大亏", st.get("pnl_min")])
|
||||
w.writerow([])
|
||||
w.writerow(["【日表明细】"])
|
||||
w.writerow(
|
||||
[
|
||||
"结算日",
|
||||
"星期",
|
||||
"周末",
|
||||
"窗起点",
|
||||
"窗终点",
|
||||
"开盘",
|
||||
"最高",
|
||||
"最低",
|
||||
"收盘",
|
||||
"开→高",
|
||||
"开→低",
|
||||
"振幅",
|
||||
"涨跌值",
|
||||
"有效波动",
|
||||
"触达止盈",
|
||||
"收益",
|
||||
]
|
||||
)
|
||||
for r in payload.get("rows") or []:
|
||||
w.writerow(
|
||||
[
|
||||
r.get("settlement_day"),
|
||||
r.get("weekday_label") or "",
|
||||
"是" if r.get("is_weekend") else "否",
|
||||
r.get("window_start"),
|
||||
r.get("window_end"),
|
||||
r.get("open"),
|
||||
r.get("high"),
|
||||
r.get("low"),
|
||||
r.get("close"),
|
||||
r.get("up_points"),
|
||||
r.get("down_points"),
|
||||
r.get("amplitude"),
|
||||
r.get("change"),
|
||||
r.get("effective_move"),
|
||||
"是" if r.get("take_profit_hit") else "否",
|
||||
r.get("profit"),
|
||||
]
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def export_filename(payload: dict[str, Any]) -> str:
|
||||
sym = (payload.get("symbol") or "eth").lower()
|
||||
sh = int(payload.get("start_hour") or 16)
|
||||
period = str(payload.get("period") or "2m").replace(":", "")
|
||||
day = datetime.now(APP_TZ).strftime("%Y%m%d")
|
||||
return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv"
|
||||
@@ -672,6 +672,72 @@ def register_hub_routes(app):
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/hub/options/review/archive")
|
||||
@_hub_auth_required
|
||||
def api_hub_options_review_archive():
|
||||
"""中控期权档案:近 N 天已平仓复盘记录(默认排除对冲腿)."""
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lib.options.options_review_lib import (
|
||||
compute_review_stats,
|
||||
ensure_local_review_synced,
|
||||
list_review_trades,
|
||||
)
|
||||
|
||||
c = _ctx()
|
||||
get_db = c.get("get_db")
|
||||
if not get_db:
|
||||
return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500
|
||||
try:
|
||||
days = int(request.args.get("days") or "365")
|
||||
except ValueError:
|
||||
days = 365
|
||||
days = max(1, min(days, 3650))
|
||||
try:
|
||||
limit = int(request.args.get("limit") or "2000")
|
||||
except ValueError:
|
||||
limit = 2000
|
||||
limit = max(1, min(limit, 5000))
|
||||
include_hedge_legs = str(request.args.get("include_hedge_legs") or "").strip() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
closed_from = (datetime.now(tz) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
cfg = (current_app.extensions or {}).get("options_cfg") or {}
|
||||
ex = cfg.get("exchange_options")
|
||||
conn = get_db()
|
||||
try:
|
||||
ensure_local_review_synced(conn, ex=ex, backfill_exchange_pnl=bool(ex))
|
||||
trades = list_review_trades(
|
||||
conn,
|
||||
include_hedge_legs=include_hedge_legs,
|
||||
closed_from=closed_from,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
stats = compute_review_stats(
|
||||
conn,
|
||||
include_hedge_legs=include_hedge_legs,
|
||||
closed_from=closed_from,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"days": days,
|
||||
"limit": limit,
|
||||
"product": "options",
|
||||
"trades": trades,
|
||||
"stats": stats,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/hub/trades/today")
|
||||
@_hub_auth_required
|
||||
def api_hub_trades_today():
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""中控策略对比:同风险额下 合约 / 单期权 / 期期7:3 情景测算(纯函数)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def default_contract_size(base: str) -> float:
|
||||
"""OKX 线性永续常用面值(币/张);与计算器缺省一致."""
|
||||
b = (base or "ETH").strip().upper()
|
||||
return 0.01
|
||||
|
||||
|
||||
def default_ct_mult(base: str) -> float:
|
||||
return 0.01
|
||||
|
||||
|
||||
def floor_sheets(n: float, step: float = 1.0) -> float:
|
||||
if n is None or not math.isfinite(n) or n <= 0:
|
||||
return 0.0
|
||||
s = float(step) if step and step > 0 else 1.0
|
||||
return math.floor(n / s + 1e-12) * s
|
||||
|
||||
|
||||
def option_unit_cost(*, ask: float, ct_mult: float) -> float:
|
||||
return float(ask) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_intrinsic_value(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
) -> float:
|
||||
o = (opt_type or "").strip().upper()
|
||||
k = float(strike)
|
||||
s = float(spot)
|
||||
if o == "C":
|
||||
intrinsic = max(0.0, s - k)
|
||||
elif o == "P":
|
||||
intrinsic = max(0.0, k - s)
|
||||
else:
|
||||
intrinsic = 0.0
|
||||
return intrinsic * float(sheets) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_pnl_at_spot(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
premium_paid: float,
|
||||
) -> float:
|
||||
return option_intrinsic_value(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
spot=spot,
|
||||
sheets=sheets,
|
||||
ct_mult=ct_mult,
|
||||
) - float(premium_paid)
|
||||
|
||||
|
||||
def perp_pnl(
|
||||
*,
|
||||
direction: str,
|
||||
entry: float,
|
||||
exit_px: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
) -> float:
|
||||
coins = float(contracts) * float(contract_size or 0.01)
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return (float(entry) - float(exit_px)) * coins
|
||||
return (float(exit_px) - float(entry)) * coins
|
||||
|
||||
|
||||
def _validate_common(inp: dict[str, Any]) -> Optional[str]:
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
if base not in ("ETH", "BTC"):
|
||||
return "标的仅支持 ETH / BTC"
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long / short"
|
||||
s0 = _f(inp.get("entry"))
|
||||
sl = _f(inp.get("sl"))
|
||||
tp = _f(inp.get("tp"))
|
||||
risk = _f(inp.get("risk_u"))
|
||||
if s0 is None or s0 <= 0:
|
||||
return "请填写有效入场价"
|
||||
if sl is None or sl <= 0:
|
||||
return "请填写有效止损价"
|
||||
if tp is None or tp <= 0:
|
||||
return "请填写有效止盈价"
|
||||
if risk is None or risk <= 0:
|
||||
return "请填写有效风险额 R"
|
||||
if direction == "long" and not (sl < s0 < tp):
|
||||
return "做多须满足 止损 < 入场 < 止盈"
|
||||
if direction == "short" and not (tp < s0 < sl):
|
||||
return "做空须满足 止盈 < 入场 < 止损"
|
||||
return None
|
||||
|
||||
|
||||
def _calc_perp(inp: dict[str, Any], *, contract_size: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
s0 = float(inp["entry"])
|
||||
sl = float(inp["sl"])
|
||||
tp = float(inp["tp"])
|
||||
risk = float(inp["risk_u"])
|
||||
per_sheet_sl = abs(s0 - sl) * contract_size
|
||||
sheets = floor_sheets(risk / per_sheet_sl) if per_sheet_sl > 0 else 0.0
|
||||
actual_sl_loss = abs(perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=sl, contracts=sheets, contract_size=contract_size
|
||||
))
|
||||
tp_pnl = perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=tp, contracts=sheets, contract_size=contract_size
|
||||
)
|
||||
# 路径 C:本单已止损 −actual;踏空未拿到 = 原止盈盈利
|
||||
path_a = round(tp_pnl, 4)
|
||||
path_b = round(-actual_sl_loss if sheets > 0 else -risk, 4)
|
||||
path_c_realized = path_b
|
||||
path_c_missed = path_a
|
||||
return {
|
||||
"kind": "perp",
|
||||
"sheets": sheets,
|
||||
"contract_size": contract_size,
|
||||
"per_sheet_sl_u": round(per_sheet_sl, 6),
|
||||
"risk_used_u": round(actual_sl_loss, 4),
|
||||
"path_a_tp": path_a,
|
||||
"path_b_sl": path_b,
|
||||
"path_c_realized": path_c_realized,
|
||||
"path_c_missed": path_c_missed,
|
||||
"path_c_note": "本单已止损;踏空未拿到原止盈空间",
|
||||
"worst_u": path_b,
|
||||
}
|
||||
|
||||
|
||||
def _calc_single_option(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_opt") if inp.get("tp_opt") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
opt = inp.get("option") if isinstance(inp.get("option"), dict) else {}
|
||||
default_type = "C" if direction == "long" else "P"
|
||||
opt_type = str(opt.get("opt_type") or default_type).strip().upper()
|
||||
if opt_type not in ("C", "P"):
|
||||
opt_type = default_type
|
||||
strike = _f(opt.get("strike"))
|
||||
ask = _f(opt.get("ask"))
|
||||
if strike is None or strike <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权行权价"}
|
||||
if ask is None or ask <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权卖一价"}
|
||||
unit = option_unit_cost(ask=ask, ct_mult=ct_mult)
|
||||
sheets = floor_sheets(risk / unit) if unit > 0 else 0.0
|
||||
premium = option_unit_cost(ask=ask, ct_mult=ct_mult) * sheets if sheets else 0.0
|
||||
# 若张数为 0
|
||||
path_a = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_at_sl = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_worst = -premium
|
||||
# 踏空路径:合约被洗后标的仍到 TP,期权仍持有 → 同止盈
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "option",
|
||||
"opt_type": opt_type,
|
||||
"strike": strike,
|
||||
"ask": ask,
|
||||
"ct_mult": ct_mult,
|
||||
"sheets": sheets,
|
||||
"unit_cost_u": round(unit, 6),
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下期权仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def _calc_hedge(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_hedge") if inp.get("tp_hedge") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
hedge = inp.get("hedge") if isinstance(inp.get("hedge"), dict) else {}
|
||||
main_default = "C" if direction == "long" else "P"
|
||||
side_default = "P" if direction == "long" else "C"
|
||||
main = hedge.get("main") if isinstance(hedge.get("main"), dict) else {}
|
||||
side = hedge.get("side") if isinstance(hedge.get("side"), dict) else {}
|
||||
main_type = str(main.get("opt_type") or main_default).strip().upper()
|
||||
side_type = str(side.get("opt_type") or side_default).strip().upper()
|
||||
if main_type not in ("C", "P"):
|
||||
main_type = main_default
|
||||
if side_type not in ("C", "P"):
|
||||
side_type = side_default
|
||||
main_k = _f(main.get("strike"))
|
||||
main_ask = _f(main.get("ask"))
|
||||
side_k = _f(side.get("strike"))
|
||||
side_ask = _f(side.get("ask"))
|
||||
if None in (main_k, main_ask, side_k, side_ask) or min(
|
||||
main_k or 0, main_ask or 0, side_k or 0, side_ask or 0
|
||||
) <= 0:
|
||||
return {"ok": False, "msg": "请填写期期对冲两腿的行权价与卖一"}
|
||||
main_budget = 0.7 * risk
|
||||
side_budget = 0.3 * risk
|
||||
main_unit = option_unit_cost(ask=float(main_ask), ct_mult=ct_mult)
|
||||
side_unit = option_unit_cost(ask=float(side_ask), ct_mult=ct_mult)
|
||||
main_sheets = floor_sheets(main_budget / main_unit) if main_unit > 0 else 0.0
|
||||
side_sheets = floor_sheets(side_budget / side_unit) if side_unit > 0 else 0.0
|
||||
main_prem = main_unit * main_sheets
|
||||
side_prem = side_unit * side_sheets
|
||||
premium = main_prem + side_prem
|
||||
|
||||
def combo_at(spot: float) -> float:
|
||||
a = option_pnl_at_spot(
|
||||
opt_type=main_type,
|
||||
strike=float(main_k),
|
||||
spot=spot,
|
||||
sheets=main_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=main_prem,
|
||||
)
|
||||
b = option_pnl_at_spot(
|
||||
opt_type=side_type,
|
||||
strike=float(side_k),
|
||||
spot=spot,
|
||||
sheets=side_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=side_prem,
|
||||
)
|
||||
return a + b
|
||||
|
||||
path_a = combo_at(tp)
|
||||
path_b_at_sl = combo_at(sl)
|
||||
path_b_worst = -premium
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "hedge",
|
||||
"ratio": "7:3",
|
||||
"ct_mult": ct_mult,
|
||||
"main": {
|
||||
"opt_type": main_type,
|
||||
"strike": main_k,
|
||||
"ask": main_ask,
|
||||
"sheets": main_sheets,
|
||||
"premium_u": round(main_prem, 4),
|
||||
"budget_u": round(main_budget, 4),
|
||||
},
|
||||
"side": {
|
||||
"opt_type": side_type,
|
||||
"strike": side_k,
|
||||
"ask": side_ask,
|
||||
"sheets": side_sheets,
|
||||
"premium_u": round(side_prem, 4),
|
||||
"budget_u": round(side_budget, 4),
|
||||
},
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下对冲组合仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def recommend(perp: dict[str, Any], opt: dict[str, Any], hedge: dict[str, Any], risk: float) -> dict[str, Any]:
|
||||
"""可解释规则推荐."""
|
||||
candidates: list[tuple[str, float, dict[str, Any]]] = []
|
||||
if perp and perp.get("sheets", 0) > 0:
|
||||
candidates.append(("合约", float(perp.get("path_a_tp") or 0), perp))
|
||||
if opt and opt.get("ok") and opt.get("sheets", 0) > 0:
|
||||
candidates.append(("单期权", float(opt.get("path_a_tp") or 0), opt))
|
||||
if hedge and hedge.get("ok") and (hedge.get("premium_u") or 0) > 0:
|
||||
candidates.append(("期期对冲", float(hedge.get("path_a_tp") or 0), hedge))
|
||||
if not candidates:
|
||||
return {
|
||||
"choice": "—",
|
||||
"reason": "输入不足,无法推荐",
|
||||
"bullets": ["请检查风险额与卖一/止损距是否过小导致张数为 0"],
|
||||
}
|
||||
|
||||
best_name, best_a, _ = max(candidates, key=lambda x: x[1])
|
||||
perp_a = float(perp.get("path_a_tp") or 0) if perp else 0.0
|
||||
opt_a = float(opt.get("path_a_tp") or 0) if opt and opt.get("ok") else 0.0
|
||||
hedge_a = float(hedge.get("path_a_tp") or 0) if hedge and hedge.get("ok") else 0.0
|
||||
|
||||
# 踏空:合约 C 实现为亏损,期权/对冲 C 仍接近 A
|
||||
perp_miss = float(perp.get("path_c_missed") or 0) if perp else 0.0
|
||||
opt_c = float(opt.get("path_c_hold_to_tp") or 0) if opt and opt.get("ok") else None
|
||||
hedge_c = float(hedge.get("path_c_hold_to_tp") or 0) if hedge and hedge.get("ok") else None
|
||||
anti_whipsaw = False
|
||||
if perp_miss > 0 and (
|
||||
(opt_c is not None and opt_c > 0) or (hedge_c is not None and hedge_c > 0)
|
||||
):
|
||||
anti_whipsaw = True
|
||||
|
||||
# 合约止盈明显更高(>= 另两者 1.15 倍)且用户能接受踏空 → 推合约
|
||||
others_max = max(opt_a, hedge_a, 0.0)
|
||||
choice = best_name
|
||||
if perp_a > 0 and perp_a >= others_max * 1.15 and perp_a >= best_a * 0.99:
|
||||
choice = "合约"
|
||||
if anti_whipsaw:
|
||||
reason = "合约止盈赔付更高,但震荡易洗时存在踏空;能接受洗盘再走可选合约"
|
||||
else:
|
||||
reason = "同风险下合约干净止盈赔付最高"
|
||||
elif anti_whipsaw and (opt_a > 0 or hedge_a > 0):
|
||||
# 抗踏空优先期权类;期期与单腿接近时推期期
|
||||
if hedge_a > 0 and (opt_a <= 0 or hedge_a >= opt_a * 0.85):
|
||||
choice = "期期对冲"
|
||||
reason = "震荡易洗时期权类更抗踏空;期期 7:3 兼顾方向与保护"
|
||||
else:
|
||||
choice = "单期权"
|
||||
reason = "震荡易洗时单期权仍可持有到目标,抗踏空优于合约"
|
||||
else:
|
||||
reason = f"同风险下「{best_name}」干净止盈赔付最高"
|
||||
|
||||
bullets = [
|
||||
f"止盈对比:合约 {perp_a:.2f}U / 单期权 {opt_a:.2f}U / 期期 {hedge_a:.2f}U(风险 R={risk:.2f}U)",
|
||||
(
|
||||
"止损与踏空:合约打止损即结束并可能踏空;"
|
||||
"期权/对冲最坏约亏满权利金,踏空路径下常仍持有至目标"
|
||||
if anti_whipsaw
|
||||
else "止损与踏空:三者最坏接近 −R;关注合约是否易被洗后错过止盈"
|
||||
),
|
||||
f"选用建议:{reason}",
|
||||
]
|
||||
return {"choice": choice, "reason": reason, "bullets": bullets}
|
||||
|
||||
|
||||
def run_compare(inp: dict[str, Any]) -> dict[str, Any]:
|
||||
err = _validate_common(inp)
|
||||
if err:
|
||||
return {"ok": False, "msg": err}
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
risk = float(inp["risk_u"])
|
||||
cs = _f(inp.get("contract_size")) or default_contract_size(base)
|
||||
ct = _f(inp.get("ct_mult")) or default_ct_mult(base)
|
||||
perp = _calc_perp(inp, contract_size=float(cs))
|
||||
opt = _calc_single_option(inp, ct_mult=float(ct))
|
||||
hedge = _calc_hedge(inp, ct_mult=float(ct))
|
||||
rec = recommend(
|
||||
perp,
|
||||
opt if opt.get("ok") else {"ok": False},
|
||||
hedge if hedge.get("ok") else {"ok": False},
|
||||
risk,
|
||||
)
|
||||
warnings: list[str] = []
|
||||
if perp.get("sheets", 0) <= 0:
|
||||
warnings.append("合约张数为 0:止损距过大或 R 过小")
|
||||
if isinstance(opt, dict) and opt.get("ok") and opt.get("sheets", 0) <= 0:
|
||||
warnings.append("单期权张数为 0:卖一过高或 R 过小")
|
||||
if isinstance(hedge, dict) and hedge.get("ok") and hedge.get("premium_u", 0) <= 0:
|
||||
warnings.append("期期对冲未开出张数:卖一过高或 R 过小")
|
||||
if isinstance(opt, dict) and not opt.get("ok"):
|
||||
warnings.append(str(opt.get("msg") or "单期权输入不完整"))
|
||||
if isinstance(hedge, dict) and not hedge.get("ok"):
|
||||
warnings.append(str(hedge.get("msg") or "期期对冲输入不完整"))
|
||||
return {
|
||||
"ok": True,
|
||||
"base": base,
|
||||
"direction": str(inp.get("direction") or "long").strip().lower(),
|
||||
"entry": float(inp["entry"]),
|
||||
"sl": float(inp["sl"]),
|
||||
"tp": float(inp["tp"]),
|
||||
"risk_u": risk,
|
||||
"contract_size": float(cs),
|
||||
"ct_mult": float(ct),
|
||||
"perp": perp,
|
||||
"option": opt,
|
||||
"hedge": hedge,
|
||||
"recommend": rec,
|
||||
"warnings": warnings,
|
||||
"notes": [
|
||||
"期权止盈按标的到价的内在价值近似,非盘口卖出价",
|
||||
"到期小盈/小亏未纳入主表与推荐",
|
||||
"仅本地测算,不下单",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
"""中控期权档案:同步 OKX options_review_trades 到 hub_symbol_archive.db."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.hub.hub_symbol_archive_lib import (
|
||||
TRADING_DAY_RESET_HOUR,
|
||||
_connect,
|
||||
default_db_path,
|
||||
init_db as init_perp_archive_db,
|
||||
ms_to_trading_day,
|
||||
parse_wall_clock_ms,
|
||||
resolve_period_bounds,
|
||||
trading_day_bounds_ms,
|
||||
)
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def init_options_archive_db(db_path: Path | None = None) -> None:
|
||||
"""确保期权缓存表存在(与永续共用同一 SQLite)."""
|
||||
init_perp_archive_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS archive_options_trade_cache (
|
||||
exchange_key TEXT NOT NULL,
|
||||
history_key TEXT NOT NULL,
|
||||
source_type TEXT,
|
||||
underlying TEXT,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
opened_at_ms INTEGER,
|
||||
closed_at_ms INTEGER,
|
||||
hold_seconds INTEGER,
|
||||
realized_pnl_total REAL,
|
||||
status_raw TEXT,
|
||||
pos_id TEXT,
|
||||
inst_id TEXT,
|
||||
opt_type TEXT,
|
||||
strike REAL,
|
||||
exp_time TEXT,
|
||||
sheets INTEGER,
|
||||
open_avg REAL,
|
||||
close_avg REAL,
|
||||
premium_paid REAL,
|
||||
realized_pnl REAL,
|
||||
hedge_plan_id INTEGER,
|
||||
plan_close_reason TEXT,
|
||||
realized_pnl_perp REAL,
|
||||
realized_pnl_options REAL,
|
||||
premium_total REAL,
|
||||
direction TEXT,
|
||||
tp REAL,
|
||||
sl REAL,
|
||||
target_price REAL,
|
||||
target_price_up REAL,
|
||||
target_price_down REAL,
|
||||
legs_json TEXT,
|
||||
linked_hedge_plan_id INTEGER,
|
||||
excluded_as_hedge_leg INTEGER DEFAULT 0,
|
||||
strategy_tag TEXT,
|
||||
result_tag TEXT,
|
||||
reviewed INTEGER DEFAULT 0,
|
||||
source_label TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
synced_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (exchange_key, history_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_options_closed
|
||||
ON archive_options_trade_cache (exchange_key, closed_at_ms)
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def purge_stale_options_trades_cache(
|
||||
exchange_key: str,
|
||||
active_history_keys: list[str],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
init_options_archive_db(db_path)
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
return 0
|
||||
active = {str(k).strip() for k in (active_history_keys or []) if str(k).strip()}
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT history_key FROM archive_options_trade_cache WHERE exchange_key=?",
|
||||
(ex_k,),
|
||||
).fetchall()
|
||||
stale = [r["history_key"] for r in rows if r["history_key"] not in active]
|
||||
removed = 0
|
||||
for hk in stale:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM archive_options_trade_cache WHERE exchange_key=? AND history_key=?",
|
||||
(ex_k, hk),
|
||||
)
|
||||
removed += int(cur.rowcount or 0)
|
||||
return removed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _optional_float(raw: Any) -> float | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _optional_int(raw: Any) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def upsert_options_trades_cache(
|
||||
exchange_key: str,
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
prune_missing: bool = True,
|
||||
) -> dict[str, int]:
|
||||
init_options_archive_db(db_path)
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
return {"upserted": 0, "removed": 0}
|
||||
now = _now_ms()
|
||||
n = 0
|
||||
active_keys: list[str] = []
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
for t in trades or []:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
hk = str(t.get("history_key") or "").strip()
|
||||
if not hk:
|
||||
continue
|
||||
if int(t.get("excluded_as_hedge_leg") or 0):
|
||||
continue
|
||||
active_keys.append(hk)
|
||||
opened_at = t.get("opened_at")
|
||||
closed_at = t.get("closed_at")
|
||||
opened_ms = t.get("opened_at_ms") or parse_wall_clock_ms(opened_at)
|
||||
closed_ms = t.get("closed_at_ms") or parse_wall_clock_ms(closed_at)
|
||||
entry = t.get("entry") if isinstance(t.get("entry"), dict) else {}
|
||||
strategy_tag = t.get("strategy_tag") or (entry or {}).get("strategy_tag")
|
||||
result_tag = t.get("result_tag") or (entry or {}).get("result_tag")
|
||||
reviewed = 1 if t.get("reviewed") or entry else 0
|
||||
row = dict(t)
|
||||
row["exchange_key"] = ex_k
|
||||
payload = json.dumps(row, ensure_ascii=False, default=str)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO archive_options_trade_cache (
|
||||
exchange_key, history_key, source_type, underlying,
|
||||
opened_at, closed_at, opened_at_ms, closed_at_ms, hold_seconds,
|
||||
realized_pnl_total, status_raw,
|
||||
pos_id, inst_id, opt_type, strike, exp_time, sheets,
|
||||
open_avg, close_avg, premium_paid, realized_pnl,
|
||||
hedge_plan_id, plan_close_reason, realized_pnl_perp, realized_pnl_options,
|
||||
premium_total, direction, tp, sl, target_price, target_price_up, target_price_down,
|
||||
legs_json, linked_hedge_plan_id, excluded_as_hedge_leg,
|
||||
strategy_tag, result_tag, reviewed, source_label,
|
||||
payload_json, synced_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(exchange_key, history_key) DO UPDATE SET
|
||||
source_type=excluded.source_type,
|
||||
underlying=excluded.underlying,
|
||||
opened_at=excluded.opened_at,
|
||||
closed_at=excluded.closed_at,
|
||||
opened_at_ms=excluded.opened_at_ms,
|
||||
closed_at_ms=excluded.closed_at_ms,
|
||||
hold_seconds=excluded.hold_seconds,
|
||||
realized_pnl_total=excluded.realized_pnl_total,
|
||||
status_raw=excluded.status_raw,
|
||||
pos_id=excluded.pos_id,
|
||||
inst_id=excluded.inst_id,
|
||||
opt_type=excluded.opt_type,
|
||||
strike=excluded.strike,
|
||||
exp_time=excluded.exp_time,
|
||||
sheets=excluded.sheets,
|
||||
open_avg=excluded.open_avg,
|
||||
close_avg=excluded.close_avg,
|
||||
premium_paid=excluded.premium_paid,
|
||||
realized_pnl=excluded.realized_pnl,
|
||||
hedge_plan_id=excluded.hedge_plan_id,
|
||||
plan_close_reason=excluded.plan_close_reason,
|
||||
realized_pnl_perp=excluded.realized_pnl_perp,
|
||||
realized_pnl_options=excluded.realized_pnl_options,
|
||||
premium_total=excluded.premium_total,
|
||||
direction=excluded.direction,
|
||||
tp=excluded.tp,
|
||||
sl=excluded.sl,
|
||||
target_price=excluded.target_price,
|
||||
target_price_up=excluded.target_price_up,
|
||||
target_price_down=excluded.target_price_down,
|
||||
legs_json=excluded.legs_json,
|
||||
linked_hedge_plan_id=excluded.linked_hedge_plan_id,
|
||||
excluded_as_hedge_leg=excluded.excluded_as_hedge_leg,
|
||||
strategy_tag=excluded.strategy_tag,
|
||||
result_tag=excluded.result_tag,
|
||||
reviewed=excluded.reviewed,
|
||||
source_label=excluded.source_label,
|
||||
payload_json=excluded.payload_json,
|
||||
synced_at=excluded.synced_at
|
||||
""",
|
||||
(
|
||||
ex_k,
|
||||
hk,
|
||||
t.get("source_type"),
|
||||
t.get("underlying"),
|
||||
opened_at,
|
||||
closed_at,
|
||||
int(opened_ms) if opened_ms else None,
|
||||
int(closed_ms) if closed_ms else None,
|
||||
_optional_int(t.get("hold_seconds")),
|
||||
float(t.get("realized_pnl_total") or t.get("realized_pnl") or 0),
|
||||
t.get("status_raw"),
|
||||
t.get("pos_id"),
|
||||
t.get("inst_id"),
|
||||
t.get("opt_type"),
|
||||
_optional_float(t.get("strike")),
|
||||
t.get("exp_time"),
|
||||
_optional_int(t.get("sheets")),
|
||||
_optional_float(t.get("open_avg")),
|
||||
_optional_float(t.get("close_avg")),
|
||||
_optional_float(t.get("premium_paid")),
|
||||
_optional_float(t.get("realized_pnl")),
|
||||
_optional_int(t.get("hedge_plan_id")),
|
||||
t.get("plan_close_reason"),
|
||||
_optional_float(t.get("realized_pnl_perp")),
|
||||
_optional_float(t.get("realized_pnl_options")),
|
||||
_optional_float(t.get("premium_total")),
|
||||
t.get("direction"),
|
||||
_optional_float(t.get("tp")),
|
||||
_optional_float(t.get("sl")),
|
||||
_optional_float(t.get("target_price")),
|
||||
_optional_float(t.get("target_price_up")),
|
||||
_optional_float(t.get("target_price_down")),
|
||||
t.get("legs_json")
|
||||
if isinstance(t.get("legs_json"), str)
|
||||
else (json.dumps(t.get("legs"), ensure_ascii=False) if t.get("legs") else None),
|
||||
_optional_int(t.get("linked_hedge_plan_id")),
|
||||
int(t.get("excluded_as_hedge_leg") or 0),
|
||||
strategy_tag,
|
||||
result_tag,
|
||||
reviewed,
|
||||
t.get("source_label"),
|
||||
payload,
|
||||
now,
|
||||
),
|
||||
)
|
||||
n += 1
|
||||
finally:
|
||||
conn.close()
|
||||
removed = 0
|
||||
if prune_missing:
|
||||
removed = purge_stale_options_trades_cache(ex_k, active_keys, db_path=db_path)
|
||||
return {"upserted": n, "removed": removed}
|
||||
|
||||
|
||||
def _options_row_to_dict(row: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = dict(row)
|
||||
payload = {}
|
||||
raw = out.get("payload_json")
|
||||
if raw:
|
||||
try:
|
||||
payload = json.loads(raw) if isinstance(raw, str) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
payload = {}
|
||||
if isinstance(payload, dict):
|
||||
for k, v in payload.items():
|
||||
if k not in out or out.get(k) in (None, ""):
|
||||
out[k] = v
|
||||
pnl = float(out.get("realized_pnl_total") or out.get("realized_pnl") or 0)
|
||||
out["realized_pnl_total"] = pnl
|
||||
out["pnl_amount"] = pnl # 复用永续统计/日历字段名
|
||||
hold_sec = out.get("hold_seconds")
|
||||
if hold_sec is not None:
|
||||
try:
|
||||
out["hold_minutes"] = round(float(hold_sec) / 60.0, 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not out.get("opened_at_ms") and out.get("opened_at"):
|
||||
ms = parse_wall_clock_ms(out.get("opened_at"))
|
||||
if ms:
|
||||
out["opened_at_ms"] = int(ms)
|
||||
if not out.get("closed_at_ms") and out.get("closed_at"):
|
||||
ms = parse_wall_clock_ms(out.get("closed_at"))
|
||||
if ms:
|
||||
out["closed_at_ms"] = int(ms)
|
||||
out["trade_id"] = out.get("history_key")
|
||||
out["id"] = out.get("history_key")
|
||||
out["symbol"] = out.get("inst_id") or out.get("underlying") or ""
|
||||
return out
|
||||
|
||||
|
||||
def _empty_options_stats() -> dict[str, Any]:
|
||||
return {
|
||||
"open_count": 0,
|
||||
"sick_count": 0,
|
||||
"sick_pct": 0.0,
|
||||
"pnl_total": 0.0,
|
||||
"pnl_ex_sick": 0.0,
|
||||
"win_count": 0,
|
||||
"loss_count": 0,
|
||||
"avg_win": 0.0,
|
||||
"avg_loss": 0.0,
|
||||
"max_win": 0.0,
|
||||
"max_loss": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"profit_loss_ratio": 0.0,
|
||||
"turnover_total": 0.0,
|
||||
"commission_total": 0.0,
|
||||
"premium_total": 0.0,
|
||||
"by_exchange": {},
|
||||
"by_source_type": {},
|
||||
}
|
||||
|
||||
|
||||
def _compute_options_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
st = _empty_options_stats()
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
by_ex: dict[str, dict[str, Any]] = {}
|
||||
by_src: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def bucket() -> dict[str, Any]:
|
||||
return {
|
||||
"open_count": 0,
|
||||
"pnl_total": 0.0,
|
||||
"win_count": 0,
|
||||
"loss_count": 0,
|
||||
"premium_total": 0.0,
|
||||
}
|
||||
|
||||
for td in trade_rows:
|
||||
pnl = float(td.get("pnl_amount") or td.get("realized_pnl_total") or 0)
|
||||
ex = str(td.get("exchange_key") or "okx")
|
||||
src = str(td.get("source_type") or td.get("source_label") or "?")
|
||||
prem = float(td.get("premium_total") or td.get("premium_paid") or 0)
|
||||
st["open_count"] += 1
|
||||
st["pnl_total"] += pnl
|
||||
st["premium_total"] += prem
|
||||
if pnl > 0.0001:
|
||||
st["win_count"] += 1
|
||||
wins.append(pnl)
|
||||
elif pnl < -0.0001:
|
||||
st["loss_count"] += 1
|
||||
losses.append(pnl)
|
||||
if ex not in by_ex:
|
||||
by_ex[ex] = bucket()
|
||||
by_ex[ex]["open_count"] += 1
|
||||
by_ex[ex]["pnl_total"] += pnl
|
||||
by_ex[ex]["premium_total"] += prem
|
||||
if pnl > 0.0001:
|
||||
by_ex[ex]["win_count"] += 1
|
||||
elif pnl < -0.0001:
|
||||
by_ex[ex]["loss_count"] += 1
|
||||
if src not in by_src:
|
||||
by_src[src] = bucket()
|
||||
by_src[src]["open_count"] += 1
|
||||
by_src[src]["pnl_total"] += pnl
|
||||
|
||||
total = int(st["open_count"] or 0)
|
||||
st["pnl_ex_sick"] = round(float(st["pnl_total"]), 4)
|
||||
st["pnl_total"] = round(float(st["pnl_total"]), 4)
|
||||
st["premium_total"] = round(float(st["premium_total"]), 4)
|
||||
st["avg_win"] = round(sum(wins) / len(wins), 4) if wins else 0.0
|
||||
st["avg_loss"] = round(sum(losses) / len(losses), 4) if losses else 0.0
|
||||
st["max_win"] = round(max(wins), 4) if wins else 0.0
|
||||
st["max_loss"] = round(min(losses), 4) if losses else 0.0
|
||||
st["win_rate"] = round(st["win_count"] / total * 100, 1) if total else 0.0
|
||||
if wins and losses and abs(st["avg_loss"]) > 1e-9:
|
||||
st["profit_loss_ratio"] = round(abs(st["avg_win"] / st["avg_loss"]), 2)
|
||||
for ex, b in by_ex.items():
|
||||
b["pnl_total"] = round(float(b["pnl_total"]), 4)
|
||||
b["premium_total"] = round(float(b["premium_total"]), 4)
|
||||
b["sick_count"] = 0
|
||||
b["sick_pct"] = 0.0
|
||||
b["pnl_ex_sick"] = b["pnl_total"]
|
||||
b["avg_win"] = 0.0
|
||||
b["avg_loss"] = 0.0
|
||||
b["max_win"] = 0.0
|
||||
b["max_loss"] = 0.0
|
||||
b["win_rate"] = (
|
||||
round(b["win_count"] / b["open_count"] * 100, 1) if b["open_count"] else 0.0
|
||||
)
|
||||
b["profit_loss_ratio"] = 0.0
|
||||
b["turnover_total"] = 0.0
|
||||
b["commission_total"] = 0.0
|
||||
for src, b in by_src.items():
|
||||
b["pnl_total"] = round(float(b["pnl_total"]), 4)
|
||||
st["by_exchange"] = by_ex
|
||||
st["by_source_type"] = by_src
|
||||
return st
|
||||
|
||||
|
||||
def list_daily_options_trades(
|
||||
trading_day: str = "",
|
||||
*,
|
||||
period: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
exchange_key: str = "",
|
||||
filter_profit: bool = False,
|
||||
filter_loss: bool = False,
|
||||
search: str = "",
|
||||
source_type: str = "",
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
init_options_archive_db(db_path)
|
||||
p = (period or "today").strip().lower() or "today"
|
||||
start_ms, end_ms, df, dt, period_label = resolve_period_bounds(
|
||||
period=p,
|
||||
trading_day=trading_day,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
ex_filter = (exchange_key or "").strip().lower()
|
||||
src_filter = (source_type or "").strip().lower()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
params: list[Any] = [start_ms, end_ms]
|
||||
where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||
where += " AND COALESCE(excluded_as_hedge_leg,0)=0"
|
||||
if ex_filter:
|
||||
where += " AND exchange_key=?"
|
||||
params.append(ex_filter)
|
||||
if src_filter:
|
||||
where += " AND LOWER(COALESCE(source_type,''))=?"
|
||||
params.append(src_filter)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM archive_options_trade_cache
|
||||
WHERE {where}
|
||||
ORDER BY closed_at_ms DESC, history_key DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
trades: list[dict[str, Any]] = []
|
||||
q = (search or "").strip().lower()
|
||||
for r in rows:
|
||||
td = _options_row_to_dict(r)
|
||||
pnl = float(td.get("pnl_amount") or 0)
|
||||
if filter_profit and pnl <= 0.0001:
|
||||
continue
|
||||
if filter_loss and pnl >= -0.0001:
|
||||
continue
|
||||
if q:
|
||||
blob = " ".join(
|
||||
str(td.get(k) or "")
|
||||
for k in (
|
||||
"underlying",
|
||||
"inst_id",
|
||||
"exchange_key",
|
||||
"source_type",
|
||||
"source_label",
|
||||
"opt_type",
|
||||
"strategy_tag",
|
||||
"result_tag",
|
||||
"direction",
|
||||
)
|
||||
).lower()
|
||||
if q not in blob:
|
||||
continue
|
||||
trades.append(td)
|
||||
return {
|
||||
"period": p,
|
||||
"period_label": period_label,
|
||||
"trading_day": dt,
|
||||
"date_from": df,
|
||||
"date_to": dt,
|
||||
"product": "options",
|
||||
"trades": trades,
|
||||
"stats": _compute_options_period_stats(trades),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_archive_options_calendar(
|
||||
year: int,
|
||||
month: int,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
db_path: Path | None = None,
|
||||
reset_hour: int = TRADING_DAY_RESET_HOUR,
|
||||
) -> dict[str, Any]:
|
||||
init_options_archive_db(db_path)
|
||||
y = int(year)
|
||||
m = int(month)
|
||||
if m < 1 or m > 12:
|
||||
raise ValueError("month 无效")
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
first = f"{y:04d}-{m:02d}-01"
|
||||
if m == 12:
|
||||
next_first = datetime(y + 1, 1, 1)
|
||||
else:
|
||||
next_first = datetime(y, m + 1, 1)
|
||||
last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour)
|
||||
_, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour)
|
||||
ex_filter = (exchange_key or "").strip().lower()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
params: list[Any] = [start_ms, end_ms]
|
||||
where = (
|
||||
"closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||
" AND COALESCE(excluded_as_hedge_leg,0)=0"
|
||||
)
|
||||
if ex_filter:
|
||||
where += " AND exchange_key=?"
|
||||
params.append(ex_filter)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM archive_options_trade_cache WHERE {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
days: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
td = _options_row_to_dict(r)
|
||||
closed_ms = td.get("closed_at_ms") or parse_wall_clock_ms(td.get("closed_at"))
|
||||
if not closed_ms:
|
||||
continue
|
||||
day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour)
|
||||
if not day or day < first or day > last:
|
||||
continue
|
||||
bucket = days.setdefault(
|
||||
day,
|
||||
{
|
||||
"trading_day": day,
|
||||
"open_count": 0,
|
||||
"sick_count": 0,
|
||||
"pnl_total": 0.0,
|
||||
"turnover_total": 0.0,
|
||||
"commission_total": 0.0,
|
||||
"has_sick": False,
|
||||
},
|
||||
)
|
||||
bucket["open_count"] += 1
|
||||
bucket["pnl_total"] += float(td.get("pnl_amount") or 0)
|
||||
for d in days.values():
|
||||
d["pnl_total"] = round(float(d["pnl_total"]), 4)
|
||||
month_pnl = sum(float(d["pnl_total"]) for d in days.values())
|
||||
month_count = sum(int(d["open_count"]) for d in days.values())
|
||||
return {
|
||||
"year": y,
|
||||
"month": m,
|
||||
"date_from": first,
|
||||
"date_to": last,
|
||||
"product": "options",
|
||||
"days": days,
|
||||
"month_pnl_total": round(month_pnl, 4),
|
||||
"month_open_count": month_count,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sync_options_exchange_archive(
|
||||
exchange_key: str,
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""仅缓存期权交易,不做 K 线."""
|
||||
r = upsert_options_trades_cache(
|
||||
exchange_key, trades, db_path=db_path, prune_missing=True
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange_key": (exchange_key or "").strip().lower(),
|
||||
"product": "options",
|
||||
"trades_upserted": r.get("upserted", 0),
|
||||
"trades_removed": r.get("removed", 0),
|
||||
"trade_count": len(trades or []),
|
||||
}
|
||||
@@ -10,15 +10,32 @@ from typing import Any
|
||||
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
STRATEGY_EXCHANGES: tuple[str, ...] = ("playbook", "binance", "okx", "gate")
|
||||
STRATEGY_EXCHANGES: tuple[str, ...] = (
|
||||
"playbook_v2",
|
||||
"playbook",
|
||||
"behavior",
|
||||
"binance",
|
||||
"okx",
|
||||
"gate",
|
||||
)
|
||||
|
||||
STRATEGY_META: dict[str, dict[str, str]] = {
|
||||
"playbook_v2": {
|
||||
"label": "执行手册v2",
|
||||
"title": "交易执行手册 v2(期权 / 合约 · 无对冲)",
|
||||
"md_rel": "docs/交易执行手册-v2-期权与合约.md",
|
||||
},
|
||||
"playbook": {
|
||||
"label": "执行手册",
|
||||
"title": "交易执行手册(期权为主 · Gate 为辅)",
|
||||
"label": "执行手册v1",
|
||||
"title": "交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)",
|
||||
# 相对仓库根;其余条目用 md_file 相对 docs/strategy
|
||||
"md_rel": "docs/交易执行手册-期权与Gate.md",
|
||||
},
|
||||
"behavior": {
|
||||
"label": "行为准则",
|
||||
"title": "交易行为准则(开单三检)",
|
||||
"md_rel": "docs/交易行为准则-开单三检.md",
|
||||
},
|
||||
"binance": {
|
||||
"label": "币安",
|
||||
"title": "币安·山寨多头趋势",
|
||||
@@ -219,11 +236,30 @@ def load_strategy_payload(exchange_key: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def strategy_meta_payload() -> dict[str, Any]:
|
||||
tabs = [
|
||||
{"key": k, "label": STRATEGY_META[k]["label"], "title": STRATEGY_META[k]["title"]}
|
||||
for k in STRATEGY_EXCHANGES
|
||||
]
|
||||
_STRATEGY_TAB_DISPLAY_PREF: dict[str, str] = {
|
||||
"playbook_v2": "show_strategy_playbook_v2",
|
||||
"playbook": "show_strategy_playbook",
|
||||
"behavior": "show_strategy_behavior",
|
||||
"binance": "show_strategy_binance",
|
||||
"okx": "show_strategy_okx",
|
||||
"gate": "show_strategy_gate",
|
||||
}
|
||||
|
||||
|
||||
def strategy_meta_payload(display: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
prefs = display if isinstance(display, dict) else {}
|
||||
tabs = []
|
||||
for k in STRATEGY_EXCHANGES:
|
||||
pref_key = _STRATEGY_TAB_DISPLAY_PREF.get(k)
|
||||
if pref_key and prefs.get(pref_key) is False:
|
||||
continue
|
||||
tabs.append(
|
||||
{
|
||||
"key": k,
|
||||
"label": STRATEGY_META[k]["label"],
|
||||
"title": STRATEGY_META[k]["title"],
|
||||
}
|
||||
)
|
||||
return {"ok": True, "exchanges": tabs}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ DISPLAY_RUNTIME_PREFIX = "display."
|
||||
|
||||
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
"show_nav_dashboard": False,
|
||||
"show_nav_key_monitor": True,
|
||||
"show_nav_trade": True,
|
||||
"show_nav_strategy": True,
|
||||
"show_nav_strategy_records": True,
|
||||
"show_nav_records": True,
|
||||
@@ -28,6 +30,8 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
|
||||
DISPLAY_LABELS: dict[str, str] = {
|
||||
"show_nav_dashboard": "数据看板",
|
||||
"show_nav_key_monitor": "关键位监控",
|
||||
"show_nav_trade": "实盘下单",
|
||||
"show_nav_strategy": "策略交易",
|
||||
"show_nav_strategy_records": "策略交易记录",
|
||||
"show_nav_records": "交易记录与复盘",
|
||||
@@ -47,6 +51,8 @@ DISPLAY_LABELS: dict[str, str] = {
|
||||
|
||||
NAV_TAB_ALLOWED: dict[str, str] = {
|
||||
"dashboard": "show_nav_dashboard",
|
||||
"key_monitor": "show_nav_key_monitor",
|
||||
"trade": "show_nav_trade",
|
||||
"strategy": "show_nav_strategy",
|
||||
"strategy_records": "show_nav_strategy_records",
|
||||
"records": "show_nav_records",
|
||||
@@ -110,6 +116,8 @@ def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
||||
def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||
nav_keys = [
|
||||
"show_nav_dashboard",
|
||||
"show_nav_key_monitor",
|
||||
"show_nav_trade",
|
||||
"show_nav_strategy",
|
||||
"show_nav_strategy_records",
|
||||
"show_nav_records",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# 系统设置 · 导航显示开关(SSR 预渲染,保存仍走 API) #}
|
||||
<div class="settings-tab-inner" id="display-prefs-card">
|
||||
<h2>导航显示</h2>
|
||||
<p class="settings-env-hint">以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.关键位监控,实盘下单,系统设置为固定项.</p>
|
||||
<p class="settings-env-hint">以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.系统设置为固定项.</p>
|
||||
<div id="display-prefs-form" class="display-prefs-form" data-prefs-ssr="1">
|
||||
{% if display_meta %}
|
||||
{% for group in display_meta %}
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
</div>
|
||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
||||
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}">实盘下单</a>
|
||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == '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 initial_tab == '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" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -118,8 +118,8 @@
|
||||
</div>
|
||||
<div class="top-nav">
|
||||
<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="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
||||
<a href="/trade" class="{% if page == 'trade' %}active{% 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>
|
||||
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -95,6 +95,14 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
ON options_target_monitors(status)
|
||||
"""
|
||||
)
|
||||
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",
|
||||
):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
init_options_review_tables(conn)
|
||||
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@ def sync_open_options_trades(
|
||||
*,
|
||||
live_inst_ids: set[str],
|
||||
fetch_history_fn: Callable[[str], list[dict[str, Any]]],
|
||||
notify_cfg: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
交易所已无持仓时,将本地 open 记录同步为 closed.
|
||||
@@ -319,6 +320,24 @@ def sync_open_options_trades(
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
if notify_cfg is not None:
|
||||
try:
|
||||
from lib.options.options_notify_lib import notify_options_close
|
||||
|
||||
reason = "到期结算" if close_reason == "expired" else "交易所平仓"
|
||||
notify_options_close(
|
||||
notify_cfg,
|
||||
conn,
|
||||
inst_id=inst_id,
|
||||
reason=reason,
|
||||
trade_id=int(row["id"]),
|
||||
premium_paid=paid,
|
||||
premium_received=prem_recv,
|
||||
realized_pnl=realized_pnl,
|
||||
close_quote=close_quote,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return updated
|
||||
|
||||
|
||||
@@ -414,6 +433,7 @@ def options_monitor_loop(
|
||||
close_fn=target_close_fn,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
cfg={"send_wechat": send_wechat, "account_label": account_label},
|
||||
)
|
||||
if sync_trades_fn is not None:
|
||||
sync_trades_fn(conn)
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def _fmt(v: Any, d: int = 4) -> str:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return "—"
|
||||
return f"{float(v):.{d}f}"
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
def _opt_type_label(opt_type: Any) -> str:
|
||||
t = str(opt_type or "").strip().upper()
|
||||
if t in ("C", "CALL"):
|
||||
return "Call"
|
||||
if t in ("P", "PUT"):
|
||||
return "Put"
|
||||
return t or "—"
|
||||
|
||||
|
||||
def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
|
||||
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",
|
||||
):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def notify_options_send(cfg: dict[str, Any], content: str) -> bool:
|
||||
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
|
||||
if not callable(send):
|
||||
return False
|
||||
try:
|
||||
send(content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def build_options_open_message(
|
||||
*,
|
||||
account_label: str,
|
||||
inst_id: str,
|
||||
underlying: str = "",
|
||||
opt_type: Any = None,
|
||||
sheets: Any = None,
|
||||
premium_paid: Any = None,
|
||||
open_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
signal_note: str = "",
|
||||
trade_id: Any = None,
|
||||
) -> str:
|
||||
lines = [
|
||||
"【OKX期权·开仓】",
|
||||
f"账户:{account_label or 'OKX期权'}",
|
||||
]
|
||||
if trade_id is not None:
|
||||
lines.append(f"本地单号:#{trade_id}")
|
||||
lines.extend(
|
||||
[
|
||||
f"合约:{inst_id}",
|
||||
f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}",
|
||||
f"张数:{sheets if sheets is not None else '—'}",
|
||||
f"开仓报价:{_fmt(open_quote)} USDC",
|
||||
f"权利金:{_fmt(premium_paid)} USDC",
|
||||
]
|
||||
)
|
||||
if target_index is not None and str(target_index).strip() != "":
|
||||
try:
|
||||
lines.append(f"目标指数:{float(target_index):g}")
|
||||
except (TypeError, ValueError):
|
||||
lines.append(f"目标指数:{target_index}")
|
||||
if signal_note:
|
||||
lines.append(f"备注:{signal_note[:200]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_options_close_message(
|
||||
*,
|
||||
account_label: str,
|
||||
inst_id: str,
|
||||
reason: str = "",
|
||||
underlying: str = "",
|
||||
opt_type: Any = None,
|
||||
sheets: Any = None,
|
||||
premium_paid: Any = None,
|
||||
premium_received: Any = None,
|
||||
realized_pnl: Any = None,
|
||||
close_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
trigger_idx: Any = None,
|
||||
trade_id: Any = None,
|
||||
) -> str:
|
||||
lines = [
|
||||
"【OKX期权·平仓】",
|
||||
f"账户:{account_label or 'OKX期权'}",
|
||||
]
|
||||
if trade_id is not None:
|
||||
lines.append(f"本地单号:#{trade_id}")
|
||||
lines.extend(
|
||||
[
|
||||
f"合约:{inst_id}",
|
||||
f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}",
|
||||
f"原因:{(reason or '平仓').strip()}",
|
||||
f"张数:{sheets if sheets is not None else '—'}",
|
||||
f"平仓报价:{_fmt(close_quote)} USDC",
|
||||
f"已付/收回:{_fmt(premium_paid)} / {_fmt(premium_received)} USDC",
|
||||
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
|
||||
]
|
||||
)
|
||||
if target_index is not None and str(target_index).strip() != "":
|
||||
try:
|
||||
lines.append(f"目标指数:{float(target_index):g}")
|
||||
except (TypeError, ValueError):
|
||||
lines.append(f"目标指数:{target_index}")
|
||||
if trigger_idx is not None and str(trigger_idx).strip() != "":
|
||||
try:
|
||||
lines.append(f"触发指数:{float(trigger_idx):g}")
|
||||
except (TypeError, ValueError):
|
||||
lines.append(f"触发指数:{trigger_idx}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def notify_options_open(
|
||||
cfg: dict[str, Any],
|
||||
conn: sqlite3.Connection | None,
|
||||
*,
|
||||
trade_id: int | None,
|
||||
inst_id: str,
|
||||
underlying: str = "",
|
||||
opt_type: Any = None,
|
||||
sheets: Any = None,
|
||||
premium_paid: Any = None,
|
||||
open_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
signal_note: str = "",
|
||||
) -> bool:
|
||||
ensure_options_notify_columns(conn) if conn is not None else None
|
||||
if conn is not None and trade_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT wechat_open_sent FROM options_trades WHERE id=?",
|
||||
(int(trade_id),),
|
||||
).fetchone()
|
||||
if row and int(row["wechat_open_sent"] or 0):
|
||||
return False
|
||||
msg = build_options_open_message(
|
||||
account_label=str(cfg.get("account_label") or "OKX期权"),
|
||||
inst_id=inst_id,
|
||||
underlying=underlying,
|
||||
opt_type=opt_type,
|
||||
sheets=sheets,
|
||||
premium_paid=premium_paid,
|
||||
open_quote=open_quote,
|
||||
target_index=target_index,
|
||||
signal_note=signal_note,
|
||||
trade_id=trade_id,
|
||||
)
|
||||
ok = notify_options_send(cfg, msg)
|
||||
if ok and conn is not None and trade_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE options_trades SET wechat_open_sent=1 WHERE id=?",
|
||||
(int(trade_id),),
|
||||
)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return ok
|
||||
|
||||
|
||||
def _load_trade_row(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None:
|
||||
row = conn.execute("SELECT * FROM options_trades WHERE id=?", (int(trade_id),)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def notify_options_close(
|
||||
cfg: dict[str, Any],
|
||||
conn: sqlite3.Connection | None,
|
||||
*,
|
||||
inst_id: str,
|
||||
reason: str = "平仓",
|
||||
trade_id: int | None = None,
|
||||
underlying: str = "",
|
||||
opt_type: Any = None,
|
||||
sheets: Any = None,
|
||||
premium_paid: Any = None,
|
||||
premium_received: Any = None,
|
||||
realized_pnl: Any = None,
|
||||
close_quote: Any = None,
|
||||
target_index: Any = None,
|
||||
trigger_idx: Any = None,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""平仓必发.默认按 trade_id / 同合约未标记行幂等."""
|
||||
if conn is not None:
|
||||
ensure_options_notify_columns(conn)
|
||||
rows: list[dict[str, Any]] = []
|
||||
if conn is not None and trade_id is not None:
|
||||
r = _load_trade_row(conn, int(trade_id))
|
||||
if r:
|
||||
rows = [r]
|
||||
elif conn is not None and inst_id:
|
||||
q = conn.execute(
|
||||
"""
|
||||
SELECT * FROM options_trades
|
||||
WHERE inst_id=? AND status='closed'
|
||||
AND COALESCE(wechat_close_sent,0)=0
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchall()
|
||||
rows = [dict(x) for x in q]
|
||||
if not rows and force:
|
||||
q2 = conn.execute(
|
||||
"""
|
||||
SELECT * FROM options_trades
|
||||
WHERE inst_id=? AND status='closed'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if q2:
|
||||
rows = [dict(q2)]
|
||||
|
||||
if rows:
|
||||
# 同次平仓可能多腿:合并一条推送,逐条标记
|
||||
total_paid = sum(float(r.get("premium_paid") or 0) for r in rows)
|
||||
total_recv = sum(float(r.get("premium_received") or 0) for r in rows if r.get("premium_received") is not None)
|
||||
pnls = [float(r["realized_pnl"]) for r in rows if r.get("realized_pnl") is not None]
|
||||
total_pnl = sum(pnls) if pnls else None
|
||||
if total_pnl is None and (premium_received is not None or realized_pnl is not None):
|
||||
total_pnl = realized_pnl
|
||||
total_recv = premium_received if premium_received is not None else total_recv
|
||||
total_paid = premium_paid if premium_paid is not None else total_paid
|
||||
head = rows[0]
|
||||
pending = [r for r in rows if not int(r.get("wechat_close_sent") or 0)]
|
||||
if not pending and not force:
|
||||
return False
|
||||
msg = build_options_close_message(
|
||||
account_label=str(cfg.get("account_label") or "OKX期权"),
|
||||
inst_id=inst_id or str(head.get("inst_id") or ""),
|
||||
reason=reason,
|
||||
underlying=underlying or str(head.get("underlying") or ""),
|
||||
opt_type=opt_type or head.get("opt_type"),
|
||||
sheets=sheets if sheets is not None else sum(int(r.get("sheets") or 0) for r in rows),
|
||||
premium_paid=total_paid,
|
||||
premium_received=total_recv if rows else premium_received,
|
||||
realized_pnl=total_pnl,
|
||||
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
|
||||
target_index=target_index,
|
||||
trigger_idx=trigger_idx,
|
||||
trade_id=head.get("id") if len(rows) == 1 else None,
|
||||
)
|
||||
ok = notify_options_send(cfg, msg)
|
||||
if ok and conn is not None:
|
||||
for r in pending or rows:
|
||||
conn.execute(
|
||||
"UPDATE options_trades SET wechat_close_sent=1 WHERE id=?",
|
||||
(int(r["id"]),),
|
||||
)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return ok
|
||||
|
||||
# 无库行时仍发一条(尽量不丢提醒)
|
||||
msg = build_options_close_message(
|
||||
account_label=str(cfg.get("account_label") or "OKX期权"),
|
||||
inst_id=inst_id,
|
||||
reason=reason,
|
||||
underlying=underlying,
|
||||
opt_type=opt_type,
|
||||
sheets=sheets,
|
||||
premium_paid=premium_paid,
|
||||
premium_received=premium_received,
|
||||
realized_pnl=realized_pnl,
|
||||
close_quote=close_quote,
|
||||
target_index=target_index,
|
||||
trigger_idx=trigger_idx,
|
||||
trade_id=trade_id,
|
||||
)
|
||||
return notify_options_send(cfg, msg)
|
||||
|
||||
|
||||
def notify_options_close_trade_ids(
|
||||
cfg: dict[str, Any],
|
||||
conn: sqlite3.Connection,
|
||||
trade_ids: list[int],
|
||||
*,
|
||||
reason: str,
|
||||
) -> bool:
|
||||
ids = [int(x) for x in trade_ids if x is not None]
|
||||
if not ids:
|
||||
return False
|
||||
ensure_options_notify_columns(conn)
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM options_trades
|
||||
WHERE id IN ({placeholders}) AND COALESCE(wechat_close_sent,0)=0
|
||||
""",
|
||||
ids,
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return False
|
||||
first = dict(rows[0])
|
||||
return notify_options_close(
|
||||
cfg,
|
||||
conn,
|
||||
inst_id=str(first.get("inst_id") or ""),
|
||||
reason=reason,
|
||||
trade_id=int(first["id"]) if len(rows) == 1 else None,
|
||||
underlying=str(first.get("underlying") or ""),
|
||||
opt_type=first.get("opt_type"),
|
||||
sheets=sum(int(r["sheets"] or 0) for r in rows),
|
||||
premium_paid=sum(float(r["premium_paid"] or 0) for r in rows),
|
||||
premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None),
|
||||
realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None),
|
||||
close_quote=first.get("close_quote"),
|
||||
)
|
||||
@@ -640,11 +640,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn = cfg["get_db"]()
|
||||
trade_id = None
|
||||
target_mon = None
|
||||
open_underlying = ""
|
||||
open_opt_type = None
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
meta = q.get("meta") or {}
|
||||
u = str(meta.get("uly") or inst_id).split("-")[0]
|
||||
opt_type = meta.get("optType")
|
||||
open_underlying = u
|
||||
open_opt_type = opt_type
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
@@ -683,9 +687,30 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
finally:
|
||||
conn.close()
|
||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||
from lib.options.options_notify_lib import notify_options_open
|
||||
|
||||
invalidate_option_positions_cache()
|
||||
_sync_options_trades(cfg, force=True)
|
||||
try:
|
||||
conn_n = cfg["get_db"]()
|
||||
try:
|
||||
notify_options_open(
|
||||
cfg,
|
||||
conn_n,
|
||||
trade_id=trade_id,
|
||||
inst_id=inst_id,
|
||||
underlying=open_underlying,
|
||||
opt_type=open_opt_type,
|
||||
sheets=sheets,
|
||||
premium_paid=sizing.get("total_premium"),
|
||||
open_quote=float(ask) if ask is not None else None,
|
||||
target_index=target_index,
|
||||
signal_note=signal_note,
|
||||
)
|
||||
finally:
|
||||
conn_n.close()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -938,11 +963,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
if result.get("fully_closed"):
|
||||
try:
|
||||
from lib.options.options_target_lib import cancel_target_monitor
|
||||
from lib.options.options_notify_lib import notify_options_close
|
||||
|
||||
conn2 = cfg["get_db"]()
|
||||
try:
|
||||
cancel_target_monitor(conn2, inst_id=inst_id)
|
||||
conn2.commit()
|
||||
notify_options_close(
|
||||
cfg,
|
||||
conn2,
|
||||
inst_id=inst_id,
|
||||
reason="手动平仓",
|
||||
sheets=result.get("submitted_sheets"),
|
||||
premium_received=result.get("premium_received"),
|
||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||||
)
|
||||
finally:
|
||||
conn2.close()
|
||||
except Exception:
|
||||
@@ -1253,6 +1288,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn,
|
||||
live_inst_ids=live_ids,
|
||||
fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id),
|
||||
notify_cfg=cfg,
|
||||
)
|
||||
|
||||
def _target_close(inst_id: str) -> dict[str, Any]:
|
||||
|
||||
@@ -292,6 +292,7 @@ def close_option_by_bid_depth(
|
||||
|
||||
|
||||
def _notify_target_close(
|
||||
cfg: dict[str, Any] | None,
|
||||
send_wechat: Callable[[str], None] | None,
|
||||
*,
|
||||
account_label: str,
|
||||
@@ -299,7 +300,28 @@ def _notify_target_close(
|
||||
target: float,
|
||||
idx: float,
|
||||
result: dict[str, Any],
|
||||
conn: Any = None,
|
||||
) -> None:
|
||||
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
||||
if result.get("fully_closed") or result.get("already_flat"):
|
||||
if cfg is not None:
|
||||
try:
|
||||
from lib.options.options_notify_lib import notify_options_close
|
||||
|
||||
notify_options_close(
|
||||
cfg,
|
||||
conn,
|
||||
inst_id=inst_id,
|
||||
reason="目标位平仓",
|
||||
sheets=result.get("submitted_sheets"),
|
||||
premium_received=result.get("premium_received"),
|
||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||||
target_index=target,
|
||||
trigger_idx=idx,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if not send_wechat:
|
||||
return
|
||||
try:
|
||||
@@ -313,6 +335,7 @@ def _notify_target_close(
|
||||
f"触发指数:{idx:g}",
|
||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
||||
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
|
||||
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -339,6 +362,7 @@ def run_options_target_closes(
|
||||
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
||||
send_wechat: Callable[[str], None] | None = None,
|
||||
account_label: str = "OKX期权",
|
||||
cfg: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
扫描 active 目标委托;指数到位后限价平仓.
|
||||
@@ -433,11 +457,13 @@ def run_options_target_closes(
|
||||
_commit_monitor(conn)
|
||||
triggered += 1
|
||||
_notify_target_close(
|
||||
cfg,
|
||||
send_wechat,
|
||||
account_label=account_label,
|
||||
inst_id=inst_id,
|
||||
target=target,
|
||||
idx=idx,
|
||||
result=result,
|
||||
conn=conn,
|
||||
)
|
||||
return triggered
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""中控振幅统计 API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from amp_stats_store import delete_history, get_history, list_history, save_history
|
||||
from lib.hub.amp_stats_lib import (
|
||||
build_export_csv,
|
||||
compute_amp_stats,
|
||||
export_filename,
|
||||
normalize_straddle_premium,
|
||||
normalize_take_profit,
|
||||
normalize_weekend_filter,
|
||||
reframe_amp_stats,
|
||||
rows_page,
|
||||
)
|
||||
|
||||
|
||||
class ComputeBody(BaseModel):
|
||||
symbol: str = "eth"
|
||||
start_hour: int = 16
|
||||
period: str = "2m"
|
||||
custom_days: Optional[int] = None
|
||||
straddle_premium: Optional[float] = None
|
||||
take_profit: Optional[float] = None
|
||||
weekend_filter: str = "all"
|
||||
page: int = 1
|
||||
page_size: int = 20
|
||||
|
||||
|
||||
class SaveBody(BaseModel):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ReframeBody(BaseModel):
|
||||
"""已有日表上改周末/权利金/止盈(不拉 K 线)."""
|
||||
|
||||
rows_all: list[dict[str, Any]] = Field(default_factory=list)
|
||||
symbol: str = "eth"
|
||||
start_hour: int = 16
|
||||
period: str = "2m"
|
||||
sample_days: int = 60
|
||||
straddle_premium: Optional[float] = None
|
||||
take_profit: Optional[float] = None
|
||||
weekend_filter: str = "all"
|
||||
price_source: str = ""
|
||||
inst_id: str = ""
|
||||
page: int = 1
|
||||
page_size: int = 20
|
||||
|
||||
|
||||
def create_amp_stats_router() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"])
|
||||
|
||||
@router.get("/meta")
|
||||
def api_meta():
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange": "okx",
|
||||
"symbols": [
|
||||
{"key": "eth", "label": "ETH"},
|
||||
{"key": "btc", "label": "BTC"},
|
||||
],
|
||||
"end_hour": 16,
|
||||
"start_hours": list(range(24)),
|
||||
"periods": [
|
||||
{"key": "1m", "label": "1个月"},
|
||||
{"key": "2m", "label": "2个月"},
|
||||
{"key": "3m", "label": "3个月"},
|
||||
{"key": "6m", "label": "半年"},
|
||||
{"key": "1y", "label": "1年"},
|
||||
{"key": "custom", "label": "自定义"},
|
||||
],
|
||||
"weekend_filters": [
|
||||
{"key": "all", "label": "全部"},
|
||||
{"key": "exclude", "label": "排除周末"},
|
||||
{"key": "only", "label": "仅周末"},
|
||||
],
|
||||
"default_period": "2m",
|
||||
"default_weekend_filter": "all",
|
||||
"timeframe": "1H",
|
||||
"metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)",
|
||||
"straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金",
|
||||
}
|
||||
|
||||
@router.post("/compute")
|
||||
def api_compute(body: ComputeBody):
|
||||
try:
|
||||
result = compute_amp_stats(
|
||||
symbol=body.symbol,
|
||||
start_hour=body.start_hour,
|
||||
period=body.period,
|
||||
custom_days=body.custom_days,
|
||||
straddle_premium=body.straddle_premium,
|
||||
take_profit=body.take_profit,
|
||||
weekend_filter=body.weekend_filter,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
page = rows_page(result.get("rows") or [], page=body.page, page_size=body.page_size)
|
||||
return {
|
||||
"ok": True,
|
||||
"result": result,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
@router.post("/reframe")
|
||||
def api_reframe(body: ReframeBody):
|
||||
rows_all = body.rows_all or []
|
||||
if not rows_all:
|
||||
raise HTTPException(status_code=400, detail="无日表可重算")
|
||||
try:
|
||||
result = reframe_amp_stats(
|
||||
rows_all=rows_all,
|
||||
symbol=body.symbol,
|
||||
start_hour=body.start_hour,
|
||||
period=body.period,
|
||||
sample_days=body.sample_days,
|
||||
straddle_premium=body.straddle_premium,
|
||||
take_profit=body.take_profit,
|
||||
weekend_filter=body.weekend_filter,
|
||||
price_source=body.price_source,
|
||||
inst_id=body.inst_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
page = rows_page(result.get("rows") or [], page=body.page, page_size=body.page_size)
|
||||
return {"ok": True, "result": result, "page": page}
|
||||
|
||||
@router.get("/history")
|
||||
def api_history(symbol: str = "", limit: int = 50):
|
||||
return {"ok": True, "items": list_history(symbol=symbol, limit=limit)}
|
||||
|
||||
@router.post("/history")
|
||||
def api_history_save(body: SaveBody):
|
||||
payload = body.result if isinstance(body.result, dict) else {}
|
||||
if not payload.get("rows") and not payload.get("rows_all") and not payload.get("summary"):
|
||||
raise HTTPException(status_code=400, detail="无可保存的结果")
|
||||
item = save_history(payload)
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
@router.get("/history/{item_id}")
|
||||
def api_history_detail(item_id: str):
|
||||
item = get_history(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="历史不存在")
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
@router.delete("/history/{item_id}")
|
||||
def api_history_delete(item_id: str):
|
||||
if not delete_history(item_id):
|
||||
raise HTTPException(status_code=404, detail="历史不存在")
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/export")
|
||||
def api_export(
|
||||
history_id: str = Query(default=""),
|
||||
symbol: str = Query(default="eth"),
|
||||
start_hour: int = Query(default=16),
|
||||
period: str = Query(default="2m"),
|
||||
custom_days: Optional[int] = Query(default=None),
|
||||
straddle_premium: Optional[float] = Query(default=None),
|
||||
take_profit: Optional[float] = Query(default=None),
|
||||
weekend_filter: str = Query(default="all"),
|
||||
):
|
||||
if (history_id or "").strip():
|
||||
item = get_history(history_id.strip())
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="历史不存在")
|
||||
rows_all = item.get("rows_all") or item.get("rows") or []
|
||||
try:
|
||||
payload = reframe_amp_stats(
|
||||
rows_all=rows_all,
|
||||
symbol=item.get("symbol") or symbol,
|
||||
start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour),
|
||||
period=str(item.get("period") or period),
|
||||
sample_days=int(item.get("sample_days_requested") or 60),
|
||||
straddle_premium=straddle_premium
|
||||
if straddle_premium is not None
|
||||
else item.get("straddle_premium"),
|
||||
take_profit=take_profit if take_profit is not None else item.get("take_profit"),
|
||||
weekend_filter=weekend_filter or item.get("weekend_filter") or "all",
|
||||
price_source=str(item.get("price_source") or ""),
|
||||
inst_id=str(item.get("inst_id") or ""),
|
||||
missing=item.get("missing_days") or [],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
else:
|
||||
try:
|
||||
# validate enums early
|
||||
normalize_weekend_filter(weekend_filter)
|
||||
normalize_straddle_premium(straddle_premium)
|
||||
normalize_take_profit(take_profit)
|
||||
payload = compute_amp_stats(
|
||||
symbol=symbol,
|
||||
start_hour=start_hour,
|
||||
period=period,
|
||||
custom_days=custom_days,
|
||||
straddle_premium=straddle_premium,
|
||||
take_profit=take_profit,
|
||||
weekend_filter=weekend_filter,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
csv_text = build_export_csv(payload)
|
||||
name = export_filename(payload)
|
||||
return Response(
|
||||
content=csv_text.encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,122 @@
|
||||
"""振幅统计历史作业存储(中控 JSON)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_STORE_NAME = "amp_stats_history.json"
|
||||
_MAX_ITEMS = 80
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return Path(__file__).resolve().parent / _STORE_NAME
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _load() -> dict[str, Any]:
|
||||
path = _store_path()
|
||||
if not path.is_file():
|
||||
return {"items": []}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"items": []}
|
||||
if not isinstance(data, dict):
|
||||
return {"items": []}
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
return {"items": items}
|
||||
|
||||
|
||||
def _save(data: dict[str, Any]) -> None:
|
||||
path = _store_path()
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def list_history(*, symbol: str = "", limit: int = 50) -> list[dict[str, Any]]:
|
||||
with _LOCK:
|
||||
items = list(_load().get("items") or [])
|
||||
sym = (symbol or "").strip().lower()
|
||||
if sym:
|
||||
items = [x for x in items if str(x.get("symbol") or "").lower() == sym]
|
||||
limit = max(1, min(200, int(limit or 50)))
|
||||
out = []
|
||||
for it in items[:limit]:
|
||||
out.append(
|
||||
{
|
||||
"id": it.get("id"),
|
||||
"created_at": it.get("created_at"),
|
||||
"symbol": it.get("symbol"),
|
||||
"symbol_label": it.get("symbol_label"),
|
||||
"start_hour": it.get("start_hour"),
|
||||
"end_hour": it.get("end_hour"),
|
||||
"period": it.get("period"),
|
||||
"price_source": it.get("price_source"),
|
||||
"sample_count": (it.get("summary") or {}).get("sample_count"),
|
||||
"max_amplitude": (it.get("summary") or {}).get("max_amplitude"),
|
||||
"max_amplitude_day": (it.get("summary") or {}).get("max_amplitude_day"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def get_history(item_id: str) -> Optional[dict[str, Any]]:
|
||||
iid = (item_id or "").strip()
|
||||
if not iid:
|
||||
return None
|
||||
with _LOCK:
|
||||
for it in _load().get("items") or []:
|
||||
if str(it.get("id")) == iid:
|
||||
return dict(it)
|
||||
return None
|
||||
|
||||
|
||||
def save_history(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
item = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"created_at": _now_iso(),
|
||||
"exchange": payload.get("exchange"),
|
||||
"symbol": payload.get("symbol"),
|
||||
"symbol_label": payload.get("symbol_label"),
|
||||
"start_hour": payload.get("start_hour"),
|
||||
"end_hour": payload.get("end_hour"),
|
||||
"period": payload.get("period"),
|
||||
"timeframe": payload.get("timeframe"),
|
||||
"price_source": payload.get("price_source"),
|
||||
"inst_id": payload.get("inst_id"),
|
||||
"timezone": payload.get("timezone"),
|
||||
"summary": payload.get("summary") or {},
|
||||
"rows": payload.get("rows") or [],
|
||||
"missing_count": payload.get("missing_count") or 0,
|
||||
}
|
||||
with _LOCK:
|
||||
data = _load()
|
||||
items = list(data.get("items") or [])
|
||||
items.insert(0, item)
|
||||
data["items"] = items[:_MAX_ITEMS]
|
||||
_save(data)
|
||||
return item
|
||||
|
||||
|
||||
def delete_history(item_id: str) -> bool:
|
||||
iid = (item_id or "").strip()
|
||||
if not iid:
|
||||
return False
|
||||
with _LOCK:
|
||||
data = _load()
|
||||
items = list(data.get("items") or [])
|
||||
new_items = [x for x in items if str(x.get("id")) != iid]
|
||||
if len(new_items) == len(items):
|
||||
return False
|
||||
data["items"] = new_items
|
||||
_save(data)
|
||||
return True
|
||||
@@ -7,7 +7,8 @@
|
||||
| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 |
|
||||
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
|
||||
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
|
||||
| **策略说明** | 执行手册 + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
|
||||
| **策略说明** | 执行手册 + 行为准则(开单三检) + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
|
||||
| **振幅统计** | OKX ETH/BTC 时段点数振幅档案(只读,固定 16:00 收窗) |
|
||||
| **使用说明** | 本页:中控与实例怎么用 |
|
||||
| **行情区** | K 线、指标、画线;可从持仓跳转带币种 |
|
||||
| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) |
|
||||
|
||||
+197
-20
@@ -79,6 +79,12 @@ from lib.hub.hub_symbol_archive_lib import (
|
||||
update_review_quote,
|
||||
upsert_trade_overlay,
|
||||
)
|
||||
from lib.hub.hub_options_archive_lib import (
|
||||
init_options_archive_db,
|
||||
list_archive_options_calendar,
|
||||
list_daily_options_trades,
|
||||
sync_options_exchange_archive,
|
||||
)
|
||||
from lib.hub.hub_entry_plan_lib import (
|
||||
compute_entry_plan_stats,
|
||||
create_entry_plan,
|
||||
@@ -355,9 +361,11 @@ def _schedule_board_refresh() -> None:
|
||||
async def _run_archive_sync_once() -> dict:
|
||||
global _last_archive_sync
|
||||
init_archive_db()
|
||||
init_options_archive_db()
|
||||
settings = load_settings()
|
||||
targets = enabled_exchanges(settings)
|
||||
results: list[dict] = []
|
||||
options_results: list[dict] = []
|
||||
for ex in targets:
|
||||
ex_key = str(ex.get("key") or "").strip().lower()
|
||||
if not ex_key:
|
||||
@@ -390,34 +398,71 @@ async def _run_archive_sync_once() -> dict:
|
||||
"msg": msg,
|
||||
}
|
||||
)
|
||||
else:
|
||||
trades = trades_resp.get("trades") or []
|
||||
for t in trades:
|
||||
if isinstance(t, dict):
|
||||
t["exchange_key"] = ex_key
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
return _fetch_instance_ohlcv_sync(
|
||||
ex,
|
||||
symbol=kwargs.get("symbol") or "",
|
||||
timeframe=kwargs.get("timeframe") or "5m",
|
||||
since_ms=kwargs.get("since_ms"),
|
||||
limit=int(kwargs.get("limit") or 500),
|
||||
)
|
||||
|
||||
r = await asyncio.to_thread(
|
||||
sync_exchange_symbol_archives,
|
||||
ex_key,
|
||||
trades,
|
||||
remote_fetch,
|
||||
)
|
||||
r["name"] = ex.get("name")
|
||||
r["trade_count"] = len(trades)
|
||||
results.append(r)
|
||||
|
||||
caps = [str(x).lower() for x in (ex.get("capabilities") or [])]
|
||||
if "options" not in caps:
|
||||
continue
|
||||
trades = trades_resp.get("trades") or []
|
||||
for t in trades:
|
||||
opt_resp = await asyncio.to_thread(
|
||||
_fetch_instance_options_review_archive_sync,
|
||||
ex,
|
||||
days=ARCHIVE_TRADE_DAYS,
|
||||
limit=ARCHIVE_TRADE_LIMIT,
|
||||
)
|
||||
if not opt_resp.get("ok"):
|
||||
options_results.append(
|
||||
{
|
||||
"exchange_key": ex_key,
|
||||
"name": ex.get("name"),
|
||||
"ok": False,
|
||||
"status": opt_resp.get("status"),
|
||||
"msg": opt_resp.get("msg")
|
||||
or opt_resp.get("error")
|
||||
or opt_resp.get("detail")
|
||||
or "拉取期权复盘失败",
|
||||
"product": "options",
|
||||
}
|
||||
)
|
||||
continue
|
||||
opt_trades = opt_resp.get("trades") or []
|
||||
for t in opt_trades:
|
||||
if isinstance(t, dict):
|
||||
t["exchange_key"] = ex_key
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
return _fetch_instance_ohlcv_sync(
|
||||
ex,
|
||||
symbol=kwargs.get("symbol") or "",
|
||||
timeframe=kwargs.get("timeframe") or "5m",
|
||||
since_ms=kwargs.get("since_ms"),
|
||||
limit=int(kwargs.get("limit") or 500),
|
||||
)
|
||||
|
||||
r = await asyncio.to_thread(
|
||||
sync_exchange_symbol_archives,
|
||||
orow = await asyncio.to_thread(
|
||||
sync_options_exchange_archive,
|
||||
ex_key,
|
||||
trades,
|
||||
remote_fetch,
|
||||
opt_trades,
|
||||
)
|
||||
r["name"] = ex.get("name")
|
||||
r["trade_count"] = len(trades)
|
||||
results.append(r)
|
||||
orow["name"] = ex.get("name")
|
||||
options_results.append(orow)
|
||||
out = {
|
||||
"ok": True,
|
||||
"exchanges": len(targets),
|
||||
"results": results,
|
||||
"options_results": options_results,
|
||||
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
_last_archive_sync = out
|
||||
@@ -991,6 +1036,7 @@ def root_redirect():
|
||||
@app.get("/monitor")
|
||||
@app.get("/plan")
|
||||
@app.get("/calculator")
|
||||
@app.get("/compare")
|
||||
@app.get("/market")
|
||||
@app.get("/archive")
|
||||
@app.get("/quotes")
|
||||
@@ -998,6 +1044,7 @@ def root_redirect():
|
||||
@app.get("/funds")
|
||||
@app.get("/ai")
|
||||
@app.get("/strategy")
|
||||
@app.get("/amp-stats")
|
||||
@app.get("/help")
|
||||
@app.get("/logs")
|
||||
@app.get("/settings")
|
||||
@@ -1012,8 +1059,10 @@ def _all_exchanges_for_ai() -> list:
|
||||
|
||||
from hub_ai.routes import create_hub_ai_router
|
||||
from hub_dashboard import build_dashboard_payload, default_trading_day
|
||||
from amp_stats_routes import create_amp_stats_router
|
||||
|
||||
app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai))
|
||||
app.include_router(create_amp_stats_router())
|
||||
|
||||
|
||||
async def _run_dashboard_aggregate() -> dict:
|
||||
@@ -1110,9 +1159,21 @@ class SettingsDisplayBody(BaseModel):
|
||||
show_nav_quotes: bool = True
|
||||
show_nav_ai: bool = True
|
||||
show_nav_calculator: bool = True
|
||||
show_nav_compare: bool = True
|
||||
show_nav_strategy: bool = True
|
||||
show_nav_amp_stats: bool = True
|
||||
show_nav_help: bool = True
|
||||
show_nav_logs: bool = True
|
||||
show_monitor_binance: bool = True
|
||||
show_monitor_okx_perp: bool = True
|
||||
show_monitor_okx_options: bool = True
|
||||
show_monitor_gate: bool = True
|
||||
show_strategy_playbook_v2: bool = True
|
||||
show_strategy_playbook: bool = True
|
||||
show_strategy_behavior: bool = True
|
||||
show_strategy_binance: bool = True
|
||||
show_strategy_okx: bool = True
|
||||
show_strategy_gate: bool = True
|
||||
|
||||
|
||||
class SupervisorSettingsBody(BaseModel):
|
||||
@@ -1208,6 +1269,27 @@ class RollCalculatorBody(BaseModel):
|
||||
base: str = "ETH"
|
||||
|
||||
|
||||
class CompareOptionLegBody(BaseModel):
|
||||
opt_type: str = "C"
|
||||
strike: float | None = None
|
||||
ask: float | None = None
|
||||
|
||||
|
||||
class CompareBody(BaseModel):
|
||||
base: str = "ETH"
|
||||
direction: str = "long"
|
||||
entry: float = Field(gt=0)
|
||||
sl: float = Field(gt=0)
|
||||
tp: float = Field(gt=0)
|
||||
risk_u: float = Field(gt=0)
|
||||
tp_opt: float | None = None
|
||||
tp_hedge: float | None = None
|
||||
contract_size: float | None = None
|
||||
ct_mult: float | None = None
|
||||
option: CompareOptionLegBody | None = None
|
||||
hedge: dict | None = None
|
||||
|
||||
|
||||
@app.get("/api/calculator/exchanges")
|
||||
def api_calculator_exchanges():
|
||||
from lib.hub.hub_calculator_market_lib import list_calculator_exchanges
|
||||
@@ -1268,6 +1350,26 @@ def api_calculator_roll(body: RollCalculatorBody):
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
@app.post("/api/compare/calc")
|
||||
def api_compare_calc(body: CompareBody):
|
||||
from lib.hub.hub_compare_lib import run_compare
|
||||
|
||||
payload = body.model_dump()
|
||||
hedge = payload.get("hedge") if isinstance(payload.get("hedge"), dict) else {}
|
||||
# normalize hedge legs from nested dicts
|
||||
if hedge:
|
||||
payload["hedge"] = {
|
||||
"main": hedge.get("main") if isinstance(hedge.get("main"), dict) else {},
|
||||
"side": hedge.get("side") if isinstance(hedge.get("side"), dict) else {},
|
||||
}
|
||||
if payload.get("option") is None:
|
||||
payload["option"] = {}
|
||||
data = run_compare(payload)
|
||||
if not data.get("ok"):
|
||||
return JSONResponse(data, status_code=400)
|
||||
return data
|
||||
|
||||
|
||||
def _find_exchange_by_key(exchange_key: str) -> dict | None:
|
||||
key = (exchange_key or "").strip().lower()
|
||||
if not key:
|
||||
@@ -1308,6 +1410,34 @@ def _fetch_instance_trades_archive_sync(
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
def _fetch_instance_options_review_archive_sync(
|
||||
ex: dict,
|
||||
*,
|
||||
days: int = 365,
|
||||
limit: int = 2000,
|
||||
) -> dict:
|
||||
base = (ex.get("flask_url") or "").rstrip("/")
|
||||
if not base:
|
||||
return {"ok": False, "msg": "未配置 flask_url"}
|
||||
params = {"days": str(int(days)), "limit": str(int(limit))}
|
||||
url = f"{base}/api/hub/options/review/archive?{urlencode(params)}"
|
||||
try:
|
||||
with httpx.Client(timeout=max(HUB_FLASK_TIMEOUT, 120.0)) as client:
|
||||
r = client.get(url, headers=_hub_headers())
|
||||
if r.status_code >= 400:
|
||||
parsed = _parse_http_json_body(r)
|
||||
parsed.setdefault("ok", False)
|
||||
parsed.setdefault("status", r.status_code)
|
||||
return parsed
|
||||
data = r.json() if r.content else {}
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("ok", True)
|
||||
return data
|
||||
return {"ok": False, "msg": "无效 JSON"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
def _fetch_instance_ohlcv_sync(
|
||||
ex: dict,
|
||||
*,
|
||||
@@ -3088,6 +3218,52 @@ def api_archive_calendar(
|
||||
return {"ok": True, **payload}
|
||||
|
||||
|
||||
@app.get("/api/archive/options/daily-trades")
|
||||
def api_archive_options_daily_trades(
|
||||
period: str = "",
|
||||
trading_day: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
exchange_key: str = "",
|
||||
filter_profit: str = "",
|
||||
filter_loss: str = "",
|
||||
search: str = "",
|
||||
source_type: str = "",
|
||||
):
|
||||
init_options_archive_db()
|
||||
payload = list_daily_options_trades(
|
||||
trading_day=trading_day,
|
||||
period=period or "today",
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
exchange_key=exchange_key,
|
||||
filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"),
|
||||
filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"),
|
||||
search=search,
|
||||
source_type=source_type,
|
||||
)
|
||||
return {"ok": True, **payload}
|
||||
|
||||
|
||||
@app.get("/api/archive/options/calendar")
|
||||
def api_archive_options_calendar(
|
||||
year: int = 0,
|
||||
month: int = 0,
|
||||
exchange_key: str = "",
|
||||
):
|
||||
init_options_archive_db()
|
||||
if year <= 0 or month <= 0:
|
||||
td = today_trading_day()
|
||||
parts = td.split("-")
|
||||
year = int(parts[0])
|
||||
month = int(parts[1])
|
||||
try:
|
||||
payload = list_archive_options_calendar(year, month, exchange_key=exchange_key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"ok": True, **payload}
|
||||
|
||||
|
||||
@app.get("/api/archive/quotes")
|
||||
def api_archive_quotes():
|
||||
init_archive_db()
|
||||
@@ -3301,7 +3477,8 @@ async def api_archive_sync():
|
||||
|
||||
@app.get("/api/strategy/meta")
|
||||
def api_strategy_meta():
|
||||
return strategy_meta_payload()
|
||||
display = (load_settings() or {}).get("display") or {}
|
||||
return strategy_meta_payload(display)
|
||||
|
||||
|
||||
@app.get("/api/help/meta")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""交易教练用的执行手册短摘要(来源 docs/交易执行手册-期权与Gate.md)."""
|
||||
"""交易教练用的执行手册短摘要(现行 v2:无对冲)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
@@ -6,18 +6,21 @@ from pathlib import Path
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
# 控制 token:保持简短;手册大改时同步修订本摘要.
|
||||
_PLAYBOOK_BRIEF = """【用户策略执行手册·摘要】(来源:docs/交易执行手册-期权与Gate.md)
|
||||
一句话:横盘对冲(可偏置实值);突破用一天期权;假破确认后小仓合约加强;先过方向/空间/值不值得;期权不手平;一位置两次,错完收工;单笔小亏、组合回撤可控.
|
||||
分工:OKX 期权=主业;Gate 合约=辅业;其它账户暂不做.同一时段尽量只让一边说话.
|
||||
入场三类:①横盘较久→期期对冲(一天 Call+Put,总权利金约10U),期间一般不开 Gate;②方向/空间/值不值得过关且结构突破→一天期权方向单,默认不上合约;③已有突破期权后出现反向假破确认→Gate 小仓加强(加重暴露,按一笔故事控风险).
|
||||
仓位(总资约800U):单笔期权约10U且一次一仓;期期对冲合计约10U;Gate 保证金约50U×约10x,止损约5U,单笔最亏约≤10U;日最坏约≤20U.
|
||||
期权纪律:不手动平仓,只认规则止盈或到期(紧急手平非策略样本);默认一天期,尽量北京时间16:00后开次日到期.
|
||||
Gate 纪律:只做很明确位置;同一位置最多两次机会(结构突破/假突破);两次都错→当日收工.
|
||||
教练用法:对照上述纪律讨论执行与心态;勿另造策略或鼓励期权手平/超仓."""
|
||||
_PLAYBOOK_BRIEF = """【用户策略执行手册·摘要】(来源:docs/交易执行手册-v2-期权与合约.md + docs/交易行为准则-开单三检.md)
|
||||
开单防火墙(强制):信号判断→流程确认→情绪自检。三检不过不开。成败先看三检是否跑完。
|
||||
主链条(强制):1H方向(含N字)→空间(支撑/阻力,至少约≥2%)→结构(约8h+/48×15m:收敛/两段回调/箱体/假突破等)→定损盈(结构突破=外沿;假突破=针尖)→选工具(只剩期权或合约)。
|
||||
丢掉对冲:不做期期对冲/偏置对冲;对冲易产生「有保护就能多做」的幻觉。日更不是目标,过检才开。
|
||||
一句话:1H定方向→量空间→等够结构→按模型定损盈→只在期权与合约里选;期权不手平;Gate一位置两次,错完收工;珍惜机会。
|
||||
分工:OKX期权与Gate合约;其它账户暂不做。同一时段尽量只让一边说话。
|
||||
入场:①主链条过关→一天期期权方向单(空间够可优先虚值);②极明确位置→Gate合约;独立假破优先只做合约或空仓。明确不做横盘双买对冲。
|
||||
仓位(总资约800U):单笔期权约10U且一次一仓;Gate保证金约50U×约10x,止损约5U,单笔最亏约≤10U;日最坏约≤20U。
|
||||
期权纪律:开仓后中间不手动平仓,只认规则止盈或到期;默认一天期,尽量北京时间16:00后开次日到期。
|
||||
Gate纪律:只做很明确位置;同一位置最多两次(结构突破/假突破);两次都错→当日收工。
|
||||
教练用法:对照上述纪律讨论执行与心态;开单前优先提醒主链条与三检;勿另造策略或鼓励对冲/期权手平/超仓/每天默认开期权。"""
|
||||
|
||||
|
||||
def playbook_md_path() -> Path:
|
||||
return REPO_ROOT / "docs" / "交易执行手册-期权与Gate.md"
|
||||
return REPO_ROOT / "docs" / "交易执行手册-v2-期权与合约.md"
|
||||
|
||||
|
||||
def format_playbook_brief_for_chat(max_chars: int = 1200) -> str:
|
||||
|
||||
@@ -29,9 +29,23 @@ DEFAULT_DISPLAY = {
|
||||
"show_nav_quotes": True,
|
||||
"show_nav_ai": True,
|
||||
"show_nav_calculator": True,
|
||||
"show_nav_compare": True,
|
||||
"show_nav_strategy": True,
|
||||
"show_nav_amp_stats": True,
|
||||
"show_nav_help": True,
|
||||
"show_nav_logs": True,
|
||||
# 监控区卡片(仅隐藏界面,不关闭账户)
|
||||
"show_monitor_binance": True,
|
||||
"show_monitor_okx_perp": True,
|
||||
"show_monitor_okx_options": True,
|
||||
"show_monitor_gate": True,
|
||||
# 策略说明页签
|
||||
"show_strategy_playbook_v2": True,
|
||||
"show_strategy_playbook": True,
|
||||
"show_strategy_behavior": True,
|
||||
"show_strategy_binance": True,
|
||||
"show_strategy_okx": True,
|
||||
"show_strategy_gate": True,
|
||||
}
|
||||
|
||||
DEFAULT_EXCHANGES = [
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选.
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-amp-stats");
|
||||
if (!page) return;
|
||||
|
||||
let lastResult = null;
|
||||
let pageNo = 1;
|
||||
let bound = false;
|
||||
let reframeTimer = null;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
|
||||
const ct = (r.headers.get("content-type") || "").toLowerCase();
|
||||
if (ct.includes("application/json")) {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error((data && (data.detail || data.msg)) || r.statusText || "请求失败");
|
||||
return data;
|
||||
}
|
||||
if (!r.ok) throw new Error(r.statusText || "请求失败");
|
||||
return r;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function pct(ratio) {
|
||||
if (ratio == null || ratio === "") return "—";
|
||||
const n = Number(ratio);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return (n * 100).toFixed(1) + "%";
|
||||
}
|
||||
|
||||
function readPremium() {
|
||||
const raw = (el("amp-straddle-premium")?.value || "").trim();
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function readTakeProfit() {
|
||||
const raw = (el("amp-take-profit")?.value || "").trim();
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function readWeekend() {
|
||||
return el("amp-weekend-filter")?.value || "all";
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
const s = el("amp-status");
|
||||
if (s) s.textContent = msg || "";
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
const isHist = view === "history";
|
||||
el("amp-panel-stats")?.classList.toggle("hidden", isHist);
|
||||
el("amp-panel-history")?.classList.toggle("hidden", !isHist);
|
||||
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
|
||||
const on = btn.getAttribute("data-view") === view;
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
if (isHist) void loadHistory();
|
||||
}
|
||||
|
||||
function syncCustomDays() {
|
||||
const period = el("amp-period")?.value || "2m";
|
||||
const wrap = el("amp-custom-wrap");
|
||||
if (wrap) wrap.classList.toggle("hidden", period !== "custom");
|
||||
}
|
||||
|
||||
function fillMetaControls() {
|
||||
const hourSel = el("amp-start-hour");
|
||||
if (hourSel && !hourSel.options.length) {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(h);
|
||||
opt.textContent = String(h).padStart(2, "0") + ":00";
|
||||
if (h === 16) opt.selected = true;
|
||||
hourSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n === 0) return "";
|
||||
return n > 0 ? "is-pos" : "is-neg";
|
||||
}
|
||||
|
||||
function renderSummary(summary, result) {
|
||||
const box = el("amp-summary");
|
||||
if (!box) return;
|
||||
const s = summary || {};
|
||||
if (!s.sample_count) {
|
||||
box.innerHTML = '<p class="amp-empty">暂无汇总</p>';
|
||||
renderStraddle(null);
|
||||
return;
|
||||
}
|
||||
box.innerHTML =
|
||||
`<div class="amp-sum-grid">` +
|
||||
`<div><span class="amp-sum-k">样本</span><span class="amp-sum-v">${esc(s.sample_count)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">最大振幅</span><span class="amp-sum-v">${esc(s.max_amplitude)} <small>(${esc(s.max_amplitude_day)})</small></span></div>` +
|
||||
`<div><span class="amp-sum-k">振幅均值</span><span class="amp-sum-v">${esc(s.avg_amplitude)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">振幅中位</span><span class="amp-sum-v">${esc(s.median_amplitude)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→高最大/均</span><span class="amp-sum-v">${esc(s.max_up_points)} / ${esc(s.avg_up_points)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→低最大/均</span><span class="amp-sum-v">${esc(s.max_down_points)} / ${esc(s.avg_down_points)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
|
||||
`</div>`;
|
||||
renderStraddle(s.straddle);
|
||||
}
|
||||
|
||||
function renderStraddle(st) {
|
||||
const box = el("amp-straddle");
|
||||
if (!box) return;
|
||||
if (!st) {
|
||||
box.innerHTML = '<p class="amp-empty">填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏</p>';
|
||||
return;
|
||||
}
|
||||
const verdict =
|
||||
st.pnl_total == null
|
||||
? "—"
|
||||
: Number(st.pnl_total) > 0
|
||||
? "样本合计盈利"
|
||||
: Number(st.pnl_total) < 0
|
||||
? "样本合计亏损"
|
||||
: "样本合计持平";
|
||||
const tpLine =
|
||||
st.take_profit != null
|
||||
? `<div><span class="amp-sum-k">止盈点 / 触达</span><span class="amp-sum-v">${esc(st.take_profit)} · ${esc(st.tp_hit_days)} 天 · ${esc(pct(st.tp_hit_ratio))}</span></div>`
|
||||
: `<div><span class="amp-sum-k">止盈点</span><span class="amp-sum-v">未设(按|涨跌|)</span></div>`;
|
||||
box.innerHTML =
|
||||
`<div class="amp-sum-grid">` +
|
||||
`<div><span class="amp-sum-k">双边权利金</span><span class="amp-sum-v">${esc(st.premium)}</span></div>` +
|
||||
tpLine +
|
||||
`<div><span class="amp-sum-k">开→高超过权利金</span><span class="amp-sum-v">${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→低超过权利金</span><span class="amp-sum-v">${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">|涨跌|超过权利金</span><span class="amp-sum-v">${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">买跨盈亏合计</span><span class="amp-sum-v ${pnlClass(st.pnl_total)}">${esc(st.pnl_total)} <small>(${esc(verdict)})</small></span></div>` +
|
||||
`<div><span class="amp-sum-k">日均盈亏</span><span class="amp-sum-v ${pnlClass(st.pnl_avg)}">${esc(st.pnl_avg)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">赚钱天数/胜率</span><span class="amp-sum-v">${esc(st.win_days)} · ${esc(pct(st.win_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">单日最大赚/亏</span><span class="amp-sum-v">${esc(st.pnl_max)} / ${esc(st.pnl_min)}</span></div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function dayLabel(r) {
|
||||
const day = esc(r.settlement_day);
|
||||
if (r.is_weekend && r.weekday_label) {
|
||||
return `${day}<span class="amp-wd-tag">${esc(r.weekday_label)}</span>`;
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
function renderTable(pagePayload) {
|
||||
const body = el("amp-table-body");
|
||||
const pager = el("amp-pager");
|
||||
if (!body) return;
|
||||
const rows = (pagePayload && pagePayload.rows) || [];
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>';
|
||||
} else {
|
||||
body.innerHTML = rows
|
||||
.map((r) => {
|
||||
const profit =
|
||||
r.profit == null || r.profit === ""
|
||||
? "—"
|
||||
: `<span class="amp-pnl ${pnlClass(r.profit)}">${esc(r.profit)}</span>`;
|
||||
const trClass = r.is_weekend ? ' class="amp-row-weekend"' : "";
|
||||
return (
|
||||
`<tr${trClass}>` +
|
||||
`<td>${dayLabel(r)}</td>` +
|
||||
`<td>${esc(r.window_start)}</td>` +
|
||||
`<td>${esc(r.open)}</td>` +
|
||||
`<td>${esc(r.high)}</td>` +
|
||||
`<td>${esc(r.low)}</td>` +
|
||||
`<td>${esc(r.close)}</td>` +
|
||||
`<td>${esc(r.up_points)}</td>` +
|
||||
`<td>${esc(r.down_points)}</td>` +
|
||||
`<td><strong>${esc(r.amplitude)}</strong></td>` +
|
||||
`<td>${esc(r.change)}</td>` +
|
||||
`<td>${profit}</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
if (pager && pagePayload) {
|
||||
pager.innerHTML =
|
||||
`<button type="button" class="ghost" id="amp-page-prev" ${pagePayload.page <= 1 ? "disabled" : ""}>上一页</button>` +
|
||||
`<span class="amp-pager-meta">第 ${esc(pagePayload.page)} / ${esc(pagePayload.total_pages)} 页 · 共 ${esc(pagePayload.total)} 天</span>` +
|
||||
`<button type="button" class="ghost" id="amp-page-next" ${pagePayload.page >= pagePayload.total_pages ? "disabled" : ""}>下一页</button>`;
|
||||
el("amp-page-prev")?.addEventListener("click", () => {
|
||||
if (pageNo > 1) {
|
||||
pageNo -= 1;
|
||||
void reframe(false);
|
||||
}
|
||||
});
|
||||
el("amp-page-next")?.addEventListener("click", () => {
|
||||
if (pagePayload.page < pagePayload.total_pages) {
|
||||
pageNo += 1;
|
||||
void reframe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rowsAllFromLast() {
|
||||
if (!lastResult) return [];
|
||||
if (Array.isArray(lastResult.rows_all) && lastResult.rows_all.length) return lastResult.rows_all;
|
||||
return lastResult.rows || [];
|
||||
}
|
||||
|
||||
async function reframe(resetPage) {
|
||||
if (!lastResult) {
|
||||
renderStraddle(null);
|
||||
return;
|
||||
}
|
||||
if (resetPage) pageNo = 1;
|
||||
const rowsAll = rowsAllFromLast();
|
||||
if (!rowsAll.length) return;
|
||||
try {
|
||||
const data = await apiFetch("/api/amp-stats/reframe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rows_all: rowsAll,
|
||||
symbol: lastResult.symbol || el("amp-symbol")?.value || "eth",
|
||||
start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16),
|
||||
period: lastResult.period || el("amp-period")?.value || "2m",
|
||||
sample_days: lastResult.sample_days_requested || 60,
|
||||
straddle_premium: readPremium(),
|
||||
take_profit: readTakeProfit(),
|
||||
weekend_filter: readWeekend(),
|
||||
price_source: lastResult.price_source || "",
|
||||
inst_id: lastResult.inst_id || "",
|
||||
page: pageNo,
|
||||
page_size: 20,
|
||||
}),
|
||||
});
|
||||
const prevAll = rowsAll;
|
||||
lastResult = data.result || lastResult;
|
||||
if (!lastResult.rows_all || !lastResult.rows_all.length) lastResult.rows_all = prevAll;
|
||||
renderSummary(lastResult.summary, lastResult);
|
||||
renderTable(data.page);
|
||||
setStatus(`完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`);
|
||||
} catch (e) {
|
||||
setStatus(String(e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReframe() {
|
||||
if (!lastResult) return;
|
||||
if (reframeTimer) clearTimeout(reframeTimer);
|
||||
reframeTimer = setTimeout(() => void reframe(true), 280);
|
||||
}
|
||||
|
||||
async function compute(resetPage) {
|
||||
if (resetPage) pageNo = 1;
|
||||
const symbol = el("amp-symbol")?.value || "eth";
|
||||
const startHour = Number(el("amp-start-hour")?.value || 16);
|
||||
const period = el("amp-period")?.value || "2m";
|
||||
const customDays = Number(el("amp-custom-days")?.value || 60);
|
||||
setStatus("计算中…(长周期会分页拉 OKX,遇限频会自动重试,请稍候)");
|
||||
try {
|
||||
const data = await apiFetch("/api/amp-stats/compute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
symbol,
|
||||
start_hour: startHour,
|
||||
period,
|
||||
custom_days: period === "custom" ? customDays : null,
|
||||
straddle_premium: readPremium(),
|
||||
take_profit: readTakeProfit(),
|
||||
weekend_filter: readWeekend(),
|
||||
page: pageNo,
|
||||
page_size: 20,
|
||||
}),
|
||||
});
|
||||
lastResult = data.result || null;
|
||||
renderSummary(lastResult && lastResult.summary, lastResult);
|
||||
renderTable(data.page);
|
||||
const miss = (lastResult && lastResult.missing_count) || 0;
|
||||
setStatus(
|
||||
miss
|
||||
? `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0} · 缺 ${miss} 天`
|
||||
: `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(String(e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHistory() {
|
||||
if (!lastResult) {
|
||||
setStatus("请先计算");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiFetch("/api/amp-stats/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result: lastResult }),
|
||||
});
|
||||
setStatus("已保存到历史");
|
||||
} catch (e) {
|
||||
setStatus(String(e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
if (!lastResult) {
|
||||
setStatus("请先计算");
|
||||
return;
|
||||
}
|
||||
const symbol = el("amp-symbol")?.value || "eth";
|
||||
const startHour = Number(el("amp-start-hour")?.value || 16);
|
||||
const period = el("amp-period")?.value || "2m";
|
||||
const customDays = Number(el("amp-custom-days")?.value || 60);
|
||||
const prem = readPremium();
|
||||
const tp = readTakeProfit();
|
||||
const q = new URLSearchParams({
|
||||
symbol,
|
||||
start_hour: String(startHour),
|
||||
period,
|
||||
weekend_filter: readWeekend(),
|
||||
});
|
||||
if (period === "custom") q.set("custom_days", String(customDays));
|
||||
if (prem != null) q.set("straddle_premium", String(prem));
|
||||
if (tp != null) q.set("take_profit", String(tp));
|
||||
window.location.href = "/api/amp-stats/export?" + q.toString();
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const box = el("amp-history-list");
|
||||
if (!box) return;
|
||||
box.innerHTML = '<p class="amp-empty">加载中…</p>';
|
||||
try {
|
||||
const data = await apiFetch("/api/amp-stats/history?limit=50");
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<p class="amp-empty">暂无历史</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = items
|
||||
.map(
|
||||
(it) =>
|
||||
`<div class="amp-hist-card" data-id="${esc(it.id)}">` +
|
||||
`<div class="amp-hist-main">` +
|
||||
`<strong>${esc(it.symbol_label || it.symbol)}</strong> · ${esc(String(it.start_hour).padStart(2, "0"))}:00→16:00 · ${esc(it.period)}` +
|
||||
`<div class="amp-hist-sub">${esc(it.created_at)} · 样本 ${esc(it.sample_count)} · 最大振幅 ${esc(it.max_amplitude)} (${esc(it.max_amplitude_day)})</div>` +
|
||||
`</div>` +
|
||||
`<div class="amp-hist-actions">` +
|
||||
`<button type="button" class="ghost amp-hist-view">查看</button>` +
|
||||
`<button type="button" class="ghost amp-hist-dl">下载</button>` +
|
||||
`<button type="button" class="danger amp-hist-del">删除</button>` +
|
||||
`</div></div>`
|
||||
)
|
||||
.join("");
|
||||
box.querySelectorAll(".amp-hist-card").forEach((card) => {
|
||||
const id = card.getAttribute("data-id");
|
||||
card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id));
|
||||
card.querySelector(".amp-hist-dl")?.addEventListener("click", () => {
|
||||
const prem = readPremium();
|
||||
const tp = readTakeProfit();
|
||||
let url =
|
||||
"/api/amp-stats/export?history_id=" +
|
||||
encodeURIComponent(id) +
|
||||
"&weekend_filter=" +
|
||||
encodeURIComponent(readWeekend());
|
||||
if (prem != null) url += "&straddle_premium=" + encodeURIComponent(String(prem));
|
||||
if (tp != null) url += "&take_profit=" + encodeURIComponent(String(tp));
|
||||
window.location.href = url;
|
||||
});
|
||||
card.querySelector(".amp-hist-del")?.addEventListener("click", async () => {
|
||||
if (!confirm("删除该历史记录?")) return;
|
||||
await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id), { method: "DELETE" });
|
||||
void loadHistory();
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
box.innerHTML = `<p class="amp-empty">${esc(String(e && e.message ? e.message : e))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory(id) {
|
||||
try {
|
||||
const data = await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id));
|
||||
lastResult = data.item || null;
|
||||
setView("stats");
|
||||
if (lastResult) {
|
||||
if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth";
|
||||
if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16);
|
||||
if (lastResult.straddle_premium != null && el("amp-straddle-premium")) {
|
||||
el("amp-straddle-premium").value = String(lastResult.straddle_premium);
|
||||
}
|
||||
if (lastResult.take_profit != null && el("amp-take-profit")) {
|
||||
el("amp-take-profit").value = String(lastResult.take_profit);
|
||||
}
|
||||
if (lastResult.weekend_filter && el("amp-weekend-filter")) {
|
||||
el("amp-weekend-filter").value = lastResult.weekend_filter;
|
||||
}
|
||||
pageNo = 1;
|
||||
setStatus("已载入历史 " + id);
|
||||
await reframe(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(String(e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
fillMetaControls();
|
||||
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => setView(btn.getAttribute("data-view")));
|
||||
});
|
||||
el("amp-period")?.addEventListener("change", syncCustomDays);
|
||||
el("amp-btn-compute")?.addEventListener("click", () => void compute(true));
|
||||
el("amp-btn-save")?.addEventListener("click", () => void saveHistory());
|
||||
el("amp-btn-download")?.addEventListener("click", downloadCurrent);
|
||||
el("amp-straddle-premium")?.addEventListener("input", scheduleReframe);
|
||||
el("amp-take-profit")?.addEventListener("input", scheduleReframe);
|
||||
el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true));
|
||||
syncCustomDays();
|
||||
}
|
||||
|
||||
window.hubAmpStatsPage = {
|
||||
init() {
|
||||
bind();
|
||||
setView("stats");
|
||||
setStatus("");
|
||||
renderStraddle(null);
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -2357,6 +2357,12 @@ html[data-theme="light"] .hub-pos-card .pos-tp-profit {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-row-options .stat-label {
|
||||
color: var(--accent);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: var(--inset-surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
@@ -3495,6 +3501,18 @@ button.btn-sm {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.settings-display-subtitle {
|
||||
margin: 16px 0 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-display-subtitle + .settings-display-hint {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-display-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.78rem;
|
||||
@@ -8039,6 +8057,34 @@ body.funds-fullscreen-open {
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.archive-product-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.archive-product-tab {
|
||||
border: 1px solid var(--border-soft);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.archive-product-tab.is-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
border-color: rgba(16, 185, 129, 0.55);
|
||||
color: var(--text);
|
||||
}
|
||||
body.archive-product-options .archive-toolbar-desktop[data-archive-perp-only],
|
||||
body.archive-product-options #archive-btn-chart-toggle,
|
||||
body.archive-product-options #archive-filter-sick,
|
||||
body.archive-product-options #archive-tab-viz {
|
||||
display: none !important;
|
||||
}
|
||||
.archive-content-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -10939,3 +10985,181 @@ html[data-theme="light"] .hub-logs-card-hint {
|
||||
min-height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— 振幅统计 —— */
|
||||
.amp-view-tabs { display: flex; gap: 8px; margin: 0 0 12px; }
|
||||
.amp-view-tab {
|
||||
min-height: 34px; padding: 6px 14px; border: 1px solid var(--border-soft);
|
||||
border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.amp-view-tab.is-active { color: var(--text); border-color: var(--accent); background: rgba(0, 212, 255, 0.08); }
|
||||
.amp-panel { padding: 14px 16px 18px; }
|
||||
.amp-form {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px 12px; align-items: end; margin-bottom: 8px;
|
||||
}
|
||||
.amp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.amp-field select, .amp-field input {
|
||||
min-height: 34px; padding: 6px 8px; border-radius: 8px;
|
||||
border: 1px solid var(--border-soft); background: var(--panel-solid); color: var(--text);
|
||||
}
|
||||
.amp-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.amp-hint { font-size: 12px; color: var(--muted); margin: 4px 0 12px; }
|
||||
.amp-status { margin: 0 0 8px; }
|
||||
.amp-block-title { font-size: 14px; margin: 14px 0 8px; }
|
||||
.amp-sum-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px;
|
||||
}
|
||||
.amp-sum-grid > div {
|
||||
border: 1px solid var(--border-soft); border-radius: 8px; padding: 8px 10px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.amp-sum-k { font-size: 11px; color: var(--muted); }
|
||||
.amp-sum-v { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.amp-sum-v.is-pos { color: var(--green); }
|
||||
.amp-sum-v.is-neg { color: var(--red); }
|
||||
.amp-pnl.is-pos { color: var(--green); font-weight: 600; }
|
||||
.amp-pnl.is-neg { color: var(--red); font-weight: 600; }
|
||||
.amp-straddle { margin-bottom: 4px; }
|
||||
.amp-table tr.amp-row-weekend td { background: rgba(255, 180, 60, 0.08); }
|
||||
.amp-wd-tag {
|
||||
display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 4px;
|
||||
font-size: 11px; font-weight: 600; color: #f0c14b;
|
||||
border: 1px solid rgba(240, 193, 75, 0.45);
|
||||
}
|
||||
.amp-table-wrap { overflow-x: auto; }
|
||||
.amp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.amp-table th, .amp-table td {
|
||||
border-bottom: 1px solid var(--border-soft); padding: 7px 8px; text-align: right; white-space: nowrap;
|
||||
}
|
||||
.amp-table th:first-child, .amp-table td:first-child,
|
||||
.amp-table th:nth-child(2), .amp-table td:nth-child(2) { text-align: left; }
|
||||
.amp-pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
|
||||
.amp-pager-meta { font-size: 12px; color: var(--muted); }
|
||||
.amp-empty { color: var(--muted); text-align: center; padding: 16px; }
|
||||
.amp-history-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.amp-hist-card {
|
||||
display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
border: 1px solid var(--border-soft); border-radius: 10px; padding: 10px 12px;
|
||||
}
|
||||
.amp-hist-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.amp-hist-actions { display: flex; gap: 6px; align-items: center; }
|
||||
@media (max-width: 720px) {
|
||||
.amp-form { grid-template-columns: 1fr 1fr; }
|
||||
.amp-actions { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* --- strategy compare --- */
|
||||
#page-compare .toolbar {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cmp-form { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
|
||||
.cmp-form .card,
|
||||
.cmp-common-card,
|
||||
.cmp-sum-card,
|
||||
.cmp-rec-card {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.cmp-common-card h2,
|
||||
.cmp-form .card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.cmp-subhead {
|
||||
margin: 16px 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cmp-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
}
|
||||
.cmp-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.cmp-field input,
|
||||
.cmp-field select {
|
||||
background: var(--inset-surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cmp-input-cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.cmp-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cmp-sum-card h3 { margin: 0 0 12px; font-size: 14px; }
|
||||
.cmp-sum-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
margin: 6px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.cmp-sum-row strong { color: var(--text); font-weight: 600; }
|
||||
.cmp-muted { color: var(--muted); font-size: 12px; margin: 0; }
|
||||
.cmp-table-wrap { margin-bottom: 16px; }
|
||||
.cmp-table-scroll { overflow-x: auto; }
|
||||
.cmp-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmp-table th,
|
||||
.cmp-table td {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
padding: 14px 16px;
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
}
|
||||
.cmp-table th:first-child,
|
||||
.cmp-table td:first-child { width: 22%; }
|
||||
.cmp-table tr:last-child td { border-bottom: none; }
|
||||
.cmp-cell-note {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cmp-pnl-pos { color: var(--green); font-weight: 600; }
|
||||
.cmp-pnl-neg { color: var(--red); font-weight: 600; }
|
||||
.cmp-rec-head { font-size: 16px; margin-bottom: 8px; }
|
||||
.cmp-rec-reason { margin: 0 0 10px; color: var(--muted); font-size: 13px; }
|
||||
.cmp-rec-list { margin: 0; padding-left: 20px; font-size: 13px; line-height: 1.55; }
|
||||
.cmp-warn { margin-top: 12px; font-size: 12px; color: var(--warn, #e6a23c); }
|
||||
.cmp-foot-note { margin: 12px 0 0; font-size: 11px; color: var(--muted); }
|
||||
@media (max-width: 900px) {
|
||||
.cmp-form .card,
|
||||
.cmp-common-card,
|
||||
.cmp-sum-card,
|
||||
.cmp-rec-card {
|
||||
padding: 16px;
|
||||
}
|
||||
.cmp-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cmp-input-cols,
|
||||
.cmp-summary { grid-template-columns: 1fr; }
|
||||
.cmp-table th,
|
||||
.cmp-table td { padding: 12px 14px; }
|
||||
}
|
||||
|
||||
+228
-101
@@ -9,10 +9,42 @@
|
||||
return !!d[key];
|
||||
}
|
||||
|
||||
window.hubDisplayPref = displayPref;
|
||||
|
||||
function showAccountPnlPref() {
|
||||
return displayPref("show_account_pnl", true);
|
||||
}
|
||||
|
||||
window.hubShowAccountPnlPref = showAccountPnlPref;
|
||||
|
||||
function showMonitorBinancePref() {
|
||||
return displayPref("show_monitor_binance", true);
|
||||
}
|
||||
|
||||
function showMonitorOkxPerpPref() {
|
||||
return displayPref("show_monitor_okx_perp", true);
|
||||
}
|
||||
|
||||
function showMonitorOkxOptionsPref() {
|
||||
return displayPref("show_monitor_okx_options", true);
|
||||
}
|
||||
|
||||
function showMonitorGatePref() {
|
||||
return displayPref("show_monitor_gate", true);
|
||||
}
|
||||
|
||||
function monitorExchangeKeyVisible(key) {
|
||||
const k = String(key || "").toLowerCase();
|
||||
if (k === "binance") return showMonitorBinancePref();
|
||||
if (k === "gate") return showMonitorGatePref();
|
||||
if (k === "okx") return showMonitorOkxPerpPref() || showMonitorOkxOptionsPref();
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterVisibleMonitorRows(rows) {
|
||||
return (rows || []).filter((r) => monitorExchangeKeyVisible(r && r.key));
|
||||
}
|
||||
|
||||
function showNavFundsPref() {
|
||||
return displayPref("show_nav_funds", true);
|
||||
}
|
||||
@@ -41,10 +73,18 @@
|
||||
return displayPref("show_nav_calculator", true);
|
||||
}
|
||||
|
||||
function showNavComparePref() {
|
||||
return displayPref("show_nav_compare", true);
|
||||
}
|
||||
|
||||
function showNavStrategyPref() {
|
||||
return displayPref("show_nav_strategy", true);
|
||||
}
|
||||
|
||||
function showNavAmpStatsPref() {
|
||||
return displayPref("show_nav_amp_stats", true);
|
||||
}
|
||||
|
||||
function showNavHelpPref() {
|
||||
return displayPref("show_nav_help", true);
|
||||
}
|
||||
@@ -63,7 +103,9 @@
|
||||
["nav-quotes", "m-nav-quotes", d.show_nav_quotes === false],
|
||||
["nav-ai", "m-tab-ai", d.show_nav_ai === false],
|
||||
["nav-calculator", "m-tab-calculator", d.show_nav_calculator === false],
|
||||
["nav-compare", "m-nav-compare", d.show_nav_compare === false],
|
||||
["nav-strategy", "m-nav-strategy", d.show_nav_strategy === false],
|
||||
["nav-amp-stats", "m-nav-amp-stats", d.show_nav_amp_stats === false],
|
||||
["nav-help", "m-nav-help", d.show_nav_help === false],
|
||||
["nav-logs", "m-nav-logs", d.show_nav_logs === false],
|
||||
];
|
||||
@@ -135,7 +177,9 @@
|
||||
if (page === "quotes") return showNavQuotesPref();
|
||||
if (page === "ai") return showNavAiPref();
|
||||
if (page === "calculator") return showNavCalculatorPref();
|
||||
if (page === "compare") return showNavComparePref();
|
||||
if (page === "strategy") return showNavStrategyPref();
|
||||
if (page === "amp-stats") return showNavAmpStatsPref();
|
||||
if (page === "help") return showNavHelpPref();
|
||||
if (page === "logs") return showNavLogsPref();
|
||||
return true;
|
||||
@@ -143,28 +187,33 @@
|
||||
|
||||
function syncDisplayPrefsUI(data) {
|
||||
const d = (data && data.display) || {};
|
||||
const pnlCb = document.getElementById("pref-show-account-pnl");
|
||||
const fundsCb = document.getElementById("pref-show-nav-funds");
|
||||
const dashCb = document.getElementById("pref-show-nav-dashboard");
|
||||
const planCb = document.getElementById("pref-show-nav-plan");
|
||||
const archiveCb = document.getElementById("pref-show-nav-archive");
|
||||
const quotesCb = document.getElementById("pref-show-nav-quotes");
|
||||
const aiCb = document.getElementById("pref-show-nav-ai");
|
||||
const calcCb = document.getElementById("pref-show-nav-calculator");
|
||||
const strategyCb = document.getElementById("pref-show-nav-strategy");
|
||||
const helpCb = document.getElementById("pref-show-nav-help");
|
||||
const logsCb = document.getElementById("pref-show-nav-logs");
|
||||
if (pnlCb) pnlCb.checked = d.show_account_pnl !== false;
|
||||
if (fundsCb) fundsCb.checked = d.show_nav_funds !== false;
|
||||
if (dashCb) dashCb.checked = d.show_nav_dashboard !== false;
|
||||
if (planCb) planCb.checked = d.show_nav_plan !== false;
|
||||
if (archiveCb) archiveCb.checked = d.show_nav_archive !== false;
|
||||
if (quotesCb) quotesCb.checked = d.show_nav_quotes !== false;
|
||||
if (aiCb) aiCb.checked = d.show_nav_ai !== false;
|
||||
if (calcCb) calcCb.checked = d.show_nav_calculator !== false;
|
||||
if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false;
|
||||
if (helpCb) helpCb.checked = d.show_nav_help !== false;
|
||||
if (logsCb) logsCb.checked = d.show_nav_logs !== false;
|
||||
const setChk = (id, key) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.checked = d[key] !== false;
|
||||
};
|
||||
setChk("pref-show-account-pnl", "show_account_pnl");
|
||||
setChk("pref-show-nav-funds", "show_nav_funds");
|
||||
setChk("pref-show-nav-dashboard", "show_nav_dashboard");
|
||||
setChk("pref-show-nav-plan", "show_nav_plan");
|
||||
setChk("pref-show-nav-archive", "show_nav_archive");
|
||||
setChk("pref-show-nav-quotes", "show_nav_quotes");
|
||||
setChk("pref-show-nav-ai", "show_nav_ai");
|
||||
setChk("pref-show-nav-calculator", "show_nav_calculator");
|
||||
setChk("pref-show-nav-compare", "show_nav_compare");
|
||||
setChk("pref-show-nav-strategy", "show_nav_strategy");
|
||||
setChk("pref-show-nav-amp-stats", "show_nav_amp_stats");
|
||||
setChk("pref-show-nav-help", "show_nav_help");
|
||||
setChk("pref-show-nav-logs", "show_nav_logs");
|
||||
setChk("pref-show-monitor-binance", "show_monitor_binance");
|
||||
setChk("pref-show-monitor-okx-perp", "show_monitor_okx_perp");
|
||||
setChk("pref-show-monitor-okx-options", "show_monitor_okx_options");
|
||||
setChk("pref-show-monitor-gate", "show_monitor_gate");
|
||||
setChk("pref-show-strategy-playbook-v2", "show_strategy_playbook_v2");
|
||||
setChk("pref-show-strategy-playbook", "show_strategy_playbook");
|
||||
setChk("pref-show-strategy-behavior", "show_strategy_behavior");
|
||||
setChk("pref-show-strategy-binance", "show_strategy_binance");
|
||||
setChk("pref-show-strategy-okx", "show_strategy_okx");
|
||||
setChk("pref-show-strategy-gate", "show_strategy_gate");
|
||||
syncNavVisibility(data);
|
||||
}
|
||||
|
||||
@@ -1277,7 +1326,9 @@
|
||||
if (p.includes("funds")) return "funds";
|
||||
if (p.includes("plan")) return "plan";
|
||||
if (p.includes("calculator")) return "calculator";
|
||||
if (p.includes("compare")) return "compare";
|
||||
if (p.includes("help")) return "help";
|
||||
if (p.includes("amp-stats")) return "amp-stats";
|
||||
if (p.includes("strategy")) return "strategy";
|
||||
if (p.includes("logs")) return "logs";
|
||||
if (p.includes("market")) return "market";
|
||||
@@ -1293,8 +1344,10 @@
|
||||
if (page === "funds") return "page-funds";
|
||||
if (page === "plan") return "page-plan";
|
||||
if (page === "calculator") return "page-calculator";
|
||||
if (page === "compare") return "page-compare";
|
||||
if (page === "help") return "page-help";
|
||||
if (page === "strategy") return "page-strategy";
|
||||
if (page === "amp-stats") return "page-amp-stats";
|
||||
if (page === "logs") return "page-logs";
|
||||
if (page === "market") return "page-market";
|
||||
if (page === "ai") return "page-ai";
|
||||
@@ -1324,11 +1377,13 @@
|
||||
document.body.classList.toggle("hub-page-monitor", page === "monitor");
|
||||
document.body.classList.toggle("hub-page-market", page === "market");
|
||||
document.body.classList.toggle("hub-page-calculator", page === "calculator");
|
||||
document.body.classList.toggle("hub-page-compare", page === "compare");
|
||||
document.body.classList.toggle("hub-page-settings", page === "settings");
|
||||
document.body.classList.toggle("hub-page-archive", page === "archive");
|
||||
document.body.classList.toggle("hub-page-quotes", page === "quotes");
|
||||
document.body.classList.toggle("hub-page-plan", page === "plan");
|
||||
document.body.classList.toggle("hub-page-strategy", page === "strategy");
|
||||
document.body.classList.toggle("hub-page-amp-stats", page === "amp-stats");
|
||||
document.body.classList.toggle("hub-page-logs", page === "logs");
|
||||
document.body.classList.toggle("hub-page-help", page === "help");
|
||||
syncHubPhoneShellClass();
|
||||
@@ -1363,6 +1418,11 @@
|
||||
if (page === "calculator" && window.hubCalculatorPage) {
|
||||
window.hubCalculatorPage.init();
|
||||
}
|
||||
if (page === "compare" && window.hubComparePage) {
|
||||
window.hubComparePage.init();
|
||||
} else if (window.hubComparePage && window.hubComparePage.destroy) {
|
||||
window.hubComparePage.destroy();
|
||||
}
|
||||
if (page === "funds" && window.hubFundsPage) {
|
||||
window.hubFundsPage.init();
|
||||
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
|
||||
@@ -1373,6 +1433,9 @@
|
||||
} else if (window.hubStrategyPage && window.hubStrategyPage.destroy) {
|
||||
window.hubStrategyPage.destroy();
|
||||
}
|
||||
if (page === "amp-stats" && window.hubAmpStatsPage) {
|
||||
window.hubAmpStatsPage.init();
|
||||
}
|
||||
if (page === "help" && window.hubHelpPage) {
|
||||
window.hubHelpPage.init();
|
||||
} else if (window.hubHelpPage && window.hubHelpPage.destroy) {
|
||||
@@ -1537,7 +1600,7 @@
|
||||
if (upd) upd.textContent = txt;
|
||||
if (updSum) updSum.textContent = txt;
|
||||
}
|
||||
updateMonitorAlertSummary(rows || []);
|
||||
updateMonitorAlertSummary(filterVisibleMonitorRows(rows || []));
|
||||
void refreshMacroRiskBanner(rows || []);
|
||||
renderMonitorGrid(rows || []);
|
||||
}
|
||||
@@ -2048,19 +2111,20 @@
|
||||
if (lastMonitorRows.length && nowMobile !== wasMobile) {
|
||||
wasMobile = nowMobile;
|
||||
renderMonitorGrid(lastMonitorRows);
|
||||
updateMonitorAlertSummary(lastMonitorRows);
|
||||
updateMonitorAlertSummary(filterVisibleMonitorRows(lastMonitorRows));
|
||||
syncHubMobileTabActive(currentPage());
|
||||
return;
|
||||
}
|
||||
wasMobile = nowMobile;
|
||||
const box = document.getElementById("monitor-grid");
|
||||
if (box && lastMonitorRows.length) {
|
||||
const split = monitorOptionsSplitActive(lastMonitorRows);
|
||||
syncMonitorGridColumns(box, lastMonitorRows.length + (lastMonitorTotals ? 1 : 0), {
|
||||
const visible = filterVisibleMonitorRows(lastMonitorRows);
|
||||
const split = monitorOptionsSplitActive(visible);
|
||||
syncMonitorGridColumns(box, visible.length + (lastMonitorTotals ? 1 : 0), {
|
||||
statsFirst: !!lastMonitorTotals && !split,
|
||||
optionsSplit: split,
|
||||
});
|
||||
updateMonitorAlertSummary(lastMonitorRows);
|
||||
updateMonitorAlertSummary(visible);
|
||||
}
|
||||
syncHubMobileTabActive(currentPage());
|
||||
}, 120);
|
||||
@@ -2412,10 +2476,20 @@
|
||||
lossN > 0 && Number.isFinite(Number(t.loss_pnl_u))
|
||||
? `<span class="${pnlCls(t.loss_pnl_u)}">${esc(pnlSigned(t.loss_pnl_u, 2))}U</span>`
|
||||
: "—";
|
||||
const showFloat = showAccountPnlPref();
|
||||
const floatMain = esc(pnlSigned(floatVal, 2)) + "U";
|
||||
const floatCls = Math.abs(floatVal) > 1e-9 ? pnlCls(floatVal) : "";
|
||||
const foldLabel = collapsed ? "展开明细" : "收起";
|
||||
return `<div class="card card-online monitor-stats-card${collapsed ? " is-collapsed" : ""}" data-monitor-stats="1">
|
||||
const floatSummary = showFloat
|
||||
? `<div class="monitor-stats-float-summary">
|
||||
<div class="monitor-stat-label">总浮盈亏</div>
|
||||
<div class="monitor-stat-value monitor-stats-float-value ${floatCls}">${floatMain}</div>
|
||||
</div>`
|
||||
: "";
|
||||
const floatCell = showFloat ? cell("总浮盈亏", floatMain, "", floatCls) : "";
|
||||
return `<div class="card card-online monitor-stats-card${collapsed ? " is-collapsed" : ""}${
|
||||
showFloat ? "" : " hide-float-pnl"
|
||||
}" data-monitor-stats="1">
|
||||
<div class="card-head monitor-stats-head">
|
||||
<div class="monitor-stats-head-main">
|
||||
<div class="card-title-row">
|
||||
@@ -2424,10 +2498,7 @@
|
||||
</div>
|
||||
<div class="card-sub">交易日 ${esc(day)} · 北京时间 ${esc(String(resetH))}:00 切日</div>
|
||||
</div>
|
||||
<div class="monitor-stats-float-summary">
|
||||
<div class="monitor-stat-label">总浮盈亏</div>
|
||||
<div class="monitor-stat-value monitor-stats-float-value ${floatCls}">${floatMain}</div>
|
||||
</div>
|
||||
${floatSummary}
|
||||
</div>
|
||||
<div class="card-body monitor-stats-detail">
|
||||
<div class="monitor-stats-grid">
|
||||
@@ -2436,7 +2507,7 @@
|
||||
${cell("持有仓位", String(Number(t.open_position_count) || 0), "", "")}
|
||||
${cell("盈利", String(winN), winSub, winN > 0 ? "pnl-pos" : "")}
|
||||
${cell("亏损", String(lossN), lossSub, lossN > 0 ? "pnl-neg" : "")}
|
||||
${cell("总浮盈亏", floatMain, "", floatCls)}
|
||||
${floatCell}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -2447,11 +2518,12 @@
|
||||
const fs = document.getElementById("exchange-fullscreen");
|
||||
const fsInner = document.getElementById("exchange-fullscreen-inner");
|
||||
if (!box) return;
|
||||
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
|
||||
const visibleSource = filterVisibleMonitorRows(rows);
|
||||
if (expandedExchangeId && !visibleSource.some((r) => String(r.id) === String(expandedExchangeId))) {
|
||||
closeExchangeFullscreen();
|
||||
}
|
||||
const mobileTiles = isMobileLayout() && !expandedExchangeId;
|
||||
const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows;
|
||||
const displayRows = mobileTiles ? sortRowsForMobileDashboard(visibleSource) : visibleSource;
|
||||
const optionsSplit = monitorOptionsSplitActive(displayRows);
|
||||
monitorGridOptionsSplit = optionsSplit;
|
||||
const showStatsCard = !expandedExchangeId;
|
||||
@@ -2464,20 +2536,33 @@
|
||||
let cardsHtml = "";
|
||||
if (optionsSplit) {
|
||||
const okxRow = displayRows.find((r) => rowHasOptionsLayout(r));
|
||||
const otherRows = displayRows.filter((r) => !rowHasOptionsLayout(r));
|
||||
const ph =
|
||||
'<div class="card card-monitor-split-side card-monitor-placeholder" aria-hidden="true"></div>';
|
||||
/* 平铺 2×2 顺序:永续|币安 / 期权|Gate —— 同行左右同高,多仓时该行一起长高 */
|
||||
const cells = [
|
||||
okxRow ? renderMonitorCard(okxRow, { okxPart: "perp", splitSide: true }) : ph,
|
||||
otherRows[0] ? renderMonitorCard(otherRows[0], { splitSide: true }) : ph,
|
||||
okxRow ? renderMonitorCard(okxRow, { okxPart: "options", splitSide: true }) : ph,
|
||||
otherRows[1] ? renderMonitorCard(otherRows[1], { splitSide: true }) : ph,
|
||||
];
|
||||
for (let i = 2; i < otherRows.length; i++) {
|
||||
cells.push(renderMonitorCard(otherRows[i], { splitSide: true }));
|
||||
const otherByKey = {};
|
||||
displayRows
|
||||
.filter((r) => !rowHasOptionsLayout(r))
|
||||
.forEach((r) => {
|
||||
otherByKey[String(r.key || "").toLowerCase()] = r;
|
||||
});
|
||||
/* 平铺顺序尽量保持:永续|币安 / 期权|Gate;隐藏项不占位 */
|
||||
const cells = [];
|
||||
if (okxRow && showMonitorOkxPerpPref()) {
|
||||
cells.push(renderMonitorCard(okxRow, { okxPart: "perp", splitSide: true }));
|
||||
}
|
||||
cardsHtml = `<div class="monitor-split-body monitor-split-2x2">${cells.join("")}</div>`;
|
||||
if (otherByKey.binance && showMonitorBinancePref()) {
|
||||
cells.push(renderMonitorCard(otherByKey.binance, { splitSide: true }));
|
||||
}
|
||||
if (okxRow && showMonitorOkxOptionsPref()) {
|
||||
cells.push(renderMonitorCard(okxRow, { okxPart: "options", splitSide: true }));
|
||||
}
|
||||
if (otherByKey.gate && showMonitorGatePref()) {
|
||||
cells.push(renderMonitorCard(otherByKey.gate, { splitSide: true }));
|
||||
}
|
||||
Object.keys(otherByKey).forEach((k) => {
|
||||
if (k === "binance" || k === "gate") return;
|
||||
cells.push(renderMonitorCard(otherByKey[k], { splitSide: true }));
|
||||
});
|
||||
cardsHtml = cells.length
|
||||
? `<div class="monitor-split-body monitor-split-2x2">${cells.join("")}</div>`
|
||||
: "";
|
||||
} else {
|
||||
cardsHtml =
|
||||
displayRows
|
||||
@@ -2506,7 +2591,7 @@
|
||||
}
|
||||
|
||||
if (expandedExchangeId && fs && fsInner) {
|
||||
const row = rows.find((r) => String(r.id) === String(expandedExchangeId));
|
||||
const row = visibleSource.find((r) => String(r.id) === String(expandedExchangeId));
|
||||
if (row) {
|
||||
try {
|
||||
fsInner.innerHTML = renderFullscreenExchange(row);
|
||||
@@ -3741,22 +3826,39 @@
|
||||
}
|
||||
|
||||
function optionsBalanceFields(opt) {
|
||||
const bal = (opt && opt.balances) || opt || {};
|
||||
if (!opt || typeof opt !== "object") {
|
||||
return { funding: null, trading: null, upl: null };
|
||||
}
|
||||
const bal =
|
||||
opt.balances && typeof opt.balances === "object" ? opt.balances : {};
|
||||
const pick = (a, b) => (a != null && a !== "" ? a : b);
|
||||
return {
|
||||
funding: sumUsdtEquiv(bal.funding_usdt, bal.funding_usdc),
|
||||
trading: sumUsdtEquiv(bal.trading_usdt, bal.trading_usdc),
|
||||
upl: opt && opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc))
|
||||
? Number(opt.upl_total_usdc)
|
||||
: null,
|
||||
funding: sumUsdtEquiv(
|
||||
pick(bal.funding_usdt, opt.funding_usdt),
|
||||
pick(bal.funding_usdc, opt.funding_usdc)
|
||||
),
|
||||
trading: sumUsdtEquiv(
|
||||
pick(bal.trading_usdt, opt.trading_usdt),
|
||||
pick(bal.trading_usdc, opt.trading_usdc)
|
||||
),
|
||||
upl:
|
||||
opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc))
|
||||
? Number(opt.upl_total_usdc)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderStatRow(funding, trading, upnl) {
|
||||
function renderStatRow(funding, trading, upnl, kind) {
|
||||
if (!showAccountPnlPref()) return "";
|
||||
return `<div class="stat-row">
|
||||
<div class="stat-box"><div class="stat-label">资金账户</div><div class="stat-value">${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
|
||||
<div class="stat-box"><div class="stat-label">交易账户</div><div class="stat-value">${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
|
||||
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></div>
|
||||
const isOpt = kind === "options";
|
||||
const fundLabel = isOpt ? "期权资金账户" : "资金账户";
|
||||
const tradeLabel = isOpt ? "期权交易账户" : "交易账户";
|
||||
const pnlLabel = isOpt ? "期权浮盈" : "浮盈合计";
|
||||
const rowCls = isOpt ? "stat-row stat-row-options" : "stat-row";
|
||||
return `<div class="${rowCls}">
|
||||
<div class="stat-box"><div class="stat-label">${fundLabel}</div><div class="stat-value">${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
|
||||
<div class="stat-box"><div class="stat-label">${tradeLabel}</div><div class="stat-value">${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
|
||||
<div class="stat-box"><div class="stat-label">${pnlLabel}</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -3764,6 +3866,11 @@
|
||||
return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl);
|
||||
}
|
||||
|
||||
function renderOptionsAccountStatRow(opt) {
|
||||
const bal = optionsBalanceFields(opt);
|
||||
return renderStatRow(bal.funding, bal.trading, bal.upl, "options");
|
||||
}
|
||||
|
||||
function shortOptionsInst(instId) {
|
||||
const s = String(instId || "");
|
||||
if (s.length <= 22) return s;
|
||||
@@ -3799,9 +3906,10 @@
|
||||
|
||||
function renderOptionsPositionsTable(pos, targets) {
|
||||
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
|
||||
const showPnl = showAccountPnlPref();
|
||||
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
|
||||
html +=
|
||||
"<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th><th>净盈亏</th><th>收益率</th>";
|
||||
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th>";
|
||||
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
|
||||
html += "</tr></thead><tbody>";
|
||||
pos.forEach((p) => {
|
||||
const optType =
|
||||
@@ -3825,10 +3933,12 @@
|
||||
<td>${esc(optType)}</td>
|
||||
<td>${esc(p.pos)}</td>
|
||||
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
|
||||
${renderOptionsTargetCell(target)}
|
||||
<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
|
||||
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>
|
||||
</tr>`;
|
||||
${renderOptionsTargetCell(target)}`;
|
||||
if (showPnl) {
|
||||
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
|
||||
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
|
||||
}
|
||||
html += "</tr>";
|
||||
});
|
||||
html += "</tbody></table></div>";
|
||||
return html;
|
||||
@@ -3836,7 +3946,8 @@
|
||||
|
||||
function monitorOptionsSplitActive(rows) {
|
||||
if (isMobileLayout() || expandedExchangeId) return false;
|
||||
return (rows || []).some((r) => rowHasOptionsLayout(r));
|
||||
if (!(rows || []).some((r) => rowHasOptionsLayout(r))) return false;
|
||||
return showMonitorOkxPerpPref() || showMonitorOkxOptionsPref();
|
||||
}
|
||||
|
||||
function renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday) {
|
||||
@@ -3856,8 +3967,9 @@
|
||||
}
|
||||
const cls = hubPosListCountClass(pos.length);
|
||||
let html = `<div class="hub-pos-list hub-opt-pos-list ${cls}" data-pos-count="${pos.length}">`;
|
||||
const hidePnl = !showAccountPnlPref();
|
||||
pos.forEach((p) => {
|
||||
html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true });
|
||||
html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true, hidePnl });
|
||||
});
|
||||
html += "</div>";
|
||||
return html;
|
||||
@@ -3869,18 +3981,17 @@
|
||||
const opt = row.options || {};
|
||||
let html = "";
|
||||
if (opt.enabled === false) {
|
||||
html += renderStatRow(null, null, null);
|
||||
html += renderOptionsAccountStatRow(opt);
|
||||
html += '<div class="section-title hub-options-title">期权持仓</div>';
|
||||
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>';
|
||||
} else if (opt.ok === false) {
|
||||
html += renderStatRow(null, null, null);
|
||||
html += renderOptionsAccountStatRow(opt);
|
||||
html += '<div class="section-title hub-options-title">期权持仓</div>';
|
||||
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
|
||||
} else {
|
||||
const pos = Array.isArray(opt.positions) ? opt.positions : [];
|
||||
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
|
||||
const bal = optionsBalanceFields(opt);
|
||||
html += renderStatRow(bal.funding, bal.trading, bal.upl);
|
||||
html += renderOptionsAccountStatRow(opt);
|
||||
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
|
||||
html +=
|
||||
layout === "cards"
|
||||
@@ -4312,17 +4423,22 @@
|
||||
opt.position_count != null ? opt.position_count : (opt.positions || []).length
|
||||
);
|
||||
const n = Number.isFinite(optCount) ? optCount : 0;
|
||||
const bal = typeof optionsBalanceFields === "function" ? optionsBalanceFields(opt) : {};
|
||||
const optUpl = bal && bal.upl != null ? bal.upl : null;
|
||||
optLine = n > 0 ? `期权 ${n}仓` : "期权 空仓";
|
||||
if (optUpl != null && Number.isFinite(Number(optUpl))) {
|
||||
optLine += ` · 浮盈 ${fmt(optUpl, 2)}U`;
|
||||
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U
|
||||
if (openCount === 0) {
|
||||
const bal = optionsBalanceFields(opt);
|
||||
const optUpl = bal.upl != null ? bal.upl : null;
|
||||
const parts = [n > 0 ? `期权 ${n}仓` : "期权 空仓"];
|
||||
if (showAccountPnlPref()) {
|
||||
if (bal.funding != null) parts.push(`资金 ${fmt(bal.funding, 2)}U`);
|
||||
if (bal.trading != null) parts.push(`交易 ${fmt(bal.trading, 2)}U`);
|
||||
if (optUpl != null && Number.isFinite(Number(optUpl))) {
|
||||
parts.push(`浮盈 ${fmt(optUpl, 2)}U`);
|
||||
}
|
||||
if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) {
|
||||
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U
|
||||
pnlShow = optUpl;
|
||||
pnlSuffix = "期权";
|
||||
}
|
||||
}
|
||||
optLine = parts.join(" · ");
|
||||
}
|
||||
}
|
||||
const hm = row.hub_monitor || {};
|
||||
@@ -5043,17 +5159,10 @@
|
||||
|
||||
function collectSettingsFromUI() {
|
||||
const rows = [...document.querySelectorAll("#settings-list .settings-card")];
|
||||
const pnlCb = document.getElementById("pref-show-account-pnl");
|
||||
const fundsCb = document.getElementById("pref-show-nav-funds");
|
||||
const dashCb = document.getElementById("pref-show-nav-dashboard");
|
||||
const planCb = document.getElementById("pref-show-nav-plan");
|
||||
const archiveCb = document.getElementById("pref-show-nav-archive");
|
||||
const quotesCb = document.getElementById("pref-show-nav-quotes");
|
||||
const aiCb = document.getElementById("pref-show-nav-ai");
|
||||
const calcCb = document.getElementById("pref-show-nav-calculator");
|
||||
const strategyCb = document.getElementById("pref-show-nav-strategy");
|
||||
const helpCb = document.getElementById("pref-show-nav-help");
|
||||
const logsCb = document.getElementById("pref-show-nav-logs");
|
||||
const chk = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
return el ? !!el.checked : true;
|
||||
};
|
||||
const supEnabled = document.getElementById("supervisor-enabled");
|
||||
const supProg = document.getElementById("supervisor-wechat-program");
|
||||
const supWebhook = document.getElementById("supervisor-wechat-webhook");
|
||||
@@ -5066,17 +5175,29 @@
|
||||
return {
|
||||
version: 1,
|
||||
display: {
|
||||
show_account_pnl: pnlCb ? !!pnlCb.checked : true,
|
||||
show_nav_funds: fundsCb ? !!fundsCb.checked : true,
|
||||
show_nav_dashboard: dashCb ? !!dashCb.checked : true,
|
||||
show_nav_plan: planCb ? !!planCb.checked : true,
|
||||
show_nav_archive: archiveCb ? !!archiveCb.checked : true,
|
||||
show_nav_quotes: quotesCb ? !!quotesCb.checked : true,
|
||||
show_nav_ai: aiCb ? !!aiCb.checked : true,
|
||||
show_nav_calculator: calcCb ? !!calcCb.checked : true,
|
||||
show_nav_strategy: strategyCb ? !!strategyCb.checked : true,
|
||||
show_nav_help: helpCb ? !!helpCb.checked : true,
|
||||
show_nav_logs: logsCb ? !!logsCb.checked : true,
|
||||
show_account_pnl: chk("pref-show-account-pnl"),
|
||||
show_nav_funds: chk("pref-show-nav-funds"),
|
||||
show_nav_dashboard: chk("pref-show-nav-dashboard"),
|
||||
show_nav_plan: chk("pref-show-nav-plan"),
|
||||
show_nav_archive: chk("pref-show-nav-archive"),
|
||||
show_nav_quotes: chk("pref-show-nav-quotes"),
|
||||
show_nav_ai: chk("pref-show-nav-ai"),
|
||||
show_nav_calculator: chk("pref-show-nav-calculator"),
|
||||
show_nav_compare: chk("pref-show-nav-compare"),
|
||||
show_nav_strategy: chk("pref-show-nav-strategy"),
|
||||
show_nav_amp_stats: chk("pref-show-nav-amp-stats"),
|
||||
show_nav_help: chk("pref-show-nav-help"),
|
||||
show_nav_logs: chk("pref-show-nav-logs"),
|
||||
show_monitor_binance: chk("pref-show-monitor-binance"),
|
||||
show_monitor_okx_perp: chk("pref-show-monitor-okx-perp"),
|
||||
show_monitor_okx_options: chk("pref-show-monitor-okx-options"),
|
||||
show_monitor_gate: chk("pref-show-monitor-gate"),
|
||||
show_strategy_playbook_v2: chk("pref-show-strategy-playbook-v2"),
|
||||
show_strategy_playbook: chk("pref-show-strategy-playbook"),
|
||||
show_strategy_behavior: chk("pref-show-strategy-behavior"),
|
||||
show_strategy_binance: chk("pref-show-strategy-binance"),
|
||||
show_strategy_okx: chk("pref-show-strategy-okx"),
|
||||
show_strategy_gate: chk("pref-show-strategy-gate"),
|
||||
},
|
||||
supervisor: {
|
||||
enabled: supEnabled ? !!supEnabled.checked : true,
|
||||
@@ -5134,6 +5255,12 @@
|
||||
loadSettingsMetaLine();
|
||||
}
|
||||
if (lastMonitorRows.length) renderMonitorGrid(lastMonitorRows);
|
||||
if (window.hubDashboardPage && window.hubDashboardPage.refresh) {
|
||||
window.hubDashboardPage.refresh();
|
||||
}
|
||||
if (window.hubStrategyPage && typeof window.hubStrategyPage.reloadMeta === "function") {
|
||||
window.hubStrategyPage.reloadMeta();
|
||||
}
|
||||
if (!pageNavAllowed(currentPage())) {
|
||||
history.replaceState({}, "", "/monitor");
|
||||
setActiveNav();
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
const elQuoteContent = document.getElementById("archive-quote-content");
|
||||
const elQuoteSubmit = document.getElementById("archive-quote-submit");
|
||||
const elContentTabs = document.getElementById("archive-content-tabs");
|
||||
const elProductTabs = document.getElementById("archive-product-tabs");
|
||||
const elPanelViz = document.getElementById("archive-panel-viz");
|
||||
const elPanelCalendar = document.getElementById("archive-panel-calendar");
|
||||
const elPanelTrades = document.getElementById("archive-panel-trades");
|
||||
@@ -76,6 +77,7 @@
|
||||
let selectedQuoteId = null;
|
||||
let editingQuoteId = null;
|
||||
let archiveContentTab = "trades";
|
||||
let archiveProduct = "perp";
|
||||
let quoteDayTrades = [];
|
||||
let quoteDayTradesDay = "";
|
||||
let quoteDayTradesReq = 0;
|
||||
@@ -416,6 +418,44 @@
|
||||
syncPeriodUI();
|
||||
}
|
||||
|
||||
function isOptionsProduct() {
|
||||
return archiveProduct === "options";
|
||||
}
|
||||
|
||||
function syncProductUI() {
|
||||
document.body.classList.toggle("archive-product-options", isOptionsProduct());
|
||||
if (elProductTabs) {
|
||||
elProductTabs.querySelectorAll(".archive-product-tab").forEach(function (btn) {
|
||||
const on = btn.getAttribute("data-archive-product") === archiveProduct;
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
}
|
||||
if (isOptionsProduct()) {
|
||||
setChartOpen(false);
|
||||
if (archiveContentTab === "viz") setArchiveContentTab("trades");
|
||||
}
|
||||
}
|
||||
|
||||
function setArchiveProduct(product) {
|
||||
const next = product === "options" ? "options" : "perp";
|
||||
if (next === archiveProduct) return;
|
||||
archiveProduct = next;
|
||||
selected = null;
|
||||
selectedTradeKey = null;
|
||||
syncProductUI();
|
||||
void loadDailyTrades();
|
||||
void loadCalendar();
|
||||
}
|
||||
|
||||
function dailyTradesApiPath() {
|
||||
return isOptionsProduct() ? "/api/archive/options/daily-trades" : "/api/archive/daily-trades";
|
||||
}
|
||||
|
||||
function calendarApiPath() {
|
||||
return isOptionsProduct() ? "/api/archive/options/calendar" : "/api/archive/calendar";
|
||||
}
|
||||
|
||||
function queryDailyParams() {
|
||||
const q = new URLSearchParams();
|
||||
q.set("period", periodMode);
|
||||
@@ -430,7 +470,7 @@
|
||||
if (ex) q.set("exchange_key", ex);
|
||||
if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1");
|
||||
if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1");
|
||||
if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1");
|
||||
if (!isOptionsProduct() && elFilterSick && elFilterSick.checked) q.set("filter_sick", "1");
|
||||
if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim());
|
||||
return q.toString();
|
||||
}
|
||||
@@ -554,7 +594,7 @@
|
||||
return q;
|
||||
},
|
||||
fetchFn: async function (q) {
|
||||
const r = await apiFetch("/api/archive/calendar?" + q.toString());
|
||||
const r = await apiFetch(calendarApiPath() + "?" + q.toString());
|
||||
return r.json();
|
||||
},
|
||||
parseResponse: function (data) {
|
||||
@@ -1089,7 +1129,7 @@
|
||||
elQuoteDayTradesBody.innerHTML = '<p class="archive-empty">加载当日已平仓…</p>';
|
||||
if (elQuoteDayTradesMeta) elQuoteDayTradesMeta.textContent = day;
|
||||
try {
|
||||
const r = await apiFetch("/api/archive/daily-trades?" + q.toString());
|
||||
const r = await apiFetch(dailyTradesApiPath() + "?" + q.toString());
|
||||
const j = await r.json();
|
||||
if (req !== quoteDayTradesReq) return;
|
||||
if (!r.ok) {
|
||||
@@ -1827,6 +1867,80 @@
|
||||
return;
|
||||
}
|
||||
const pageRows = pagedDailyTrades();
|
||||
if (isOptionsProduct()) {
|
||||
elTrades.innerHTML =
|
||||
'<table class="archive-trades-table"><thead><tr>' +
|
||||
"<th>交易所</th><th>标的</th><th>合约/来源</th><th>开仓时间</th><th>平仓时间</th><th>持仓</th>" +
|
||||
"<th>类型</th><th>策略</th><th>盈亏</th><th>权利金</th><th>复盘</th>" +
|
||||
"</tr></thead><tbody>" +
|
||||
pageRows
|
||||
.map(function (t) {
|
||||
const rowKey = tradeRowKey(t);
|
||||
const active = rowKey && rowKey === selectedTradeKey ? " is-active" : "";
|
||||
const holdMin =
|
||||
t.hold_minutes != null
|
||||
? t.hold_minutes
|
||||
: t.hold_seconds != null
|
||||
? Number(t.hold_seconds) / 60
|
||||
: null;
|
||||
const optLabel =
|
||||
t.source_label ||
|
||||
t.source_type ||
|
||||
(t.opt_type === "C" || t.opt_type === "CALL"
|
||||
? "Call"
|
||||
: t.opt_type === "P" || t.opt_type === "PUT"
|
||||
? "Put"
|
||||
: "—");
|
||||
const pnl = t.pnl_amount != null ? t.pnl_amount : t.realized_pnl_total;
|
||||
return (
|
||||
'<tr class="archive-trade-row' +
|
||||
active +
|
||||
'" data-key="' +
|
||||
esc(rowKey) +
|
||||
'">' +
|
||||
"<td>" +
|
||||
esc(tradeRowExchange(t)) +
|
||||
"</td>" +
|
||||
'<td class="archive-symbol">' +
|
||||
esc(t.underlying || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
esc(t.inst_id || t.source_label || "—") +
|
||||
"</td>" +
|
||||
'<td class="archive-dt">' +
|
||||
fmtDt(t.opened_at) +
|
||||
"</td>" +
|
||||
'<td class="archive-dt">' +
|
||||
fmtDt(t.closed_at) +
|
||||
"</td>" +
|
||||
'<td class="archive-hold">' +
|
||||
fmtDurationMinutes(holdMin) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
esc(optLabel) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
esc(t.strategy_tag || "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(pnl) +
|
||||
'">' +
|
||||
fmtPnl(pnl) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtVolStat(t.premium_total != null ? t.premium_total : t.premium_paid) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
(t.reviewed ? "已复盘" : "—") +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
"</tbody></table>";
|
||||
updateTradesPager();
|
||||
return;
|
||||
}
|
||||
elTrades.innerHTML =
|
||||
'<table class="archive-trades-table"><thead><tr>' +
|
||||
"<th>交易所</th><th>合约</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
|
||||
@@ -2051,7 +2165,7 @@
|
||||
|
||||
async function loadDailyTrades() {
|
||||
setStatus("加载交易记录…");
|
||||
const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams());
|
||||
const r = await apiFetch(dailyTradesApiPath() + "?" + queryDailyParams());
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
setStatus(j.detail || "加载失败");
|
||||
@@ -2080,7 +2194,8 @@
|
||||
void loadCalendar();
|
||||
if (archiveContentTab === "quotes") void loadQuoteDayTrades();
|
||||
setStatus(
|
||||
(periodLabel || tradingDay || "当日") +
|
||||
(isOptionsProduct() ? "期权 · " : "永续 · ") +
|
||||
(periodLabel || tradingDay || "当日") +
|
||||
" · 列表 " +
|
||||
dailyTrades.length +
|
||||
" 笔 · " +
|
||||
@@ -2105,6 +2220,7 @@
|
||||
|
||||
function formatSyncSummary(j) {
|
||||
const results = j.results || [];
|
||||
const optResults = j.options_results || [];
|
||||
const okN = results.filter(function (x) {
|
||||
return x.ok !== false;
|
||||
}).length;
|
||||
@@ -2118,6 +2234,19 @@
|
||||
parts.push(line);
|
||||
}
|
||||
});
|
||||
optResults.forEach(function (row) {
|
||||
const label = (row.exchange_key || row.name || "?") + "期权";
|
||||
if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误"));
|
||||
else {
|
||||
let line =
|
||||
label +
|
||||
" " +
|
||||
(row.trade_count != null ? row.trade_count : row.trades_upserted || 0) +
|
||||
" 笔";
|
||||
if (row.trades_removed > 0) line += " 清" + row.trades_removed;
|
||||
parts.push(line);
|
||||
}
|
||||
});
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
@@ -2217,6 +2346,13 @@
|
||||
setArchiveContentTab(btn.getAttribute("data-archive-tab") || "trades");
|
||||
});
|
||||
}
|
||||
if (elProductTabs) {
|
||||
elProductTabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".archive-product-tab");
|
||||
if (!btn) return;
|
||||
setArchiveProduct(btn.getAttribute("data-archive-product") || "perp");
|
||||
});
|
||||
}
|
||||
if (elTfTabs) {
|
||||
elTfTabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".archive-tf-btn");
|
||||
@@ -2249,6 +2385,7 @@
|
||||
syncPeriodUI();
|
||||
syncTradesLayout();
|
||||
bindEvents();
|
||||
syncProductUI();
|
||||
setArchiveContentTab("trades");
|
||||
inited = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 中控策略对比:同风险额 R 下 合约 / 单期权 / 期期7:3
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-compare");
|
||||
if (!page) return;
|
||||
|
||||
let inited = false;
|
||||
let calcTimer = null;
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function num(id) {
|
||||
const el = $(id);
|
||||
if (!el) return null;
|
||||
const n = Number(el.value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function text(id) {
|
||||
const el = $(id);
|
||||
return el ? String(el.value || "").trim() : "";
|
||||
}
|
||||
|
||||
function fmtU(v) {
|
||||
if (v == null || !Number.isFinite(Number(v))) return "—";
|
||||
const n = Number(v);
|
||||
const abs = Math.abs(n).toFixed(2);
|
||||
if (Math.abs(n) < 1e-9) return "0.00U";
|
||||
return (n > 0 ? "+" : "-") + abs + "U";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
|
||||
return n > 0 ? "cmp-pnl-pos" : "cmp-pnl-neg";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
const el = $("cmp-status");
|
||||
if (!el) return;
|
||||
el.textContent = msg || "";
|
||||
el.className = "toolbar-meta" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function syncDirectionDefaults() {
|
||||
const dir = text("cmp-direction") || "long";
|
||||
const isLong = dir === "long";
|
||||
const optType = $("cmp-opt-type");
|
||||
const mainType = $("cmp-hedge-main-type");
|
||||
const sideType = $("cmp-hedge-side-type");
|
||||
if (optType && !optType.dataset.touched) optType.value = isLong ? "C" : "P";
|
||||
if (mainType && !mainType.dataset.touched) mainType.value = isLong ? "C" : "P";
|
||||
if (sideType && !sideType.dataset.touched) sideType.value = isLong ? "P" : "C";
|
||||
}
|
||||
|
||||
function collectPayload() {
|
||||
const tp = num("cmp-tp");
|
||||
return {
|
||||
base: text("cmp-base") || "ETH",
|
||||
direction: text("cmp-direction") || "long",
|
||||
entry: num("cmp-entry"),
|
||||
sl: num("cmp-sl"),
|
||||
tp: tp,
|
||||
risk_u: num("cmp-risk"),
|
||||
tp_opt: num("cmp-tp-opt") != null ? num("cmp-tp-opt") : tp,
|
||||
tp_hedge: num("cmp-tp-hedge") != null ? num("cmp-tp-hedge") : tp,
|
||||
option: {
|
||||
opt_type: text("cmp-opt-type") || "C",
|
||||
strike: num("cmp-opt-strike"),
|
||||
ask: num("cmp-opt-ask"),
|
||||
},
|
||||
hedge: {
|
||||
main: {
|
||||
opt_type: text("cmp-hedge-main-type") || "C",
|
||||
strike: num("cmp-hedge-main-strike"),
|
||||
ask: num("cmp-hedge-main-ask"),
|
||||
},
|
||||
side: {
|
||||
opt_type: text("cmp-hedge-side-type") || "P",
|
||||
strike: num("cmp-hedge-side-strike"),
|
||||
ask: num("cmp-hedge-side-ask"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderSummaryCards(data) {
|
||||
const box = $("cmp-summary");
|
||||
if (!box) return;
|
||||
const perp = data.perp || {};
|
||||
const opt = data.option || {};
|
||||
const hedge = data.hedge || {};
|
||||
const cards = [];
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>单独合约</h3>
|
||||
<div class="cmp-sum-row"><span>张数</span><strong>${esc(perp.sheets)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>止损占用</span><strong>${fmtU(perp.risk_used_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>面值</span><strong>${esc(perp.contract_size)} 币/张</strong></div>
|
||||
</article>`);
|
||||
if (opt.ok) {
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>单独期权 · ${esc(opt.opt_type)} ${esc(opt.strike)}</h3>
|
||||
<div class="cmp-sum-row"><span>张数</span><strong>${esc(opt.sheets)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>权利金</span><strong>${fmtU(opt.premium_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>单张成本</span><strong>${fmtU(opt.unit_cost_u)}</strong></div>
|
||||
</article>`);
|
||||
} else {
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>单独期权</h3>
|
||||
<p class="cmp-muted">${esc(opt.msg || "输入不完整")}</p>
|
||||
</article>`);
|
||||
}
|
||||
if (hedge.ok) {
|
||||
const m = hedge.main || {};
|
||||
const s = hedge.side || {};
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>期期对冲 7:3</h3>
|
||||
<div class="cmp-sum-row"><span>主腿 ${esc(m.opt_type)} ${esc(m.strike)}</span><strong>${esc(m.sheets)} 张 · ${fmtU(m.premium_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>次腿 ${esc(s.opt_type)} ${esc(s.strike)}</span><strong>${esc(s.sheets)} 张 · ${fmtU(s.premium_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>总权利金</span><strong>${fmtU(hedge.premium_u)}</strong></div>
|
||||
</article>`);
|
||||
} else {
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>期期对冲</h3>
|
||||
<p class="cmp-muted">${esc(hedge.msg || "输入不完整")}</p>
|
||||
</article>`);
|
||||
}
|
||||
box.innerHTML = cards.join("");
|
||||
}
|
||||
|
||||
function cell(v, note) {
|
||||
const main = `<span class="${pnlClass(v)}">${fmtU(v)}</span>`;
|
||||
if (!note) return main;
|
||||
return `${main}<div class="cmp-cell-note">${esc(note)}</div>`;
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
const box = $("cmp-table-wrap");
|
||||
if (!box) return;
|
||||
const perp = data.perp || {};
|
||||
const opt = data.option && data.option.ok ? data.option : null;
|
||||
const hedge = data.hedge && data.hedge.ok ? data.hedge : null;
|
||||
const dash = "—";
|
||||
box.innerHTML = `<div class="cmp-table-scroll"><table class="cmp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>路径</th>
|
||||
<th>单独合约</th>
|
||||
<th>单独期权</th>
|
||||
<th>期期对冲</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>A 干净止盈</strong><div class="cmp-cell-note">盈利能力主对比</div></td>
|
||||
<td>${cell(perp.path_a_tp)}</td>
|
||||
<td>${opt ? cell(opt.path_a_tp) : dash}</td>
|
||||
<td>${hedge ? cell(hedge.path_a_tp) : dash}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>B 打止损</strong><div class="cmp-cell-note">合约实现亏损;期权另注最坏</div></td>
|
||||
<td>${cell(perp.path_b_sl)}</td>
|
||||
<td>${
|
||||
opt
|
||||
? cell(opt.path_b_sl, "最坏到期亏满权利金 " + fmtU(opt.path_b_worst))
|
||||
: dash
|
||||
}</td>
|
||||
<td>${
|
||||
hedge
|
||||
? cell(hedge.path_b_sl, "最坏双腿归零 " + fmtU(hedge.path_b_worst))
|
||||
: dash
|
||||
}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>C 先止损再去止盈</strong><div class="cmp-cell-note">合约踏空对照</div></td>
|
||||
<td>${cell(
|
||||
perp.path_c_realized,
|
||||
"踏空未拿到 " + fmtU(perp.path_c_missed)
|
||||
)}</td>
|
||||
<td>${opt ? cell(opt.path_c_hold_to_tp, opt.path_c_note || "") : dash}</td>
|
||||
<td>${hedge ? cell(hedge.path_c_hold_to_tp, hedge.path_c_note || "") : dash}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
function renderRecommend(data) {
|
||||
const box = $("cmp-recommend");
|
||||
if (!box) return;
|
||||
const rec = data.recommend || {};
|
||||
const bullets = Array.isArray(rec.bullets) ? rec.bullets : [];
|
||||
const warns = Array.isArray(data.warnings) ? data.warnings : [];
|
||||
box.innerHTML = `<div class="cmp-rec-card card">
|
||||
<div class="cmp-rec-head">推荐:<strong>${esc(rec.choice || "—")}</strong></div>
|
||||
<p class="cmp-rec-reason">${esc(rec.reason || "")}</p>
|
||||
<ul class="cmp-rec-list">${bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>
|
||||
${
|
||||
warns.length
|
||||
? `<div class="cmp-warn">${warns.map((w) => esc(w)).join(" · ")}</div>`
|
||||
: ""
|
||||
}
|
||||
<p class="cmp-foot-note">${(data.notes || []).map(esc).join(" · ")}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function runCalc() {
|
||||
const payload = collectPayload();
|
||||
if (
|
||||
payload.entry == null ||
|
||||
payload.sl == null ||
|
||||
payload.tp == null ||
|
||||
payload.risk_u == null
|
||||
) {
|
||||
setStatus("请填写入场 / 止损 / 止盈 / 风险额", true);
|
||||
return;
|
||||
}
|
||||
setStatus("计算中…");
|
||||
try {
|
||||
const r = await fetch("/api/compare/calc", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data.ok) {
|
||||
setStatus(data.msg || "计算失败", true);
|
||||
return;
|
||||
}
|
||||
renderSummaryCards(data);
|
||||
renderTable(data);
|
||||
renderRecommend(data);
|
||||
setStatus("已更新");
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCalc() {
|
||||
if (calcTimer) clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
void runCalc();
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const form = $("cmp-form");
|
||||
if (!form || form.dataset.bound === "1") return;
|
||||
form.dataset.bound = "1";
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
void runCalc();
|
||||
});
|
||||
form.querySelectorAll("input, select").forEach((el) => {
|
||||
el.addEventListener("change", () => {
|
||||
if (el.id === "cmp-direction") syncDirectionDefaults();
|
||||
if (
|
||||
el.id === "cmp-opt-type" ||
|
||||
el.id === "cmp-hedge-main-type" ||
|
||||
el.id === "cmp-hedge-side-type"
|
||||
) {
|
||||
el.dataset.touched = "1";
|
||||
}
|
||||
scheduleCalc();
|
||||
});
|
||||
el.addEventListener("input", scheduleCalc);
|
||||
});
|
||||
const btn = $("cmp-btn-run");
|
||||
if (btn) btn.addEventListener("click", () => void runCalc());
|
||||
}
|
||||
|
||||
window.hubComparePage = {
|
||||
init() {
|
||||
if (!inited) {
|
||||
bind();
|
||||
syncDirectionDefaults();
|
||||
inited = true;
|
||||
}
|
||||
scheduleCalc();
|
||||
},
|
||||
destroy() {
|
||||
/* keep form state */
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -54,8 +54,16 @@
|
||||
elStatus.className = "dash-status" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function showAccountPnlPref() {
|
||||
if (typeof window.hubShowAccountPnlPref === "function") {
|
||||
return !!window.hubShowAccountPnlPref();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderKpi(totals) {
|
||||
if (!elKpi || !totals) return;
|
||||
const showPnl = showAccountPnlPref();
|
||||
const closed = Number(totals.total_pnl_u);
|
||||
const floating = Number(totals.float_pnl_u);
|
||||
const funding = totals.total_funding_usdt;
|
||||
@@ -68,16 +76,20 @@
|
||||
totals.perpetual_open_position_count != null
|
||||
? Number(totals.perpetual_open_position_count) || 0
|
||||
: Math.max(0, totalPos - optPos);
|
||||
const items = [
|
||||
kpiItem("交易日", esc(totals.trading_day || "—")),
|
||||
kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"),
|
||||
const items = [kpiItem("交易日", esc(totals.trading_day || "—"))];
|
||||
if (showPnl) {
|
||||
items.push(kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"));
|
||||
}
|
||||
items.push(
|
||||
kpiItem("总持仓数量", `${totalPos}`),
|
||||
kpiItem("期权持仓", `${optPos}`),
|
||||
kpiItem("永续持仓", `${perpPos}`),
|
||||
kpiItem("平仓数量", `${totals.closed_count || 0}`),
|
||||
kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)),
|
||||
kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)),
|
||||
];
|
||||
kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed))
|
||||
);
|
||||
if (showPnl) {
|
||||
items.push(kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)));
|
||||
}
|
||||
elKpi.innerHTML = `<div class="dash-kpi-summary">${items.join("")}</div>`;
|
||||
}
|
||||
|
||||
@@ -197,6 +209,7 @@
|
||||
|
||||
function renderUnifiedPerpTable(rows) {
|
||||
if (!rows.length) return "";
|
||||
const showPnl = showAccountPnlPref();
|
||||
const body = rows
|
||||
.map(({ ac, ln }) => {
|
||||
const source = String((ln && ln.source) || "—");
|
||||
@@ -213,7 +226,7 @@
|
||||
<td>${contracts}</td>
|
||||
<td>${slTpCell(ln, "sl")}</td>
|
||||
<td>${slTpCell(ln, "tp")}</td>
|
||||
<td>${floatPnlCell(ln)}</td>
|
||||
${showPnl ? `<td>${floatPnlCell(ln)}</td>` : ""}
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -222,7 +235,9 @@
|
||||
<div class="dash-table-wrap">
|
||||
<table class="dash-table dash-pos-table">
|
||||
<thead><tr>
|
||||
<th>交易所</th><th>类型</th><th>合约</th><th>方向</th><th>开仓价</th><th>标记价</th><th>张数</th><th>止损</th><th>止盈</th><th>浮盈</th>
|
||||
<th>交易所</th><th>类型</th><th>合约</th><th>方向</th><th>开仓价</th><th>标记价</th><th>张数</th><th>止损</th><th>止盈</th>${
|
||||
showPnl ? "<th>浮盈</th>" : ""
|
||||
}
|
||||
</tr></thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
@@ -291,7 +306,7 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderOptionsLegRow(ac, p) {
|
||||
function renderOptionsLegRow(ac, p, showPnl) {
|
||||
const optType =
|
||||
(p.opt_type || "").toUpperCase() === "C"
|
||||
? "Call"
|
||||
@@ -303,21 +318,25 @@
|
||||
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
|
||||
const net = optionsNetPnl(p);
|
||||
const roi = optionsRoiPct(p);
|
||||
return `<tr>
|
||||
let html = `<tr>
|
||||
<td>${exchangeLinkCell(ac)}</td>
|
||||
<td>${sourceTypeCell(source)}</td>
|
||||
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
|
||||
<td>${esc(optType)}</td>
|
||||
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
|
||||
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
|
||||
<td><span class="${targetCls}">${esc(target)}</span></td>
|
||||
<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
|
||||
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>
|
||||
</tr>`;
|
||||
<td><span class="${targetCls}">${esc(target)}</span></td>`;
|
||||
if (showPnl) {
|
||||
html += `<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
|
||||
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>`;
|
||||
}
|
||||
html += "</tr>";
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderUnifiedOptionsTable(rows) {
|
||||
if (!rows.length) return "";
|
||||
const showPnl = showAccountPnlPref();
|
||||
// 同所同计划相邻,Call 在前 Put 在后;不额外画分组框
|
||||
const sorted = rows.slice().sort((a, b) => {
|
||||
const ka = optionsGroupKey(a.ac, a.p);
|
||||
@@ -330,14 +349,16 @@
|
||||
if (tb === "C") return 1;
|
||||
return ta.localeCompare(tb);
|
||||
});
|
||||
const body = sorted.map(({ ac, p }) => renderOptionsLegRow(ac, p)).join("");
|
||||
const body = sorted.map(({ ac, p }) => renderOptionsLegRow(ac, p, showPnl)).join("");
|
||||
|
||||
return `<div class="dash-pos-block dash-options-block">
|
||||
<div class="dash-ac-section-label">期权持仓</div>
|
||||
<div class="dash-table-wrap dash-options-table-wrap">
|
||||
<table class="dash-table dash-options-table">
|
||||
<thead><tr>
|
||||
<th>交易所</th><th>类型</th><th>合约</th><th>Call/Put</th><th>到期倒计时</th><th>指数</th><th>目标监控</th><th>净盈亏</th><th>收益率</th>
|
||||
<th>交易所</th><th>类型</th><th>合约</th><th>Call/Put</th><th>到期倒计时</th><th>指数</th><th>目标监控</th>${
|
||||
showPnl ? "<th>净盈亏</th><th>收益率</th>" : ""
|
||||
}
|
||||
</tr></thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
@@ -539,5 +560,9 @@
|
||||
inited = false;
|
||||
stopLive();
|
||||
},
|
||||
refresh() {
|
||||
if (!inited) return;
|
||||
void fetchDashboardSnapshot({ silent: true, force: true });
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260722-monitor-m-stats" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260724-opt-archive" />
|
||||
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
|
||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||
@@ -53,9 +53,11 @@
|
||||
<a href="/plan" id="nav-plan">开仓计划</a>
|
||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||
<a href="/strategy" id="nav-strategy">策略说明</a>
|
||||
<a href="/amp-stats" id="nav-amp-stats">振幅统计</a>
|
||||
<a href="/help" id="nav-help">使用说明</a>
|
||||
<a href="/market" id="nav-market">行情区</a>
|
||||
<a href="/calculator" id="nav-calculator">计算器</a>
|
||||
<a href="/compare" id="nav-compare">策略对比</a>
|
||||
<a href="/archive" id="nav-archive">内照明心</a>
|
||||
<a href="/quotes" id="nav-quotes">语录</a>
|
||||
<a href="/dashboard" id="nav-dashboard">数据看板</a>
|
||||
@@ -449,7 +451,11 @@
|
||||
<div id="page-archive" class="page hidden">
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">IN</span> 内照明心</h1>
|
||||
<p class="page-desc">交易记录 · 交易日历 · 图表概览 · 复盘语录</p>
|
||||
<p class="page-desc">永续 / 期权交易记录 · 交易日历 · 图表概览 · 复盘语录</p>
|
||||
</div>
|
||||
<div class="archive-product-tabs" id="archive-product-tabs" role="tablist" aria-label="品种">
|
||||
<button type="button" class="archive-product-tab is-active" role="tab" aria-selected="true" data-archive-product="perp">永续</button>
|
||||
<button type="button" class="archive-product-tab" role="tab" aria-selected="false" data-archive-product="options">期权</button>
|
||||
</div>
|
||||
<div class="archive-toolbar toolbar">
|
||||
<label class="chk-label archive-toolbar-desktop"><input type="checkbox" id="archive-filter-profit" /> 盈利单</label>
|
||||
@@ -956,11 +962,130 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-compare" class="page hidden">
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">CMP</span> 策略对比</h1>
|
||||
<p class="page-desc">同风险额下对比 · 单独合约 / 单独期权 / 期期对冲(7:3) · 看止盈谁强、谁更易踏空</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="cmp-btn-run" class="primary">计算对比</button>
|
||||
<span id="cmp-status" class="toolbar-meta"></span>
|
||||
</div>
|
||||
<form id="cmp-form" class="cmp-form">
|
||||
<section class="card cmp-common-card">
|
||||
<h2>公共参数</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>标的</span>
|
||||
<select id="cmp-base">
|
||||
<option value="ETH" selected>ETH</option>
|
||||
<option value="BTC">BTC</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>方向</span>
|
||||
<select id="cmp-direction">
|
||||
<option value="long" selected>做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>入场价</span>
|
||||
<input id="cmp-entry" type="number" min="0" step="any" value="3500" required />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>统一风险 R (U)</span>
|
||||
<input id="cmp-risk" type="number" min="0.01" step="any" value="10" required />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>统一止损价</span>
|
||||
<input id="cmp-sl" type="number" min="0" step="any" value="3400" required />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>止盈价</span>
|
||||
<input id="cmp-tp" type="number" min="0" step="any" value="3700" required />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<div class="cmp-input-cols">
|
||||
<section class="card">
|
||||
<h2>单独期权</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>类型</span>
|
||||
<select id="cmp-opt-type">
|
||||
<option value="C" selected>Call</option>
|
||||
<option value="P">Put</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>行权价</span>
|
||||
<input id="cmp-opt-strike" type="number" min="0" step="any" value="3600" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>卖一价(每币)</span>
|
||||
<input id="cmp-opt-ask" type="number" min="0" step="any" value="50" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>期权目标价(默认同止盈)</span>
|
||||
<input id="cmp-tp-opt" type="number" min="0" step="any" placeholder="空=用止盈价" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>期期对冲 · 主腿 70%</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>类型</span>
|
||||
<select id="cmp-hedge-main-type">
|
||||
<option value="C" selected>Call</option>
|
||||
<option value="P">Put</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>行权价</span>
|
||||
<input id="cmp-hedge-main-strike" type="number" min="0" step="any" value="3600" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>卖一价</span>
|
||||
<input id="cmp-hedge-main-ask" type="number" min="0" step="any" value="50" />
|
||||
</label>
|
||||
</div>
|
||||
<h2 class="cmp-subhead">次腿 30%</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>类型</span>
|
||||
<select id="cmp-hedge-side-type">
|
||||
<option value="C">Call</option>
|
||||
<option value="P" selected>Put</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>行权价</span>
|
||||
<input id="cmp-hedge-side-strike" type="number" min="0" step="any" value="3400" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>卖一价</span>
|
||||
<input id="cmp-hedge-side-ask" type="number" min="0" step="any" value="30" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>对冲目标价(默认同止盈)</span>
|
||||
<input id="cmp-tp-hedge" type="number" min="0" step="any" placeholder="空=用止盈价" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
<div id="cmp-summary" class="cmp-summary"></div>
|
||||
<div id="cmp-table-wrap" class="cmp-table-wrap"></div>
|
||||
<div id="cmp-recommend" class="cmp-recommend"></div>
|
||||
</div>
|
||||
|
||||
<div id="page-strategy" class="page hidden">
|
||||
<div class="page-head strategy-page-head">
|
||||
<div>
|
||||
<h1><span class="head-tag">STR</span> 策略说明</h1>
|
||||
<p class="page-desc">执行手册 · 三所策略正文(带目录) · 执行清单(打印对照)</p>
|
||||
<p class="page-desc">执行手册v2(无对冲)· 行为准则 · 三所策略正文 · 执行清单</p>
|
||||
</div>
|
||||
<div class="strategy-page-actions no-print">
|
||||
<button type="button" id="strategy-btn-download" class="ghost">下载 HTML</button>
|
||||
@@ -1003,6 +1128,102 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-amp-stats" class="page hidden">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1><span class="head-tag">AMP</span> 振幅统计</h1>
|
||||
<p class="page-desc">OKX 指数 · 整点起点 → 固定 16:00 · 点数振幅档案(只读)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amp-view-tabs" role="tablist" aria-label="振幅视图">
|
||||
<button type="button" class="amp-view-tab is-active" data-view="stats" role="tab" aria-selected="true">统计</button>
|
||||
<button type="button" class="amp-view-tab" data-view="history" role="tab" aria-selected="false">历史</button>
|
||||
</div>
|
||||
<section id="amp-panel-stats" class="card amp-panel">
|
||||
<div class="amp-form">
|
||||
<label class="amp-field">
|
||||
<span>标的</span>
|
||||
<select id="amp-symbol">
|
||||
<option value="eth" selected>ETH</option>
|
||||
<option value="btc">BTC</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>数据源</span>
|
||||
<input type="text" value="OKX" disabled />
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>起点整点</span>
|
||||
<select id="amp-start-hour"></select>
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>终点</span>
|
||||
<input type="text" value="16:00" disabled />
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>周期</span>
|
||||
<select id="amp-period">
|
||||
<option value="1m">1个月</option>
|
||||
<option value="2m" selected>2个月</option>
|
||||
<option value="3m">3个月</option>
|
||||
<option value="6m">半年</option>
|
||||
<option value="1y">1年</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="amp-field hidden" id="amp-custom-wrap">
|
||||
<span>自定义天数</span>
|
||||
<input id="amp-custom-days" type="number" min="7" max="400" value="60" />
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>周末</span>
|
||||
<select id="amp-weekend-filter">
|
||||
<option value="all" selected>全部</option>
|
||||
<option value="exclude">排除周末</option>
|
||||
<option value="only">仅周末</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>买跨·双边权利金(点)</span>
|
||||
<input id="amp-straddle-premium" type="number" min="0" step="any" placeholder="如 30" />
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>止盈点(点)</span>
|
||||
<input id="amp-take-profit" type="number" min="0" step="any" placeholder="空=按涨跌" />
|
||||
</label>
|
||||
<div class="amp-actions">
|
||||
<button type="button" id="amp-btn-compute" class="primary">计算</button>
|
||||
<button type="button" id="amp-btn-save" class="ghost">保存到历史</button>
|
||||
<button type="button" id="amp-btn-download" class="ghost">下载 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="amp-status" class="toolbar-meta amp-status"></p>
|
||||
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金;止盈≥触达则有效波动=止盈点,否则用|涨跌|.周末按结算日标注/筛选.</p>
|
||||
<h3 class="amp-block-title">汇总</h3>
|
||||
<div id="amp-summary" class="amp-summary"></div>
|
||||
<h3 class="amp-block-title">买跨对照</h3>
|
||||
<div id="amp-straddle" class="amp-summary amp-straddle"></div>
|
||||
<h3 class="amp-block-title">日表明细</h3>
|
||||
<div class="amp-table-wrap">
|
||||
<table class="amp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>结算日</th><th>窗起点</th><th>开</th><th>高</th><th>低</th><th>收</th>
|
||||
<th>开→高</th><th>开→低</th><th>振幅</th><th>涨跌</th><th>收益</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="amp-table-body">
|
||||
<tr><td colspan="11" class="amp-empty">点击「计算」加载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="amp-pager" class="amp-pager"></div>
|
||||
</section>
|
||||
<section id="amp-panel-history" class="card amp-panel hidden">
|
||||
<div id="amp-history-list" class="amp-history-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="page-help" class="page hidden">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
@@ -1115,7 +1336,7 @@
|
||||
</div>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-account-pnl" checked />
|
||||
监控区显示资金账户,交易账户与浮动盈亏
|
||||
监控区/数据看板显示资金账户、交易账户与浮动盈亏(关闭可隐藏期权盈亏与总浮盈)
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-funds" checked />
|
||||
@@ -1145,10 +1366,18 @@
|
||||
<input type="checkbox" id="pref-show-nav-calculator" checked />
|
||||
顶栏显示「计算器」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-compare" checked />
|
||||
顶栏显示「策略对比」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-strategy" checked />
|
||||
顶栏显示「策略说明」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-amp-stats" checked />
|
||||
顶栏显示「振幅统计」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-help" checked />
|
||||
顶栏显示「使用说明」
|
||||
@@ -1157,6 +1386,50 @@
|
||||
<input type="checkbox" id="pref-show-nav-logs" checked />
|
||||
顶栏显示「系统日志」
|
||||
</label>
|
||||
<p class="settings-display-subtitle">监控区卡片</p>
|
||||
<p class="settings-display-hint">仅隐藏监控区界面卡片,不关闭账户与后台拉取.例:只做 OKX 期权与 Gate 时可关掉币安与 OKX 永续.</p>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-monitor-binance" checked />
|
||||
监控区显示「币安」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-monitor-okx-perp" checked />
|
||||
监控区显示「OKX 永续」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-monitor-okx-options" checked />
|
||||
监控区显示「OKX 期权」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-monitor-gate" checked />
|
||||
监控区显示「Gate」
|
||||
</label>
|
||||
<p class="settings-display-subtitle">策略说明页签</p>
|
||||
<p class="settings-display-hint">关闭后该页签从策略说明中消失;顶栏「策略说明」入口仍由上方导航开关控制.</p>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-strategy-playbook-v2" checked />
|
||||
策略说明显示「执行手册v2」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-strategy-playbook" checked />
|
||||
策略说明显示「执行手册v1」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-strategy-behavior" checked />
|
||||
策略说明显示「行为准则」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-strategy-binance" checked />
|
||||
策略说明显示「币安」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-strategy-okx" checked />
|
||||
策略说明显示「OKX」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-strategy-gate" checked />
|
||||
策略说明显示「Gate」
|
||||
</label>
|
||||
<p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效.关闭导航后对应页面将不可从顶栏进入,直接访问 URL 会跳回监控区.</p>
|
||||
</section>
|
||||
|
||||
@@ -1343,6 +1616,8 @@
|
||||
<a href="/quotes" id="m-nav-quotes">语录</a>
|
||||
<a href="/dashboard" id="m-nav-dashboard">数据看板</a>
|
||||
<a href="/strategy" id="m-nav-strategy">策略说明</a>
|
||||
<a href="/amp-stats" id="m-nav-amp-stats">振幅统计</a>
|
||||
<a href="/compare" id="m-nav-compare">策略对比</a>
|
||||
<a href="/help" id="m-nav-help">使用说明</a>
|
||||
<a href="/logs" id="m-nav-logs">系统日志</a>
|
||||
<a href="/settings" id="m-nav-settings">系统设置</a>
|
||||
@@ -1400,19 +1675,21 @@
|
||||
<script src="/assets/chart.js?v=20260720-option-day-1600"></script>
|
||||
<script src="/assets/plan.js?v=20260720-autofill"></script>
|
||||
<script src="/assets/calculator.js?v=20260715-calc-tabs"></script>
|
||||
<script src="/assets/compare.js?v=20260723-compare"></script>
|
||||
<script src="/assets/trade_stats_calendar.js?v=3"></script>
|
||||
<script src="/assets/archive.js?v=20260717-archive-cal-chart"></script>
|
||||
<script src="/assets/archive.js?v=20260724-opt-archive"></script>
|
||||
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
|
||||
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
|
||||
<script src="/assets/dashboard.js?v=20260720-dash-sl-tp"></script>
|
||||
<script src="/assets/strategy.js?v=9"></script>
|
||||
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
|
||||
<script src="/assets/strategy.js?v=11"></script>
|
||||
<script src="/assets/amp_stats.js?v=5"></script>
|
||||
<script src="/assets/help.js?v=1"></script>
|
||||
<script src="/assets/logs.js?v=1"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
<script src="/assets/time_close_ui.js?v=3"></script>
|
||||
<script src="/assets/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/assets/options_position_cards.js?v=2"></script>
|
||||
<script src="/assets/options_position_cards.js?v=3"></script>
|
||||
<script src="/assets/backup.js?v=1"></script>
|
||||
<script src="/assets/app.js?v=20260720-dash-back"></script>
|
||||
<script src="/assets/app.js?v=20260724-display-hide"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
const btnPrintChecklistInline = document.getElementById("strategy-btn-print-checklist-inline");
|
||||
const btnDownload = document.getElementById("strategy-btn-download");
|
||||
|
||||
let activeKey = "playbook";
|
||||
let activeKey = "playbook_v2";
|
||||
let activeView = "doc";
|
||||
let tabsMeta = [];
|
||||
let cache = {};
|
||||
@@ -74,6 +74,14 @@
|
||||
|
||||
function renderExchangeTabs() {
|
||||
if (!tabsEl) return;
|
||||
if (!tabsMeta.length) {
|
||||
tabsEl.innerHTML = "";
|
||||
if (statusEl) statusEl.textContent = "当前无可显示的策略页签(可在系统设置·显示与导航中开启)";
|
||||
if (docBody) docBody.innerHTML = "";
|
||||
if (docToc) docToc.innerHTML = "";
|
||||
if (checklistBody) checklistBody.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
tabsEl.innerHTML = tabsMeta
|
||||
.map(
|
||||
(t) =>
|
||||
@@ -274,6 +282,15 @@
|
||||
renderExchangeTabs();
|
||||
}
|
||||
|
||||
async function reloadMeta() {
|
||||
try {
|
||||
await loadMeta();
|
||||
if (tabsMeta.length) await loadExchange(activeKey);
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function printSection(mode) {
|
||||
const part = mode === "checklist" ? "checklist" : "doc";
|
||||
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
|
||||
@@ -334,7 +351,8 @@
|
||||
setView(activeView);
|
||||
try {
|
||||
await loadMeta();
|
||||
await loadExchange(activeKey);
|
||||
if (tabsMeta.length) await loadExchange(activeKey);
|
||||
else if (statusEl) statusEl.textContent = "当前无可显示的策略页签(可在系统设置·显示与导航中开启)";
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = String(e);
|
||||
}
|
||||
@@ -347,5 +365,5 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.hubStrategyPage = { init, destroy };
|
||||
window.hubStrategyPage = { init, destroy, reloadMeta };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""振幅统计核心逻辑单元测试(不打交易所)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.hub.amp_stats_lib import (
|
||||
build_export_csv,
|
||||
compute_amp_stats,
|
||||
compute_day_row,
|
||||
list_settlement_dates,
|
||||
summarize_rows,
|
||||
window_bounds_for_settlement,
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _bar(ts_ms: int, o: float, h: float, l: float, c: float) -> dict:
|
||||
return {"ts": ts_ms, "o": o, "h": h, "l": l, "c": c}
|
||||
|
||||
|
||||
class AmpStatsLibTests(unittest.TestCase):
|
||||
def test_window_cross_day_22_to_16(self):
|
||||
start, end = window_bounds_for_settlement(date(2026, 7, 22), 22)
|
||||
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-21 22:00")
|
||||
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-22 16:00")
|
||||
|
||||
def test_window_same_day_8_to_16(self):
|
||||
start, end = window_bounds_for_settlement(date(2026, 7, 22), 8)
|
||||
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-22 08:00")
|
||||
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-22 16:00")
|
||||
|
||||
def test_settlement_excludes_incomplete_today(self):
|
||||
now = datetime(2026, 7, 22, 10, 0, tzinfo=TZ)
|
||||
days = list_settlement_dates(sample_days=3, now=now)
|
||||
self.assertEqual(days[0].isoformat(), "2026-07-21")
|
||||
self.assertEqual(len(days), 3)
|
||||
|
||||
def test_settlement_includes_today_after_1600(self):
|
||||
now = datetime(2026, 7, 22, 16, 0, tzinfo=TZ)
|
||||
days = list_settlement_dates(sample_days=1, now=now)
|
||||
self.assertEqual(days[0].isoformat(), "2026-07-22")
|
||||
|
||||
def test_day_row_points(self):
|
||||
# 22:00 D-1 → 16:00 D; O=2000 H=2500 L=1800 C=2100 → up500 down200 amp700
|
||||
settlement = date(2026, 7, 22)
|
||||
start, end = window_bounds_for_settlement(settlement, 22)
|
||||
bar_map = {}
|
||||
t = int(start.timestamp() * 1000)
|
||||
last = int((end.replace(hour=15)).timestamp() * 1000)
|
||||
# first bar
|
||||
bar_map[t] = {"o": 2000.0, "h": 2100.0, "l": 1950.0, "c": 2050.0}
|
||||
cur = t + 3600 * 1000
|
||||
while cur < last:
|
||||
bar_map[cur] = {"o": 2050.0, "h": 2200.0, "l": 1900.0, "c": 2100.0}
|
||||
cur += 3600 * 1000
|
||||
# peak and trough somewhere
|
||||
mid = t + 5 * 3600 * 1000
|
||||
bar_map[mid] = {"o": 2100.0, "h": 2500.0, "l": 1800.0, "c": 2000.0}
|
||||
bar_map[last] = {"o": 2000.0, "h": 2150.0, "l": 1990.0, "c": 2100.0}
|
||||
# fill any missing hours with flat
|
||||
cur = t
|
||||
while cur <= last:
|
||||
if cur not in bar_map:
|
||||
bar_map[cur] = {"o": 2000.0, "h": 2000.0, "l": 2000.0, "c": 2000.0}
|
||||
cur += 3600 * 1000
|
||||
row = compute_day_row(settlement, 22, bar_map)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["open"], 2000.0)
|
||||
self.assertEqual(row["high"], 2500.0)
|
||||
self.assertEqual(row["low"], 1800.0)
|
||||
self.assertEqual(row["up_points"], 500.0)
|
||||
self.assertEqual(row["down_points"], 200.0)
|
||||
self.assertEqual(row["amplitude"], 700.0)
|
||||
self.assertEqual(row["change"], 100.0)
|
||||
|
||||
def test_summary_max_amplitude(self):
|
||||
rows = [
|
||||
{"amplitude": 100, "up_points": 40, "down_points": 60, "change": 10, "settlement_day": "2026-07-01"},
|
||||
{"amplitude": 700, "up_points": 500, "down_points": 200, "change": -5, "settlement_day": "2026-07-02"},
|
||||
{"amplitude": 200, "up_points": 50, "down_points": 150, "change": 20, "settlement_day": "2026-07-03"},
|
||||
]
|
||||
s = summarize_rows(rows)
|
||||
self.assertEqual(s["max_amplitude"], 700)
|
||||
self.assertEqual(s["max_amplitude_day"], "2026-07-02")
|
||||
self.assertEqual(s["max_up_points"], 500)
|
||||
self.assertEqual(s["max_down_points"], 200)
|
||||
self.assertIsNone(s["straddle"])
|
||||
|
||||
def test_long_straddle_stats(self):
|
||||
rows = [
|
||||
# |chg|=40>30 win+10; up=40>30; down=10
|
||||
{"up_points": 40, "down_points": 10, "change": 40, "amplitude": 50, "settlement_day": "2026-07-01"},
|
||||
# |chg|=10 lose-20; up=5; down=35>30
|
||||
{"up_points": 5, "down_points": 35, "change": -10, "amplitude": 40, "settlement_day": "2026-07-02"},
|
||||
# |chg|=30 not >30 lose-30; boundary
|
||||
{"up_points": 30, "down_points": 30, "change": 30, "amplitude": 60, "settlement_day": "2026-07-03"},
|
||||
]
|
||||
s = summarize_rows(rows, straddle_premium=30)
|
||||
st = s["straddle"]
|
||||
self.assertEqual(st["side"], "long_straddle")
|
||||
self.assertEqual(st["premium"], 30)
|
||||
self.assertEqual(st["up_exceed_days"], 1) # only 40
|
||||
self.assertEqual(st["down_exceed_days"], 1) # only 35
|
||||
self.assertEqual(st["abs_change_exceed_days"], 1) # only 40
|
||||
self.assertAlmostEqual(st["pnl_total"], 40 - 30 + 10 - 30 + 30 - 30)
|
||||
self.assertEqual(st["win_days"], 1)
|
||||
self.assertEqual(st["win_ratio"], round(1 / 3, 4))
|
||||
csv_text = build_export_csv(
|
||||
{"exchange": "okx", "symbol_label": "ETH", "summary": s, "rows": rows, "start_hour": 22, "end_hour": 16}
|
||||
)
|
||||
self.assertIn("买跨对照", csv_text)
|
||||
self.assertIn("买跨点数盈亏合计", csv_text)
|
||||
|
||||
def test_take_profit_and_weekend(self):
|
||||
from lib.hub.amp_stats_lib import (
|
||||
enrich_rows_pnl,
|
||||
filter_weekend_rows,
|
||||
reframe_amp_stats,
|
||||
)
|
||||
|
||||
# Sat 2026-07-18, Sun 2026-07-19, Mon 2026-07-20
|
||||
rows = [
|
||||
{
|
||||
"settlement_day": "2026-07-18",
|
||||
"is_weekend": True,
|
||||
"weekday_label": "六",
|
||||
"up_points": 100,
|
||||
"down_points": 10,
|
||||
"change": -5,
|
||||
"amplitude": 110,
|
||||
},
|
||||
{
|
||||
"settlement_day": "2026-07-19",
|
||||
"is_weekend": True,
|
||||
"weekday_label": "日",
|
||||
"up_points": 20,
|
||||
"down_points": 15,
|
||||
"change": 12,
|
||||
"amplitude": 35,
|
||||
},
|
||||
{
|
||||
"settlement_day": "2026-07-20",
|
||||
"is_weekend": False,
|
||||
"weekday_label": "",
|
||||
"up_points": 50,
|
||||
"down_points": 40,
|
||||
"change": 8,
|
||||
"amplitude": 90,
|
||||
},
|
||||
]
|
||||
excl = filter_weekend_rows(rows, "exclude")
|
||||
self.assertEqual(len(excl), 1)
|
||||
self.assertEqual(excl[0]["settlement_day"], "2026-07-20")
|
||||
only = filter_weekend_rows(rows, "only")
|
||||
self.assertEqual(len(only), 2)
|
||||
|
||||
# TP=80: day1 hit → move 80; day2 no → |12|; day3 no → 8
|
||||
enriched = enrich_rows_pnl(rows, straddle_premium=10, take_profit=80)
|
||||
self.assertTrue(enriched[0]["take_profit_hit"])
|
||||
self.assertEqual(enriched[0]["effective_move"], 80)
|
||||
self.assertEqual(enriched[0]["profit"], 70)
|
||||
self.assertFalse(enriched[1]["take_profit_hit"])
|
||||
self.assertEqual(enriched[1]["effective_move"], 12)
|
||||
self.assertEqual(enriched[1]["profit"], 2)
|
||||
# TP empty → use |change|
|
||||
no_tp = enrich_rows_pnl(rows[:1], straddle_premium=10, take_profit=None)
|
||||
self.assertEqual(no_tp[0]["effective_move"], 5)
|
||||
self.assertEqual(no_tp[0]["profit"], -5)
|
||||
|
||||
# TP boundary >= : up=80 counts as hit
|
||||
edge = enrich_rows_pnl(
|
||||
[{"up_points": 80, "down_points": 1, "change": 2, "settlement_day": "2026-07-20", "is_weekend": False}],
|
||||
straddle_premium=10,
|
||||
take_profit=80,
|
||||
)
|
||||
self.assertTrue(edge[0]["take_profit_hit"])
|
||||
self.assertEqual(edge[0]["profit"], 70)
|
||||
|
||||
reframed = reframe_amp_stats(
|
||||
rows_all=rows,
|
||||
symbol="eth",
|
||||
weekend_filter="exclude",
|
||||
straddle_premium=10,
|
||||
take_profit=80,
|
||||
)
|
||||
self.assertEqual(reframed["summary"]["sample_count"], 1)
|
||||
# Mon: 未触达止盈 → |8|-10
|
||||
self.assertEqual(reframed["rows"][0]["profit"], -2)
|
||||
self.assertIn("收益", build_export_csv(reframed))
|
||||
|
||||
def test_fetch_switches_to_history_endpoint(self):
|
||||
"""近期接口到头后应切 history 续拉."""
|
||||
from lib.hub.amp_stats_lib import fetch_okx_candles
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, data, url="https://x", status_code=200):
|
||||
self._data = data
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.request = httpx.Request("GET", url)
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise httpx.HTTPStatusError(
|
||||
"err", request=self.request, response=self
|
||||
)
|
||||
|
||||
def json(self):
|
||||
return {"code": "0", "data": self._data}
|
||||
|
||||
class FakeClient:
|
||||
def get(self, url, params=None):
|
||||
calls.append(url)
|
||||
after = (params or {}).get("after")
|
||||
# recent: only 2 pages then empty; history continues
|
||||
if "history" not in url:
|
||||
if after is None:
|
||||
return FakeResp([["2000", "1", "2", "0.5", "1.5"], ["1900", "1", "2", "0.5", "1.5"]], url=url)
|
||||
if after == "1900":
|
||||
return FakeResp([], url=url) # recent exhausted
|
||||
return FakeResp([], url=url)
|
||||
# history
|
||||
if after == "1900":
|
||||
return FakeResp([["1800", "1", "2", "0.5", "1.5"], ["1000", "1", "2", "0.5", "1.5"]], url=url)
|
||||
return FakeResp([], url=url)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
bars = fetch_okx_candles(
|
||||
url="https://www.okx.com/api/v5/market/index-candles",
|
||||
history_url="https://www.okx.com/api/v5/market/history-index-candles",
|
||||
inst_id="ETH-USD",
|
||||
since_ms=1000,
|
||||
until_ms=3000,
|
||||
client=FakeClient(),
|
||||
max_pages=10,
|
||||
page_pause_sec=0,
|
||||
history_page_pause_sec=0,
|
||||
)
|
||||
self.assertTrue(any("history-index-candles" in u for u in calls))
|
||||
self.assertGreaterEqual(len(bars), 3)
|
||||
self.assertEqual(bars[0]["ts"], 1000)
|
||||
|
||||
def test_fetch_retries_on_429(self):
|
||||
from lib.hub.amp_stats_lib import fetch_okx_candles
|
||||
import httpx as _httpx
|
||||
|
||||
hits = {"n": 0}
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, status_code, data=None):
|
||||
self.status_code = status_code
|
||||
self.url = "https://www.okx.com/api/v5/market/history-candles"
|
||||
self.request = _httpx.Request("GET", self.url)
|
||||
self._data = data or []
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise _httpx.HTTPStatusError("429", request=self.request, response=self)
|
||||
|
||||
def json(self):
|
||||
return {"code": "0", "data": self._data}
|
||||
|
||||
class FakeClient:
|
||||
def get(self, url, params=None):
|
||||
hits["n"] += 1
|
||||
if hits["n"] < 3:
|
||||
return FakeResp(429)
|
||||
return FakeResp(200, [["1000", "1", "2", "0.5", "1.5"]])
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
bars = fetch_okx_candles(
|
||||
url="https://www.okx.com/api/v5/market/candles",
|
||||
history_url=None,
|
||||
inst_id="ETH-USDT-SWAP",
|
||||
since_ms=1000,
|
||||
until_ms=2000,
|
||||
client=FakeClient(),
|
||||
max_pages=3,
|
||||
page_pause_sec=0,
|
||||
history_page_pause_sec=0,
|
||||
)
|
||||
self.assertGreaterEqual(hits["n"], 3)
|
||||
self.assertEqual(len(bars), 1)
|
||||
|
||||
def test_compute_with_mock_fetch(self):
|
||||
now = datetime(2026, 7, 22, 18, 0, tzinfo=TZ)
|
||||
|
||||
def fetch_fn(*, inst_id, since_ms, until_ms):
|
||||
bars = []
|
||||
t = since_ms - (since_ms % (3600 * 1000))
|
||||
while t <= until_ms:
|
||||
# synthetic: open 2000, one spike day
|
||||
o = 2000.0
|
||||
h = 2500.0 if t == since_ms + 5 * 3600 * 1000 else 2050.0
|
||||
l = 1800.0 if t == since_ms + 5 * 3600 * 1000 else 1950.0
|
||||
c = 2020.0
|
||||
bars.append(_bar(t, o, h, l, c))
|
||||
t += 3600 * 1000
|
||||
return bars
|
||||
|
||||
result = compute_amp_stats(
|
||||
symbol="eth",
|
||||
start_hour=16,
|
||||
period="custom",
|
||||
custom_days=7,
|
||||
now=now,
|
||||
fetch_fn=fetch_fn,
|
||||
)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["exchange"], "okx")
|
||||
self.assertGreaterEqual(result["summary"]["sample_count"], 1)
|
||||
csv_text = build_export_csv(result)
|
||||
self.assertIn("最大振幅", csv_text)
|
||||
self.assertIn("日表明细", csv_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""策略对比仓位与情景测算."""
|
||||
from __future__ import annotations
|
||||
|
||||
from lib.hub.hub_compare_lib import run_compare
|
||||
|
||||
|
||||
def test_long_eth_realistic_asks():
|
||||
out = run_compare(
|
||||
{
|
||||
"base": "ETH",
|
||||
"direction": "long",
|
||||
"entry": 3500,
|
||||
"sl": 3400,
|
||||
"tp": 3700,
|
||||
"risk_u": 10,
|
||||
"option": {"opt_type": "C", "strike": 3600, "ask": 50},
|
||||
"hedge": {
|
||||
"main": {"opt_type": "C", "strike": 3600, "ask": 50},
|
||||
"side": {"opt_type": "P", "strike": 3400, "ask": 30},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert out["ok"] is True
|
||||
perp = out["perp"]
|
||||
# 每张止损 = 100 * 0.01 = 1U → 10 张
|
||||
assert perp["sheets"] == 10
|
||||
assert abs(perp["path_b_sl"] + 10) < 1e-6
|
||||
assert perp["path_a_tp"] > 0
|
||||
assert perp["path_c_realized"] == perp["path_b_sl"]
|
||||
assert perp["path_c_missed"] == perp["path_a_tp"]
|
||||
|
||||
opt = out["option"]
|
||||
assert opt["ok"] is True
|
||||
# unit = 50 * 0.01 = 0.5U → 20 张, premium = 10
|
||||
assert opt["sheets"] == 20
|
||||
assert abs(opt["premium_u"] - 10) < 1e-6
|
||||
assert abs(opt["path_b_worst"] + 10) < 1e-6
|
||||
# at TP 3700, call 3600 intrinsic = 100 * 20 * 0.01 = 20, pnl = 20-10 = 10
|
||||
assert abs(opt["path_a_tp"] - 10) < 1e-6
|
||||
assert abs(opt["path_c_hold_to_tp"] - opt["path_a_tp"]) < 1e-6
|
||||
|
||||
hedge = out["hedge"]
|
||||
assert hedge["ok"] is True
|
||||
# main budget 7, unit 0.5 → 14 sheets; side budget 3, unit 0.3 → 10 sheets
|
||||
assert hedge["main"]["sheets"] == 14
|
||||
assert hedge["side"]["sheets"] == 10
|
||||
assert out["recommend"]["choice"] in ("合约", "单期权", "期期对冲")
|
||||
|
||||
|
||||
def test_short_validation():
|
||||
bad = run_compare(
|
||||
{
|
||||
"base": "ETH",
|
||||
"direction": "short",
|
||||
"entry": 3500,
|
||||
"sl": 3400,
|
||||
"tp": 3300,
|
||||
"risk_u": 10,
|
||||
}
|
||||
)
|
||||
assert bad["ok"] is False
|
||||
|
||||
|
||||
def test_recommend_has_bullets():
|
||||
out = run_compare(
|
||||
{
|
||||
"base": "ETH",
|
||||
"direction": "long",
|
||||
"entry": 3500,
|
||||
"sl": 3490,
|
||||
"tp": 3520,
|
||||
"risk_u": 10,
|
||||
"option": {"opt_type": "C", "strike": 3500, "ask": 20},
|
||||
"hedge": {
|
||||
"main": {"opt_type": "C", "strike": 3500, "ask": 20},
|
||||
"side": {"opt_type": "P", "strike": 3480, "ask": 15},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["recommend"]["choice"]
|
||||
assert len(out["recommend"]["bullets"]) == 3
|
||||
@@ -0,0 +1,69 @@
|
||||
"""期权档案缓存 upsert / 列表 / 日历."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hub.hub_options_archive_lib import (
|
||||
init_options_archive_db,
|
||||
list_archive_options_calendar,
|
||||
list_daily_options_trades,
|
||||
upsert_options_trades_cache,
|
||||
)
|
||||
|
||||
|
||||
class TestHubOptionsArchive(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.db = Path(self._td.name) / "hub_symbol_archive.db"
|
||||
init_options_archive_db(self.db)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._td.cleanup()
|
||||
|
||||
def test_upsert_and_list_daily(self) -> None:
|
||||
trades = [
|
||||
{
|
||||
"history_key": "local_opt:1",
|
||||
"source_type": "option_spot",
|
||||
"source_label": "纯期权",
|
||||
"underlying": "ETH",
|
||||
"inst_id": "ETH-USD-250725-3200-C",
|
||||
"opt_type": "C",
|
||||
"opened_at": "2026-07-20 10:00:00",
|
||||
"closed_at": "2026-07-20 16:00:00",
|
||||
"hold_seconds": 21600,
|
||||
"realized_pnl_total": 12.5,
|
||||
"premium_paid": 8.0,
|
||||
"reviewed": True,
|
||||
"strategy_tag": "假突破",
|
||||
},
|
||||
{
|
||||
"history_key": "local_opt:2",
|
||||
"source_type": "option_spot",
|
||||
"underlying": "ETH",
|
||||
"opened_at": "2026-07-19 10:00:00",
|
||||
"closed_at": "2026-07-19 12:00:00",
|
||||
"realized_pnl_total": -3.0,
|
||||
"excluded_as_hedge_leg": 1,
|
||||
},
|
||||
]
|
||||
r = upsert_options_trades_cache("okx", trades, db_path=self.db)
|
||||
self.assertEqual(r["upserted"], 1)
|
||||
payload = list_daily_options_trades(
|
||||
"2026-07-20",
|
||||
period="today",
|
||||
db_path=self.db,
|
||||
)
|
||||
self.assertEqual(len(payload["trades"]), 1)
|
||||
self.assertEqual(payload["trades"][0]["history_key"], "local_opt:1")
|
||||
self.assertAlmostEqual(payload["stats"]["pnl_total"], 12.5)
|
||||
cal = list_archive_options_calendar(2026, 7, db_path=self.db)
|
||||
self.assertIn("2026-07-20", cal["days"])
|
||||
self.assertEqual(cal["days"]["2026-07-20"]["open_count"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -21,6 +21,19 @@ class TestInstanceDisplayPrefs(unittest.TestCase):
|
||||
prefs = normalize_display_prefs({"show_nav_stats": False})
|
||||
self.assertFalse(tab_allowed("stats", prefs))
|
||||
self.assertTrue(tab_allowed("trade", prefs))
|
||||
self.assertTrue(tab_allowed("key_monitor", prefs))
|
||||
|
||||
def test_key_monitor_and_trade_nav_can_hide(self):
|
||||
prefs = normalize_display_prefs(
|
||||
{"show_nav_key_monitor": False, "show_nav_trade": False}
|
||||
)
|
||||
self.assertFalse(tab_allowed("key_monitor", prefs))
|
||||
self.assertFalse(tab_allowed("trade", prefs))
|
||||
on = normalize_display_prefs({})
|
||||
self.assertTrue(on["show_nav_key_monitor"])
|
||||
self.assertTrue(on["show_nav_trade"])
|
||||
self.assertTrue(tab_allowed("key_monitor", on))
|
||||
self.assertTrue(tab_allowed("trade", on))
|
||||
|
||||
def test_dashboard_nav_default_off(self):
|
||||
prefs = normalize_display_prefs({})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""期权开平仓微信文案."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.options.options_notify_lib import (
|
||||
build_options_close_message,
|
||||
build_options_open_message,
|
||||
)
|
||||
|
||||
|
||||
class TestOptionsNotify(unittest.TestCase):
|
||||
def test_open_close_messages(self) -> None:
|
||||
open_msg = build_options_open_message(
|
||||
account_label="OKX期权",
|
||||
inst_id="ETH-USD-250725-3200-C",
|
||||
underlying="ETH",
|
||||
opt_type="C",
|
||||
sheets=2,
|
||||
premium_paid=8.5,
|
||||
open_quote=0.01,
|
||||
target_index=3400,
|
||||
signal_note="假突破",
|
||||
trade_id=12,
|
||||
)
|
||||
self.assertIn("【OKX期权·开仓】", open_msg)
|
||||
self.assertIn("ETH-USD-250725-3200-C", open_msg)
|
||||
self.assertIn("目标指数:3400", open_msg)
|
||||
|
||||
close_msg = build_options_close_message(
|
||||
account_label="OKX期权",
|
||||
inst_id="ETH-USD-250725-3200-C",
|
||||
reason="手动平仓",
|
||||
underlying="ETH",
|
||||
opt_type="C",
|
||||
sheets=2,
|
||||
premium_paid=8.5,
|
||||
premium_received=12.0,
|
||||
realized_pnl=3.5,
|
||||
)
|
||||
self.assertIn("【OKX期权·平仓】", close_msg)
|
||||
self.assertIn("手动平仓", close_msg)
|
||||
self.assertIn("3.5000", close_msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user