13 Commits

Author SHA1 Message Date
dekun 644dcdf092 Rename points-mode label to absolute coin counts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:57:50 +08:00
dekun d89aff3ad6 Use absolute coin counts in perp-options points mode.
Treat 2:4 as 2 perp + 4 option coins instead of normalizing to 1:2, and disable embed page caching so hub iframe picks up trade UI updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:54:16 +08:00
dekun 9f3395de2f Add sideways max-loss to perpetual-options calculator.
Show premium wipeout plus flat round-trip perp fees as case C for both size and points modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:38:20 +08:00
dekun 28a329cb63 Restructure live order form into labeled rows for clearer hierarchy.
Group policy selects, SL/RR fields, options, and submit so the unlabeled RR input and scrambled checkbox/price row no longer fight for space.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:24:17 +08:00
dekun 1c2c012dd7 Align snapshot/20260728-2 hash with tag target.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:41:53 +08:00
dekun 05864d72c2 Fix snapshot tag commit hash in docs table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:40:44 +08:00
dekun 722c511543 Document snapshot/20260728-2 after amp-stats move-points and two-day amp.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:40:43 +08:00
dekun 26bc19f047 Add two-day amplitude window to amp-stats.
For each settlement day, also compute H-L over start minus one day through 16:00 (e.g. 25 16:00 to 27 16:00).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:05:38 +08:00
dekun c81ba147cc Replace amp-stats straddle/perp overlays with move-points amplitude ratio.
Input points now drives amplitude hit share; table keeps both-side moves and amp达标.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:56:48 +08:00
dekun 90be23e845 Fix amp-stats perp PnL to exit at daily profit target.
Hit A/B via open-to-high/low; day PnL equals target when touched, otherwise settle at close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:41:29 +08:00
dekun 2ce67da8e8 Use daily open as perp-hedge entry and toggle buy-straddle vs perp overlays.
Amp-stats now prices premium from each day's open, and the form switches mutually between straddle and perpetual-options对照.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:31:17 +08:00
dekun d049c5d317 Add perpetual-options hedge overlay to amp-stats with hit rates and daily PnL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:20:42 +08:00
dekun 845884fc67 Document perpetual-options hedge calculator and snapshot/20260728.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:08:25 +08:00
20 changed files with 1551 additions and 1162 deletions
+5 -3
View File
@@ -6,10 +6,12 @@
| 标签 | 指向提交 | 说明 |
|------|----------|------|
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/关键位/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
| `snapshot/20260728-2` | `05864d7` | 2026-07-28 午后:振幅统计改为波动点数→振幅占比、两日振幅(例25日16:00→27日16:00);去掉买跨/永期对照 |
| `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 |
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
| `snapshot/20260726-2` | `4a79e01` | 2026-07-26 午:执行手册脑图(业务主题)、`.xmind` 按二进制入库、去掉缩略图避免 Gitea raw 换行损坏 |
| `snapshot/20260726` | `a2075ba` | 2026-07-26:Gate划转币种大写修复、系统设置划转页签停留、自动划转账户/币种下拉默认、期权「按可用余额打满」=min(余额,单笔预算)及说明 |
| `snapshot/20260724` | `890659f` | 2026-07-24:执行手册v2(无对冲)、监控/策略页签显隐、内照明心期权档案同步、期权开平仓微信必发、实例导航显隐关键位/实盘下单等 |
| `snapshot/20260724` | `890659f` | 2026-07-24:执行手册v2(无对冲)、监控/策略页签显隐、内照明心期权档案同步、期权开平仓微信必发、实例导航显隐持仓/实盘下单等 |
| `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 执行手册文档等 |
@@ -32,7 +34,7 @@
git tag -l 'snapshot/*'
# 检出快照(只读查看,勿在此分支直接开发)
git checkout snapshot/20260727
git checkout snapshot/20260728-2
# 回到主线
git checkout main
+25 -23
View File
@@ -3,7 +3,8 @@
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
> **不改下单链路**;不算 IV / 权利金
> **不改下单链路**;不算 IV。
> 买跨 / 永期对冲测算请用中控 **策略计算器**,本页不再做对照盈亏。
---
@@ -21,8 +22,9 @@
2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
3. **起点整点**0023);终点固定 **16:00**
4. **周期**1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
5. **计算** → 下方看汇总 + 分页日表
6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
5. 可选填 **波动点数**(如 `50`)→ 看振幅达标占比
6. **计算** → 下方看汇总 + 振幅占比 + 分页日表
7. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
**跨天例子**
@@ -42,14 +44,15 @@
| 字段 | 算法 |
|------|------|
| 开→高 | `H O` |
| 开→低 | `O L` |
| **振幅** | `H L`= 开→高 + 开→低) |
| 涨跌值 | `C O` |
| 开→高 | `H O`(一边波动) |
| 开→低 | `O L`(另一边波动) |
| **振幅** | `H L`= 开→高 + 开→低),窗为起点整点 → 当日 16:00 |
| **两日振幅** | 同上口径,但起点再往前推 1 天;例起点 16:00、结算 27 日 → **25日16:00 → 27日16:00** |
| 涨跌值 | `C O`(单日窗) |
例:O=2000H=2500L=1800 → 开→高 500,开→低 200,振幅 **700**
汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。
汇总必含:最大振幅(及日期)、两日振幅最大/均值/中位、开→高/开→低的最大与均值等。
K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。
近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`
@@ -57,22 +60,20 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
---
## 买跨对照(赌波动)
## 波动点数 → 振幅占比
表单可填 **双边权利金(点)**,例`30`;旁边可填 **止盈点**(可空)
表单可填 **波动点数**`50`)。填写后下方 **振幅占比** 块显示
| 汇总项 | 口径 |
|--------|------|
| 开→高超过权利金 | `HO > 权利金` 的天数与占比 |
| 开→低超过权利金 | `OL > 权利金` 的天数与占比 |
| \|涨跌\|超过权利金 | `\|CO\| > 权利金` 天数与占比 |
| 有效波动 | 若设止盈且 `开→高≥止盈``开→低≥止盈` → 用止盈点;否则用 `\|CO\|` |
| 买跨收益 | `有效波动 权利金`(日表「收益」列同口径) |
| 振幅≥点数 | 单日窗 `HL ≥ 点数` 的天数与**占比**(主指标) |
| 两日振幅≥点数 | 两日窗振幅 ≥ 点数 的天数与占比 |
| 开→高≥点数 | `HO ≥ 点数` 天数与占比 |
| 开→低≥点数 | `OL ≥ 点数` 天数与占比 |
| \|涨跌\|≥点数 | `\|CO\| ≥ 点数` 天数与占比 |
- 方向:**买跨**
- 权利金越过:严格 **`>`**;止盈触达:**`≥`**
- 止盈留空 / ≤0:有效波动一律按 `|涨跌|`
- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线)
日表保留 **开→高 / 开→低**、**振幅**、**两日振幅**(悬停可见两日窗起止),并标 **振幅达标**
改点数 / 周末筛选会在已有日表上**本地重算**(不重拉 K 线)。
### 周末
@@ -97,7 +98,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
| `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` | 单元测试 |
| `tests/test_amp_stats_lib.py` | 单 |
---
@@ -106,6 +107,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
| 日期 | 说明 |
|------|------|
| 2026-07-23 | 首版上线说明 |
| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 |
| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 |
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 |
| 2026-07-23 | 买跨对照、周末筛选、止盈点 |
| 2026-07-28 | 永期对冲对照(后已移除) |
| 2026-07-28 | 去掉买跨/永期;改为波动点数→振幅占比 |
| 2026-07-28 | 增加两日振幅(例 25日16:00→27日16:00) |
+195
View File
@@ -0,0 +1,195 @@
# 永期对冲计算器
中控 **策略计算器** 第 3 个 tab:永期对冲。用于离线测算「永续 1 币 + 买方期权」在目标盈利口径下的期权仓位,或按永续:期权比例反推达目标所需波动点数。
入口:中控 → 策略计算器 → **永期对冲**
API`POST /api/calculator/perp-options`
逻辑库:`lib/hub/hub_perp_options_calc_lib.py`
单测:`tests/test_hub_perp_options_calc_lib.py`
与实例页「对冲计划」不同:本页**不实盘下单、不拉期权链**,价格与杠杆均为手填。
---
## 共同假设
| 项 | 口径 |
|----|------|
| 品种 | BTC / ETH |
| 永续仓位 | 固定 **1 币** |
| 单币权利金 | `现价 / 期权杠杆`(例:1800÷100=18U |
| 权利金 | **按全亏**计入;忽略时间价值 / Theta |
| 永续手续费 | 开+平各 `0.05%``PERP_TAKER_FEE_RATE`,默认 0.0005 |
| 期权手续费 | **不算** |
| 交易资金 | 仅参考:与 `现价/永续杠杆` 比保证金是否够开 |
| `ct_mult` | 默认 0.01;张数 = 期权币数 / ct_mult |
| 展示 | 金额与点数统一 **小数点后两位** |
---
## 模式一:由波动推期权仓位(`calc_mode=size`
已知波动(点数或波动率%)、目标盈利、期权杠杆 → 反推期权开多少币/张。
### 公式
```text
单币权利金 = 现价 / 期权杠杆
永续毛收益 = 波动点数 × 1
(波动率模式:现价 × 波动率% × 1)
平仓价 ≈ 现价 + 波动点数(永续方向对按上涨测算)
永续手续费 = (开仓名义 + 平仓名义) × 0.05%
权利金预算 = 永续毛收益 − 目标盈利 − 永续手续费
期权币数 = 权利金预算 / 单币权利金
期权张数 = 期权币数 / ct_mult
```
若权利金预算 ≤ 0:提示「波动收益不足以覆盖目标盈利+手续费,无法开期权」。
### 情景
**A · 永续方向对(期权全亏)**
```text
净利 = 永续毛收益 − 权利金总额 − 永续手续费
(设计上 ≈ 目标盈利)
```
**B · 期权方向对(永续 1 币反向亏同等波动)**
```text
期权内在 = 期权币数 × 波动点数
期权净利 = 期权内在 − 权利金总额
永续亏损 = −永续毛收益
组合净利 = 期权净利 + 永续亏损
```
**C · 横盘(最大亏损)**
波动≈0、期权到期无内在价值:
```text
永续盈亏 ≈ 0
永续开平手续费 = 2 × 现价 × 1 × 0.05% (同价开平)
最大亏损 = 权利金总额 + 永续开平手续费
组合净利 = −最大亏损
```
忽略资金费 / Theta 过程中的中间态;口径与「权利金按全亏」一致。
### 手测示例
现价 1800、波动 50 点、目标盈利 15、期权杠杆 100、永续杠杆 10:
| 量 | 约值 |
|----|------|
| 单币权利金 | 18U |
| 永续手续费 | 1.83U |
| 权利金预算 | 33.18U |
| 期权币数 / 张数 | ≈1.84 币 / ≈184 张 |
| A 净利 | ≈15U |
| B 期权净利 / 组合 | ≈59U / ≈9U |
---
## 模式二:由币数推波动点数(`calc_mode=points`
已知永续币数 / 期权币数(如 **1:2****2:4**)、目标盈利、期权杠杆 → 反推两套情景要涨/跌多少点才能达到目标。
**按绝对币数**,不再把输入归一到「永续 1 币」。填 2 与 4 → 永续 2 币 + 期权 4 币(权利金、保证金、手续费均按 2 倍于 1:2 放大;达同一目标盈利所需点数会变小)。
### 仓位
```text
永续币数 = 输入的永续币数
期权币数 = 输入的期权币数
权利金总额 = 期权币数 × (现价 / 期权杠杆)
永续保证金 = 现价 × 永续币数 / 永续杠杆
```
### 情景 A · 永续方向对
净利 = 目标盈利:
```text
qty×move 权利金 fee(move,qty) = 目标
fee = (2×现价 + move) × qty × 0.05%
move = (目标 + 权利金 + 2×现价×qty×0.05%) / (qty × (1 0.05%))
```
### 情景 B · 期权方向对(以组合净利为准)
组合净利 = 目标盈利:
```text
组合 = 期权币数×move − 权利金 − 永续币数×move
= move×(期权币数 − 永续币数) − 权利金
move = (目标 + 权利金) / (期权币数 − 永续币数)
```
要求期权币数 > 永续币数;若相等,组合恒为 −权利金,无法解出正目标。
结果区展示:所需波动点数(及折合%)、组合净利、其中期权净利、其中永续盈亏。
### 手测示例
现价 1800、目标 15、期权杠杆 100、币数 1:2 → 权利金总额 36U:
| 情景 | 所需点数(约) |
|------|----------------|
| A 永续方向对(净利=15 | ≈52.83 |
| B 组合净利=15 | 51.00 |
| C 横盘最大亏损 | 37.80(权利金 36 + 同价开平费 1.8) |
币数 **2:4**(权利金 72U、保证金 360U):
| 情景 | 约值 |
|------|------|
| 仓位 | 永续 2 币 / 期权 4 币(400 张) |
| A 所需点数 | ≈45.32 |
| B 组合达目标 | 43.50 |
| C 横盘最大亏损 | 75.60 |
---
## API 请求体(摘要)
```json
{
"calc_mode": "size | points",
"base": "ETH",
"spot": 1800,
"capital_usdt": 3000,
"target_profit_u": 15,
"move_mode": "points",
"move_value": 50,
"perp_leverage": 10,
"option_leverage": 100,
"ct_mult": 0.01,
"ratio_perp": 1,
"ratio_opt": 2
}
```
- `size` 模式必填 `move_value``points` 模式用 `ratio_perp` / `ratio_opt`,可不填波动。
---
## 相关文件
| 路径 | 作用 |
|------|------|
| `lib/hub/hub_perp_options_calc_lib.py` | 纯函数测算 |
| `manual_trading_hub/hub.py` | `POST /api/calculator/perp-options` |
| `manual_trading_hub/static/index.html` | 计算器 tab UI |
| `manual_trading_hub/static/calculator.js` | 提交与结果渲染 |
| `lib/trade/trade_fee_lib.py` | 永续双边手续费 |
## 不做
实盘开平仓、拉 OKX 期权链卖一、把本页结果自动写入对冲计划。
振幅统计页可对历史日表做同口径对照,见 [振幅统计说明.md](./振幅统计说明.md)「永期对冲对照」。
+1
View File
@@ -294,6 +294,7 @@
async function fetchTabHtml(tab) {
const r = await fetch(embedPageUrl(tab), {
credentials: "same-origin",
cache: "no-store",
headers: { "X-Instance-Soft-Nav": "1" },
});
const ct = (r.headers.get("content-type") || "").toLowerCase();
+17 -1
View File
@@ -22,8 +22,24 @@
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
/* 实盘下单监控:分层布局 */
.order-monitor-form{display:flex;flex-direction:column;gap:10px;margin-bottom:4px}
.order-monitor-form .om-row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px}
.order-monitor-form .om-row-policy > input:not([type=checkbox]):not([type=radio]),
.order-monitor-form .om-row-policy > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
.order-monitor-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-monitor-form .om-field{display:flex;flex-direction:column;gap:4px;min-width:7.5rem}
.order-monitor-form .om-field-lab{font-size:.72rem;color:#9aa3c7;line-height:1;letter-spacing:.02em}
.order-monitor-form .om-field input{width:9.5rem;max-width:160px;box-sizing:border-box}
.order-monitor-form .om-live-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-bottom:2px;margin-left:auto}
.order-monitor-form .om-row-opts{align-items:center;gap:12px;padding-top:2px}
.order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none}
.order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef}
.order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px}
.order-monitor-form .om-row-action{padding-top:2px}
.order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600}
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-preview-risk{color:#ff6b6b}
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
.order-preview-profit{color:#4cd97f}
+39
View File
@@ -1959,6 +1959,15 @@ html[data-theme="light"] .order-plan-preview {
border-color: #b8c8d8 !important;
}
html[data-theme="light"] .order-monitor-form .om-field-lab {
color: #5a6a82;
}
html[data-theme="light"] .order-monitor-form .om-check,
html[data-theme="light"] .order-monitor-form .om-time-close {
color: #3a4a62;
}
html[data-theme="light"] .order-preview-rr {
color: #4a6078 !important;
}
@@ -5680,6 +5689,36 @@ html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover
max-width: 100%;
}
body.inst-phone .order-monitor-form .om-row {
align-items: stretch;
}
body.inst-phone .order-monitor-form .om-row-policy > input:not([type="checkbox"]):not([type="radio"]),
body.inst-phone .order-monitor-form .om-row-policy > select,
body.inst-phone .order-monitor-form #sltp-mode,
body.inst-phone .order-monitor-form .om-field,
body.inst-phone .order-monitor-form .om-live-meta,
body.inst-phone .order-monitor-form .om-check,
body.inst-phone .order-monitor-form .om-time-close,
body.inst-phone .order-monitor-form .order-entry-model-row,
body.inst-phone .order-monitor-form .om-submit {
flex: 1 1 100% !important;
width: 100% !important;
max-width: 100% !important;
min-width: 0;
box-sizing: border-box;
margin-left: 0;
}
body.inst-phone .order-monitor-form .om-field input {
width: 100% !important;
max-width: 100% !important;
}
body.inst-phone .order-monitor-form .om-live-meta {
padding-bottom: 0;
}
body.inst-phone .order-plan-preview {
flex-direction: column;
align-items: flex-start;
+233 -178
View File
@@ -68,10 +68,20 @@ def resolve_sample_days(period: str, custom_days: Any = None) -> int:
return PERIOD_DAYS[p]
def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]:
"""返回 [start, end) 的本地时刻;end 为结算日 16:00."""
def window_bounds_for_settlement(
settlement: date,
start_hour: int,
*,
span_days: int = 1,
) -> tuple[datetime, datetime]:
"""返回 [start, end) 的本地时刻;end 为结算日 16:00.
span_days=1: 与现口径相同( 26日16:0027日16:00)
span_days=2: 再往前推 1 ( 25日16:0027日16:00)
"""
if not (0 <= int(start_hour) <= 23):
raise ValueError("起点须为 0-23 整点")
span = max(1, int(span_days or 1))
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
sh = int(start_hour)
if sh >= END_HOUR:
@@ -79,6 +89,8 @@ def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[dat
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)
if span > 1:
start = start - timedelta(days=span - 1)
return start, end
@@ -127,12 +139,12 @@ def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
return m
def compute_day_row(
settlement: date,
start_hour: int,
def _ohlc_window_metrics(
start: datetime,
end: datetime,
bar_map: dict[int, dict[str, float]],
) -> Optional[dict[str, Any]]:
start, end = window_bounds_for_settlement(settlement, start_hour)
"""在 [start, end) 上算开高低收与开→高/开→低/振幅/涨跌."""
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)
@@ -153,15 +165,9 @@ def compute_day_row(
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),
@@ -173,27 +179,67 @@ def compute_day_row(
}
def normalize_straddle_premium(raw: Any) -> Optional[float]:
"""双边权利金(点数).空/≤0 表示不做跨式对照."""
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, span_days=1)
m1 = _ohlc_window_metrics(start, end, bar_map)
if m1 is None:
return None
start2, end2 = window_bounds_for_settlement(settlement, start_hour, span_days=2)
m2 = _ohlc_window_metrics(start2, end2, bar_map)
wd = settlement.weekday() # Mon=0 … Sun=6
is_we = wd >= 5
row: dict[str, Any] = {
"settlement_day": settlement.isoformat(),
"weekday": wd,
"weekday_label": "" if wd == 5 else ("" if wd == 6 else ""),
"is_weekend": is_we,
**m1,
}
if m2 is None:
row.update(
{
"window2_start": start2.strftime("%Y-%m-%d %H:%M"),
"window2_end": end2.strftime("%Y-%m-%d %H:%M"),
"open_2d": None,
"high_2d": None,
"low_2d": None,
"close_2d": None,
"up_points_2d": None,
"down_points_2d": None,
"amplitude_2d": None,
"change_2d": None,
}
)
else:
row.update(
{
"window2_start": m2["window_start"],
"window2_end": m2["window_end"],
"open_2d": m2["open"],
"high_2d": m2["high"],
"low_2d": m2["low"],
"close_2d": m2["close"],
"up_points_2d": m2["up_points"],
"down_points_2d": m2["down_points"],
"amplitude_2d": m2["amplitude"],
"change_2d": m2["change"],
}
)
return row
def normalize_move_points(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
raise ValueError("波动点数须为数字") from None
if v <= 0:
return None
return v
@@ -231,32 +277,13 @@ def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all")
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"):
def _ensure_weekend_flags(item: dict[str, Any]) -> None:
if "is_weekend" in item:
return
if not item.get("settlement_day"):
item.setdefault("weekday_label", "")
item.setdefault("is_weekend", False)
return
try:
wd = date.fromisoformat(str(item["settlement_day"])).weekday()
item["weekday"] = wd
@@ -265,85 +292,113 @@ def enrich_rows_pnl(
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
def enrich_rows(
rows: list[dict[str, Any]],
*,
move_points: Any = None,
) -> list[dict[str, Any]]:
"""为日表附加周末标注,以及相对波动点数的两边达标."""
mp = normalize_move_points(move_points)
out: list[dict[str, Any]] = []
for r in rows or []:
item = dict(r)
_ensure_weekend_flags(item)
up = float(item.get("up_points") or 0)
down = float(item.get("down_points") or 0)
amp = float(item.get("amplitude") or 0)
hit_up = bool(mp is not None and up >= mp)
hit_down = bool(mp is not None and down >= mp)
amp_hit = bool(mp is not None and amp >= mp)
amp2 = item.get("amplitude_2d")
amp2_v = float(amp2) if amp2 is not None and amp2 != "" else None
amp_hit_2d = bool(mp is not None and amp2_v is not None and amp2_v >= mp)
item["move_points"] = mp
item["hit_up"] = hit_up
item["hit_down"] = hit_down
item["hit_either"] = hit_up or hit_down
item["hit_both"] = hit_up and hit_down
item["amp_hit"] = amp_hit
item["amp_hit_2d"] = amp_hit_2d
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,
# 兼容旧调用名
def enrich_rows_pnl(rows: list[dict[str, Any]], **kwargs: Any) -> list[dict[str, Any]]:
return enrich_rows(rows, move_points=kwargs.get("move_points"))
def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[str, Any]:
"""波动点数达标汇总:开→高/开→低两边."""
mp = float(move_points)
if mp <= 0:
raise ValueError("波动点数须 > 0")
work = enrich_rows(rows, move_points=mp)
n = len(work)
empty = {
"move_points": round(mp, 4),
"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),
"up_hit_days": 0,
"up_hit_ratio": None,
"down_hit_days": 0,
"down_hit_ratio": None,
"either_hit_days": 0,
"either_hit_ratio": None,
"both_hit_days": 0,
"both_hit_ratio": None,
"amp_hit_days": 0,
"amp_hit_ratio": None,
"amp_2d_hit_days": 0,
"amp_2d_hit_ratio": None,
"abs_change_hit_days": 0,
"abs_change_hit_ratio": None,
}
if n <= 0:
return empty
up_hit = sum(1 for r in work if r.get("hit_up"))
down_hit = sum(1 for r in work if r.get("hit_down"))
either = sum(1 for r in work if r.get("hit_either"))
both = sum(1 for r in work if r.get("hit_both"))
amp_hit = sum(1 for r in work if r.get("amp_hit"))
amp2_rows = [r for r in work if r.get("amplitude_2d") is not None]
amp2_hit = sum(1 for r in work if r.get("amp_hit_2d"))
n2 = len(amp2_rows)
abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp)
empty.update(
{
"up_hit_days": up_hit,
"up_hit_ratio": round(up_hit / n, 4),
"down_hit_days": down_hit,
"down_hit_ratio": round(down_hit / n, 4),
"either_hit_days": either,
"either_hit_ratio": round(either / n, 4),
"both_hit_days": both,
"both_hit_ratio": round(both / n, 4),
"amp_hit_days": amp_hit,
"amp_hit_ratio": round(amp_hit / n, 4),
"amp_2d_hit_days": amp2_hit,
"amp_2d_hit_ratio": round(amp2_hit / n2, 4) if n2 else None,
"abs_change_hit_days": abs_hit,
"abs_change_hit_ratio": round(abs_hit / n, 4),
}
)
return empty
def summarize_rows(
rows: list[dict[str, Any]],
*,
straddle_premium: Any = None,
take_profit: Any = None,
move_points: Any = None,
) -> dict[str, Any]:
mp = normalize_move_points(move_points)
empty_2d = {
"max_amplitude_2d": None,
"max_amplitude_2d_day": None,
"avg_amplitude_2d": None,
"median_amplitude_2d": None,
}
if not rows:
out = {
"sample_count": 0,
@@ -357,11 +412,11 @@ def summarize_rows(
"avg_down_points": None,
"up_day_ratio": None,
"down_day_ratio": None,
"straddle": None,
**empty_2d,
"move_points_stats": None,
}
prem = normalize_straddle_premium(straddle_premium)
if prem is not None:
out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit)
if mp is not None:
out["move_points_stats"] = move_points_stats([], mp)
return out
amps = [float(r["amplitude"]) for r in rows]
ups = [float(r["up_points"]) for r in rows]
@@ -371,6 +426,7 @@ def summarize_rows(
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)
amps2 = [float(r["amplitude_2d"]) for r in rows if r.get("amplitude_2d") is not None]
out: dict[str, Any] = {
"sample_count": n,
"max_amplitude": round(max_amp, 4),
@@ -383,11 +439,19 @@ def summarize_rows(
"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,
**empty_2d,
"move_points_stats": None,
}
prem = normalize_straddle_premium(straddle_premium)
if prem is not None:
out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit)
if amps2:
max_a2 = max(amps2)
out["max_amplitude_2d"] = round(max_a2, 4)
out["max_amplitude_2d_day"] = next(
r["settlement_day"] for r in rows if r.get("amplitude_2d") is not None and float(r["amplitude_2d"]) == max_a2
)
out["avg_amplitude_2d"] = round(statistics.fmean(amps2), 4)
out["median_amplitude_2d"] = round(statistics.median(amps2), 4)
if mp is not None:
out["move_points_stats"] = move_points_stats(rows, mp)
return out
@@ -591,8 +655,7 @@ def compute_amp_stats(
start_hour: int = 16,
period: str = "2m",
custom_days: Any = None,
straddle_premium: Any = None,
take_profit: Any = None,
move_points: Any = None,
weekend_filter: Any = "all",
now: Optional[datetime] = None,
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
@@ -601,18 +664,17 @@ def compute_amp_stats(
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)
mp = normalize_move_points(move_points)
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("无可用结算日")
# 最远窗起点
# 最远窗起点(含两日振幅,多拉 1 天)
oldest = settlements[-1]
newest = settlements[0]
start0, _ = window_bounds_for_settlement(oldest, sh)
_, end1 = window_bounds_for_settlement(newest, sh)
start0, _ = window_bounds_for_settlement(oldest, sh, span_days=2)
_, end1 = window_bounds_for_settlement(newest, sh, span_days=1)
since_ms = int(start0.timestamp() * 1000)
until_ms = int(end1.timestamp() * 1000)
bars, price_source, inst_id = fetch_symbol_bars(
@@ -633,8 +695,7 @@ def compute_amp_stats(
start_hour=sh,
period=period,
sample_days=sample_days,
straddle_premium=prem,
take_profit=tp,
move_points=mp,
weekend_filter=we_mode,
price_source=price_source,
inst_id=inst_id,
@@ -649,19 +710,17 @@ def build_amp_result(
start_hour: int,
period: str,
sample_days: int,
straddle_premium: Any = None,
take_profit: Any = None,
move_points: 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)
mp = normalize_move_points(move_points)
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)
rows = enrich_rows(filtered, move_points=mp)
summary = summarize_rows(rows, move_points=mp)
if period == "custom" or str(period).startswith("custom:"):
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
else:
@@ -676,8 +735,7 @@ def build_amp_result(
"end_hour": END_HOUR,
"period": period_label,
"sample_days_requested": sample_days,
"straddle_premium": prem,
"take_profit": tp,
"move_points": mp,
"weekend_filter": we_mode,
"timeframe": TIMEFRAME,
"price_source": price_source,
@@ -698,14 +756,13 @@ def reframe_amp_stats(
start_hour: int = 16,
period: str = "2m",
sample_days: int = 60,
straddle_premium: Any = None,
take_profit: Any = None,
move_points: Any = None,
weekend_filter: Any = "all",
price_source: str = "",
inst_id: str = "",
missing: Optional[list[str]] = None,
) -> dict[str, Any]:
"""已有日表上改周末/权利金/止盈,不拉 K 线."""
"""已有日表上改周末/波动点数,不拉 K 线."""
key = normalize_symbol(symbol)
return build_amp_result(
rows_all=list(rows_all or []),
@@ -713,8 +770,7 @@ def reframe_amp_stats(
start_hour=int(start_hour),
period=period,
sample_days=int(sample_days or 60),
straddle_premium=straddle_premium,
take_profit=take_profit,
move_points=move_points,
weekend_filter=weekend_filter,
price_source=price_source,
inst_id=inst_id,
@@ -754,31 +810,20 @@ def build_export_csv(payload: dict[str, Any]) -> str:
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_amplitude_2d"), "日期", s.get("max_amplitude_2d_day")])
w.writerow(["两日振幅均值", s.get("avg_amplitude_2d"), "中位数", s.get("median_amplitude_2d")])
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:
mp = s.get("move_points_stats") or {}
if mp:
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(["波动点数·振幅占比】", mp.get("move_points")])
w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")])
w.writerow(["两日振幅≥点数天数", mp.get("amp_2d_hit_days"), "占比", mp.get("amp_2d_hit_ratio")])
w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")])
w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")])
w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_hit_ratio")])
w.writerow([])
w.writerow(["【日表明细】"])
w.writerow(
@@ -796,9 +841,14 @@ def build_export_csv(payload: dict[str, Any]) -> str:
"开→低",
"振幅",
"涨跌值",
"有效波动",
"触达止盈",
"收益",
"两日窗起点",
"两日窗终点",
"两日振幅",
"两日开→高",
"两日开→低",
"对照点数",
"振幅达标",
"两日振幅达标",
]
)
for r in payload.get("rows") or []:
@@ -817,9 +867,14 @@ def build_export_csv(payload: dict[str, Any]) -> str:
r.get("down_points"),
r.get("amplitude"),
r.get("change"),
r.get("effective_move"),
"" if r.get("take_profit_hit") else "",
r.get("profit"),
r.get("window2_start"),
r.get("window2_end"),
r.get("amplitude_2d"),
r.get("up_points_2d"),
r.get("down_points_2d"),
r.get("move_points") if r.get("move_points") is not None else "",
"" if r.get("amp_hit") else ("" if r.get("move_points") is not None else ""),
"" if r.get("amp_hit_2d") else ("" if r.get("move_points") is not None and r.get("amplitude_2d") is not None else ""),
]
)
return buf.getvalue()
+67 -21
View File
@@ -63,16 +63,51 @@ def _parse_base_common(
}, None
def _move_for_perp_correct(*, spot: float, target: float, premium: float, fee_rate: float) -> float:
"""净利 = move premium fee(move) = target → 解 move.
def _move_for_perp_correct(
*,
spot: float,
target: float,
premium: float,
fee_rate: float,
perp_coins: float = 1.0,
) -> float:
"""净利 = qty*move premium fee(move,qty) = target → 解 move.
fee = (2*spot + move) * fee_rate
move*(1-fee_rate) = target + premium + 2*spot*fee_rate
fee = (2*spot + move) * qty * fee_rate
qty*move*(1-fee_rate) = target + premium + 2*spot*qty*fee_rate
"""
denom = 1.0 - float(fee_rate)
qty = float(perp_coins)
if qty <= 0:
return 0.0
denom = qty * (1.0 - float(fee_rate))
if denom <= 0:
return 0.0
return (float(target) + float(premium) + 2.0 * float(spot) * float(fee_rate)) / denom
return (float(target) + float(premium) + 2.0 * float(spot) * qty * float(fee_rate)) / denom
def _case_sideways(
*,
spot: float,
premium_total: float,
perp_coins: float = 1.0,
) -> dict[str, Any]:
"""横盘/到期无方向:永续≈0,期权权利金全亏,另计永续开平同价手续费.
最大亏损(正数) = 权利金总额 + 开平手续费(exit=entry)
组合净利 = 最大亏损
"""
qty = float(perp_coins) if float(perp_coins) > 0 else PERP_COINS
fee_flat = estimate_roundtrip_fee_usdt(spot, spot, qty=qty, contract_size=1.0)
prem = float(premium_total)
max_loss = prem + float(fee_flat)
return {
"label": "横盘",
"perp_pnl_u": 0.0,
"premium_u": round(prem, 8),
"fee_u": round(float(fee_flat), 8),
"max_loss_u": round(max_loss, 8),
"net_u": round(-max_loss, 8),
}
def calc_perp_options_hedge(
@@ -186,6 +221,7 @@ def calc_perp_options_hedge(
"perp_pnl_u": round(perp_loss, 8),
"portfolio_net_u": round(portfolio_net, 8),
},
"case_sideways": _case_sideways(spot=s, premium_total=premium_total),
}, None
@@ -201,12 +237,12 @@ def calc_perp_options_points(
ratio_opt: float = 2.0,
ct_mult: float = DEFAULT_CT_MULT,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""按永续:期权比例 + 目标盈利,反推两套情景所需波动点数.
"""按永续/期权币数 + 目标盈利,反推两套情景所需波动点数.
永续币数固定为 ratio 归一后的 1 币侧(perp_coins = PERP_COINS).
期权币数 = PERP_COINS * (ratio_opt / ratio_perp), 1:2 2 .
永续币数 = ratio_perp, 期权币数 = ratio_opt(按绝对币数,不再归一到 1 ).
2:4 永续 2 + 期权 4 ;1:2 永续 1 + 期权 2 .
A 永续方向对: move premium fee(move) = 目标盈利
A 永续方向对: qty*move premium fee(move,qty) = 目标盈利
B 期权方向对:
- 期权净利达目标: opt_coins*move premium = 目标
- 组合净利达目标: move*(opt_coins perp_coins) premium = 目标
@@ -226,7 +262,7 @@ def calc_perp_options_points(
rp = _f(ratio_perp)
ro = _f(ratio_opt)
if rp is None or ro is None or rp <= 0 or ro <= 0:
return None, "永续:期权比例须大于 0"
return None, "永续/期权币数须大于 0"
s = common["spot"]
capital = common["capital"]
@@ -235,28 +271,35 @@ def calc_perp_options_points(
o_lev = common["o_lev"]
ct = common["ct"]
prem_per_coin = common["prem_per_coin"]
margin = common["margin"]
fee_rate = common["fee_rate"]
b = (base or "ETH").strip().upper()
opt_coins = PERP_COINS * (ro / rp)
perp_coins = rp
opt_coins = ro
premium_total = opt_coins * prem_per_coin
opt_sheets = opt_coins / ct
margin = (s * perp_coins) / p_lev
move_a = _move_for_perp_correct(spot=s, target=target, premium=premium_total, fee_rate=fee_rate)
move_a = _move_for_perp_correct(
spot=s,
target=target,
premium=premium_total,
fee_rate=fee_rate,
perp_coins=perp_coins,
)
if move_a <= 0:
return None, "无法解出永续方向对所需点数"
fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=PERP_COINS, contract_size=1.0)
net_a = move_a * PERP_COINS - premium_total - fee_a
fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=perp_coins, contract_size=1.0)
net_a = move_a * perp_coins - premium_total - fee_a
# 期权净利 = 目标
move_b_opt = (target + premium_total) / opt_coins
opt_net_at_b_opt = opt_coins * move_b_opt - premium_total
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * PERP_COINS
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * perp_coins
# 组合净利 = 目标
edge = opt_coins - PERP_COINS
edge = opt_coins - perp_coins
if edge <= 0:
move_b_port = None
port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利"
@@ -265,7 +308,7 @@ def calc_perp_options_points(
port_err = None
if move_b_port is not None:
opt_net_at_b_port = opt_coins * move_b_port - premium_total
portfolio_at_b_port = opt_net_at_b_port - move_b_port * PERP_COINS
portfolio_at_b_port = opt_net_at_b_port - move_b_port * perp_coins
else:
opt_net_at_b_port = None
portfolio_at_b_port = None
@@ -279,7 +322,7 @@ def calc_perp_options_points(
"ratio_perp": round(rp, 8),
"ratio_opt": round(ro, 8),
"ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}",
"perp_coins": PERP_COINS,
"perp_coins": round(perp_coins, 8),
"opt_coins": round(opt_coins, 8),
"opt_sheets": round(opt_sheets, 8),
"perp_leverage": round(p_lev, 8),
@@ -294,7 +337,7 @@ def calc_perp_options_points(
"label": "永续方向对",
"move_points": round(move_a, 8),
"move_pct": round(move_a / s * 100.0, 8),
"perp_pnl_u": round(move_a * PERP_COINS, 8),
"perp_pnl_u": round(move_a * perp_coins, 8),
"premium_u": round(premium_total, 8),
"fee_u": round(fee_a, 8),
"net_u": round(net_a, 8),
@@ -316,6 +359,9 @@ def calc_perp_options_points(
"portfolio_error": port_err,
"premium_u": round(premium_total, 8),
},
"case_sideways": _case_sideways(
spot=s, premium_total=premium_total, perp_coins=perp_coins
),
}, None
+9 -3
View File
@@ -7,7 +7,7 @@ import os
from typing import Callable
from urllib.parse import parse_qsl, urlencode, urlsplit
from flask import Flask, Response, jsonify, redirect, request, session
from flask import Flask, Response, jsonify, make_response, redirect, request, session
from jinja2 import ChoiceLoader, FileSystemLoader
EMBED_TABS: tuple[str, ...] = (
@@ -184,7 +184,10 @@ def register_embed_routes(
if tab not in EMBED_TABS:
tab = "trade"
session["hub_embed_shell"] = True
return render_main_page_fn(tab, embed_mode="shell")
resp = make_response(render_main_page_fn(tab, embed_mode="shell"))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
@login_required
@app.route("/api/embed/page/<tab>")
@@ -198,7 +201,10 @@ def register_embed_routes(
html = render_main_page_fn(tab, embed_mode="fragment")
if isinstance(html, Response):
html = html.get_data(as_text=True)
return jsonify({"ok": True, "page": tab, "html": html})
resp = jsonify({"ok": True, "page": tab, "html": html})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
def pwa_app_name(exchange_key: str) -> str:
+11 -5
View File
@@ -1279,6 +1279,12 @@ if(fullMarginEl){
}
const sltpModeEl = document.getElementById("sltp-mode");
function setOmFieldVisible(inputEl, show){
if(!inputEl) return;
inputEl.style.display = show ? "" : "none";
const wrap = inputEl.closest(".om-field");
if(wrap) wrap.style.display = show ? "" : "none";
}
function toggleSltpMode(){
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
const slEl = document.getElementById("order-sl");
@@ -1289,14 +1295,14 @@ function toggleSltpMode(){
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
const pct = mode === "pct";
const fixed = mode === "fixed_rr";
slEl.style.display = pct ? "none" : "";
tpEl.style.display = (pct || fixed) ? "none" : "";
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none";
setOmFieldVisible(slEl, !pct);
setOmFieldVisible(tpEl, !(pct || fixed));
setOmFieldVisible(fixedRrEl, fixed);
slEl.required = !pct;
tpEl.required = !pct && !fixed;
if(fixedRrEl) fixedRrEl.required = fixed;
slPctEl.style.display = pct ? "" : "none";
tpPctEl.style.display = pct ? "" : "none";
setOmFieldVisible(slPctEl, pct);
setOmFieldVisible(tpPctEl, pct);
slPctEl.required = pct;
tpPctEl.required = pct;
refreshOrderTpPreview();
@@ -97,50 +97,7 @@
{% endif %}
</div>
{% include order_rule_tips_tpl %}
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
{% if not intraday_discipline %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">{{ open_position_button_label }}</button>
</form>
{% include 'order_plan_preview_bar.html' %}
{% include 'order_monitor_open_form.html' %}
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
+3 -3
View File
@@ -7,8 +7,8 @@
<script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=108">
<link rel="stylesheet" href="/static/instance_page.css?v=12">
<link rel="stylesheet" href="/static/instance_theme.css?v=109">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ pwa_app_name }}</title>
@@ -168,7 +168,7 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=16"></script>
<script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=29"></script>
<script src="/static/instance_embed.js?v=30"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script>
</body>
</html>
+14 -51
View File
@@ -17,8 +17,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=105">
<link rel="stylesheet" href="/static/instance_page.css?v=12">
<link rel="stylesheet" href="/static/instance_theme.css?v=109">
</head>
<body
@@ -174,50 +174,7 @@
{% endif %}
</div>
{% include order_rule_tips_tpl %}
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
{% if not intraday_discipline %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">{{ open_position_button_label }}</button>
</form>
{% include 'order_plan_preview_bar.html' %}
{% include 'order_monitor_open_form.html' %}
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
@@ -1791,6 +1748,12 @@ if(fullMarginEl){
}
const sltpModeEl = document.getElementById("sltp-mode");
function setOmFieldVisible(inputEl, show){
if(!inputEl) return;
inputEl.style.display = show ? "" : "none";
const wrap = inputEl.closest(".om-field");
if(wrap) wrap.style.display = show ? "" : "none";
}
function toggleSltpMode(){
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
const slEl = document.getElementById("order-sl");
@@ -1801,14 +1764,14 @@ function toggleSltpMode(){
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
const pct = mode === "pct";
const fixed = mode === "fixed_rr";
slEl.style.display = pct ? "none" : "";
tpEl.style.display = (pct || fixed) ? "none" : "";
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none";
setOmFieldVisible(slEl, !pct);
setOmFieldVisible(tpEl, !(pct || fixed));
setOmFieldVisible(fixedRrEl, fixed);
slEl.required = !pct;
tpEl.required = !pct && !fixed;
if(fixedRrEl) fixedRrEl.required = fixed;
slPctEl.style.display = pct ? "" : "none";
tpPctEl.style.display = pct ? "" : "none";
setOmFieldVisible(slPctEl, pct);
setOmFieldVisible(tpPctEl, pct);
slPctEl.required = pct;
tpPctEl.required = pct;
refreshOrderTpPreview();
@@ -0,0 +1,76 @@
{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #}
<form id="add-order-form" action="/add_order" method="post" class="order-monitor-form" data-risk-percent="{{ risk_percent }}">
<div class="om-row om-row-policy">
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode" title="止盈止损模式">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
</div>
<div class="om-row om-row-levels">
<label class="om-field" id="om-field-sl">
<span class="om-field-lab">止损价格</span>
<input id="order-sl" name="sl" step="any" placeholder="必填" required>
</label>
<label class="om-field" id="om-field-rr">
<span class="om-field-lab">盈亏比</span>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="默认1.5" value="1.5" title="止盈距离=止损距离×盈亏比">
</label>
<label class="om-field" id="om-field-tp" style="display:none">
<span class="om-field-lab">止盈价格</span>
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格">
</label>
<label class="om-field" id="om-field-sl-pct" style="display:none">
<span class="om-field-lab">止损%</span>
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%">
</label>
<label class="om-field" id="om-field-tp-pct" style="display:none">
<span class="om-field-lab">止盈%</span>
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%">
</label>
<div class="om-live-meta">
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">成交价以交易所回报为准</span>
</div>
</div>
<div class="om-row om-row-opts">
{% if not intraday_discipline %}
<label class="om-check">
<input type="checkbox" name="breakeven_enabled" value="1" checked>
<span>移动保本</span>
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap om-time-close">
<label class="om-check" style="margin:0">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb">
<span>时间平仓</span>
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label class="om-check" title="开仓后生成多周期K线图(各周期100根,含开平仓标记)">
<input type="checkbox" name="order_chart" value="true">
<span>开仓后生成多周期K线</span>
</label>
</div>
<div class="om-row om-row-action">
<button type="submit" class="om-submit">{{ open_position_button_label }}</button>
</div>
</form>
{% include 'order_plan_preview_bar.html' %}
+12 -23
View File
@@ -12,8 +12,7 @@ from lib.hub.amp_stats_lib import (
build_export_csv,
compute_amp_stats,
export_filename,
normalize_straddle_premium,
normalize_take_profit,
normalize_move_points,
normalize_weekend_filter,
reframe_amp_stats,
rows_page,
@@ -25,8 +24,7 @@ class ComputeBody(BaseModel):
start_hour: int = 16
period: str = "2m"
custom_days: Optional[int] = None
straddle_premium: Optional[float] = None
take_profit: Optional[float] = None
move_points: Optional[float] = None
weekend_filter: str = "all"
page: int = 1
page_size: int = 20
@@ -37,15 +35,14 @@ class SaveBody(BaseModel):
class ReframeBody(BaseModel):
"""已有日表上改周末/权利金/止盈(不拉 K 线)."""
"""已有日表上改周末/波动点数(不拉 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
move_points: Optional[float] = None
weekend_filter: str = "all"
price_source: str = ""
inst_id: str = ""
@@ -84,7 +81,7 @@ def create_amp_stats_router() -> APIRouter:
"default_weekend_filter": "all",
"timeframe": "1H",
"metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)",
"straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金",
"move_points_note": "填波动点数后统计振幅≥该点数的天数占比;日表显示开→高/开→低两边点数与振幅是否达标",
}
@router.post("/compute")
@@ -95,8 +92,7 @@ def create_amp_stats_router() -> APIRouter:
start_hour=body.start_hour,
period=body.period,
custom_days=body.custom_days,
straddle_premium=body.straddle_premium,
take_profit=body.take_profit,
move_points=body.move_points,
weekend_filter=body.weekend_filter,
)
except ValueError as exc:
@@ -122,8 +118,7 @@ def create_amp_stats_router() -> APIRouter:
start_hour=body.start_hour,
period=body.period,
sample_days=body.sample_days,
straddle_premium=body.straddle_premium,
take_profit=body.take_profit,
move_points=body.move_points,
weekend_filter=body.weekend_filter,
price_source=body.price_source,
inst_id=body.inst_id,
@@ -165,8 +160,7 @@ def create_amp_stats_router() -> APIRouter:
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),
move_points: Optional[float] = Query(default=None),
weekend_filter: str = Query(default="all"),
):
if (history_id or "").strip():
@@ -174,6 +168,7 @@ def create_amp_stats_router() -> APIRouter:
if not item:
raise HTTPException(status_code=404, detail="历史不存在")
rows_all = item.get("rows_all") or item.get("rows") or []
use_mp = move_points if move_points is not None else item.get("move_points")
try:
payload = reframe_amp_stats(
rows_all=rows_all,
@@ -181,10 +176,7 @@ def create_amp_stats_router() -> APIRouter:
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"),
move_points=use_mp,
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 ""),
@@ -194,17 +186,14 @@ def create_amp_stats_router() -> APIRouter:
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)
normalize_move_points(move_points)
payload = compute_amp_stats(
symbol=symbol,
start_hour=start_hour,
period=period,
custom_days=custom_days,
straddle_premium=straddle_premium,
take_profit=take_profit,
move_points=move_points,
weekend_filter=weekend_filter,
)
except ValueError as exc:
+53 -80
View File
@@ -1,5 +1,5 @@
/**
* 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选.
* 中控振幅统计:OKX ETH/BTC + 波动点数振幅占比 + 周末筛选.
*/
(function () {
const page = document.getElementById("page-amp-stats");
@@ -39,16 +39,8 @@
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();
function readMovePoints() {
const raw = (el("amp-move-points")?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
@@ -95,19 +87,13 @@
}
}
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);
renderMoveStats(null);
return;
}
box.innerHTML =
@@ -116,44 +102,31 @@
`<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_amplitude_2d)} <small>(${esc(s.max_amplitude_2d_day)})</small></span></div>` +
`<div><span class="amp-sum-k">两日振幅均值/中位</span><span class="amp-sum-v">${esc(s.avg_amplitude_2d)} / ${esc(s.median_amplitude_2d)}</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);
renderMoveStats(s.move_points_stats);
}
function renderStraddle(st) {
const box = el("amp-straddle");
function renderMoveStats(ms) {
const box = el("amp-move-stats");
if (!box) return;
if (!st) {
box.innerHTML = '<p class="amp-empty">填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏</p>';
if (!ms) {
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><span class="amp-sum-k">对照点数</span><span class="amp-sum-v">${esc(ms.move_points)}</span></div>` +
`<div><span class="amp-sum-k">振幅≥点数</span><span class="amp-sum-v">${esc(ms.amp_hit_days)} 天 · <strong>${esc(pct(ms.amp_hit_ratio))}</strong></span></div>` +
`<div><span class="amp-sum-k">两日振幅≥点数</span><span class="amp-sum-v">${esc(ms.amp_2d_hit_days)} 天 · <strong>${esc(pct(ms.amp_2d_hit_ratio))}</strong></span></div>` +
`<div><span class="amp-sum-k">开→高≥点数</span><span class="amp-sum-v">${esc(ms.up_hit_days)} 天 · ${esc(pct(ms.up_hit_ratio))}</span></div>` +
`<div><span class="amp-sum-k">开→低≥点数</span><span class="amp-sum-v">${esc(ms.down_hit_days)} 天 · ${esc(pct(ms.down_hit_ratio))}</span></div>` +
`<div><span class="amp-sum-k">|涨跌|≥点数</span><span class="amp-sum-v">${esc(ms.abs_change_hit_days)} 天 · ${esc(pct(ms.abs_change_hit_ratio))}</span></div>` +
`</div>`;
}
@@ -165,21 +138,31 @@
return day;
}
function hitCell(r) {
if (r.move_points == null) return "—";
if (r.amp_hit) return '<span class="amp-pnl is-pos">是</span>';
return '<span class="amp-pnl is-neg">否</span>';
}
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>';
body.innerHTML = '<tr><td colspan="12" 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"' : "";
const upCls = r.hit_up ? ' class="amp-pnl is-pos"' : "";
const downCls = r.hit_down ? ' class="amp-pnl is-pos"' : "";
const ampCls = r.amp_hit ? ' class="amp-pnl is-pos"' : "";
const amp2Cls = r.amp_hit_2d ? ' class="amp-pnl is-pos"' : "";
const amp2 =
r.amplitude_2d == null || r.amplitude_2d === ""
? "—"
: `<span title="${esc(r.window2_start || "")}${esc(r.window2_end || "")}">${esc(r.amplitude_2d)}</span>`;
return (
`<tr${trClass}>` +
`<td>${dayLabel(r)}</td>` +
@@ -188,11 +171,12 @@
`<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${upCls}>${esc(r.up_points)}</td>` +
`<td${downCls}>${esc(r.down_points)}</td>` +
`<td${ampCls}><strong>${esc(r.amplitude)}</strong></td>` +
`<td${amp2Cls}><strong>${amp2}</strong></td>` +
`<td>${esc(r.change)}</td>` +
`<td>${profit}</td>` +
`<td>${hitCell(r)}</td>` +
`</tr>`
);
})
@@ -226,7 +210,7 @@
async function reframe(resetPage) {
if (!lastResult) {
renderStraddle(null);
renderMoveStats(null);
return;
}
if (resetPage) pageNo = 1;
@@ -242,8 +226,7 @@
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(),
move_points: readMovePoints(),
weekend_filter: readWeekend(),
price_source: lastResult.price_source || "",
inst_id: lastResult.inst_id || "",
@@ -284,8 +267,7 @@
start_hour: startHour,
period,
custom_days: period === "custom" ? customDays : null,
straddle_premium: readPremium(),
take_profit: readTakeProfit(),
move_points: readMovePoints(),
weekend_filter: readWeekend(),
page: pageNo,
page_size: 20,
@@ -331,8 +313,7 @@
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 mp = readMovePoints();
const q = new URLSearchParams({
symbol,
start_hour: String(startHour),
@@ -340,8 +321,7 @@
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));
if (mp != null) q.set("move_points", String(mp));
window.location.href = "/api/amp-stats/export?" + q.toString();
}
@@ -375,16 +355,13 @@
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;
const mp = readMovePoints();
const q = new URLSearchParams({
history_id: id,
weekend_filter: readWeekend(),
});
if (mp != null) q.set("move_points", String(mp));
window.location.href = "/api/amp-stats/export?" + q.toString();
});
card.querySelector(".amp-hist-del")?.addEventListener("click", async () => {
if (!confirm("删除该历史记录?")) return;
@@ -405,11 +382,8 @@
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.move_points != null && el("amp-move-points")) {
el("amp-move-points").value = String(lastResult.move_points);
}
if (lastResult.weekend_filter && el("amp-weekend-filter")) {
el("amp-weekend-filter").value = lastResult.weekend_filter;
@@ -434,8 +408,7 @@
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-move-points")?.addEventListener("input", scheduleReframe);
el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true));
syncCustomDays();
}
@@ -445,7 +418,7 @@
bind();
setView("stats");
setStatus("");
renderStraddle(null);
renderMoveStats(null);
},
};
})();
+43 -1
View File
@@ -572,6 +572,35 @@
}
}
function renderSidewaysCase(sw) {
sw = sw || {};
return (
'<section class="calc-po-case">' +
"<h4>情景 C · 横盘(最大亏损)</h4>" +
'<div class="calc-summary">' +
"<div><span>永续盈亏</span><strong>" +
fmtU(sw.perp_pnl_u != null ? sw.perp_pnl_u : 0) +
"</strong></div>" +
"<div><span>权利金(全亏)</span><strong>" +
fmt(sw.premium_u, 2) +
"U</strong></div>" +
"<div><span>永续开平手续费</span><strong>" +
fmt(sw.fee_u, 2) +
"U</strong></div>" +
"<div><span>最大亏损</span><strong class=\"" +
pnlClass(sw.net_u) +
'">' +
fmt(sw.max_loss_u, 2) +
"U</strong></div>" +
"<div><span>组合净利</span><strong class=\"" +
pnlClass(sw.net_u) +
'">' +
fmtU(sw.net_u) +
"</strong></div>" +
"</div></section>"
);
}
function renderPerpOptionsResult(data) {
const box = $("calc-po-result");
if (!box) return;
@@ -614,6 +643,11 @@
"<div><span>永续保证金</span><strong>" +
fmt(data.perp_margin_u, 2) +
"U</strong></div>" +
"<div><span>横盘最大亏损</span><strong class=\"" +
pnlClass((data.case_sideways || {}).net_u) +
'">' +
fmt((data.case_sideways || {}).max_loss_u, 2) +
"U</strong></div>" +
"<div><span>开仓参考</span><strong>" +
esc(capitalHint) +
"</strong></div>" +
@@ -663,7 +697,9 @@
'">' +
fmtU(b.portfolio_net_u) +
"</strong></div>" +
"</div></section></div>";
"</div></section>" +
renderSidewaysCase(data.case_sideways) +
"</div>";
}
function renderPerpOptionsPointsResult(data) {
@@ -743,6 +779,11 @@
"<div><span>永续保证金</span><strong>" +
fmt(data.perp_margin_u, 2) +
"U</strong></div>" +
"<div><span>横盘最大亏损</span><strong class=\"" +
pnlClass((data.case_sideways || {}).net_u) +
'">' +
fmt((data.case_sideways || {}).max_loss_u, 2) +
"U</strong></div>" +
"<div><span>开仓参考</span><strong>" +
esc(capitalHint) +
"</strong></div>" +
@@ -774,6 +815,7 @@
"</strong></div>" +
"</div></section>" +
caseB +
renderSidewaysCase(data.case_sideways) +
"</div>";
}
+13 -17
View File
@@ -966,14 +966,14 @@
<div class="calc-pane-split">
<div class="calc-input-panel">
<h2>永期对冲计算器</h2>
<p class="calc-hint">永续固定 1 币;单币权利金 = 现价 / 期权杠杆;权利金按全亏;只扣永续开平手续费(各 0.05%).「推仓位」由波动反推期权数量;「推点数」按永续:期权比例反推达目标盈利所需波动.</p>
<p class="calc-hint">推点数模式:「永续币数/期权币数」按绝对仓位计算(填 2 与 4 → 永续 2 币 + 期权 4 币,不再归一成 1:2).单币权利金 = 现价 / 期权杠杆;权利金按全亏;只扣永续开平手续费(各 0.05%).「推仓位」仍固定永续 1 币、由波动反推期权数量.横盘最大亏损 = 权利金全亏 + 永续开平同价手续费.</p>
<form id="calc-po-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>测算模式</span>
<select id="calc-po-calc-mode">
<option value="size" selected>由波动推期权仓位</option>
<option value="points">比例推波动点数</option>
<option value="points">币数推波动点数</option>
</select>
</label>
<label class="calc-field">
@@ -1007,11 +1007,11 @@
<input id="calc-po-move" type="number" min="0" step="any" value="50" />
</label>
<label class="calc-field calc-po-points-only hidden">
<span>永续比例</span>
<span>永续币数</span>
<input id="calc-po-ratio-perp" type="number" min="0.01" step="any" value="1" />
</label>
<label class="calc-field calc-po-points-only hidden">
<span>期权比例</span>
<span>期权币数</span>
<input id="calc-po-ratio-opt" type="number" min="0.01" step="any" value="2" />
</label>
<label class="calc-field">
@@ -1265,12 +1265,8 @@
</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="空=按涨跌" />
<span>波动点数</span>
<input id="amp-move-points" type="number" min="0" step="any" placeholder="如 50" />
</label>
<div class="amp-actions">
<button type="button" id="amp-btn-compute" class="primary">计算</button>
@@ -1279,22 +1275,22 @@
</div>
</div>
<p id="amp-status" class="toolbar-meta amp-status"></p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金;止盈≥触达则有效波动=止盈点,否则用|涨跌|.周末按结算日标注/筛选.</p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.两日振幅=起点再往前推1天到当日16:00(例:25日16:00→27日16:00).填写波动点数后看振幅/两日振幅≥该点数的天数占比.周末按结算日标注/筛选.</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 id="amp-move-stats" class="amp-summary amp-move-stats"></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>
<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>
<tr><td colspan="12" class="amp-empty">点击「计算」加载</td></tr>
</tbody>
</table>
</div>
@@ -1755,7 +1751,7 @@
<script src="/assets/chart_draw.js?v=20260720-option-day-1600"></script>
<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=20260728-po-2dp"></script>
<script src="/assets/calculator.js?v=20260729-po-abs-coins"></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=20260724-opt-archive"></script>
@@ -1763,7 +1759,7 @@
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></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/amp_stats.js?v=20260728-hedge"></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>
+40 -54
View File
@@ -34,6 +34,14 @@ class AmpStatsLibTests(unittest.TestCase):
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_window_two_day_16_to_16(self):
# 结算 27 日 → 两日窗 25日16:00 → 27日16:00
start, end = window_bounds_for_settlement(date(2026, 7, 27), 16, span_days=2)
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-25 16:00")
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-27 16:00")
one_start, _ = window_bounds_for_settlement(date(2026, 7, 27), 16, span_days=1)
self.assertEqual(one_start.strftime("%Y-%m-%d %H:%M"), "2026-07-26 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)
@@ -89,41 +97,38 @@ class AmpStatsLibTests(unittest.TestCase):
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"])
self.assertIsNone(s["move_points_stats"])
def test_long_straddle_stats(self):
def test_move_points_amp_ratio(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"},
{"amplitude": 100, "up_points": 40, "down_points": 60, "change": 10, "settlement_day": "2026-07-01"},
{"amplitude": 40, "up_points": 10, "down_points": 30, "change": -5, "settlement_day": "2026-07-02"},
{"amplitude": 50, "up_points": 50, "down_points": 0, "change": 20, "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))
s = summarize_rows(rows, move_points=50)
ms = s["move_points_stats"]
self.assertIsNotNone(ms)
self.assertEqual(ms["move_points"], 50)
self.assertEqual(ms["amp_hit_days"], 2) # 100, 50
self.assertEqual(ms["amp_hit_ratio"], round(2 / 3, 4))
self.assertEqual(ms["up_hit_days"], 1) # 50
self.assertEqual(ms["down_hit_days"], 1) # 60
csv_text = build_export_csv(
{"exchange": "okx", "symbol_label": "ETH", "summary": s, "rows": rows, "start_hour": 22, "end_hour": 16}
{
"exchange": "okx",
"symbol_label": "ETH",
"summary": s,
"rows": rows,
"start_hour": 16,
"end_hour": 16,
}
)
self.assertIn("买跨对照", csv_text)
self.assertIn("买跨点数盈亏合计", csv_text)
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,
)
def test_weekend_and_reframe_move_points(self):
from lib.hub.amp_stats_lib import enrich_rows, filter_weekend_rows, reframe_amp_stats
# Sat 2026-07-18, Sun 2026-07-19, Mon 2026-07-20
rows = [
{
"settlement_day": "2026-07-18",
@@ -159,39 +164,20 @@ class AmpStatsLibTests(unittest.TestCase):
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)
enriched = enrich_rows(rows, move_points=80)
self.assertTrue(enriched[0]["amp_hit"])
self.assertFalse(enriched[1]["amp_hit"])
self.assertTrue(enriched[2]["amp_hit"])
reframed = reframe_amp_stats(
rows_all=rows,
symbol="eth",
weekend_filter="exclude",
straddle_premium=10,
take_profit=80,
move_points=80,
)
self.assertEqual(reframed["summary"]["sample_count"], 1)
# Mon: 未触达止盈 → |8|-10
self.assertEqual(reframed["rows"][0]["profit"], -2)
self.assertIn("收益", build_export_csv(reframed))
self.assertTrue(reframed["rows"][0]["amp_hit"])
self.assertIn("振幅占比", build_export_csv(reframed))
def test_fetch_switches_to_history_endpoint(self):
"""近期接口到头后应切 history 续拉."""
+39
View File
@@ -38,6 +38,11 @@ class HubPerpOptionsCalcTests(unittest.TestCase):
self.assertAlmostEqual(data["case_b"]["portfolio_net_u"], coins * 32.0 - 50.0, places=6)
self.assertAlmostEqual(data["perp_margin_u"], 180.0, places=6)
self.assertTrue(data["capital_ok"])
sw = data["case_sideways"]
self.assertAlmostEqual(sw["premium_u"], data["premium_total_u"], places=6)
self.assertAlmostEqual(sw["fee_u"], 1.8, places=6)
self.assertAlmostEqual(sw["max_loss_u"], data["premium_total_u"] + 1.8, places=6)
self.assertAlmostEqual(sw["net_u"], -sw["max_loss_u"], places=6)
def test_pct_mode(self):
data, err = calc_perp_options_hedge(
@@ -113,6 +118,40 @@ class HubPerpOptionsCalcTests(unittest.TestCase):
# 组合净利=目标: (15+36)/(2-1) = 51
self.assertAlmostEqual(data["case_b"]["move_points_portfolio"], 51.0, places=6)
self.assertAlmostEqual(data["case_b"]["portfolio_net_u"], 15.0, places=6)
# 横盘:权利金36 + 同价开平费 2*1800*0.0005=1.8 → 37.8
sw = data["case_sideways"]
self.assertAlmostEqual(sw["premium_u"], 36.0, places=6)
self.assertAlmostEqual(sw["fee_u"], 1.8, places=6)
self.assertAlmostEqual(sw["max_loss_u"], 37.8, places=6)
self.assertAlmostEqual(sw["net_u"], -37.8, places=6)
def test_points_absolute_2_to_4(self):
# 2:4 按绝对币数,不再归一成 1:2
data, err = calc_perp_options_points(
base="ETH",
spot=1800,
capital_usdt=3000,
target_profit_u=15,
perp_leverage=10,
option_leverage=100,
ratio_perp=2,
ratio_opt=4,
ct_mult=0.01,
)
self.assertIsNone(err)
assert data is not None
self.assertEqual(data["perp_coins"], 2.0)
self.assertEqual(data["opt_coins"], 4.0)
self.assertEqual(data["premium_total_u"], 72.0)
self.assertAlmostEqual(data["perp_margin_u"], 360.0, places=6)
expect_a = (15 + 72 + 2 * 1800 * 2 * 0.0005) / (2 * 0.9995)
self.assertAlmostEqual(data["case_a"]["move_points"], expect_a, places=6)
self.assertAlmostEqual(data["case_a"]["net_u"], 15.0, places=5)
self.assertAlmostEqual(data["case_b"]["move_points_portfolio"], 43.5, places=6)
self.assertAlmostEqual(data["case_b"]["portfolio_net_u"], 15.0, places=6)
sw = data["case_sideways"]
self.assertAlmostEqual(sw["fee_u"], 3.6, places=6)
self.assertAlmostEqual(sw["max_loss_u"], 75.6, places=6)
def test_points_ratio_1_to_1_no_portfolio(self):
data, err = calc_perp_options(