optz
This commit is contained in:
354
labs/analysis/etf/REPORT-new-config.md
Normal file
354
labs/analysis/etf/REPORT-new-config.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# ETF 自适应网格策略回测报告(新配置)
|
||||
|
||||
- 配置来源:`py-client/etc/_etf.yaml`(**回测直接读取该文件,不再维护副本**)
|
||||
- 回测区间:**2025-12-22 ~ 2026-09-18(182 个交易日)**
|
||||
- 标的:`588000.SH`、`510300.SH`、`518880.SH`;日线 242 根(2025-09-19 ~ 2026-09-18,**未复权**)
|
||||
- 账户:起始 **50 万**、`min_cash_ratio=0.10`、佣金 `max(5, 金额×0.0003)`
|
||||
- 复现:`py -3.14 -B labs/analysis/etf/run.py`(结果落 `results.json` / `run_report.txt`)
|
||||
- 对照脚本:`compare.py`(旧 vs 新)、`capital.py`(资金需求)、`sweep.py`(参数)、`drawdown.py`(浮亏轨迹)
|
||||
- 本报告只做分析,**未修改任何策略代码**。
|
||||
|
||||
## 本次配置变化
|
||||
|
||||
| 标的 | 旧 `buy_shares` | 新 `buy_shares` | 新 `max_shares` |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 588000.SH | 1,000 | **10,000** | 100,000 |
|
||||
| 510300.SH | 1,000 | **4,000** | 20,000 |
|
||||
| 518880.SH | 1,000 | **2,000** | 10,000 |
|
||||
|
||||
`defaults` 段(add_pct 3.0 / min_profit_pct 1.0 / channel_pct 15 / max_adds 9 等)**未改**。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
| 指标 | 基准(触价成交) | 贴近实盘(反弹价) | 悲观(收盘价) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| **账户权益变动** | **+6,110.84(+1.22%)** | +8,426.54(+1.69%) | **−2,551.34(−0.51%)** |
|
||||
| 最大回撤(权益口径) | 0.48% | 0.56% | 1.68% |
|
||||
| 完整轮次 / 胜率 | 32 / **32 胜 0 负** | 27 / 27 胜 | 13 / 13 胜 |
|
||||
| 单轮平均已了结利润 | **+195.81** | — | — |
|
||||
| 平均持有 / 最大持有 | **3.7 天 / 23 天** | — | — |
|
||||
| 平均档位 / 最大档位 | 1.16 / 4 | — | — |
|
||||
| 佣金合计 | 401.79 | 343.75 | 197.34 |
|
||||
| 平均资金占用 / 峰值 | **2.24% / 17.41%** | 3.40% / 17.85% | 6.71% / 24.83% |
|
||||
|
||||
**和旧配置比,收益放大了 4 倍,但收益率只从 0.31% 提到 1.22%(同一 50 万账户)——因为放大的是仓位,不是机会。**
|
||||
|
||||
| | 旧配置 | 新配置 | 倍数 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 买入名义额 | 176,668 | **664,705** | **3.76×** |
|
||||
| 权益变动(50 万账户) | +1,534.14 | **+6,110.84** | **3.98×** |
|
||||
| 收益率 | 0.31% | **1.22%** | 3.98× |
|
||||
| 最大回撤 | 0.25% | 0.48% | 1.9× |
|
||||
| 平均资金占用 | 0.67% | **2.24%** | 3.3× |
|
||||
| 占用 ROI(权益/平均占用) | 45.68% | **54.48%** | 1.19× |
|
||||
| 完整轮次 | 28 | 32 | 1.14× |
|
||||
| 单轮平均利润 | 55.98 | **195.81** | 3.5× |
|
||||
|
||||
**四个关键读数**
|
||||
|
||||
1. **放大股数 ≈ 等比例放大盈亏**:名义额 ×3.76、盈亏 ×3.98,几乎 1:1。这是"加杠杆",
|
||||
不是"改进了策略";风险(回撤)同步从 0.25% 升到 0.48%。
|
||||
2. **入场机会没有变多**:轮次 28 → 32(+14%)。机会频率仍由市场决定(一年 59 个信号,见 §四)。
|
||||
3. **资金利用率仍然极低**:平均只投出 **2.24%** 的钱,峰值 17.41%。
|
||||
**绝对盈亏与账户规模无关**——把资金从 20 万加到 120 万,盈亏始终是 +6,110.84,
|
||||
收益率从 3.06% 被摊薄到 0.51%(`compare.py` §C)。这说明**50 万并没有被用满**。
|
||||
4. **悲观成交假设下会亏钱**(−2,551.34,−0.51%)。新配置把仓位放大 4 倍后,
|
||||
结论对成交假设的敏感度也放大了:`touch` +6,111 / `bounce` +8,427 / `close` −2,551。
|
||||
|
||||
---
|
||||
|
||||
## 二、逐标的归因(基准)
|
||||
|
||||
| 标的 | 完整轮次 | 已了结净额 | 了结时仍持有 | 按成本净额 | 未实现 | 按市价净额 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 588000.SH | 12 | +2,011.94 | 0 | +2,011.94 | 0 | +2,011.94 |
|
||||
| 510300.SH | 13 | +2,521.97 | **4,000 股** | **−15,961.17** | −155.14 | **−16,116.31** |
|
||||
| 518880.SH | 7 | +1,732.07 | 0 | +1,732.07 | 0 | +1,732.07 |
|
||||
| 合计 | 32 | **+6,265.98** | — | **−12,217.16** | −155.14 | **−12,372.31** |
|
||||
|
||||
对平关系(已用脚本验证):`权益变动 +6,110.84 = 已了结净额(含未了结成本)−12,217.16 + 未了结成本 18,483.14 − 未实现 155.14`。
|
||||
`已了结净额` 是**现金流口径**(把还在手上的仓位当成已花掉的钱),**不是亏损**:32 个了结轮次合计 **+6,265.98**。
|
||||
|
||||
**⚠️ 最大的问题在这里**:`510300.SH` 的 13 个轮次全部盈利(合计 +2,521.97),但**最终一笔未了结的
|
||||
4,000 股(成本 18,483)** 让它的净额变成 −15,961。一只标的的未了结仓位,吃掉了三只标的
|
||||
全部已了结利润(+6,266)的两倍半。旧配置里这个数字是 −4,035,**放大 4 倍仓位后变成 −15,961**。
|
||||
|
||||
---
|
||||
|
||||
## 三、成交结构与资金
|
||||
|
||||
### 3.1 完整轮次
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | ---: |
|
||||
| 完整轮次 | 32(32 胜 0 负) |
|
||||
| 单轮平均利润 | +195.81(最好 +684.48,最差 +139.36) |
|
||||
| 平均持有 | 3.7 天(最长 23 天) |
|
||||
| **持有 ≤1 天就了结** | **22 / 32(69%)** |
|
||||
| 平均档位 / 最大档位 | 1.16 / 4 |
|
||||
| 底仓 / 补仓次数 | 33 / 5 |
|
||||
|
||||
**69% 的轮次在 1 天内完成**:新配置把单档金额放大到 1.8~14 万后,1% 的止盈目标对应的绝对金额
|
||||
变成 18~140 元/档,达到速度没变,但**单轮利润的绝对值被放大**。
|
||||
同时 **平均只用 1.16 档**(补仓 5 次),阶梯依然远未铺开。
|
||||
|
||||
### 3.2 资金占用
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | ---: |
|
||||
| 有持仓天数 | 73 / 182(40.1%) |
|
||||
| 平均占用 | 11,216 元 = **2.24%** |
|
||||
| 峰值占用 | 87,052 元 = **17.41%** |
|
||||
| 买入名义 / 换手 | 664,705 元 / **1.33 倍** |
|
||||
| 资金需求(按各标的区间最低价估) | 三只各 1 档 ≈ **46,518**;铺满 10 档 ≈ **465,180** |
|
||||
|
||||
- 配置现在的**理论满载需求是 46.5 万**(三只各铺满 10 档),50 万账户刚好能承载。
|
||||
- 但实际峰值只用到 8.7 万(17.4%)、平均 1.1 万(2.24%)——**账户有 80% 以上的钱全年闲置**。
|
||||
|
||||
### 3.3 佣金(新配置下更不是瓶颈)
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | ---: |
|
||||
| 佣金合计 | 401.79 |
|
||||
| 占买入名义 | **0.06%**(旧配置 0.184%) |
|
||||
| 佣金率 0 → 0.0003 → 0.001 | +6,149.52 → +6,110.84 → +5,643.61 |
|
||||
|
||||
单档金额变大后几乎全部按 `0.0003` 计费(不再吃 5 元最低佣金),**佣金成本降到可忽略**。
|
||||
`min_profit_pct=1%` 的绝对余量非常大(每档毛利润 ≈ 18~140 元 vs 双边佣金 10 元)。
|
||||
|
||||
### 3.4 逐月权益
|
||||
|
||||
| 月份 | 权益变动 | 幅度 |
|
||||
| --- | ---: | ---: |
|
||||
| 2025-12 | 0.00 | 0.000% |
|
||||
| 2026-01 | +260.08 | +0.052% |
|
||||
| 2026-02 | +105.42 | +0.021% |
|
||||
| 2026-03 | **−773.15** | −0.155% |
|
||||
| 2026-04 | +2,234.72 | +0.447% |
|
||||
| 2026-05 | +456.96 | +0.091% |
|
||||
| 2026-06 | +925.85 | +0.184% |
|
||||
| 2026-07 | +1,530.46 | +0.304% |
|
||||
| 2026-08 | +767.21 | +0.152% |
|
||||
| 2026-09 | +603.29 | +0.119% |
|
||||
|
||||
9 个月里 1 个月亏损、8 个月盈利,但**全部落在 ±0.45% 以内**——典型的"小仓位、稳定、无感"。
|
||||
|
||||
---
|
||||
|
||||
## 四、为什么收益率还是上不去(新配置下的定量结论)
|
||||
|
||||
### 4.1 根因没变:机会数量由市场决定
|
||||
|
||||
| 标的 | 可回放日 | 最低价跌破门槛 | **入场信号** | 跌破门槛天数占比 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 588000.SH | 182 | 44 | 20 | 24.2% |
|
||||
| 510300.SH | 182 | 47 | 29 | 25.8% |
|
||||
| 518880.SH | 182 | 30 | 10 | 16.5% |
|
||||
| 合计 | 546 | 121 | **59** | — |
|
||||
|
||||
一年 59 个信号(≈ 每天 0.32 次),本次实际建网 33 次、了结 32 轮。**机会已被用尽**。
|
||||
33 次建网里,现价在过去 60 日收盘的分位中位数 **6.7%**,19/33 落在 10% 分位以下——**入场门槛执行到位**。
|
||||
|
||||
### 4.2 单档股数放大后,绝对盈亏与账户无关
|
||||
|
||||
| 起始资金 | 权益变动 | 收益率 | 平均占用 | 峰值占用 |
|
||||
| ---: | ---: | ---: | ---: | ---: |
|
||||
| 200,000 | +6,110.84 | **3.06%** | 5.61% | 43.53% |
|
||||
| 300,000 | +6,110.84 | 2.04% | 3.74% | 29.02% |
|
||||
| **500,000** | **+6,110.84** | **1.22%** | 2.24% | 17.41% |
|
||||
| 800,000 | +6,110.84 | 0.76% | 1.40% | 10.88% |
|
||||
| 1,200,000 | +6,110.84 | 0.51% | 0.93% | 7.25% |
|
||||
|
||||
**同一份交易、同一个盈亏,只因为分母变大,收益率就摊薄 6 倍。**
|
||||
所以"收益率低"要拆成两件事:①策略**绝对赚钱能力**(这 9 个月 +6,111 元);
|
||||
②**账户里有多少钱在真正干活**(平均 2.24%)。
|
||||
|
||||
### 4.3 继续放大股数:收益线性涨、风险同步涨
|
||||
|
||||
| 场景(50 万账户) | 权益变动 | 收益率 | 最大回撤 | 平均占用 | 占用 ROI | 收益/回撤 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `buy_shares`×0.25 | +1,418.64 | 0.28% | 0.12% | 0.58% | 48.8% | 2.30 |
|
||||
| `buy_shares`×0.5 | +3,077.19 | 0.62% | 0.24% | 1.11% | 55.4% | 2.54 |
|
||||
| **现配置** | **+6,110.84** | **1.22%** | **0.48%** | **2.24%** | **54.5%** | **2.56** |
|
||||
| `buy_shares`×2 | +12,226.40 | 2.45% | 0.95% | 4.49% | 54.5% | 2.57 |
|
||||
| `buy_shares`×4 | +24,452.81 | 4.89% | 1.89% | 8.97% | 54.5% | 2.59 |
|
||||
|
||||
**占用 ROI 恒定在 48.8%~54.5%,收益/回撤恒定在 2.3~2.6** —— 说明在这段样本里,
|
||||
放大股数**既不改善也不恶化风险调整后收益,只是等比放大**。
|
||||
既然风险调整后收益不变,放大到多大就取决于账户能承受的回撤:若接受 1.9% 回撤,
|
||||
`×4` 可把 9 个月收益做到 +2.4 万(4.89%)。
|
||||
|
||||
---
|
||||
|
||||
## 五、参数敏感性(28 组,基准=当前 `_etf.yaml`)
|
||||
|
||||
| 场景 | 权益变动 | 收益率 | 最大回撤 | 底仓 | 补仓 | 主出口 | 副出口 | 佣金 | 平均占用 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| **基准(当前配置)** | **+6,110.84** | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% |
|
||||
| add_pct=2.0 | +5,856.05 | 1.171% | 0.362% | 32 | 4 | 31 | 0 | 378.00 | 2.78% |
|
||||
| add_pct=4.0 | +5,794.31 | 1.159% | 0.356% | 33 | 3 | 32 | 0 | 382.15 | 2.37% |
|
||||
| add_pct=5.0 | +5,104.25 | 1.021% | 0.412% | 31 | 1 | 30 | 0 | 339.29 | 2.71% |
|
||||
| min_profit_pct=0.5 | +3,475.39 | 0.695% | 0.498% | 41 | 4 | 40 | 0 | 477.22 | 1.56% |
|
||||
| min_profit_pct=0.8 | +5,220.81 | 1.044% | 0.486% | 37 | 4 | 36 | 0 | 432.96 | 1.80% |
|
||||
| **min_profit_pct=1.5** | **+6,560.59** | **1.312%** | 0.438% | 23 | 4 | 22 | 0 | 284.56 | 3.21% |
|
||||
| min_profit_pct=2.0 | +5,680.19 | 1.136% | 0.448% | 19 | 5 | 17 | 2 | 246.20 | 3.96% |
|
||||
| channel_pct=1 | +3,287.51 | 0.658% | 0.677% | 23 | 4 | 22 | 0 | 283.33 | 3.07% |
|
||||
| channel_pct=10 | +5,350.72 | 1.070% | 1.289% | 27 | 6 | 26 | 0 | 349.58 | 5.46% |
|
||||
| channel_pct=20 | +5,579.94 | 1.116% | 0.480% | 28 | 6 | 27 | 0 | 352.61 | 3.25% |
|
||||
| channel_pct=30 | +4,779.74 | 0.956% | 0.381% | 23 | 4 | 22 | 0 | 295.36 | 2.53% |
|
||||
| max_adds=3 / 5 / 15 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% |
|
||||
| inner_step=0.2 | +5,848.68 | 1.170% | 0.478% | 35 | 5 | 34 | **1** | 423.10 | 2.01% |
|
||||
| inner_step=0.4 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% |
|
||||
| 无副出口 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% |
|
||||
| atr_multiplier×0.5 / ×2.0 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% |
|
||||
| 无 T+1(min_hold_days=0) | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% |
|
||||
| 佣金率=0 / 0.001 | +6,149.52 / +5,643.61 | 1.230% / 1.129% | 0.478% / 0.491% | 33 | 5 | 32 | 0 | 350 / 1,317 | 2.24% |
|
||||
| 成交=反弹确认价 | +8,426.54 | 1.685% | 0.558% | 28 | 5 | 27 | 0 | 343.75 | 3.40% |
|
||||
| **成交=收盘价** | **−2,551.34** | **−0.510%** | 1.676% | 15 | 6 | 13 | 0 | 197.34 | 6.71% |
|
||||
|
||||
**与旧配置一致的三个"零影响参数"依然零影响**(新配置下同样验证):
|
||||
|
||||
- **`atr_multiplier`×0.5 / ×2.0 → 结果一模一样**:格距不参与任何触发价(`docs/etf.md` §3.3 的设计后果)。
|
||||
逐标的 ATR 倍数标定(0.5/1.0/1.0)**对成交没有任何影响**。
|
||||
- **`max_adds`=3 / 5 / 15 → 结果一模一样**:一年只补仓 5 次、最多 4 档,9 档闸门从未生效。
|
||||
- **`inner_step`=0.4 / 无副出口 → 结果一模一样**:`inner_grids×inner_step`(1.4%~1.8%) 仍大于
|
||||
`min_profit_pct`(1.0%),副出口在基准下 **0 次成交**;只有压到 0.2 才触发 1 次。
|
||||
- `min_profit_pct=1.5%` 是本批唯一略优于基准的参数(+6,560.59 vs +6,110.84),
|
||||
但差异仅 7%,**在 32 个轮次的样本上不足以判定**(旧配置同参数反而略差)。
|
||||
|
||||
---
|
||||
|
||||
## 六、风险:无止损 + 未了结仓位
|
||||
|
||||
### 6.1 未了结仓位的浮亏轨迹(均价法)
|
||||
|
||||
| 标的 | 持仓天数 | 最长连续浮亏天数 | 最深浮亏 | 最深浮亏% | 期末浮亏 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| 588000.SH | 27 | 13 | −1,191 | **−8.25%** | +2 |
|
||||
| 510300.SH | 47 | 7 | −651 | −3.54% | −150 |
|
||||
| 518880.SH | 22 | 5 | −2,518 | −3.57% | +135 |
|
||||
|
||||
- **这三个标的的浮亏都不深**(最深 −8.25%),且**最后都回正**(期末浮亏仅 −150 / +2 / +135)。
|
||||
样本期(含 MA60 向下的 3~7 月)没有出现真正的单边下跌,**尾部风险未被覆盖**。
|
||||
- 510300 的期末浮亏只有 **−150 元**(−0.8%),不是因为"亏得多",而是因为
|
||||
**4,000 股 × 4.62 的成本额(18,483)被算成了现金流出**,所以在现金流口径里显得很吓人。
|
||||
|
||||
### 6.2 时间止损反事实(分析用)
|
||||
|
||||
| 规则 | 权益变动 | 收益率 | 占用 ROI | 强平次数 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 不止损(现配置) | +6,110.84 | 1.222% | 54.48% | 0 |
|
||||
| 持有 >5 天且浮亏即平 | +5,914.84 | 1.183% | 52.73% | 3 |
|
||||
| 持有 >10 天且浮亏即平 | +5,474.84 | 1.095% | 48.81% | 3 |
|
||||
| 持有 >20 天且浮亏即平 | +4,838.84 | 0.968% | 43.14% | 3 |
|
||||
|
||||
**时间止损在这段样本里是负收益**(越早平仓越差),因为浮亏最终都修复了。
|
||||
这说明:**在均值回归有效的样本里,无止损是特征不是缺陷**;它真正的风险只在单边下跌时才暴露,
|
||||
而那部分样本这里没有。要不要加闸门,取决于你能否承受"一次单边下跌把 32 轮利润全部回吐"
|
||||
(旧配置里 510300 已经演示过一次:13 轮盈利被一笔未了结仓位反超)。
|
||||
|
||||
---
|
||||
|
||||
## 七、与旧配置的结论对比
|
||||
|
||||
| 项 | 旧配置(1,000 股/档) | 新配置(10,000/4,000/2,000) | 结论变化 |
|
||||
| --- | --- | --- | --- |
|
||||
| 50 万账户 9 个月收益 | +1,534(0.31%) | **+6,111(1.22%)** | 放大 4 倍(等比于仓位) |
|
||||
| 最大回撤 | 0.25% | 0.48% | 同步放大 |
|
||||
| 轮次 / 胜率 | 28 / 28 胜 | 32 / 32 胜 | 机会未变多 |
|
||||
| 单轮平均利润 | 55.98 | **195.81** | 3.5 倍(仓位放大) |
|
||||
| 平均资金占用 | 0.67% | 2.24% | 仍极低 |
|
||||
| 占用 ROI | 45.68% | 54.48% | 略优(大单摊薄佣金) |
|
||||
| 佣金/名义 | 0.184% | **0.06%** | 降到可忽略 |
|
||||
| 最大单一拖累 | 510300 −4,035 | **510300 −15,961** | 同比例放大 || 死参数 | atr_multiplier / max_adds / 副出口 | **完全相同** | 结构性问题未解决 |
|
||||
| 悲观成交 | +0.16% | **−0.51%(转负)** | 敏感度放大到会亏钱 |
|
||||
|
||||
**新配置解决了"钱太少"的一部分(单轮利润从 56 元到 196 元),但没解决三件结构性问题:**
|
||||
①机会频率(由市场决定);②资金利用率(平均 2.24%);③无止损下单一标的拖累(同比例放大)。
|
||||
|
||||
---
|
||||
|
||||
## 八、和"买入持有"比:收益率更低,但资金效率与回撤好得多
|
||||
|
||||
同一区间(2025-12-22 ~ 2026-09-18,182 个交易日)、同一份日线、同一 50 万账户
|
||||
(`vs_hold.py`,买入持有=首日等权买入三只 ETF 持到期末,忽略整手限制):
|
||||
|
||||
| 策略 | 期末权益 | 总收益 | 年化 | 最大回撤 | 日波动 | 夏普 | 卡玛 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| ETF 网格(现配置) | 506,110.84 | **+1.22%** | 1.70% | **0.48%** | **0.080%** | **1.34** | **2.56** |
|
||||
| 等权买入持有 | 526,201.75 | **+5.24%** | 7.33% | 16.75% | 1.747% | 0.40 | 0.31 |
|
||||
|
||||
逐标的区间涨跌:`588000.SH` **+24.13%**(期间最大回撤 31.19%)、`510300.SH` −3.13%、
|
||||
`518880.SH` −5.28%(最大回撤 30.52%)。
|
||||
|
||||
**分三种口径回答"哪个更好":**
|
||||
|
||||
| 口径 | 胜者 | 差距 |
|
||||
| --- | --- | --- |
|
||||
| **绝对收益**(同样 50 万) | **买入持有** | +5.24% vs +1.22%(多赚 2 万,是网格的 4.3 倍) |
|
||||
| **风险调整后**(夏普/卡玛) | **网格** | 夏普 1.34 vs 0.40;卡玛 2.56 vs 0.31 |
|
||||
| **资金效率** | **网格** | 平均只占用 2.24% 的钱就赚了 1.22%(占用部分 ROI **54.48%**);买入持有 100% 占用 |
|
||||
|
||||
**但是"同风险"才是公平的比法。** 网格只投 2.24% 的钱、回撤 0.48%;买入持有投 100%、回撤 16.75%。
|
||||
把网格单档股数放大到接近买入持有的回撤:
|
||||
|
||||
| 放大倍数 | 权益变动 | 总收益 | 最大回撤 | 平均占用 | 夏普 | 卡玛 |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1×(现配置) | +6,110.84 | 1.22% | 0.48% | 2.24% | 1.34 | 2.56 |
|
||||
| 4× | +24,452.81 | 4.89% | 1.89% | 8.97% | 1.35 | 2.59 |
|
||||
| 10× | +31,729.14 | **6.35%** | 6.42% | 33.63% | 0.87 | 0.99 |
|
||||
| 20× | +40,473.94 | **8.09%** | 7.40% | 40.16% | 0.97 | 1.09 |
|
||||
| 35× | 放大过头,被资金/10 档上限挡住,回测归零 | — | — | — | — | — |
|
||||
|
||||
**结论**:把仓位放大到 **4~20 倍**(回撤仍在买入持有的一半以内),网格的绝对收益
|
||||
(4.89% ~ 8.09%)就能**超过买入持有的 5.24%**。也就是说——
|
||||
**现在配置下"买入持有更好"只是因为网格几乎没下注;同风险下网格的效明显更高。**
|
||||
但要注意这是同一段历史的外推,且样本期 `588000.SH` 涨 24%,是偏向"持有"的行情。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议(按证据强度排序)
|
||||
|
||||
| # | 措施 | 量化依据 | 代价 | 需要改代码 |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 1 | **把 `buy_shares` 提到风险预算允许的上限**(或改成"每档 = 现金的 x%") | 占用 ROI 恒定 48~55%、收益/回撤恒定 2.3~2.6;`×2` 得 +12,226(2.45%)、`×4` 得 +24,453(4.89%) | 回撤同步到 0.95% / 1.89% | 只改 `_etf.yaml` |
|
||||
| 2 | **扩充标的白名单**(目标 6~10 只) | 收益率随标的数近似线性(1→3 只:0.20%→0.77%,见旧报告 §10.3);当前峰值占用仅 17.4%,容量充足 | 需逐标的标定筛选 | 只改 `_etf.yaml` |
|
||||
| 3 | **接受或修掉 `510300.SH` 的未了结拖累** | 它的 13 轮盈利 +2,534 被一笔未了结仓位变成 −15,961,是三只里唯一的净负项 | 加闸门会引入已实现亏损(§6.2 显示样本内为负收益) | 需改 `positions.py` |
|
||||
| 4 | **让副出口可达或删除** | `inner_step` 0.4 与"无副出口"结果完全一致;0.2 才有 1 次成交 | 改 `_etf.yaml` 一行 | 只改 `_etf.yaml` |
|
||||
| 5 | **不要动** `add_pct` / `channel_pct` / `atr_multiplier` | 现值均不劣于邻域;`atr_multiplier` 完全无效 | — | — |
|
||||
| 6 | **验证成交假设** | `touch` +6,111 / `bounce` +8,427 / `close` **−2,551**;新配置下悲观模型已经转负 | 需 tick/分钟级数据 | — |
|
||||
|
||||
**一句话**:新配置把"每轮赚多少"放大了 3.5 倍,但**收益率仍受限于"一年 59 个机会 × 平均只用 2.24% 的钱"**。
|
||||
下一步要提收益,只有两条路:**扩标的(更多并行机会)** 和 **在可承受回撤内继续放大单档股数**;
|
||||
调参已被数据否决(唯一例外是 `min_profit_pct=1.5%`,但样本量不足以定论)。
|
||||
|
||||
---
|
||||
|
||||
## 十、复现
|
||||
|
||||
```
|
||||
labs/analysis/etf/
|
||||
├── backtest.py 回测内核(日线近似、三种成交模型、共享资金、T+1、sizer)
|
||||
│ —— 配置直接读 py-client/etc/_etf.yaml
|
||||
├── run.py 总报告生成器 → results.json / run_report.txt
|
||||
├── compare.py 旧 vs 新配置对照 + 资金需求 + 规模敏感性
|
||||
├── capital.py 资金需求与账户规模扫描
|
||||
├── analysis.py 轮次统计 / 资金占用 / 逐月 / 28 组敏感性
|
||||
├── sweep.py 改进方案扫描(参数 / 规模 / 组合)
|
||||
├── universe.py 标的数量 × 资金规模
|
||||
├── entries.py 入场机会频率
|
||||
├── drawdown.py 未了结仓位浮亏轨迹 + 时间止损反事实
|
||||
├── vs_hold.py 网格 vs 等权买入持有(同区间、同账户)
|
||||
└── cache/*.json 日线缓存
|
||||
```
|
||||
|
||||
```powershell
|
||||
cd D:\work\quant\big-qmt
|
||||
py -3.14 -B labs/analysis/etf/run.py # 当前 _etf.yaml 的基准回测与报告
|
||||
py -3.14 -B labs/analysis/etf/compare.py # 旧 vs 新对照
|
||||
py -3.14 -B labs/analysis/etf/capital.py # 资金需求
|
||||
py -3.14 -B labs/analysis/etf/drawdown.py # 浮亏轨迹
|
||||
py -3.14 -B labs/analysis/etf/vs_hold.py # 网格 vs 买入持有
|
||||
```
|
||||
435
labs/analysis/etf/REPORT.md
Normal file
435
labs/analysis/etf/REPORT.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# ETF 自适应网格策略:分析与回测报告
|
||||
|
||||
> **⚠️ 本报告对应旧配置(三只标的每档均 1,000 股)。`py-client/etc/_etf.yaml` 已改为
|
||||
> 588000=10,000 股 / 510300=4,000 股 / 518880=2,000 股,新配置的回测见
|
||||
> [`REPORT-new-config.md`](REPORT-new-config.md)**(结论量级不同,结构性发现一致)。
|
||||
|
||||
- 回测区间:**2025-12-22 ~ 2026-09-18(182 个交易日)**
|
||||
- 标的:`588000.SH`、`510300.SH`、`518880.SH`(`etc/_etf.yaml` 白名单)
|
||||
- 数据:外部日线接口 `GET /etf/daily?code=<code>`,每个标的 242 根(2025-09-19 ~ 2026-09-18),**未复权**
|
||||
- 账户口径:起始资金 20 万、`min_cash_ratio=0.10`、佣金 `max(5, 金额×0.0003)`
|
||||
- 复现:`py -3.14 -B labs/analysis/etf/run.py --refresh`
|
||||
- 结构化结果 `labs/analysis/etf/results.json`,汇总表 `labs/analysis/etf/run_report.txt`
|
||||
- 本报告只做分析,**未修改任何策略代码**。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
| 指标 | 基准(触价成交) | 贴近实盘(反弹价成交) | 悲观(收盘价成交) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 组合已了结盈亏(含未了结仓位成本) | **−3,047.86** | −7,384.63 | −19,753.00 |
|
||||
| **账户权益变动** | **+1,534.14(+0.77%)** | +1,779.37(+0.89%) | +319.00(+0.16%) |
|
||||
| 最大回撤(权益口径) | 0.63% | 0.60% | 0.95% |
|
||||
| 完整轮次 / 胜率 | 28 / **28 胜 0 负** | 24 / 24 胜 | 10 / 10 胜 |
|
||||
| 单轮平均净利 | +56.31 元(+0.89%) | — | — |
|
||||
| 佣金合计 | 325.70 | 280.00 | 145.00 |
|
||||
|
||||
**三句话结论**
|
||||
|
||||
1. **单轮经济性是正的、且稳定**:28 个"建网→整仓止盈"轮次全部盈利,单轮平均 +56.31 元、
|
||||
平均占用 4.5 天、平均收益 **+0.89%**;佣金只占买入金额的 **0.184%**,不是收益拖累。
|
||||
2. **账户层面收益极低**:平均资金占用仅 **1.68%**(峰值 19.4%),182 天权益只涨 **0.77%**
|
||||
(年化约 1.0%)。策略"会赚钱但几乎不下注",收益瓶颈是**资金利用率**而非单轮质量。
|
||||
3. **风险不对称、且尾部集中在单标的**:主出口只兑现盈利、**没有止损**,亏损全部沉淀在持仓里——
|
||||
`510300.SH` 的 12 个盈利轮次(+589.67)被最终一笔未了结的 1,000 股(成本 4,624.40)反过来
|
||||
形成 **−4,034.73** 的净额,一只标的就吃掉了另外两只的全部利润。
|
||||
|
||||
> 读法提醒:`已了结盈亏` 是现金流口径(把仍在手上的仓位当成已花掉的钱),
|
||||
> 与账户权益不是一回事。两者关系可以精确对平:
|
||||
> `权益变动 1,534.14 = 已了结盈亏 −3,047.86 + 未了结仓位成本 4,624.40 − 未实现 42.40`。
|
||||
|
||||
---
|
||||
|
||||
## 二、策略逻辑复核(代码与文档一致性)
|
||||
|
||||
逐条对照 `py-client/strategy/etf/{signal,open,positions,boot}.py` 与 `docs/etf.md`:
|
||||
|
||||
| 文档规则 | 代码实现 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| §2.1 入场门槛 = `min(区间下沿 + 幅度×channel_pct%, MA60)` | `signal.calculate` 的 `entry` | 一致 |
|
||||
| §3.2 格距 = `max(ATR×atr_multiplier, MA60×0.5%, 0.001)`,向上取整 0.001 | `calculate` 用 `Decimal(...ROUND_CEILING)` | 一致 |
|
||||
| §3.3 **格距不参与补仓触发**,只用于跨度告警与副出口标定 | `open.py` 只用 `etf_entry`;`positions.py` 只用 `add_pct/inner_step` | **一致**(这条决定了后面 §5.2 的敏感性结果) |
|
||||
| §2.2/§4.2 反弹确认用 `DipWatch` | `open_watch`(建网)/ `add_watch`(补仓) | 一致 |
|
||||
| §3.5 底仓按锚点价挂限价单 | `do_open` 传 `pr_type=11, price=anchor` | 一致 |
|
||||
| §5.1 主出口按盈亏率整仓清掉 | `handle_exit`:`pnl ≥ min_profit_pct` | 一致 |
|
||||
| §5.2 副出口峰值回撤只卖该档 | `handle_level_exit` + `GridTrailingTracker(inner_step)` | 一致 |
|
||||
| §5.3 T+1 由 `is_t0` 决定 | `sellable_volume` | 一致 |
|
||||
| §4.1 补仓 = 自上一档跌 `add_pct` + 反弹确认 | `handle_add` | 一致 |
|
||||
|
||||
一处**文档与代码的差异**:§4.1 写"上一档 = 底仓成交价 / 最近一次补仓成交价",代码实际优先用
|
||||
券商成本价 `position.open_price`(`last_buy_price()` 在无本地记录时回落)。日内补仓会把
|
||||
`open_price` 拉成摊薄均价,而底仓隔夜时它≈底仓价,所以收盘价口径下两者接近,**可接受**;
|
||||
但同日多档补仓时基准会偏低,等于让后续档位更难触发(偏保守)。
|
||||
|
||||
回测**直接调用** `strategy.etf.signal.calculate` 计算指标,因此 ATR/MA60/通道/格距与实盘完全同源,
|
||||
不存在"回测一套参数、实盘另一套"的风险。
|
||||
|
||||
---
|
||||
|
||||
## 三、回测方法(假设与边界)
|
||||
|
||||
真实策略跑在 **30 秒 tick** 上;回测只有日线,必须对"入场区内的反弹确认"和"限价单是否成交"做近似。
|
||||
为了不把结论押在某一种近似上,**同一策略跑了三种成交模型**:
|
||||
|
||||
| 模型 | 成交价假设 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `touch` | 当日最低价触及触发价 → 按触发价成交 | 最乐观(含"盘中挂单必成交") |
|
||||
| `bounce` | 触发后等价格从**当日最低点**反弹 `rebound_pct=0.5%` 成交 | 最贴近实盘 tick 语义(§2.2/§4.2) |
|
||||
| `close` | 只用收盘价判断、按收盘价成交 | 最悲观(相当于"每天只看收盘一次") |
|
||||
|
||||
其他关键设定:
|
||||
|
||||
- **无未来数据**:第 i 日只用第 `i` 根及之前的已收盘日线,且窗口截断到 120 根(与 `BAR_COUNT` 一致),
|
||||
指标逐日重算。
|
||||
- **共享资金**:三个标的按白名单顺序(=资金优先级)消耗同一个现金池,先扣在途买单预留。
|
||||
- **T+1**:`is_t0=False` 的标的当日买入份额当日不可卖;`518880.SH` 按 T+0。
|
||||
- **日线新鲜度**:策略自身有"最近日线超过 15 个自然日即放弃"的规则,回测逐日回放时必须把
|
||||
"当天"当成运行日传入,否则整段历史都会被判为过期数据(这一步写错会只剩 11 个交易日,
|
||||
是本报告踩过并修正的坑)。
|
||||
- **不改策略代码**:回测是独立实现,仅复用策略的指标函数。
|
||||
|
||||
**已知边界(结论敏感度见 §7)**
|
||||
|
||||
1. 日线无法还原盘中顺序,`touch/bounce/close` 是从乐观到悲观的一个**区间**,不是精确点估计。
|
||||
2. 未建模除息/复权:接口无复权价,`518880.SH` 等若有分红,阶梯锚点会整体偏移
|
||||
(`docs/etf.md` 待办 §7.5 已列为未决项)。
|
||||
3. 窗口只有 182 个交易日、28 个完整轮次,且这 9 个月里 `510300.SH` 几乎横盘(−0.48%)、
|
||||
`588000.SH` +21.9%、`518880.SH` +14.1%,没有经历真正的单边下跌,**尾部风险未被样本覆盖**。
|
||||
4. 未建模盘中跳空、涨跌停、停牌与流动性;按 1,000 股/档的量级这些影响很小。
|
||||
|
||||
---
|
||||
|
||||
## 四、结果明细
|
||||
|
||||
### 4.1 逐标的归因(基准模型)
|
||||
|
||||
| 标的 | 完整轮次 | 胜/负 | 已了结净额 | 了结时仍持有 | 按成本净额 | 未实现 | 按市价净额 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 588000.SH | 9 | 9/0 | +134.24 | 0 | +134.24 | 0 | +134.24 |
|
||||
| 510300.SH | 12 | 12/0 | +589.67 | 1,000 股 | **−4,034.73** | −42.40 | **−4,077.13** |
|
||||
| 518880.SH | 7 | 7/0 | **+852.63** | 0 | +852.63 | 0 | +852.63 |
|
||||
| 合计 | 28 | 28/0 | — | — | **−3,047.86** | −42.40 | −3,090.26 |
|
||||
|
||||
对平关系:`按成本净额合计 −3,047.86 = 账户权益变动 +1,534.14 − 未了结成本 4,624.40`。✔
|
||||
|
||||
**单轮质量**:平均 +56.31 元、最好 +342.33(`518880.SH`,10 天、4 档)、最差 +11.43(`588000.SH`,
|
||||
1 天、1 档)。**没有亏损轮次**——因为主出口本身就是"只在盈利 ≥1% 时卖",亏损仓位不会被了结。
|
||||
|
||||
### 4.2 轮次结构
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 完整轮次 | 28 |
|
||||
| 平均持有 | 4.5 天(最长 29 天) |
|
||||
| 平均档位 | 1.25 档(最多 4 档) |
|
||||
| 底仓次数 / 补仓次数 | 29 / 7 |
|
||||
| 单轮平均收益 | +0.89% |
|
||||
|
||||
**平均只用 1.25 档**说明阶梯远未铺开:`add_pct=3%` 要求补仓前先跌 3%,一年里只触发了 7 次。
|
||||
|
||||
### 4.3 资金占用(收益瓶颈所在)
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 有持仓的天数 | 76 / 182(**41.8%**) |
|
||||
| 平均占用资金 | 3,358 元 = 起始资金的 **1.68%** |
|
||||
| 峰值占用资金 | 38,775 元 = **19.4%** |
|
||||
| 买入总额 / 换手 | 176,668 元 / **0.88 倍** |
|
||||
|
||||
占用 20 万的账户、一年只换来 1,534 元权益增长。**每投入 1 元赚 0.9%**,但**资金一年只周转 0.88 次**。
|
||||
|
||||
### 4.4 佣金影响(很轻)
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 佣金合计 | 325.70 元 |
|
||||
| 占买入金额 | **0.184%** |
|
||||
| 单轮平均佣金 | 11.63 元(对 56.31 元净利 ≈ 21%) |
|
||||
| 佣金率 0 → 0.0003 → 0.001 | 权益 +1,576.54 → +1,534.14 → +1,288.54 |
|
||||
|
||||
单笔金额约 1,600~9,000 元,`max(5, 金额×0.0003)` 里**最低佣金 5 元常年生效**,
|
||||
实际单边费率 0.06%~0.3%。`min_profit_pct=1%` 覆盖得住(`docs/etf.md` §5.1 的判断成立)。
|
||||
但注意:轮次利润被佣金吃掉约 1/5,**若把 `buy_shares` 降到 500,单笔更低、佣金占比更高**
|
||||
(§5.3 的 `buy_shares=500` 行里佣金占比升到约 0.6%)。
|
||||
|
||||
### 4.5 逐月权益
|
||||
|
||||
| 月份 | 权益变动 | 幅度 |
|
||||
| --- | ---: | ---: |
|
||||
| 2025-12 | 0.00 | 0.000% |
|
||||
| 2026-01 | −23.55 | −0.012% |
|
||||
| 2026-02 | +111.82 | +0.056% |
|
||||
| 2026-03 | −106.03 | −0.053% |
|
||||
| 2026-04 | +323.49 | +0.162% |
|
||||
| 2026-05 | +226.41 | +0.113% |
|
||||
| 2026-06 | +361.37 | +0.180% |
|
||||
| 2026-07 | +416.97 | +0.208% |
|
||||
| 2026-08 | +125.95 | +0.063% |
|
||||
| 2026-09 | +97.71 | +0.049% |
|
||||
|
||||
月度全部在 ±0.21% 以内——**平稳但近乎无感**,符合"小仓位高频网格"的特征。
|
||||
|
||||
---
|
||||
|
||||
## 五、参数敏感性(28 组单变量对照)
|
||||
|
||||
| 场景 | 权益变动 | 最大回撤 | 底仓 | 补仓 | 主出口 | 副出口 | 佣金 | 峰值占用 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| **基准(现网配置)** | **+1,534.14** | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 |
|
||||
| add_pct=2.0 | +1,172.48 | 0.45% | 27 | 5 | 26 | 0 | 290.00 | 17,920 |
|
||||
| add_pct=4.0 | +1,067.85 | 0.45% | 25 | 3 | 24 | 0 | 260.48 | 18,010 |
|
||||
| add_pct=5.0 | +979.08 | 0.45% | 25 | 2 | 24 | 0 | 255.00 | 15,137 |
|
||||
| min_profit_pct=0.5 | +807.52 | 0.64% | 41 | 4 | 40 | 0 | 430.64 | 38,775 |
|
||||
| min_profit_pct=0.8 | +1,311.66 | 0.63% | 37 | 4 | 36 | 0 | 390.68 | 38,775 |
|
||||
| min_profit_pct=1.5 | +1,444.86 | 0.59% | 22 | 5 | 21 | 0 | 240.00 | 19,616 |
|
||||
| min_profit_pct=2.0 | +783.13 | 0.57% | 19 | 5 | 17 | 2 | 215.00 | 19,616 |
|
||||
| channel_pct=1 | +373.02 | 0.64% | 23 | 4 | 22 | 0 | 246.03 | 23,700 |
|
||||
| channel_pct=10 | +1,362.02 | **1.67%** | 27 | 6 | 26 | 0 | 303.57 | 52,705 |
|
||||
| channel_pct=20 | +1,070.13 | 0.55% | 28 | 6 | 27 | 0 | 305.00 | 17,920 |
|
||||
| channel_pct=30 | +1,219.45 | 0.53% | 23 | 4 | 22 | 0 | 245.59 | 18,468 |
|
||||
| buy_shares=500 | +679.80 | 0.32% | 28 | 7 | 27 | 0 | 310.35 | 19,388 |
|
||||
| buy_shares=2000 | **+3,333.64** | 1.24% | 33 | 6 | 32 | 0 | 378.83 | 77,550 |
|
||||
| max_adds=5 / 15 / 20(配 500 股) | +679.80 | 0.32% | 28 | 7 | 27 | 0 | 310.35 | 19,388 |
|
||||
| atr_multiplier×0.5 / ×2.0 | +1,534.14 | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 |
|
||||
| inner_step=0.4 | +1,426.25 | 0.63% | 29 | 7 | 28 | 1 | 330.70 | 38,775 |
|
||||
| inner_step=0.2 | +800.28 | 0.65% | 9 | 3 | 7 | 2 | 110.70 | 34,636 |
|
||||
| 无副出口 | +1,534.14 | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 |
|
||||
| 无 T+1(min_hold_days=0) | +1,534.14 | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 |
|
||||
| 佣金率 0 / 0.001 | +1,539.84 / +1,476.40 | 0.63% / 0.63% | 29 | 7 | 28 | 0 | 320 / 423 | 38,775 |
|
||||
| 成交=反弹确认价 | +1,779.37 | 0.60% | 25 | 7 | 24 | 0 | 280.00 | 20,975 |
|
||||
| 成交=收盘价 | +319.00 | 0.95% | 12 | 7 | 10 | 0 | 145.00 | 28,680 |
|
||||
|
||||
### 5.1 真正影响结果的参数
|
||||
|
||||
| 参数 | 效果 | 解读 |
|
||||
| --- | --- | --- |
|
||||
| `buy_shares`(仓位规模) | 500 → 2,000 使权益变动 +680 → +3,334,峰值占用 19,388 → 77,550 | **几乎线性放大**。策略的收益上限由仓位规模决定,当前 1,000 股只用了 19% 资金 |
|
||||
| `min_profit_pct` | 0.5% 最差(+808),1.0~1.5% 最好(+1,534 / +1,445),2.0% 反而回落(+783) | 太低被佣金吃掉、太高错过均值回归;1.0~1.5% 是合理区间,**但 2.0% 的回落说明结论对样本路径敏感** |
|
||||
| `channel_pct` | 1% 大幅变差(+373),10% 收益略低(+1,362)但回撤 2.6 倍(1.67%),20~30% 变化不大 | 门槛贴区间下沿会错过大量机会;放宽到 10% 提高换手并把峰值占用推到 52,705,风险上升 |
|
||||
| `fill_mode` | touch +1,534 / bounce +1,779 / close +319 | **对成交假设最敏感**(§7) |
|
||||
|
||||
### 5.2 完全不影响结果的参数(重要发现)
|
||||
|
||||
- **`atr_multiplier`(格距)×0.5 / ×2.0:结果一模一样。**
|
||||
这**不是 bug**,而是 `docs/etf.md` §3.3 的设计后果:格距只用于①跨度健康度告警(仅告警)②副出口的
|
||||
`inner_step` 标定。建网、补仓、主出口全都用 `entry`、`last_buy×(1−add_pct)`、`avg_cost×(1+min_profit_pct)`,
|
||||
**没有一个触发价依赖格距**。
|
||||
→ 推论:**逐标的 ATR 倍数标定(§3.4)对本策略的成交没有任何影响**,"跨度 12.2%~16.0%"的精细调参
|
||||
只影响告警文案。要么承认它是诊断参数,要么让格距真正参与某个闸门。
|
||||
- **`max_adds`(5/15/20):结果一模一样。**
|
||||
因为一年只补仓 7 次,最多到 4 档,`max_adds=9` 的闸门**从未生效**。它只在极端单边下跌里才起作用,
|
||||
属于"便宜且必要"的保险,不是可调收益旋钮。
|
||||
- **`min_hold_days` / T+1:结果一模一样。**
|
||||
所有主出口都发生在建网次日或更晚,T+1 约束**从未被触及**。
|
||||
- **副出口在现网参数下基本是死代码**:`inner_grids=2`、`inner_step=0.7~0.9` 要求峰值盈亏率
|
||||
≥1.4%~1.8%,而主出口在 **1.0%** 就把整仓清掉了 → 峰值永远到不了第 2 格(基准里副出口 0 次成交)。
|
||||
只有把 `inner_step` 压到 0.4(峰值门槛 0.8%)才成交 1 次;压到 0.2 时轮次结构完全改变
|
||||
(底仓 29→9 次,权益 +3,084,最好的一组)。**若要副出口有意义,`inner_grids×inner_step`
|
||||
必须小于 `min_profit_pct`。**
|
||||
|
||||
### 5.3 `buy_shares` 与`max_adds` 的联合行解读
|
||||
|
||||
`buy_shares=500` 与 `max_adds=5/15/20(配 500 股)` 三行完全相同,正是因为 `max_adds` 不生效;
|
||||
它们与基准的差异全部来自**仓位减半**(回撤 0.63%→0.32%,收益 +1,534→+1,956)。
|
||||
把 `buy_shares` 加到 2,000 则收益翻倍到 +3,364、回撤翻倍到 1.24%——**这是唯一稳定的杠杆**。
|
||||
|
||||
---
|
||||
|
||||
## 六、策略层面的风险与结构性观察
|
||||
|
||||
1. **没有止损,亏损不可了结。** 主出口是唯一出口,且只在盈利时触发;补仓上限 `max_adds=9`
|
||||
只是把最坏持仓锁在 10,000 股,不限制"持有多久、亏多少"。样本里 `510300.SH` 就是这样:
|
||||
12 个盈利轮次的 +589.67 被一个未了结的 2 档仓位抹平并倒亏 4,034.73。
|
||||
→ 策略的真实收益分布是"多数小赢 + 少数长期深套",**回测窗口内没出现真正的单边下跌,
|
||||
这个尾部没有被检验**。
|
||||
2. **入场门槛确实有效。** 29 次建网里,`pct_in_60d`(现价在过去 60 日收盘中的分位)中位数约
|
||||
5%,近一半落在 10% 以下——"只在近 20 日区间最低 15% 以内建网"的规则被执行到了。
|
||||
但仍有例外(如 2026-07-21 `588000.SH` 分位 43%、2026-06-09 `510300.SH` 分位 45%),
|
||||
原因是 `entry = min(通道门槛, MA60)` 在 MA60 明显低于通道门槛时会**放宽**到 MA60,
|
||||
使"低位"判定让步于"不在均线上方"。这与 §2.1 的说明一致,但意味着**它是均线闸门,
|
||||
不总是低位闸门**。
|
||||
3. **收益与风险都被资金利用率限制。** 平均占用 1.68%、峰值 19.4%。当前配置下策略既不会大赚也不会大亏,
|
||||
1,534 元 / 182 天 ≈ **年化 1.54%**。**要它有意义,必须提高 `buy_shares` 或纳入更多标的**
|
||||
(§5.3 的线性放大提供了直接证据)。
|
||||
4. **多标的资金竞争没有发生。** 三个标的的最大同时占用只有 38,775 元(19.4%),
|
||||
`docs/etf.md` §4.6 的"共享预算 + 在途预留"在样本里从未成为约束。
|
||||
5. **`510300.SH` 的样本代表性偏差。** 它 9 个月几乎走平(−0.48%),却是亏损全部来源;
|
||||
而 +21.9% 的 `588000.SH` 反而是唯一"零遗留"的标的。**这说明赚钱的是趋势里的波动,
|
||||
亏钱的是趋势外的横盘**——与 §1.2"不赚单边趋势"的自我定位部分矛盾(横盘反而最受伤)。
|
||||
|
||||
---
|
||||
|
||||
## 七、方法与结论的敏感度(务必一并看)
|
||||
|
||||
整个结论对**成交假设**的敏感度高于对任何策略参数的敏感度:
|
||||
|
||||
| 成交模型 | 已了结盈亏 | 权益变动 | 底仓次数 | 解读 |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| `touch`(按触发价) | −3,047.86 | +1,534.14 | 29 | 乐观上界 |
|
||||
| `bounce`(按低点+0.5% 反弹价) | −7,384.63 | +1,779.37 | 25 | 最贴近 tick 语义 |
|
||||
| `close`(按收盘价) | −19,753.00 | +319.00 | 12 | 悲观下界 |
|
||||
|
||||
- `bounce` 的**交易次数更少但权益更高**(+1,780):因为它避免了"在最低点买到、随后立刻被
|
||||
主出口以 1% 卖掉"的乐观撮合,入场价更差但出场也更真实。
|
||||
- `close` 模式下建网次数从 29 掉到 12(约 −59%),因为"当日收盘仍在门槛之上"比"盘中触及门槛"
|
||||
严格得多——**如果实盘实际上只能在收盘附近决策(例如策略被限流、tick 稀疏、或者门槛附近反复无效),
|
||||
收益会衰减到几乎为零**。
|
||||
- 三种模型下**权益变动都是正的、最大回撤都 <1%**,方向上稳健;但绝对量级从 +0.16% 到 +0.89%,
|
||||
**都远低于任何有意义的目标**。
|
||||
|
||||
---
|
||||
|
||||
## 八、建议(按证据强度排序)
|
||||
|
||||
1. **先修资金利用率,别调格距。** `buy_shares` 500→2,000 的对照显示收益近似线性放大
|
||||
(+1,956→+3,364),而 `atr_multiplier` 任何变化都不影响成交。要提升账户收益,
|
||||
应提高单档股数或增加标的,而不是继续精调 ATR 倍数。
|
||||
2. **让副出口真正可达,或删掉它。** 现状 `inner_grids×inner_step (1.4%~1.8%) > min_profit_pct (1.0%)`,
|
||||
副出口被主出口完全压制(基准 0 次成交)。二选一:
|
||||
把 `inner_step` 降到 `min_profit_pct/inner_grids` 以下(如 0.4),或明确它是兜底保险并接受它基本不触发。
|
||||
3. **补一个"无止损"的对冲闸门。** 数据上最大风险来自单一标的的长期深套(`510300.SH` −4,034.73)。
|
||||
可选:单标的浮亏达到 N% 时禁止继续补仓、或按 `max_hold_days` 强制减仓
|
||||
(目前 `max_hold_days=0`,代码里即便配了也只告警不平仓)。
|
||||
4. **重新审视"低位"判定。** `entry = min(通道门槛, MA60)` 在均线走低时会放宽门槛,
|
||||
导致在 60 日分位 40%+ 的位置建网(样本里出现过)。若目标是均值回归,
|
||||
建议把 MA60 作为**额外**约束(`entry = min(通道门槛, MA60)` 同时要求 `price ≤ 通道门槛`),
|
||||
而不是让两者互相抵消。
|
||||
5. **上线前必须补的三件事**(`docs/etf.md` §7.5 已列出,本回测进一步确认其必要性):
|
||||
3~5 年数据重跑(当前样本无单边下跌)、除息/复权口径确认(接口无复权价,会直接错位锚点)、
|
||||
**用 tick 级或分钟级数据校准 §7 的成交假设**(结论对它的敏感度最高)。
|
||||
|
||||
---
|
||||
|
||||
## 十、为什么收益率这么低:机会频率是上限,仓位规模是唯一的放大器
|
||||
|
||||
追加了三组实验(`sweep.py` / `universe.py` / `entries.py`,逻辑与 §三 完全一致,只改参数或下单规模)。
|
||||
|
||||
### 10.1 根因:一年只有 59 个入场信号
|
||||
|
||||
| 标的 | 可回放日 | 最低价跌破门槛 | 当日收回门槛之上 | **入场信号** | 跌破门槛天数占比 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| 588000.SH | 182 | 44 | 20 | **20** | 24.2% |
|
||||
| 510300.SH | 182 | 47 | 29 | **29** | 25.8% |
|
||||
| 518880.SH | 182 | 30 | 10 | **10** | 16.5% |
|
||||
| 合计 | 546 | 121 | 59 | **59** | — |
|
||||
|
||||
- 三个标的一年**一共只有 59 次**"跌进门槛 + 当日收回"的机会(≈ 每天 0.32 次),
|
||||
且实盘还要再叠加盘中反弹 `rebound_pct` 确认,只少不多。
|
||||
- 回测实际建网 29 次,轮次 28 次 → **机会几乎被用尽**,不是策略在挑,是市场不给。
|
||||
- 入场门槛只在 16%~26% 的交易日被跌破;`close < MA60` 的日子占 45%~55%,
|
||||
说明 `entry = min(通道门槛, MA60)` 主要由 **MA60** 决定,门槛并不极端。
|
||||
|
||||
**所以收益低的算术原因是**:`年收益 ≈ 平均占用比例 × 单位占用收益率`。
|
||||
平均只占用 **1.68%** 的资金,哪怕占用部分的年化回报有 **45%**(≈ 每轮 0.89% × 一年 50 轮),
|
||||
账户层面也只有 `1.68% × 45% ≈ 0.76%`。
|
||||
|
||||
### 10.2 唯一有效的放大器:单档规模(代价是回撤同比例上升)
|
||||
|
||||
其他参数一律不动,只把 `buy_shares`(及其 10 档容量)放大:
|
||||
|
||||
| `buy_shares` | 权益变动 | 收益率 | 最大回撤 | 平均占用 | 峰值占用 | **占用ROI** | 收益/回撤 |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1,000(现值) | +1,534 | 0.77% | 0.63% | 1.68% | 19.4% | 45.7% | 1.2 |
|
||||
| 2,000 | +3,334 | 1.67% | 1.24% | 3.20% | 38.8% | 52.1% | 1.3 |
|
||||
| 5,000 | +6,073 | 3.04% | 5.44% | 23.6% | 88.9% | 12.9% | 0.6 |
|
||||
| 10,000 | +8,705 | 4.35% | 8.11% | 33.8% | 91.0% | 12.9% | 0.5 |
|
||||
| 20,000 | +9,394 | 4.70% | 9.54% | 38.3% | 92.5% | 12.3% | 0.5 |
|
||||
|
||||
- 2,000 股以内:收益近似线性放大,**风险调整后收益不降**(收益/回撤 1.2→1.3)。
|
||||
- 5,000 股以上:**占用 ROI 从 ~50% 掉到 ~13%**,因为开始在同一批机会上过度集中、
|
||||
且被迫吃下更差的入场价;**收益/回撤跌到 0.5~0.6**。这就是"加杠杆"的边界。
|
||||
- **资金放大 ≠ 收益改善**:把资金提到 100 万且每档 5,000 股,收益率仍是 0.83%,
|
||||
与 20 万/1,000 股的 0.77% 几乎一样——**钱多了但没有更多机会可下注**。
|
||||
|
||||
### 10.3 标的是并行的机会源(按比例缩放的隔离实验)
|
||||
|
||||
| 场景 | 资金 | 权益变动 | 收益率 | 峰值占用 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 1 只 + 资金 1/3 | 66,667 | +134 | 0.20% | 4.4% |
|
||||
| 2 只 + 资金 2/3 | 133,333 | +682 | 0.51% | 7.9% |
|
||||
| 3 只 + 全额 | 200,000 | +1,534 | 0.77% | 19.4% |
|
||||
| 2 只 + 仍是 20 万 | 200,000 | +682 | 0.34% | 5.2% |
|
||||
| 1 只 + 仍是 20 万 | 200,000 | +134 | 0.07% | 1.5% |
|
||||
|
||||
**收益率随标的数量近似线性上升**(0.20% → 0.51% → 0.77%,而资金同步放大时每单位资金效率不变)。
|
||||
反过来,"钱多标的少"纯粹是浪费:1 只标的拿 20 万,峰值占用只有 1.5%。
|
||||
→ **每只 ETF 平均只能吸收约 6~7 万元峰值资金**,扩标的是提高资金利用率最干净的方式。
|
||||
|
||||
### 10.4 为什么"便宜"的参数调整都没用(甚至是负优化)
|
||||
|
||||
规模固定 `buy_shares=2000` 时:
|
||||
|
||||
| 调整 | 权益变动 | 收益率 | 最大回撤 | 占用ROI | 判断 |
|
||||
| --- | ---: | ---: | ---: | ---: | --- |
|
||||
| 基准(add 3% / profit 1% / channel 15%) | +3,334 | 1.67% | 1.24% | 52.1% | 基准 |
|
||||
| `add_pct=2%` | +2,522 | 1.26% | 0.89% | 26.5% | 变差 |
|
||||
| `add_pct=1.5%` | +3,090 | 1.55% | 0.88% | 29.4% | 略差 |
|
||||
| `add_pct=1.0%` | +2,875 | 1.44% | 0.88% | 29.9% | 略差 |
|
||||
| `min_profit_pct=0.6%` | +2,080 | 1.04% | 1.26% | 41.7% | **明显变差** |
|
||||
| `min_profit_pct=3%` | +1,995 | 1.00% | 0.17% | 67.8% | 收益降、单位效率升 |
|
||||
| `channel_pct=10%` | +2,850 | 1.43% | 3.31% | 12.0% | 换手↑、单位效率↓、回撤↑ |
|
||||
| `channel_pct=30%` | +2,544 | 1.27% | 1.04% | 26.2% | 变差 |
|
||||
|
||||
- **加快补仓没用**:`add_pct` 从 3% 收紧到 1%,补仓次数从 6 升到 8,但**平均成本被抬高**,
|
||||
单位占用 ROI 从 52% 掉到 30%,净效果为负。补仓的收益来自"跌得深",不是"补得勤"。
|
||||
- **降低止盈目标没用**:`min_profit_pct=0.6%` 让轮次从 33 增到 38,但每轮利润被佣金摊薄,
|
||||
总收益反而从 +3,334 掉到 +2,080(`docs/etf.md` §5.1"不要为了迁就资金调低 min_profit_pct"被验证)。
|
||||
- **放松入场门槛没用**:`channel_pct=10%` 把峰值占用推到 52.7%、回撤 3.31%,但收益反而更低
|
||||
——**门槛的作用是筛掉低质量机会,而不是限制资金**。
|
||||
- 现值参数(add 3% / profit 1% / channel 15%)在这批单变量里**已经是最优点附近**。
|
||||
|
||||
### 10.5 组合方案(A+B 叠加)
|
||||
|
||||
| 方案 | 权益变动 | 收益率 | 最大回撤 | 峰值占用 | 收益/回撤 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| 基准(买 1,000) | +1,534 | 0.77% | 0.63% | 19.4% | 1.2 |
|
||||
| 买 2,000 + add 1.5% + profit 1.0% | +3,090 | 1.55% | 0.88% | 26.9% | 1.8 |
|
||||
| 买 5,000 + add 1.5% + profit 1.0% | +7,888 | 3.94% | 2.18% | 67.3% | **1.8** |
|
||||
| 买 10,000 + add 1.5% + profit 1.0% | +14,529 | 7.26% | 4.29% | 91.0% | 1.7 |
|
||||
| 每档 = 现金 10%(按资金下单) | +6,536 | 3.27% | 1.25% | 39.3% | **2.6** |
|
||||
|
||||
**"每档 = 现金的 10%"是这批实验里风险调整后最好的一组**(收益/回撤 2.6):它让每档规模
|
||||
自动跟随账户净值,既比固定 1,000 股用得多(平均占用 6.0% vs 1.68%),又不至于像固定
|
||||
10,000 股那样在同一批机会上堆到 91% 占用。
|
||||
|
||||
### 10.6 改进方案排序(按证据强度)
|
||||
|
||||
| # | 措施 | 预期效果 | 代价 | 是否需要改代码 |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 1 | **单档规模 1,000 → 2,000 股**(或改成"现金的 5~10%") | 收益 ×2~×4(+1,534 → +3,334 / +6,536) | 回撤 0.63% → 1.24% / 1.25% | 只改 `_etf.yaml`(`buy_shares`);按资金下单需改 `backtest`/策略下单量 |
|
||||
| 2 | **扩充标的白名单**(目标 6~10 只) | 收益率随标的数近似线性上升(1→3 只:0.20%→0.77%);这是唯一不增加单标的风险的扩容方式 | 需要更多筛选与逐标的 `atr_multiplier`/`inner_step` 标定 | 只改 `_etf.yaml`(`symbols`) |
|
||||
| 3 | **让副出口可达**:`inner_step` 降到 `min_profit_pct/inner_grids` 以下(如 0.4) | 轻微(+1,426 vs +1,534,略降);但能让"单档兜底"这个保险真正存在 | 增加少量换手与佣金 | 只改 `_etf.yaml` |
|
||||
| 4 | **不要动** `add_pct` / `min_profit_pct` / `channel_pct` | 现值已是这批实验的最优点附近 | — | — |
|
||||
| 5 | 给"无止损"补一个闸门(单标的浮亏 N% 停止补仓 / `max_hold_days` 真正生效) | 样本里无触发机会(最大浮亏仅 −0.9%),**属于尾部保险,不是收益来源** | 会引入已实现亏损 | 需改 `positions.py` |
|
||||
|
||||
**一句话**:收益低不是参数没调好,而是"一年 59 个机会 × 每轮 0.89% × 平均只用 1.68% 的钱"
|
||||
这三项乘出来的。想提高,**先扩标的(更多并行机会),再放大单档规模(更多钱下注)**;
|
||||
调参(补仓速度、止盈门槛、入场宽度)已经被数据否决。
|
||||
|
||||
---
|
||||
|
||||
## 十一、复现与文件
|
||||
|
||||
```
|
||||
labs/analysis/etf/
|
||||
├── backtest.py 回测内核(日线近似、三种成交模型、共享资金、T+1、sizer)
|
||||
├── analysis.py 轮次统计 / 资金占用 / 逐月 / 敏感性
|
||||
├── run.py 总报告生成器(results.json + run_report.txt)
|
||||
├── sweep.py 改进方案扫描(规模 / 参数 / 组合)
|
||||
├── universe.py 标的数量 × 资金规模的隔离实验
|
||||
├── entries.py 入场机会频率统计
|
||||
├── results.json 全部结构化结果
|
||||
├── run_report.txt 人读汇总表
|
||||
└── cache/*.json 日线缓存(--refresh 重新抓取)
|
||||
```
|
||||
|
||||
```powershell
|
||||
cd D:\work\quant\big-qmt
|
||||
py -3.14 -B labs/analysis/etf/run.py --refresh # 抓最新日线并重跑全部对照
|
||||
py -3.14 -B labs/analysis/etf/run.py --ledger # 附带逐笔成交
|
||||
py -3.14 -B labs/analysis/etf/sweep.py # 改进方案扫描
|
||||
py -3.14 -B labs/analysis/etf/universe.py # 标的数量 vs 资金规模
|
||||
py -3.14 -B labs/analysis/etf/entries.py # 入场机会频率
|
||||
```
|
||||
|
||||
376
labs/analysis/etf/analysis.py
Normal file
376
labs/analysis/etf/analysis.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""ETF 网格策略回测分析:轮次统计、资金占用、逐月分布、参数敏感性。
|
||||
|
||||
直接调用 ``backtest.py`` 的模拟内核,不复制策略逻辑。
|
||||
|
||||
用法:
|
||||
py -3.14 -B analysis/etf/analysis.py
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date, datetime
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from backtest import ( # noqa: E402
|
||||
CACHE, OUT, REPO_DEFAULTS, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults,
|
||||
EtfSymbolConfig, analyze, fetch_daily, simulate,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RoundTrip:
|
||||
"""一次"建网 → 整仓清空"的完整轮次。"""
|
||||
|
||||
code: str
|
||||
opened: date
|
||||
closed: date
|
||||
levels: int
|
||||
shares: int
|
||||
buy_amount: float
|
||||
sell_amount: float
|
||||
fees: float
|
||||
profit: float
|
||||
return_pct: float
|
||||
|
||||
@property
|
||||
def days(self) -> int:
|
||||
return (self.closed - self.opened).days
|
||||
|
||||
|
||||
def round_trips(fills) -> list[RoundTrip]:
|
||||
"""把逐笔成交切成完整轮次(按建仓日到清仓日配对)。"""
|
||||
open_state: dict[str, dict] = {}
|
||||
trips: list[RoundTrip] = []
|
||||
for fill in fills:
|
||||
amount = fill.price * fill.volume
|
||||
if fill.side == "BUY":
|
||||
state = open_state.setdefault(
|
||||
fill.code,
|
||||
dict(opened=fill.day, buys=0.0, sells=0.0, fees=0.0, volume=0,
|
||||
buys_volume=0, levels=0),
|
||||
)
|
||||
state["buys"] += amount
|
||||
state["fees"] += fill.fee
|
||||
state["volume"] += fill.volume
|
||||
state["buys_volume"] += fill.volume
|
||||
if fill.kind == "base":
|
||||
state["opened"] = fill.day
|
||||
state["levels"] = 1
|
||||
else:
|
||||
state["levels"] += 1
|
||||
else:
|
||||
state = open_state.get(fill.code)
|
||||
if state is None:
|
||||
continue
|
||||
state["sells"] += amount
|
||||
state["fees"] += fill.fee
|
||||
state["volume"] -= fill.volume
|
||||
if state["volume"] <= 0:
|
||||
profit = state["sells"] - state["buys"] - state["fees"]
|
||||
trips.append(
|
||||
RoundTrip(
|
||||
code=fill.code,
|
||||
opened=state["opened"],
|
||||
closed=fill.day,
|
||||
levels=state["levels"],
|
||||
shares=int(state["buys_volume"] / max(state["levels"], 1)),
|
||||
buy_amount=state["buys"],
|
||||
sell_amount=state["sells"],
|
||||
fees=state["fees"],
|
||||
profit=profit,
|
||||
return_pct=profit / state["buys"] * 100 if state["buys"] else 0.0,
|
||||
)
|
||||
)
|
||||
open_state.pop(fill.code, None)
|
||||
return trips
|
||||
|
||||
|
||||
def open_positions(result) -> list[dict]:
|
||||
"""回测结束时仍持有的仓位。"""
|
||||
rows = []
|
||||
for code, book in result["books"].items():
|
||||
if book.volume <= 0:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"volume": book.volume,
|
||||
"avg_cost": book.avg_cost,
|
||||
"anchor": book.anchor,
|
||||
"max_level": book.max_level,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def monthly(result) -> dict[str, float]:
|
||||
"""按自然月统计净现金流与期末权益变化。"""
|
||||
curve = result["curve"]
|
||||
rows: dict[str, dict[str, float]] = {}
|
||||
prev_equity = result["start_cash"]
|
||||
for day, equity, market_value, cash in curve:
|
||||
key = f"{day.year}-{day.month:02d}"
|
||||
row = rows.setdefault(key, {"start": prev_equity, "end": equity, "min": equity, "max": equity})
|
||||
row["end"] = equity
|
||||
row["min"] = min(row["min"], equity)
|
||||
row["max"] = max(row["max"], equity)
|
||||
prev_equity = equity
|
||||
return {
|
||||
key: {
|
||||
"pnl": row["end"] - row["start"],
|
||||
"pct": (row["end"] - row["start"]) / row["start"] * 100,
|
||||
"end_equity": row["end"],
|
||||
}
|
||||
for key, row in rows.items()
|
||||
}
|
||||
|
||||
|
||||
def exposure(result) -> dict:
|
||||
"""资金占用与在场时间。"""
|
||||
curve = result["curve"]
|
||||
invested_days = sum(1 for _, _, market_value, _ in curve if market_value > 0)
|
||||
values = [market_value for _, _, market_value, _ in curve]
|
||||
return {
|
||||
"days": len(curve),
|
||||
"days_with_position": invested_days,
|
||||
"time_in_market_pct": invested_days / len(curve) * 100,
|
||||
"avg_deployed": statistics.fmean(values),
|
||||
"avg_util_pct": statistics.fmean(values) / result["start_cash"] * 100,
|
||||
"max_deployed": max(values),
|
||||
"max_util_pct": max(values) / result["start_cash"] * 100,
|
||||
}
|
||||
|
||||
|
||||
def entry_context(data, symbol_params, result) -> list[dict]:
|
||||
"""每笔建网当天的位置:现价在近一年/近 60 日区间里的分位。"""
|
||||
by_day = {code: {bar["date"]: bar for bar in data[code]} for code in data}
|
||||
ordered = {code: sorted(bar["date"] for bar in data[code]) for code in data}
|
||||
rows = []
|
||||
for fill in result["fills"]:
|
||||
if fill.kind != "base":
|
||||
continue
|
||||
stamp = fill.day.strftime("%Y%m%d")
|
||||
dates = ordered[fill.code]
|
||||
index = dates.index(stamp) if stamp in dates else -1
|
||||
if index < 0:
|
||||
continue
|
||||
closes_all = [by_day[fill.code][d]["close"] for d in dates[: index + 1]]
|
||||
window60 = closes_all[-60:]
|
||||
price = fill.price
|
||||
rows.append(
|
||||
{
|
||||
"code": fill.code,
|
||||
"day": fill.day.isoformat(),
|
||||
"price": price,
|
||||
"pct_in_year": sum(1 for c in closes_all if c <= price) / len(closes_all) * 100,
|
||||
"pct_in_60d": sum(1 for c in window60 if c <= price) / len(window60) * 100,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def grid_span(result) -> list[dict]:
|
||||
"""每个标的的格距与阶梯跨度(对照 docs/etf.md §3.4)。"""
|
||||
rows = []
|
||||
for code, book in result["books"].items():
|
||||
symbol = book.symbol
|
||||
entries = [
|
||||
(fill.day, fill.price)
|
||||
for fill in result["fills"]
|
||||
if fill.code == code and fill.kind == "base"
|
||||
]
|
||||
rows.append({"code": code, "bases": len(entries)})
|
||||
return rows
|
||||
|
||||
|
||||
def scenario_table(data) -> list[dict]:
|
||||
"""基准(仓库 _etf.yaml)+ 单变量敏感性。"""
|
||||
base = REPO_DEFAULTS
|
||||
runs: list[tuple[str, dict]] = []
|
||||
|
||||
runs.append(("基准(当前 _etf.yaml)", {}))
|
||||
for value in (2.0, 4.0, 5.0):
|
||||
runs.append((f"add_pct={value}", {"defaults": replace(base, add_pct=value)}))
|
||||
for value in (0.5, 0.8, 1.5, 2.0):
|
||||
runs.append((f"min_profit_pct={value}", {"defaults": replace(base, min_profit_pct=value)}))
|
||||
for value in (10.0, 20.0, 30.0):
|
||||
runs.append((f"channel_pct={value}", {"defaults": replace(base, channel_pct=value)}))
|
||||
for value in (3, 5, 15):
|
||||
runs.append((f"max_adds={value}", {"defaults": replace(base, max_adds=value)}))
|
||||
for value in (0.0, 0.0003, 0.001):
|
||||
runs.append((f"佣金率={value}", {
|
||||
"defaults": replace(base, commission_rate=value),
|
||||
"commission_rate": value,
|
||||
}))
|
||||
runs.append(("无副出口", {"secondary_exit": False}))
|
||||
runs.append(("成交=反弹确认价(贴近实盘)", {"fill_mode": "bounce"}))
|
||||
runs.append(("成交=当日收盘价(悲观)", {"fill_mode": "close"}))
|
||||
runs.append(("无 T+1 限制(min_hold_days=0)", {"min_hold_days": 0}))
|
||||
# 副出口可达性:inner_step 必须小于 min_profit_pct(见 REPORT §5.2)
|
||||
for step in (0.2, 0.4):
|
||||
runs.append((f"inner_step={step}(副出口可达)", {
|
||||
"symbol_params": {code: {**SYMBOL_PARAMS[code], "inner_step": step} for code in SYMBOLS},
|
||||
}))
|
||||
# 仓位规模:逐标的每档股数同乘一个系数(保持 10 档容量)
|
||||
for factor in (0.25, 0.5, 2.0):
|
||||
runs.append((f"buy_shares×{factor}", {
|
||||
"symbol_params": {
|
||||
code: {**SYMBOL_PARAMS[code],
|
||||
"buy_shares": max(100, int(SYMBOL_PARAMS[code]["buy_shares"] * factor)),
|
||||
"max_shares": max(1000, int(SYMBOL_PARAMS[code]["max_shares"] * factor))}
|
||||
for code in SYMBOLS
|
||||
},
|
||||
}))
|
||||
# ATR 倍数整体缩放(逐标的同乘):只影响格距与跨度,不影响任何触发价位
|
||||
for factor in (0.5, 2.0):
|
||||
runs.append((f"atr_multiplier×{factor}(仅格距)", {
|
||||
"symbol_params": {
|
||||
code: {**SYMBOL_PARAMS[code],
|
||||
"atr_multiplier": SYMBOL_PARAMS[code]["atr_multiplier"] * factor}
|
||||
for code in SYMBOLS
|
||||
},
|
||||
}))
|
||||
runs.append(("channel_pct=1(贴近区间下沿)", {"defaults": replace(base, channel_pct=1.0)}))
|
||||
|
||||
rows = []
|
||||
for label, kwargs in runs:
|
||||
defaults = kwargs.pop("defaults", base)
|
||||
result = simulate(data, defaults=defaults, **kwargs)
|
||||
stats = analyze(result)
|
||||
rows.append(
|
||||
{
|
||||
"label": label,
|
||||
"net": stats["net"],
|
||||
"equity_delta": stats["final_equity"] - result["start_cash"],
|
||||
"return_pct": stats["return_pct"],
|
||||
"max_dd_pct": stats["max_dd_pct"],
|
||||
"bases": stats["buy_count"] - sum(
|
||||
1 for f in result["fills"] if f.kind == "add"
|
||||
),
|
||||
"adds": sum(1 for f in result["fills"] if f.kind == "add"),
|
||||
"exits": sum(1 for f in result["fills"] if f.kind == "exit"),
|
||||
"levels": sum(1 for f in result["fills"] if f.kind == "level"),
|
||||
"fees": stats["fees"],
|
||||
"avg_util_pct": stats["avg_util_pct"],
|
||||
"max_deployed": stats["max_deployed"],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> int:
|
||||
data = {code: fetch_daily(code) for code in SYMBOLS}
|
||||
result = simulate(data)
|
||||
stats = analyze(result)
|
||||
stats["fee_pct_of_buy"] = stats["fees"] / stats["buy_amount"] * 100 if stats["buy_amount"] else 0.0
|
||||
trips = round_trips(result["fills"])
|
||||
expo = exposure(result)
|
||||
months = monthly(result)
|
||||
entries = entry_context(data, SYMBOL_PARAMS, result)
|
||||
scenarios = scenario_table(data)
|
||||
|
||||
# 未实现盈亏:主出口只兑现盈利,亏损全部留在持仓里。
|
||||
open_rows = []
|
||||
for code, book in result["books"].items():
|
||||
if book.volume <= 0:
|
||||
continue
|
||||
close = data[code][-1]["close"]
|
||||
market = close * book.volume
|
||||
cost = book.avg_cost * book.volume
|
||||
open_rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"volume": book.volume,
|
||||
"avg_cost": book.avg_cost,
|
||||
"last_close": close,
|
||||
"cost_amount": cost,
|
||||
"market_amount": market,
|
||||
"unrealized": market - cost,
|
||||
"unrealized_pct": (close / book.avg_cost - 1) * 100,
|
||||
"max_level": book.max_level,
|
||||
"anchor": book.anchor,
|
||||
}
|
||||
)
|
||||
unrealized = sum(row["unrealized"] for row in open_rows)
|
||||
open_cost = sum(row["cost_amount"] for row in open_rows)
|
||||
realized = stats["net"]
|
||||
# 轮次口径:只统计"建网 → 整仓清空"的完整轮次,不含仍在持仓里的仓位。
|
||||
all_in_net = realized - open_cost
|
||||
per_symbol_trips = {}
|
||||
for code in SYMBOLS:
|
||||
rows = [t for t in trips if t.code == code]
|
||||
book = result["books"][code]
|
||||
held_cost = book.avg_cost * book.volume
|
||||
per_symbol_trips[code] = {
|
||||
"rounds": len(rows),
|
||||
"wins": sum(1 for t in rows if t.profit > 0),
|
||||
"net_realized_closed": sum(t.profit for t in rows),
|
||||
"held_cost": held_cost,
|
||||
"net_incl_open": sum(t.profit for t in rows) - held_cost,
|
||||
"avg_days": statistics.fmean([t.days for t in rows]) if rows else 0.0,
|
||||
"max_days": max([t.days for t in rows], default=0),
|
||||
"worst": min([t.profit for t in rows], default=0.0),
|
||||
}
|
||||
|
||||
report = {
|
||||
"period": {
|
||||
"first": result["curve"][0][0].isoformat(),
|
||||
"last": result["curve"][-1][0].isoformat(),
|
||||
"days": len(result["curve"]),
|
||||
},
|
||||
"base": stats,
|
||||
"exposure": expo,
|
||||
"pnl_bridge": {
|
||||
"realized_net": realized,
|
||||
"unrealized_net": unrealized,
|
||||
"total": realized + unrealized,
|
||||
"total_pct": (realized + unrealized) / result["start_cash"] * 100,
|
||||
"open_positions": open_rows,
|
||||
},
|
||||
"per_symbol_rounds": per_symbol_trips,
|
||||
"round_trips": {
|
||||
"count": len(trips),
|
||||
"wins": sum(1 for t in trips if t.profit > 0),
|
||||
"losses": sum(1 for t in trips if t.profit <= 0),
|
||||
"avg_days": statistics.fmean([t.days for t in trips]) if trips else 0.0,
|
||||
"max_days": max([t.days for t in trips], default=0),
|
||||
"avg_levels": statistics.fmean([t.levels for t in trips]) if trips else 0.0,
|
||||
"max_levels": max([t.levels for t in trips], default=0),
|
||||
"avg_profit": statistics.fmean([t.profit for t in trips]) if trips else 0.0,
|
||||
"best": max([t.profit for t in trips], default=0.0),
|
||||
"worst": min([t.profit for t in trips], default=0.0),
|
||||
"detail": [
|
||||
{
|
||||
"code": t.code,
|
||||
"opened": t.opened.isoformat(),
|
||||
"closed": t.closed.isoformat(),
|
||||
"days": t.days,
|
||||
"levels": t.levels,
|
||||
"buy": round(t.buy_amount, 2),
|
||||
"sell": round(t.sell_amount, 2),
|
||||
"profit": round(t.profit, 2),
|
||||
"return_pct": round(t.return_pct, 3),
|
||||
}
|
||||
for t in trips
|
||||
],
|
||||
},
|
||||
"open_positions": open_positions(result),
|
||||
"monthly": months,
|
||||
"entries": entries,
|
||||
"scenarios": scenarios,
|
||||
}
|
||||
(OUT / "results.json").write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
476
labs/analysis/etf/backtest.py
Normal file
476
labs/analysis/etf/backtest.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""ETF 网格策略离线回测(只读分析,不修改策略代码)。
|
||||
|
||||
为什么要单独写:真实策略跑在 30 秒 tick 上(``strategy/etf/boot.py`` 的 RunOnce),
|
||||
回测只有日线,必须把"入场区内反弹确认"和"限价单是否成交"用日线 OHLC 近似。
|
||||
近似口径在 ``REPORT.md`` 里逐条列出并做了敏感性对照。
|
||||
|
||||
指标计算直接调用策略自己的 ``strategy.etf.signal.calculate``,
|
||||
保证回测与实盘的 ATR/MA60/通道/格距口径完全一致。
|
||||
|
||||
用法:
|
||||
py -3.14 -B analysis/etf/backtest.py # 基准 + 敏感性
|
||||
py -3.14 -B analysis/etf/backtest.py --refresh # 重新抓取日线
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date, datetime
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
# labs/analysis/etf/backtest.py -> labs/analysis/etf -> labs/analysis -> labs -> 仓库根
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PY_CLIENT = ROOT / "py-client"
|
||||
CACHE = Path(__file__).resolve().parent / "cache"
|
||||
OUT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(PY_CLIENT))
|
||||
|
||||
from config import EtfDefaults, EtfSymbolConfig # noqa: E402
|
||||
from strategy.etf.signal import calculate # noqa: E402
|
||||
|
||||
DAILY_URL = "http://139.224.247.176:13499/etf/daily"
|
||||
# 直接从仓库配置读取,避免回测参数与实盘配置漂移。
|
||||
ETF_YAML = PY_CLIENT / "etc" / "_etf.yaml"
|
||||
|
||||
|
||||
def load_repo_config() -> tuple[EtfDefaults, dict, tuple[str, ...]]:
|
||||
"""读取 py-client/etc/_etf.yaml:全局默认、逐标的参数、白名单顺序。"""
|
||||
import yaml
|
||||
|
||||
raw = yaml.safe_load(ETF_YAML.read_text(encoding="utf-8")) or {}
|
||||
defaults = EtfDefaults(**(raw.get("defaults") or {}))
|
||||
params = {
|
||||
code: dict(values or {})
|
||||
for code, values in (raw.get("symbols") or {}).items()
|
||||
}
|
||||
return defaults, params, tuple(params)
|
||||
|
||||
|
||||
# 账户级参数(account_config;_etf.yaml 不含这两项,按账户配置写在这里)。
|
||||
MIN_CASH_RATIO = 0.10
|
||||
COMMISSION_RATE = 0.0003
|
||||
MIN_COMMISSION = 5.0
|
||||
# 起始资金:新配置三只各铺满 10 档约需 46.5 万,取 50 万作为"铺得开"的参考账户。
|
||||
# 注意该策略按固定股数下单,绝对盈亏由 _etf.yaml 的股数决定,与账户规模无关(见 REPORT §10)。
|
||||
START_CASH = 500_000.0
|
||||
# 日线最后一根是 2026-09-18,用之后的日期做"今天",保证整段日线可用。
|
||||
RUN_TODAY = date(2026, 9, 19)
|
||||
WARMUP = 61
|
||||
|
||||
# 导入时按仓库配置初始化,供按需调整的脚本直接使用。
|
||||
REPO_DEFAULTS, REPO_PARAMS, SYMBOLS = load_repo_config()
|
||||
SYMBOL_PARAMS = REPO_PARAMS
|
||||
|
||||
|
||||
def fetch_daily(code: str, refresh: bool = False) -> list[dict]:
|
||||
"""读取日线;默认用本地缓存,避免反复打接口。"""
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
path = CACHE / f"{code}.json"
|
||||
if refresh or not path.exists():
|
||||
import urllib.request
|
||||
|
||||
with urllib.request.urlopen(f"{DAILY_URL}?code={code}", timeout=30) as response:
|
||||
payload = json.load(response)
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
rows = json.loads(path.read_text(encoding="utf-8"))
|
||||
bars: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
if row.get("ts_code") != code:
|
||||
continue
|
||||
stamp = str(row.get("trade_date"))
|
||||
if len(stamp) != 8 or not stamp.isdigit():
|
||||
continue
|
||||
day = datetime.strptime(stamp, "%Y%m%d").date()
|
||||
if day >= RUN_TODAY:
|
||||
continue
|
||||
values = {key: float(row[key]) for key in ("open", "high", "low", "close")}
|
||||
if not all(math.isfinite(v) and v > 0 for v in values.values()):
|
||||
continue
|
||||
if not (values["low"] <= values["open"] <= values["high"]
|
||||
and values["low"] <= values["close"] <= values["high"]):
|
||||
continue
|
||||
bars[stamp] = dict(date=stamp, **values)
|
||||
return [bars[stamp] for stamp in sorted(bars)]
|
||||
|
||||
|
||||
def fees(amount: float, rate: float, minimum: float) -> float:
|
||||
if amount <= 0:
|
||||
return 0.0
|
||||
return max(minimum, amount * rate)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Lot:
|
||||
volume: int
|
||||
cost: float # 含买入佣金
|
||||
bought: date
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Fill:
|
||||
day: date
|
||||
code: str
|
||||
side: str # BUY / SELL
|
||||
kind: str # base / add / exit / level
|
||||
volume: int
|
||||
price: float
|
||||
fee: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
class SymbolBook:
|
||||
"""单标的网格状态;持仓档位是唯一跨轮存活的状态。"""
|
||||
|
||||
def __init__(self, code: str, symbol: EtfSymbolConfig):
|
||||
self.code = code
|
||||
self.symbol = symbol
|
||||
self.lots: list[Lot] = []
|
||||
self.anchor: float | None = None
|
||||
self.last_buy = 0.0
|
||||
self.adds = 0
|
||||
self.last_add_day: date | None = None
|
||||
self.peak_grid: int | None = None
|
||||
self.rounds = 0 # 主出口清仓次数
|
||||
self.max_level = 0 # 曾经达到的档位数
|
||||
|
||||
@property
|
||||
def volume(self) -> int:
|
||||
return sum(lot.volume for lot in self.lots)
|
||||
|
||||
@property
|
||||
def avg_cost(self) -> float:
|
||||
total = self.volume
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
return sum(lot.volume * lot.cost for lot in self.lots) / total
|
||||
|
||||
def sellable(self, day: date) -> int:
|
||||
"""当日可卖份额:is_t0 当日可卖,否则只算隔夜份额(T+1)。"""
|
||||
if self.symbol.is_t0:
|
||||
volume = self.volume
|
||||
else:
|
||||
volume = sum(lot.volume for lot in self.lots if lot.bought < day)
|
||||
return volume - volume % 100
|
||||
|
||||
def add(self, volume: int, price: float, fee: float, day: date) -> None:
|
||||
cost = (price * volume + fee) / volume if volume else price
|
||||
for lot in self.lots:
|
||||
if lot.bought == day:
|
||||
total = lot.volume + volume
|
||||
lot.cost = (lot.cost * lot.volume + cost * volume) / total
|
||||
lot.volume = total
|
||||
break
|
||||
else:
|
||||
self.lots.append(Lot(volume=volume, cost=cost, bought=day))
|
||||
self.last_buy = price
|
||||
self.max_level = max(self.max_level, self.volume // self.symbol.buy_shares)
|
||||
|
||||
def reduce(self, volume: int) -> float:
|
||||
"""先进先出减仓,返回被减仓位的含费成本。"""
|
||||
removed = 0.0
|
||||
left = volume
|
||||
while left > 0 and self.lots:
|
||||
lot = self.lots[0]
|
||||
take = min(lot.volume, left)
|
||||
removed += take * lot.cost
|
||||
lot.volume -= take
|
||||
left -= take
|
||||
if lot.volume <= 0:
|
||||
self.lots.pop(0)
|
||||
return removed
|
||||
|
||||
def held_days(self, day: date) -> int:
|
||||
return min((day - lot.bought).days for lot in self.lots) if self.lots else 0
|
||||
|
||||
|
||||
def precondition(
|
||||
bars: list[dict], symbol: EtfSymbolConfig, defaults: EtfDefaults
|
||||
) -> list[tuple[dict, dict]]:
|
||||
"""给每根日线预先算好指标。
|
||||
|
||||
关键:传给 ``calculate`` 的 ``today`` 必须是**该日线自己的日期**。
|
||||
策略里 ``today`` 是"运行当天",而 ``calculate`` 会拒绝距今超过 15 个自然日的
|
||||
日线(防停牌/缓存过期);回测必须逐日回放,否则整段历史都会被当成过期数据丢掉。
|
||||
"""
|
||||
series = []
|
||||
for index, bar in enumerate(bars):
|
||||
if index + 1 < WARMUP:
|
||||
continue
|
||||
day = datetime.strptime(bar["date"], "%Y%m%d").date()
|
||||
window = bars[max(0, index - 119): index + 1] # 与 signal.BAR_COUNT=120 一致
|
||||
try:
|
||||
ind = calculate(window, symbol, defaults, day)
|
||||
except ValueError:
|
||||
continue
|
||||
series.append((bar, ind))
|
||||
return series
|
||||
|
||||
|
||||
def fill_price(
|
||||
mode: str, trigger: float, bar: dict, side: str, rebound_pct: float = 0.5
|
||||
) -> float:
|
||||
"""把"触发价 + 当日 OHLC"折算成成交价。
|
||||
|
||||
- ``touch``:限价单在触价当天按触价成交(最乐观,隐含着"盘中挂单必成交")。
|
||||
- ``bounce``:跌到触发价后,等价格从当日最低点反弹 ``rebound_pct`` 才成交
|
||||
(最贴近实盘 tick 语义,见 ``docs/etf.md`` §2.2 / §4.2)。
|
||||
- ``close``:只在收盘时判断,并按收盘价成交(最悲观)。
|
||||
"""
|
||||
if mode == "touch":
|
||||
return trigger
|
||||
if mode == "bounce":
|
||||
rebound = bar["low"] * (1 + rebound_pct / 100)
|
||||
if side == "BUY":
|
||||
return min(bar["close"], max(trigger, rebound))
|
||||
return max(bar["close"], min(trigger, rebound))
|
||||
return bar["close"]
|
||||
|
||||
|
||||
def _order_volume(sizer, cash: float, price: float, symbol: EtfSymbolConfig) -> int:
|
||||
"""单档股数:默认用配置的 ``buy_shares``,给了 ``sizer`` 就按资金比例算。"""
|
||||
if sizer is None:
|
||||
return symbol.buy_shares
|
||||
volume = int(sizer(cash, price))
|
||||
return max(0, volume - volume % 100)
|
||||
|
||||
|
||||
def simulate(
|
||||
data: dict[str, list[dict]],
|
||||
*,
|
||||
defaults: EtfDefaults | None = None,
|
||||
symbol_params: dict | None = None,
|
||||
fill_mode: str = "touch",
|
||||
trigger_fill: bool | None = None,
|
||||
secondary_exit: bool = True,
|
||||
min_hold_days: int | None = None,
|
||||
start_cash: float = START_CASH,
|
||||
commission_rate: float = COMMISSION_RATE,
|
||||
min_commission: float = MIN_COMMISSION,
|
||||
sizer=None,
|
||||
precomputed: dict | None = None,
|
||||
) -> dict:
|
||||
"""共享资金的多标的组合回测。
|
||||
|
||||
``fill_mode``:``touch`` / ``bounce`` / ``close``,见 ``fill_price``。
|
||||
``trigger_fill``:兼容旧参数,False 等价于 ``close``。
|
||||
``secondary_exit``:是否启用单档峰值回撤副出口。
|
||||
``min_hold_days``:覆盖 min_hold_days(is_t0 标的仍不受限)。
|
||||
``sizer``:``(equity, price) -> 股数``,把固定股数换成按资金比例下单。
|
||||
``precomputed``:复用 ``precondition`` 结果加速扫描(其指标只依赖 symbol/defaults)。
|
||||
"""
|
||||
if trigger_fill is not None:
|
||||
fill_mode = "touch" if trigger_fill else "close"
|
||||
defaults = defaults or EtfDefaults()
|
||||
params = symbol_params or SYMBOL_PARAMS
|
||||
hold_days = defaults.min_hold_days if min_hold_days is None else min_hold_days
|
||||
books = {
|
||||
code: SymbolBook(code, EtfSymbolConfig(**{**params[code]}))
|
||||
for code in data
|
||||
}
|
||||
if precomputed is not None:
|
||||
series = precomputed
|
||||
else:
|
||||
series = {code: precondition(data[code], books[code].symbol, defaults) for code in data}
|
||||
by_date = {code: {bar["date"]: (bar, ind) for bar, ind in series[code]} for code in data}
|
||||
calendar = sorted({stamp for code in data for stamp in by_date[code]})
|
||||
latest_close = {code: 0.0 for code in data}
|
||||
cash = start_cash
|
||||
fills: list[Fill] = []
|
||||
curve: list[tuple[date, float, float, float]] = [] # 日期, 权益, 持仓市值, 现金
|
||||
reserve = start_cash * MIN_CASH_RATIO
|
||||
|
||||
for stamp in calendar:
|
||||
day = datetime.strptime(stamp, "%Y%m%d").date()
|
||||
for code in sorted(data): # 白名单顺序即资金优先级
|
||||
book = books[code]
|
||||
row = by_date[code].get(stamp)
|
||||
if row is None:
|
||||
continue
|
||||
bar, ind = row
|
||||
close, high, low = bar["close"], bar["high"], bar["low"]
|
||||
latest_close[code] = close
|
||||
entry, grid = ind["etf_entry"], ind["etf_grid"]
|
||||
symbol = book.symbol
|
||||
|
||||
# 1. 主出口:盈亏率 ≥ min_profit_pct,整仓止盈(受 T+1/min_hold_days 约束)
|
||||
if book.volume > 0:
|
||||
avg = book.avg_cost
|
||||
target = avg * (1 + defaults.min_profit_pct / 100)
|
||||
can_sell = book.sellable(day)
|
||||
if (not symbol.is_t0) and hold_days > 0 and book.held_days(day) < hold_days:
|
||||
can_sell = 0
|
||||
if high >= target and can_sell > 0:
|
||||
price = fill_price(fill_mode, target, bar, "SELL", defaults.rebound_pct)
|
||||
price = min(price, high)
|
||||
amount = price * can_sell
|
||||
fee = fees(amount, commission_rate, min_commission)
|
||||
cash += amount - fee
|
||||
book.reduce(can_sell)
|
||||
book.rounds += 1
|
||||
book.peak_grid = None
|
||||
fills.append(Fill(day, code, "SELL", "exit", can_sell, price, fee,
|
||||
f"目标={target:.3f} 档位={book.max_level}"))
|
||||
if book.volume == 0:
|
||||
book.anchor, book.adds, book.last_buy, book.last_add_day = None, 0, 0.0, None
|
||||
continue
|
||||
|
||||
# 2. 副出口:单档峰值回撤(只卖该档)
|
||||
if secondary_exit and book.volume > 0:
|
||||
avg = book.avg_cost
|
||||
pnl_rate = (close - avg) / avg * 100
|
||||
current = math.floor(pnl_rate / symbol.inner_step)
|
||||
if book.peak_grid is None:
|
||||
book.peak_grid = current
|
||||
elif current > book.peak_grid:
|
||||
book.peak_grid = current
|
||||
elif current < book.peak_grid and book.peak_grid >= defaults.inner_grids:
|
||||
volume = min(book.sellable(day), symbol.buy_shares)
|
||||
if volume > 0:
|
||||
amount = close * volume
|
||||
fee = fees(amount, commission_rate, min_commission)
|
||||
cash += amount - fee
|
||||
book.reduce(volume)
|
||||
fills.append(Fill(day, code, "SELL", "level", volume, close, fee,
|
||||
f"峰值={book.peak_grid}格"))
|
||||
book.peak_grid = None
|
||||
|
||||
# 3. 补仓:自上一档再跌 add_pct,当日收盘回到触发价之上
|
||||
if book.volume > 0 and book.adds < defaults.max_adds and book.last_buy > 0:
|
||||
trigger = book.last_buy * (1 - defaults.add_pct / 100)
|
||||
room = symbol.max_shares - book.volume
|
||||
volume = min(_order_volume(sizer, cash, trigger, symbol),
|
||||
room - room % 100)
|
||||
if volume > 0 and low <= trigger and close > trigger and book.last_add_day != day:
|
||||
price = min(high, fill_price(fill_mode, trigger, bar, "BUY", defaults.rebound_pct))
|
||||
amount = price * volume
|
||||
fee = fees(amount, commission_rate, min_commission)
|
||||
if amount + fee <= cash - reserve:
|
||||
cash -= amount + fee
|
||||
book.add(volume, price, fee, day)
|
||||
book.adds += 1
|
||||
book.last_add_day = day
|
||||
drop = (book.last_buy / price - 1) * 100 if book.last_buy else 0.0
|
||||
fills.append(Fill(day, code, "BUY", "add", volume, price, fee,
|
||||
f"触发={trigger:.3f} 跌幅={drop:.2f}%"))
|
||||
|
||||
# 4. 建网:跌进入场门槛且当日收在门槛之上(反弹确认的日线近似)
|
||||
if book.volume == 0 and book.anchor is None and low <= entry and close > entry:
|
||||
price = min(high, fill_price(fill_mode, entry, bar, "BUY", defaults.rebound_pct))
|
||||
volume = _order_volume(sizer, cash, entry, symbol)
|
||||
amount = price * volume
|
||||
fee = fees(amount, commission_rate, min_commission)
|
||||
if amount + fee <= cash - reserve:
|
||||
cash -= amount + fee
|
||||
book.add(volume, price, fee, day)
|
||||
book.anchor = price
|
||||
book.adds = 0
|
||||
book.last_add_day = day
|
||||
book.peak_grid = None
|
||||
fills.append(Fill(day, code, "BUY", "base", volume, price, fee,
|
||||
f"门槛={entry:.3f} MA60={ind['etf_ma60']:.3f}"))
|
||||
|
||||
market_value = sum(books[code].volume * (latest_close[code] or 0.0) for code in data)
|
||||
curve.append((day, cash + market_value, market_value, cash))
|
||||
|
||||
return {
|
||||
"fills": fills,
|
||||
"curve": curve,
|
||||
"books": books,
|
||||
"cash": cash,
|
||||
"start_cash": start_cash,
|
||||
"params": {
|
||||
"fill_mode": fill_mode,
|
||||
"secondary_exit": secondary_exit,
|
||||
"min_hold_days": hold_days,
|
||||
"add_pct": defaults.add_pct,
|
||||
"min_profit_pct": defaults.min_profit_pct,
|
||||
"channel_pct": defaults.channel_pct,
|
||||
"max_adds": defaults.max_adds,
|
||||
"commission_rate": commission_rate,
|
||||
"min_commission": min_commission,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def analyze(result: dict) -> dict:
|
||||
"""把成交与权益曲线折算成指标。"""
|
||||
fills: list[Fill] = result["fills"]
|
||||
curve = result["curve"]
|
||||
buys = [f for f in fills if f.side == "BUY"]
|
||||
sells = [f for f in fills if f.side == "SELL"]
|
||||
buy_amount = sum(f.price * f.volume for f in buys)
|
||||
sell_amount = sum(f.price * f.volume for f in sells)
|
||||
fee_total = sum(f.fee for f in fills)
|
||||
net = (sell_amount - buy_amount) - fee_total
|
||||
equity = [point[1] for point in curve]
|
||||
peak, max_dd = -math.inf, 0.0
|
||||
for value in equity:
|
||||
peak = max(peak, value)
|
||||
max_dd = max(max_dd, (peak - value) / peak)
|
||||
deployed = [point[2] for point in curve]
|
||||
initial, final = result["start_cash"], equity[-1]
|
||||
days = len(curve)
|
||||
per_symbol = {}
|
||||
for code, book in result["books"].items():
|
||||
rows = [f for f in fills if f.code == code]
|
||||
per_symbol[code] = {
|
||||
"base": len([f for f in rows if f.kind == "base"]),
|
||||
"adds": len([f for f in rows if f.kind == "add"]),
|
||||
"exits": book.rounds,
|
||||
"levels": len([f for f in rows if f.kind == "level"]),
|
||||
"held_shares": book.volume,
|
||||
"max_level": book.max_level,
|
||||
"net": sum((f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee for f in rows),
|
||||
}
|
||||
return {
|
||||
"days": days,
|
||||
"net": net,
|
||||
"gross": sell_amount - buy_amount,
|
||||
"fees": fee_total,
|
||||
"fee_share_of_gross": fee_total / (sell_amount - buy_amount) * 100 if sell_amount > buy_amount else 0.0,
|
||||
"return_pct": (final - initial) / initial * 100,
|
||||
"max_dd_pct": max_dd * 100,
|
||||
"buy_count": len(buys),
|
||||
"sell_count": len(sells),
|
||||
"buy_amount": buy_amount,
|
||||
"turnover_x": buy_amount / initial,
|
||||
"avg_deployed": statistics.fmean(deployed) if deployed else 0.0,
|
||||
"avg_util_pct": (statistics.fmean(deployed) / initial * 100) if deployed else 0.0,
|
||||
"max_deployed": max(deployed) if deployed else 0.0,
|
||||
"final_equity": final,
|
||||
"cash": result["cash"],
|
||||
"per_symbol": per_symbol,
|
||||
"params": result["params"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--refresh", action="store_true")
|
||||
parser.add_argument("--ledger", action="store_true", help="打印逐笔成交")
|
||||
args = parser.parse_args()
|
||||
|
||||
defaults = EtfDefaults()
|
||||
data = {code: fetch_daily(code, args.refresh) for code in SYMBOLS}
|
||||
for code, bars in data.items():
|
||||
print(f"{code}: {len(bars)} bars {bars[0]['date']}..{bars[-1]['date']}")
|
||||
|
||||
base = simulate(data)
|
||||
stats = analyze(base)
|
||||
print(json.dumps(stats, ensure_ascii=False, indent=2, default=str))
|
||||
if args.ledger:
|
||||
for fill in base["fills"]:
|
||||
amount = fill.price * fill.volume
|
||||
print(
|
||||
f"{fill.day} {fill.code} {fill.side:4} {fill.kind:4} "
|
||||
f"{fill.volume:6} @{fill.price:.3f} amount={amount:10.2f} "
|
||||
f"fee={fill.fee:5.2f} {fill.note}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
labs/analysis/etf/cache/510300.SH.json
vendored
Normal file
1
labs/analysis/etf/cache/510300.SH.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
labs/analysis/etf/cache/518880.SH.json
vendored
Normal file
1
labs/analysis/etf/cache/518880.SH.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
labs/analysis/etf/cache/588000.SH.json
vendored
Normal file
1
labs/analysis/etf/cache/588000.SH.json
vendored
Normal file
File diff suppressed because one or more lines are too long
41
labs/analysis/etf/capital.py
Normal file
41
labs/analysis/etf/capital.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""读仓库 _etf.yaml 后,先定"要多大的账户才铺得开",再跑基准回测。
|
||||
|
||||
用法: py -3.14 -B analysis/etf/capital.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
import backtest as B # noqa: E402
|
||||
from backtest import SYMBOL_PARAMS, SYMBOLS, SYMBOL_PARAMS, analyze, fetch_daily, simulate # noqa: E402
|
||||
|
||||
data = {code: fetch_daily(code) for code in SYMBOLS}
|
||||
|
||||
print("== 新配置的资金需求(按区间最低价估算)==")
|
||||
need = {}
|
||||
for code, p in SYMBOL_PARAMS.items():
|
||||
low = min(bar["low"] for bar in data[code])
|
||||
rung = p["buy_shares"] * low
|
||||
need[code] = rung
|
||||
print(f" {code} 每档={p['buy_shares']:>6} 股 区间最低价={low:6.3f} "
|
||||
f"1 档≈{rung:9.0f} 10 档≈{rung * 10:10.0f} 单标上限={p['max_shares']} 股")
|
||||
print(f" 三只各铺 1 档 ≈ {sum(need.values()):,.0f};三只铺满 10 档 ≈ {sum(need.values()) * 10:,.0f}")
|
||||
|
||||
print()
|
||||
print("== 不同起始资金(参数完全不变,只改账户规模)==")
|
||||
print(f"{'起始资金':>10} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'平均占用':>8} "
|
||||
f"{'峰值占用':>8} {'资金拒绝':>8} {'底仓':>4} {'补仓':>4} {'主出口':>5}")
|
||||
for cash in (200_000, 300_000, 500_000, 800_000, 1_200_000, 1_500_000):
|
||||
result = simulate(data, start_cash=cash)
|
||||
stats = analyze(result)
|
||||
delta = stats["final_equity"] - cash
|
||||
print(f"{cash:10,.0f} {delta:10.2f} {stats['return_pct']:6.2f}% {stats['max_dd_pct']:7.2f}% "
|
||||
f"{stats['avg_deployed'] / cash * 100:7.2f}% {stats['max_deployed'] / cash * 100:7.2f}% "
|
||||
f"{'':>8} {sum(1 for f in result['fills'] if f.kind == 'base'):4} "
|
||||
f"{sum(1 for f in result['fills'] if f.kind == 'add'):4} "
|
||||
f"{sum(1 for f in result['fills'] if f.kind == 'exit'):5}")
|
||||
print()
|
||||
print("说明:'资金拒绝' 未单独统计;底仓/补仓次数随资金上升而增加,说明低资金时被预算挡住。")
|
||||
104
labs/analysis/etf/compare.py
Normal file
104
labs/analysis/etf/compare.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""旧配置 vs 新配置(etc/_etf.yaml)对照,并给出新配置下的资金需求与规模敏感性。
|
||||
|
||||
用法: py -3.14 -B analysis/etf/compare.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from backtest import ( # noqa: E402
|
||||
REPO_DEFAULTS, SYMBOLS, SYMBOL_PARAMS, EtfSymbolConfig, analyze, fetch_daily,
|
||||
precondition, simulate,
|
||||
)
|
||||
|
||||
data = {code: fetch_daily(code) for code in SYMBOLS}
|
||||
|
||||
# 旧配置(本报告第一版的 etc/_etf.yaml:三只都是 1000 股 / 10000 股上限)
|
||||
OLD_PARAMS = {
|
||||
"588000.SH": dict(is_t0=False, buy_shares=1000, max_shares=10000, atr_multiplier=0.5, inner_step=0.9),
|
||||
"510300.SH": dict(is_t0=False, buy_shares=1000, max_shares=10000, atr_multiplier=1.0, inner_step=0.7),
|
||||
"518880.SH": dict(is_t0=True, buy_shares=1000, max_shares=10000, atr_multiplier=1.0, inner_step=0.8),
|
||||
}
|
||||
NEW_PARAMS = SYMBOL_PARAMS
|
||||
|
||||
|
||||
def total_rung_notional(params, cash_scale=None):
|
||||
"""三只各一档的名义金额(按区间最低价估)与铺满 10 档的总需求。"""
|
||||
one = sum(p["buy_shares"] * min(b["low"] for b in data[c]) for c, p in params.items())
|
||||
return one, one * 10
|
||||
|
||||
|
||||
def run(params, cash):
|
||||
pre = {c: precondition(data[c], EtfSymbolConfig(**params[c]), REPO_DEFAULTS) for c in data}
|
||||
result = simulate(data, symbol_params=params, precomputed=pre, start_cash=cash)
|
||||
stats = analyze(result)
|
||||
return result, stats
|
||||
|
||||
|
||||
print("=" * 120)
|
||||
print("A. 两版配置的资金需求(按各标的区间最低价估算)")
|
||||
print("=" * 120)
|
||||
for label, params in (("旧(1000 股/档)", OLD_PARAMS), ("新(10000/4000/2000 股/档)", NEW_PARAMS)):
|
||||
one, full = total_rung_notional(params)
|
||||
detail = " ".join(
|
||||
f"{c[:6]}={p['buy_shares']}股×{min(b['low'] for b in data[c]):.2f}≈{p['buy_shares'] * min(b['low'] for b in data[c]):,.0f}"
|
||||
for c, p in params.items()
|
||||
)
|
||||
print(f"{label:28} 三只各 1 档 ≈ {one:>10,.0f} 铺满 10 档 ≈ {full:>10,.0f}")
|
||||
print(f"{'':28} {detail}")
|
||||
|
||||
print()
|
||||
print("=" * 120)
|
||||
print("B. 旧 vs 新:同一资金 50 万,同一天数据、同一套逻辑")
|
||||
print("=" * 120)
|
||||
print(f"{'配置':24} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'轮次':>5} {'胜率':>6} "
|
||||
f"{'单轮均利':>8} {'平均占用':>8} {'峰值占用':>8} {'佣金':>7} {'名义周转':>8}")
|
||||
for label, params in (("旧(1000 股/档)", OLD_PARAMS), ("新(现 _etf.yaml)", NEW_PARAMS)):
|
||||
result, stats = run(params, 500_000.0)
|
||||
trips = [f for f in result["fills"] if f.kind == "exit"]
|
||||
delta = stats["final_equity"] - 500_000.0
|
||||
util = stats["avg_deployed"] / 500_000 * 100
|
||||
max_util = stats["max_deployed"] / 500_000 * 100
|
||||
print(f"{label:24} {delta:10.2f} {stats['return_pct']:6.2f}% {stats['max_dd_pct']:7.2f}% "
|
||||
f"{len(trips):5} {'':>6} {'':>8} {util:7.2f}% {max_util:7.2f}% {stats['fees']:7.2f} "
|
||||
f"{stats['buy_amount'] / 500_000:7.2f}x")
|
||||
print(f"{'':24} 已了结盈亏={stats['net']:>10.2f} 买入名义={stats['buy_amount']:>10.2f} "
|
||||
f"占用ROI={delta / stats['avg_deployed'] * 100 if stats['avg_deployed'] else 0:.2f}%")
|
||||
|
||||
print()
|
||||
print("=" * 120)
|
||||
print("C. 新配置:绝对盈亏与账户规模无关(按固定股数下单),但收益率会摊薄")
|
||||
print("=" * 120)
|
||||
print(f"{'起始资金':>10} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'平均占用':>8} {'峰值占用':>8} {'底仓':>4} {'补仓':>4}")
|
||||
for cash in (200_000, 300_000, 500_000, 800_000, 1_200_000):
|
||||
result, stats = run(NEW_PARAMS, cash)
|
||||
print(f"{cash:10,.0f} {stats['final_equity'] - cash:10.2f} {stats['return_pct']:6.2f}% "
|
||||
f"{stats['max_dd_pct']:7.2f}% {stats['avg_deployed'] / cash * 100:7.2f}% "
|
||||
f"{stats['max_deployed'] / cash * 100:7.2f}% "
|
||||
f"{sum(1 for f in result['fills'] if f.kind == 'base'):4} "
|
||||
f"{sum(1 for f in result['fills'] if f.kind == 'add'):4}")
|
||||
|
||||
print()
|
||||
print("=" * 120)
|
||||
print("D. 新配置下再放大/缩小单档股数(资金 50 万不变)")
|
||||
print("=" * 120)
|
||||
print(f"{'场景':26} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'平均占用':>8} {'峰值占用':>8} {'占用ROI':>8} {'收益/回撤':>8}")
|
||||
for factor in (0.25, 0.5, 1.0, 2.0, 4.0):
|
||||
params = {
|
||||
c: {**NEW_PARAMS[c],
|
||||
"buy_shares": max(100, int(NEW_PARAMS[c]["buy_shares"] * factor)),
|
||||
"max_shares": max(1000, int(NEW_PARAMS[c]["max_shares"] * factor))}
|
||||
for c in SYMBOLS
|
||||
}
|
||||
result, stats = run(params, 500_000.0)
|
||||
delta = stats["final_equity"] - 500_000.0
|
||||
roi = delta / stats["avg_deployed"] * 100 if stats["avg_deployed"] else 0.0
|
||||
ratio = stats["return_pct"] / stats["max_dd_pct"] if stats["max_dd_pct"] > 0.01 else 0.0
|
||||
label = "基准(现 _etf.yaml)" if factor == 1.0 else f"buy_shares×{factor}"
|
||||
print(f"{label:26} {delta:10.2f} {stats['return_pct']:6.2f}% {stats['max_dd_pct']:7.2f}% "
|
||||
f"{stats['avg_deployed'] / 500_000 * 100:7.2f}% {stats['max_deployed'] / 500_000 * 100:7.2f}% "
|
||||
f"{roi:7.2f}% {ratio:8.2f}")
|
||||
120
labs/analysis/etf/drawdown.py
Normal file
120
labs/analysis/etf/drawdown.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""未了结仓位的浮动亏损轨迹 + "时间止损"反事实对照(只做分析,不改策略代码)。
|
||||
|
||||
用法: py -3.14 -B analysis/etf/drawdown.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
# Windows 控制台默认 GBK,报告里用了「−」等字符,统一切到 UTF-8 输出。
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from analysis import round_trips # noqa: E402
|
||||
from backtest import SYMBOLS, analyze, fetch_daily, simulate # noqa: E402
|
||||
|
||||
data = {code: fetch_daily(code) for code in SYMBOLS}
|
||||
result = simulate(data)
|
||||
stats = analyze(result)
|
||||
by_date = {code: {bar["date"]: bar for bar in data[code]} for code in data}
|
||||
|
||||
# 逐日还原:每个标的的"本轮累计净买入成本"与"当前持仓量"
|
||||
cost = {code: 0.0 for code in data}
|
||||
volume = {code: 0 for code in data}
|
||||
fills_by_day = {}
|
||||
for fill in result["fills"]:
|
||||
fills_by_day.setdefault(fill.day.strftime("%Y%m%d"), []).append(fill)
|
||||
track = {code: [] for code in data}
|
||||
for day, equity, market_value, cash in result["curve"]:
|
||||
stamp = day.strftime("%Y%m%d")
|
||||
for fill in fills_by_day.get(stamp, []):
|
||||
amount = fill.price * fill.volume
|
||||
if fill.side == "BUY":
|
||||
cost[fill.code] += amount
|
||||
volume[fill.code] += fill.volume
|
||||
else:
|
||||
# 卖出按比例冲减成本基数(与均价法一致)
|
||||
if volume[fill.code] > 0:
|
||||
ratio = fill.volume / volume[fill.code]
|
||||
cost[fill.code] *= max(0.0, 1 - ratio)
|
||||
volume[fill.code] -= fill.volume
|
||||
for code in data:
|
||||
bar = by_date[code].get(stamp)
|
||||
if bar is None:
|
||||
continue
|
||||
track[code].append((day, cost[code], bar["close"], volume[code]))
|
||||
|
||||
print("== 持仓期间的浮动盈亏(均价法:市值 − 本轮累计净买入成本)==")
|
||||
print(f"{'标的':11} {'持仓天数':>8} {'最长连续浮亏天数':>16} {'最深浮亏':>10} {'最深浮亏%':>10} {'期末浮亏':>10}")
|
||||
for code in data:
|
||||
rows = [(day, c, close, vol) for day, c, close, vol in track[code] if vol > 0 and c > 0]
|
||||
if not rows:
|
||||
print(f"{code:11} {0:8} {'-':>16} {'-':>10} {'-':>10} {'-':>10}")
|
||||
continue
|
||||
pnl = [(day, close * vol - c, (close * vol - c) / c * 100) for day, c, close, vol in rows]
|
||||
longest = cur = 0
|
||||
for _, amount, _ in pnl:
|
||||
cur = cur + 1 if amount < 0 else 0
|
||||
longest = max(longest, cur)
|
||||
worst_amt = min(p[1] for p in pnl)
|
||||
worst_pct = min(p[2] for p in pnl)
|
||||
print(f"{code:11} {len(rows):8} {longest:16} {worst_amt:10.0f} {worst_pct:9.2f}% {pnl[-1][1]:10.0f}")
|
||||
|
||||
print()
|
||||
print("== 持有天数分布(完整轮次)==")
|
||||
trips = round_trips(result["fills"])
|
||||
buckets = {"≤1天": 0, "2-3天": 0, "4-7天": 0, "8-15天": 0, ">15天": 0}
|
||||
for t in trips:
|
||||
d = t.days
|
||||
key = "≤1天" if d <= 1 else "2-3天" if d <= 3 else "4-7天" if d <= 7 else "8-15天" if d <= 15 else ">15天"
|
||||
buckets[key] += 1
|
||||
print(" ", buckets)
|
||||
|
||||
print()
|
||||
print("== 时间止损反事实:把浮亏且持有超过 N 天的仓位按当日收盘价平掉 ==")
|
||||
print(f"{'规则':22} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'占用ROI':>8} {'佣金':>8} {'强平次数':>8}")
|
||||
|
||||
|
||||
def with_time_stop(limit: int) -> tuple[float, int]:
|
||||
"""在没有时间止损的结果上,找出"浮亏且连续持有 > limit 天"的仓位并按其后的实际
|
||||
主出口价格之差估算影响(近似:直接扣掉该仓位到期末的浮亏差额)。"""
|
||||
stops = 0
|
||||
delta = 0.0
|
||||
for code in data:
|
||||
rows = [(day, c, close, vol) for day, c, close, vol in track[code] if vol > 0 and c > 0]
|
||||
if not rows:
|
||||
continue
|
||||
start = rows[0][0]
|
||||
for index, (day, c, close, vol) in enumerate(rows):
|
||||
held = (day - start).days
|
||||
if held > limit and close * vol - c < 0:
|
||||
# 反事实:在当天以收盘价平掉,之后不再持有该标的
|
||||
loss = close * vol - c
|
||||
final_close = rows[-1][2]
|
||||
avoid = (final_close - close) * vol
|
||||
delta += loss * 0 - avoid # 平仓后避免了后续价格变动
|
||||
stops += 1
|
||||
break
|
||||
return delta, stops
|
||||
|
||||
|
||||
for limit in (0, 5, 10, 20):
|
||||
if limit == 0:
|
||||
print(f"{'不止损(现配置)':22} {stats['final_equity'] - 500000:10.2f} {stats['return_pct']:6.2f}% "
|
||||
f"{stats['max_dd_pct']:7.2f}% "
|
||||
f"{(stats['final_equity'] - 500000) / stats['avg_deployed'] * 100:7.2f}% "
|
||||
f"{stats['fees']:8.2f} {0:8}")
|
||||
continue
|
||||
delta, stops = with_time_stop(limit)
|
||||
equity = stats["final_equity"] - 500000 + delta
|
||||
print(f"{'持有>' + str(limit) + '天且浮亏即平':22} {equity:10.2f} {equity / 500000 * 100:6.2f}% "
|
||||
f"{stats['max_dd_pct']:7.2f}% {equity / stats['avg_deployed'] * 100:7.2f}% "
|
||||
f"{stats['fees']:8.2f} {stops:8}")
|
||||
print()
|
||||
print("注:上表是「平仓后不再持有该标的」的粗略反事实,只用于判断时间止损的方向性收益,")
|
||||
print(" 不是精确回测(未重算后续轮次与资金复用)。")
|
||||
40
labs/analysis/etf/entries.py
Normal file
40
labs/analysis/etf/entries.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""入场机会频率:日线上"进入门槛 + 当日收回门槛以上"到底出现多少次,以及 MA60 上限的影响。
|
||||
|
||||
用法: py -3.14 -B analysis/etf/entries.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from backtest import SYMBOLS, SYMBOL_PARAMS, EtfDefaults, EtfSymbolConfig, fetch_daily, precondition
|
||||
|
||||
BASE = EtfDefaults()
|
||||
data = {c: fetch_daily(c) for c in SYMBOLS}
|
||||
|
||||
print(f"{'标的':11} {'可回放日':>7} {'跌破门槛':>8} {'收在门槛上':>10} {'入场信号':>8} "
|
||||
f"{'低于门槛的天数占比':>18} {'现价<MA60 天数占比':>18}")
|
||||
for code in SYMBOLS:
|
||||
series = precondition(data[code], EtfSymbolConfig(**SYMBOL_PARAMS[code]), BASE)
|
||||
below = above = signal = 0
|
||||
below_ma = 0
|
||||
for bar, ind in series:
|
||||
entry, ma60 = ind["etf_entry"], ind["etf_ma60"]
|
||||
if bar["low"] <= entry:
|
||||
below += 1
|
||||
if bar["close"] > entry and bar["low"] <= entry:
|
||||
above += 1
|
||||
if bar["low"] <= entry and bar["close"] > entry:
|
||||
signal += 1
|
||||
if bar["close"] < ma60:
|
||||
below_ma += 1
|
||||
n = len(series)
|
||||
print(f"{code:11} {n:7} {below:8} {above:10} {signal:8} "
|
||||
f"{below / n * 100:17.1f}% {below_ma / n * 100:17.1f}%")
|
||||
|
||||
print()
|
||||
print("说明:'入场信号' = 当日最低价跌破入场门槛、且收盘价收回门槛之上(回测里建网的日线近似)。")
|
||||
print(" 它是机会频率的上界:实盘还要再满足盘中反弹 0.5% 的确认。")
|
||||
1370
labs/analysis/etf/results.json
Normal file
1370
labs/analysis/etf/results.json
Normal file
File diff suppressed because it is too large
Load Diff
232
labs/analysis/etf/run.py
Normal file
232
labs/analysis/etf/run.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""ETF 网格策略回测总报告生成器:一次跑完基准 + 三种成交模型 + 敏感性。
|
||||
|
||||
用法:
|
||||
py -3.14 -B analysis/etf/run.py # 用缓存日线
|
||||
py -3.14 -B analysis/etf/run.py --refresh # 重新抓日线
|
||||
py -3.14 -B analysis/etf/run.py --ledger # 额外打印逐笔成交
|
||||
|
||||
输出:
|
||||
analysis/etf/results.json 全部结构化结果
|
||||
analysis/etf/run_report.txt 人读的汇总表
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from datetime import date, datetime
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from backtest import ( # noqa: E402
|
||||
MIN_CASH_RATIO, OUT, REPO_DEFAULTS, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults,
|
||||
EtfSymbolConfig, analyze, fees, fetch_daily, simulate,
|
||||
)
|
||||
from analysis import ( # noqa: E402
|
||||
entry_context, exposure, monthly, open_positions, round_trips, scenario_table,
|
||||
)
|
||||
|
||||
|
||||
def attribute(result, trips, data) -> dict:
|
||||
"""按标的拆解盈亏,两种口径都成立且与权益变动对齐。
|
||||
|
||||
``net_at_cost``:把未了结仓位按**成本**入账(卖出额 − 全部买入额 − 佣金)。
|
||||
``net_at_market``:加上未实现浮动(期末市值 − 未了结成本)。
|
||||
|
||||
对冲校验:Σ net_at_market = 期末权益 − 期初权益。
|
||||
"""
|
||||
rows = {}
|
||||
for code in result["books"]:
|
||||
book = result["books"][code]
|
||||
fills = [f for f in result["fills"] if f.code == code]
|
||||
closed_profit = sum(
|
||||
(f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee
|
||||
for f in fills
|
||||
)
|
||||
unrealized = _unrealized(book, data, code)
|
||||
rows[code] = {
|
||||
"rounds": sum(1 for t in trips if t.code == code),
|
||||
"net_at_cost": closed_profit,
|
||||
"unrealized": unrealized,
|
||||
"net_at_market": closed_profit + unrealized,
|
||||
"open_cost": book.avg_cost * book.volume,
|
||||
"open_volume": book.volume,
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def _unrealized(book, data, code) -> float:
|
||||
"""未实现浮动:期末市值 − 未了结仓位成本。"""
|
||||
if book.volume <= 0:
|
||||
return 0.0
|
||||
close = data[code][-1]["close"]
|
||||
return close * book.volume - book.avg_cost * book.volume
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--refresh", action="store_true")
|
||||
parser.add_argument("--ledger", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
data = {code: fetch_daily(code, args.refresh) for code in SYMBOLS}
|
||||
defaults = REPO_DEFAULTS # 直接取仓库 _etf.yaml,避免与实盘配置漂移
|
||||
|
||||
# 三种成交模型:乐观(按触价)/ 贴近实盘(按反弹确认价)/ 悲观(按收盘价)
|
||||
modes = {}
|
||||
for mode in ("touch", "bounce", "close"):
|
||||
result = simulate(data, fill_mode=mode)
|
||||
modes[mode] = {"stats": analyze(result), "fills": result["fills"]}
|
||||
|
||||
base = simulate(data, fill_mode="touch")
|
||||
stats = analyze(base)
|
||||
stats["fee_pct_of_buy"] = stats["fees"] / stats["buy_amount"] * 100
|
||||
trips = round_trips(base["fills"])
|
||||
per_symbol = attribute(base, trips, data)
|
||||
unrealized = sum(r["unrealized"] for r in per_symbol.values())
|
||||
equity_delta = base["curve"][-1][1] - base["start_cash"]
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"config": {
|
||||
"symbols": {
|
||||
code: SYMBOL_PARAMS[code] for code in SYMBOLS
|
||||
},
|
||||
"defaults": {
|
||||
field: getattr(defaults, field)
|
||||
for field in (
|
||||
"atr_period", "min_grid_pct", "max_grid_span_pct", "channel_period",
|
||||
"channel_pct", "rebound_pct", "add_pct", "max_adds", "watch_seconds",
|
||||
"min_profit_pct", "inner_grids", "min_hold_days", "max_hold_days",
|
||||
"commission_rate", "min_commission", "max_tick_age_seconds",
|
||||
)
|
||||
},
|
||||
"account": {"start_cash": START_CASH, "min_cash_ratio": MIN_CASH_RATIO},
|
||||
},
|
||||
"period": {
|
||||
"first": base["curve"][0][0].isoformat(),
|
||||
"last": base["curve"][-1][0].isoformat(),
|
||||
"days": len(base["curve"]),
|
||||
},
|
||||
"data": {
|
||||
code: {
|
||||
"bars": len(data[code]),
|
||||
"first": data[code][0]["date"],
|
||||
"last": data[code][-1]["date"],
|
||||
"first_close": data[code][0]["close"],
|
||||
"last_close": data[code][-1]["close"],
|
||||
"year_return_pct": (data[code][-1]["close"] / data[code][0]["close"] - 1) * 100,
|
||||
}
|
||||
for code in SYMBOLS
|
||||
},
|
||||
"modes": {
|
||||
mode: {k: v for k, v in payload["stats"].items() if k not in ("per_symbol", "params")}
|
||||
for mode, payload in modes.items()
|
||||
},
|
||||
"base": stats,
|
||||
"exposure": exposure(base),
|
||||
"round_trips": {
|
||||
"count": len(trips),
|
||||
"wins": sum(1 for t in trips if t.profit > 0),
|
||||
"losses": sum(1 for t in trips if t.profit <= 0),
|
||||
"avg_days": statistics.fmean([t.days for t in trips]) if trips else 0.0,
|
||||
"max_days": max([t.days for t in trips], default=0),
|
||||
"avg_levels": statistics.fmean([t.levels for t in trips]) if trips else 0.0,
|
||||
"max_levels": max([t.levels for t in trips], default=0),
|
||||
"avg_profit": statistics.fmean([t.profit for t in trips]) if trips else 0.0,
|
||||
"best": max([t.profit for t in trips], default=0.0),
|
||||
"worst": min([t.profit for t in trips], default=0.0),
|
||||
"detail": [
|
||||
{
|
||||
"code": t.code, "opened": t.opened.isoformat(), "closed": t.closed.isoformat(),
|
||||
"days": t.days, "levels": t.levels, "shares": t.shares,
|
||||
"buy": round(t.buy_amount, 2), "sell": round(t.sell_amount, 2),
|
||||
"profit": round(t.profit, 2), "return_pct": round(t.return_pct, 3),
|
||||
"fees": round(t.fees, 2),
|
||||
}
|
||||
for t in trips
|
||||
],
|
||||
},
|
||||
"attribution": {
|
||||
"per_symbol": per_symbol,
|
||||
"unrealized": unrealized,
|
||||
"equity_delta": equity_delta,
|
||||
"check": sum(r["net_at_market"] for r in per_symbol.values()),
|
||||
"cash_delta": base["cash"] - base["start_cash"],
|
||||
"open_cost": sum(r["open_cost"] for r in per_symbol.values()),
|
||||
"open_positions": open_positions(base),
|
||||
},
|
||||
"monthly": monthly(base),
|
||||
"entries": entry_context(data, SYMBOL_PARAMS, base),
|
||||
"scenarios": scenario_table(data),
|
||||
}
|
||||
(OUT / "results.json").write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
lines = []
|
||||
lines.append(f"回测区间 {report['period']['first']} ~ {report['period']['last']} "
|
||||
f"({report['period']['days']} 个交易日)")
|
||||
lines.append("")
|
||||
lines.append("== 数据 ==")
|
||||
for code, row in report["data"].items():
|
||||
lines.append(f" {code} bars={row['bars']} {row['first']}..{row['last']} "
|
||||
f"区间涨跌={row['year_return_pct']:+.2f}%")
|
||||
lines.append("")
|
||||
lines.append("== 三种成交模型的组合结果 ==")
|
||||
lines.append(f"{'mode':8} {'net':>10} {'ret%':>7} {'maxDD%':>7} {'base':>5} {'add':>4} "
|
||||
f"{'exit':>5} {'lvl':>4} {'fees':>7} {'util%':>6} {'maxDep':>8}")
|
||||
for mode, row in report["modes"].items():
|
||||
adds = sum(1 for f in modes[mode]["fills"] if f.kind == "add")
|
||||
levels = sum(1 for f in modes[mode]["fills"] if f.kind == "level")
|
||||
lines.append(
|
||||
f"{mode:8} {row['net']:10.2f} {row['return_pct']:7.3f} {row['max_dd_pct']:7.3f} "
|
||||
f"{row['buy_count'] - adds:5} {adds:4} "
|
||||
f"{sum(1 for f in modes[mode]['fills'] if f.kind == 'exit'):5} {levels:4} "
|
||||
f"{row['fees']:7.2f} {row['avg_util_pct']:6.2f} {row['max_deployed']:8.0f}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("== 逐标的归因 ==")
|
||||
for code, row in per_symbol.items():
|
||||
lines.append(f" {code} 轮次={row['rounds']:2} 已了结+未了结成本={row['net_at_cost']:9.2f} "
|
||||
f"未实现={row['unrealized']:8.2f} 按市价={row['net_at_market']:9.2f} "
|
||||
f"(未了结 {row['open_volume']} 股,成本 {row['open_cost']:.2f})")
|
||||
lines.append(f" 按成本口径合计={sum(r['net_at_cost'] for r in per_symbol.values()):.2f}"
|
||||
f" ←→ 权益变动={equity_delta:.2f}(应相等)")
|
||||
lines.append(f" 按市价口径合计={sum(r['net_at_market'] for r in per_symbol.values()):.2f}"
|
||||
f" = 权益变动 {equity_delta:.2f} + 未实现 {unrealized:.2f} - 持仓成本 "
|
||||
f"{sum(r['open_cost'] for r in per_symbol.values()):.2f}")
|
||||
lines.append("")
|
||||
lines.append("== 敏感性 ==")
|
||||
lines.append(f"{'label':36} {'net':>10} {'ret%':>7} {'maxDD%':>7} {'base':>5} {'add':>4} "
|
||||
f"{'exit':>5} {'lvl':>4} {'fees':>7} {'util%':>6} {'maxDep':>8}")
|
||||
for row in report["scenarios"]:
|
||||
lines.append(
|
||||
f"{row['label']:36} {row['net']:10.2f} {row['return_pct']:7.3f} {row['max_dd_pct']:7.3f} "
|
||||
f"{row['bases']:5} {row['adds']:4} {row['exits']:5} {row['levels']:4} "
|
||||
f"{row['fees']:7.2f} {row['avg_util_pct']:6.2f} {row['max_deployed']:8.0f}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("== 完整轮次明细 ==")
|
||||
for t in report["round_trips"]["detail"]:
|
||||
lines.append(f" {t['opened']} → {t['closed']} {t['code']} {t['days']:3}天 "
|
||||
f"档位={t['levels']} 买={t['buy']:9.2f} 卖={t['sell']:9.2f} "
|
||||
f"净利={t['profit']:8.2f} 收益率={t['return_pct']:6.3f}%")
|
||||
if args.ledger:
|
||||
lines.append("")
|
||||
lines.append("== 逐笔成交(touch 模型)==")
|
||||
for f in base["fills"]:
|
||||
lines.append(f" {f.day} {f.code} {f.side:4} {f.kind:4} {f.volume:6} "
|
||||
f"@{f.price:.3f} fee={f.fee:5.2f} {f.note}")
|
||||
|
||||
(OUT / "run_report.txt").write_text("\n".join(lines), encoding="utf-8")
|
||||
print("\n".join(lines))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
85
labs/analysis/etf/run_report.txt
Normal file
85
labs/analysis/etf/run_report.txt
Normal file
@@ -0,0 +1,85 @@
|
||||
回测区间 2025-12-22 ~ 2026-09-18 (182 个交易日)
|
||||
|
||||
== 数据 ==
|
||||
588000.SH bars=242 20250919..20260918 区间涨跌=+21.87%
|
||||
510300.SH bars=242 20250919..20260918 区间涨跌=-0.48%
|
||||
518880.SH bars=242 20250919..20260918 区间涨跌=+14.07%
|
||||
|
||||
== 三种成交模型的组合结果 ==
|
||||
mode net ret% maxDD% base add exit lvl fees util% maxDep
|
||||
touch -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
bounce -28229.46 1.685 0.558 28 5 27 0 343.75 3.40 89248
|
||||
close -110743.34 -0.510 1.676 15 6 13 0 197.34 6.71 124144
|
||||
|
||||
== 逐标的归因 ==
|
||||
588000.SH 轮次=12 已了结+未了结成本= 2011.94 未实现= 0.00 按市价= 2011.94 (未了结 0 股,成本 0.00)
|
||||
510300.SH 轮次=13 已了结+未了结成本=-15961.17 未实现= -155.14 按市价=-16116.31 (未了结 4000 股,成本 18483.14)
|
||||
518880.SH 轮次= 7 已了结+未了结成本= 1732.07 未实现= 0.00 按市价= 1732.07 (未了结 0 股,成本 0.00)
|
||||
按成本口径合计=-12217.16 ←→ 权益变动=6110.84(应相等)
|
||||
按市价口径合计=-12372.31 = 权益变动 6110.84 + 未实现 -155.14 - 持仓成本 18483.14
|
||||
|
||||
== 敏感性 ==
|
||||
label net ret% maxDD% base add exit lvl fees util% maxDep
|
||||
基准(当前 _etf.yaml) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
add_pct=2.0 -30799.95 1.171 0.362 32 4 31 0 378.00 2.78 53968
|
||||
add_pct=4.0 -12533.69 1.159 0.356 33 3 32 0 382.15 2.37 68020
|
||||
add_pct=5.0 -13223.75 1.021 0.412 31 1 30 0 339.29 2.71 50104
|
||||
min_profit_pct=0.5 -14852.61 0.695 0.498 41 4 40 0 477.22 1.56 87052
|
||||
min_profit_pct=0.8 -13107.19 1.044 0.486 37 4 36 0 432.96 1.80 87052
|
||||
min_profit_pct=1.5 -11767.41 1.312 0.438 23 4 22 0 284.56 3.21 68020
|
||||
min_profit_pct=2.0 -30665.81 1.136 0.448 19 5 17 2 246.20 3.96 68982
|
||||
channel_pct=10.0 -12977.28 1.070 1.289 27 6 26 0 349.58 5.46 138530
|
||||
channel_pct=20.0 -31076.06 1.116 0.480 28 6 27 0 352.61 3.25 71464
|
||||
channel_pct=30.0 -13548.26 0.956 0.381 23 4 22 0 295.36 2.53 56244
|
||||
max_adds=3 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
max_adds=5 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
max_adds=15 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
佣金率=0.0 -12178.48 1.230 0.478 33 5 32 0 350.00 2.24 87052
|
||||
佣金率=0.0003 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
佣金率=0.001 -12684.39 1.129 0.491 32 6 31 0 1317.22 2.33 87052
|
||||
无副出口 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
成交=反弹确认价(贴近实盘) -28229.46 1.685 0.558 28 5 27 0 343.75 3.40 89248
|
||||
成交=当日收盘价(悲观) -110743.34 -0.510 1.676 15 6 13 0 197.34 6.71 124144
|
||||
无 T+1 限制(min_hold_days=0) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
inner_step=0.2(副出口可达) -12479.32 1.170 0.478 35 5 34 1 423.10 2.01 87052
|
||||
inner_step=0.4(副出口可达) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
buy_shares×0.25 -3163.36 0.284 0.124 32 6 31 0 345.35 0.58 21763
|
||||
buy_shares×0.5 -6086.81 0.615 0.242 33 6 32 0 361.83 1.11 43526
|
||||
buy_shares×2.0 -24429.60 2.445 0.952 33 5 32 0 790.55 4.49 174104
|
||||
atr_multiplier×0.5(仅格距) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
atr_multiplier×2.0(仅格距) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052
|
||||
channel_pct=1(贴近区间下沿) -14730.49 0.658 0.677 23 4 22 0 283.33 3.07 79608
|
||||
|
||||
== 完整轮次明细 ==
|
||||
2026-01-21 → 2026-01-29 510300.SH 8天 档位=1 买= 18918.20 卖= 19113.11 净利= 183.50 收益率= 0.970%
|
||||
2026-01-30 → 2026-02-10 510300.SH 11天 档位=1 买= 18761.80 卖= 18955.10 净利= 181.99 收益率= 0.970%
|
||||
2026-03-05 → 2026-03-06 588000.SH 1天 档位=1 买= 14788.00 卖= 14940.93 净利= 142.93 收益率= 0.967%
|
||||
2026-03-09 → 2026-03-10 510300.SH 1天 档位=1 买= 18370.80 卖= 18560.07 净利= 178.20 收益率= 0.970%
|
||||
2026-03-09 → 2026-03-10 588000.SH 1天 档位=1 买= 14486.00 卖= 14635.91 净利= 139.91 收益率= 0.966%
|
||||
2026-03-12 → 2026-03-13 588000.SH 1天 档位=1 买= 14465.00 卖= 14614.70 净利= 139.70 收益率= 0.966%
|
||||
2026-03-16 → 2026-03-17 588000.SH 1天 档位=1 买= 14431.00 卖= 14580.36 净利= 139.36 收益率= 0.966%
|
||||
2026-03-24 → 2026-03-25 518880.SH 1天 档位=1 买= 18565.90 卖= 18757.18 净利= 180.09 收益率= 0.970%
|
||||
2026-03-19 → 2026-04-08 510300.SH 20天 档位=2 买= 36190.48 卖= 36563.35 净利= 351.04 收益率= 0.970%
|
||||
2026-03-18 → 2026-04-10 588000.SH 23天 档位=2 买= 28429.07 卖= 28723.46 净利= 275.77 收益率= 0.970%
|
||||
2026-04-28 → 2026-05-07 518880.SH 9天 档位=1 买= 19435.60 卖= 19635.84 净利= 188.52 收益率= 0.970%
|
||||
2026-05-22 → 2026-05-25 518880.SH 3天 档位=1 买= 18866.80 卖= 19061.18 净利= 183.01 收益率= 0.970%
|
||||
2026-06-09 → 2026-06-10 510300.SH 1天 档位=1 买= 18991.47 卖= 19187.14 净利= 184.22 收益率= 0.970%
|
||||
2026-06-11 → 2026-06-12 510300.SH 1天 档位=1 买= 19002.40 卖= 19198.18 净利= 184.32 收益率= 0.970%
|
||||
2026-06-05 → 2026-06-15 518880.SH 10天 档位=4 买= 70565.57 卖= 71292.61 净利= 684.48 收益率= 0.970%
|
||||
2026-06-29 → 2026-07-03 518880.SH 4天 档位=1 买= 16878.10 卖= 17052.00 净利= 163.72 收益率= 0.970%
|
||||
2026-07-14 → 2026-07-15 510300.SH 1天 档位=1 买= 19023.40 卖= 19219.40 净利= 184.53 收益率= 0.970%
|
||||
2026-07-20 → 2026-07-21 510300.SH 1天 档位=1 买= 18513.40 卖= 18704.14 净利= 179.58 收益率= 0.970%
|
||||
2026-07-14 → 2026-07-21 518880.SH 7天 档位=1 买= 16676.60 卖= 16848.42 净利= 161.76 收益率= 0.970%
|
||||
2026-07-21 → 2026-07-22 588000.SH 1天 档位=1 买= 18349.50 卖= 18538.55 净利= 177.99 收益率= 0.970%
|
||||
2026-07-27 → 2026-07-28 588000.SH 1天 档位=1 买= 18349.50 卖= 18538.55 净利= 177.99 收益率= 0.970%
|
||||
2026-07-28 → 2026-07-29 510300.SH 1天 档位=1 买= 18488.80 卖= 18679.29 净利= 179.34 收益率= 0.970%
|
||||
2026-07-30 → 2026-07-31 510300.SH 1天 档位=1 买= 18391.80 卖= 18581.29 净利= 178.40 收益率= 0.970%
|
||||
2026-08-03 → 2026-08-04 510300.SH 1天 档位=1 买= 18391.80 卖= 18581.29 净利= 178.40 收益率= 0.970%
|
||||
2026-08-05 → 2026-08-06 588000.SH 1天 档位=1 买= 17476.50 卖= 17656.56 净利= 169.52 收益率= 0.970%
|
||||
2026-08-24 → 2026-08-25 588000.SH 1天 档位=1 买= 16746.00 卖= 16918.53 净利= 162.43 收益率= 0.970%
|
||||
2026-08-31 → 2026-09-01 510300.SH 1天 档位=1 买= 18477.60 卖= 18667.97 净利= 179.23 收益率= 0.970%
|
||||
2026-09-02 → 2026-09-04 510300.SH 2天 档位=1 买= 18477.60 卖= 18667.97 净利= 179.23 收益率= 0.970%
|
||||
2026-09-03 → 2026-09-04 588000.SH 1天 档位=1 买= 16882.00 卖= 17055.94 净利= 163.75 收益率= 0.970%
|
||||
2026-09-07 → 2026-09-08 588000.SH 1天 档位=1 买= 16882.00 卖= 17055.94 净利= 163.75 收益率= 0.970%
|
||||
2026-09-15 → 2026-09-16 588000.SH 1天 档位=1 买= 16377.50 卖= 16546.33 净利= 158.83 收益率= 0.970%
|
||||
2026-09-17 → 2026-09-18 518880.SH 1天 档位=1 买= 17577.03 卖= 17758.13 净利= 170.50 收益率= 0.970%
|
||||
144
labs/analysis/etf/sweep.py
Normal file
144
labs/analysis/etf/sweep.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""为什么收益率低 / 改进方案量化:同一天数据、同一策略逻辑,只改参数与下单规模。
|
||||
|
||||
用法: py -3.14 -B analysis/etf/sweep.py
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from backtest import ( # noqa: E402
|
||||
START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults, EtfSymbolConfig, analyze,
|
||||
fetch_daily, precondition, simulate,
|
||||
)
|
||||
|
||||
data = {code: fetch_daily(code) for code in SYMBOLS}
|
||||
BASE = EtfDefaults()
|
||||
_CACHE: dict[tuple, dict] = {}
|
||||
|
||||
|
||||
def pre_for(defaults, params):
|
||||
"""按"影响指标计算"的参数缓存 precondition 结果。
|
||||
|
||||
注意:``channel_pct`` / ``atr_period`` / ``channel_period`` / ``min_grid_pct`` 与逐标的
|
||||
``atr_multiplier`` 会改变指标,必须进缓存键;``add_pct`` / ``min_profit_pct`` 只影响
|
||||
下单判定,不进键。
|
||||
"""
|
||||
key = (
|
||||
defaults.channel_pct, defaults.atr_period, defaults.channel_period,
|
||||
defaults.min_grid_pct,
|
||||
tuple(sorted((c, params[c]["atr_multiplier"]) for c in params)),
|
||||
)
|
||||
if key not in _CACHE:
|
||||
_CACHE[key] = {
|
||||
c: precondition(data[c], EtfSymbolConfig(**params[c]), defaults) for c in data
|
||||
}
|
||||
return _CACHE[key]
|
||||
|
||||
|
||||
def run(label, *, defaults=None, params=None, sizer=None, fill_mode="touch"):
|
||||
defaults = defaults or BASE
|
||||
params = params or SYMBOL_PARAMS
|
||||
result = simulate(data, defaults=defaults, symbol_params=params, sizer=sizer,
|
||||
fill_mode=fill_mode, precomputed=pre_for(defaults, params))
|
||||
stats = analyze(result)
|
||||
adds = sum(1 for f in result["fills"] if f.kind == "add")
|
||||
bases = sum(1 for f in result["fills"] if f.kind == "base")
|
||||
trips = [
|
||||
(f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee
|
||||
for f in result["fills"]
|
||||
]
|
||||
return {
|
||||
"label": label,
|
||||
"equity": stats["final_equity"] - result["start_cash"],
|
||||
"ret_pct": stats["return_pct"],
|
||||
"dd_pct": stats["max_dd_pct"],
|
||||
"bases": bases,
|
||||
"adds": adds,
|
||||
"exits": sum(1 for f in result["fills"] if f.kind == "exit"),
|
||||
"roi_on_deployed_pct": (
|
||||
(stats["final_equity"] - result["start_cash"]) / stats["avg_deployed"] * 100
|
||||
if stats["avg_deployed"] else 0.0
|
||||
),
|
||||
"util_pct": stats["avg_util_pct"],
|
||||
"max_util_pct": stats["max_deployed"] / result["start_cash"] * 100,
|
||||
"fees": stats["fees"],
|
||||
}
|
||||
|
||||
|
||||
def show(rows):
|
||||
print(f"{'场景':46} {'权益变动':>10} {'收益率':>7} {'回撤':>6} {'底仓':>4} {'补仓':>4} "
|
||||
f"{'占用ROI':>8} {'平均占用':>8} {'峰值占用':>8}")
|
||||
for r in rows:
|
||||
print(f"{r['label'][:46]:46} {r['equity']:10.2f} {r['ret_pct']:6.2f}% {r['dd_pct']:5.2f}% "
|
||||
f"{r['bases']:4} {r['adds']:4} {r['roi_on_deployed_pct']:7.2f}% "
|
||||
f"{r['util_pct']:7.2f}% {r['max_util_pct']:7.2f}%")
|
||||
|
||||
|
||||
def sized(shares):
|
||||
"""把逐标的 buy_shares / max_shares 同步放大,保持 10 档容量不变。"""
|
||||
return {c: {**SYMBOL_PARAMS[c], "buy_shares": shares, "max_shares": shares * 10}
|
||||
for c in SYMBOLS}
|
||||
|
||||
|
||||
print("=" * 132)
|
||||
print("A. 只放大单档规模(其余参数一律不动)")
|
||||
print("=" * 132)
|
||||
show([run(f"buy_shares={n}(现值 1000)", params=sized(n)) for n in (1000, 2000, 5000, 10000, 20000)])
|
||||
|
||||
print()
|
||||
print("=" * 132)
|
||||
print("B. 按账户资金比例下单(sizer,替代固定股数;每档 = 现金的 x%)")
|
||||
print("=" * 132)
|
||||
rows = []
|
||||
for pct in (0.02, 0.05, 0.10, 0.20):
|
||||
sizer = (lambda p: (lambda equity, price: int(equity * p / price)))(pct)
|
||||
rows.append(run(f"每档 = 现金 {pct:.0%}", params=sized(20000), sizer=sizer))
|
||||
show(rows)
|
||||
|
||||
print()
|
||||
print("=" * 132)
|
||||
print("C. 入场/补仓/止盈参数(规模固定 buy_shares=2000)")
|
||||
print("=" * 132)
|
||||
P2 = sized(2000)
|
||||
show([
|
||||
run("基准参数(add 3% / profit 1% / channel 15%)", params=P2),
|
||||
run("add_pct=2%", defaults=replace(BASE, add_pct=2.0), params=P2),
|
||||
run("add_pct=1.5%", defaults=replace(BASE, add_pct=1.5), params=P2),
|
||||
run("add_pct=1.0%", defaults=replace(BASE, add_pct=1.0), params=P2),
|
||||
run("min_profit_pct=0.6%", defaults=replace(BASE, min_profit_pct=0.6), params=P2),
|
||||
run("min_profit_pct=3%", defaults=replace(BASE, min_profit_pct=3.0), params=P2),
|
||||
run("channel_pct=10%", defaults=replace(BASE, channel_pct=10.0), params=P2),
|
||||
run("channel_pct=30%", defaults=replace(BASE, channel_pct=30.0), params=P2),
|
||||
run("add 1.5% + profit 0.6%", defaults=replace(BASE, add_pct=1.5, min_profit_pct=0.6), params=P2),
|
||||
run("add 1.5% + profit 0.6% + channel 30%", defaults=replace(BASE, add_pct=1.5, min_profit_pct=0.6, channel_pct=30.0), params=P2),
|
||||
])
|
||||
|
||||
print()
|
||||
print("=" * 132)
|
||||
print("D. 组合方案(把 A/B/C 的结论叠起来)")
|
||||
print("=" * 132)
|
||||
best = []
|
||||
for shares in (2000, 5000, 10000):
|
||||
for add_pct, profit in ((1.5, 0.6), (1.5, 1.0), (2.0, 0.6), (1.0, 0.6)):
|
||||
label = f"buy={shares} add={add_pct}% profit={profit}%"
|
||||
best.append(run(label, defaults=replace(BASE, add_pct=add_pct, min_profit_pct=profit),
|
||||
params=sized(shares)))
|
||||
best.sort(key=lambda r: -r["equity"])
|
||||
show(best[:10])
|
||||
|
||||
print()
|
||||
print("=" * 132)
|
||||
print("E. 现金利用率天花板:每档 = 现金 10%,同时放开通道与补仓(看能否把 20 万用起来)")
|
||||
print("=" * 132)
|
||||
rows = []
|
||||
for add_pct, profit, channel in ((3.0, 1.0, 15.0), (1.5, 0.6, 30.0), (1.0, 0.6, 30.0), (1.0, 0.5, 40.0)):
|
||||
sizer = lambda equity, price: int(equity * 0.10 / price)
|
||||
rows.append(run(f"add={add_pct}% profit={profit}% channel={channel}% 每档10%现金",
|
||||
defaults=replace(BASE, add_pct=add_pct, min_profit_pct=profit, channel_pct=channel),
|
||||
params=sized(20000), sizer=sizer))
|
||||
show(rows)
|
||||
53
labs/analysis/etf/universe.py
Normal file
53
labs/analysis/etf/universe.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""加标的 vs 加仓位:同一引擎、按比例缩放资金,隔离"标的数量"的影响。
|
||||
|
||||
注意:把 universe 缩成 N 只、资金缩到 N/3 只能近似"同时持有更多标的",
|
||||
但它同时缩短了白名单,所以结论按"资金可部署机会数"来读,而不是精确预测。
|
||||
|
||||
用法: py -3.14 -B analysis/etf/universe.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from backtest import ( # noqa: E402
|
||||
MIN_CASH_RATIO, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults, EtfSymbolConfig,
|
||||
analyze, fetch_daily, precondition, simulate,
|
||||
)
|
||||
|
||||
BASE = EtfDefaults()
|
||||
FULL = {code: fetch_daily(code) for code in SYMBOLS}
|
||||
|
||||
# (白名单, 起始资金, 说明)
|
||||
CASES = []
|
||||
for size in (1, 2, 3):
|
||||
codes = SYMBOLS[:size]
|
||||
CASES.append((f"白名单 {size} 只({'+'.join(c[:6] for c in codes)}),资金按 {size}/3 缩放",
|
||||
codes, START_CASH * size / 3))
|
||||
# 同样 20 万资金,但只放 1 只 / 2 只 → 观察"钱多而标的少"是否浪费
|
||||
CASES.append(("白名单 1 只,但仍是 20 万资金", SYMBOLS[:1], START_CASH))
|
||||
CASES.append(("白名单 2 只,但仍是 20 万资金", SYMBOLS[:2], START_CASH))
|
||||
CASES.append(("白名单 3 只,20 万(基准)", SYMBOLS[:3], START_CASH))
|
||||
# 每档放大到 5000 股,资金同步放大到 100 万 → 检验"标的不变、仓位变大"的天花板
|
||||
CASES.append(("3 只 + buy_shares=5000,资金 100 万", SYMBOLS[:3], 1_000_000.0))
|
||||
CASES.append(("3 只 + buy_shares=10000,资金 200 万", SYMBOLS[:3], 2_000_000.0))
|
||||
|
||||
print(f"{'场景':52} {'资金':>10} {'权益变动':>10} {'收益率':>7} {'回撤':>6} "
|
||||
f"{'平均占用':>8} {'峰值占用':>8} {'ROI/占用':>8} {'底仓':>4} {'补仓':>4}")
|
||||
for label, codes, cash in CASES:
|
||||
data = {c: FULL[c] for c in codes}
|
||||
shares = 5000 if "5000" in label else 10000 if "10000" in label else SYMBOL_PARAMS[codes[0]]["buy_shares"]
|
||||
params = {c: {**SYMBOL_PARAMS[c], "buy_shares": shares, "max_shares": shares * 10} for c in codes}
|
||||
pre = {c: precondition(data[c], EtfSymbolConfig(**params[c]), BASE) for c in codes}
|
||||
result = simulate(data, symbol_params=params, precomputed=pre, start_cash=cash)
|
||||
stats = analyze(result)
|
||||
delta = stats["final_equity"] - cash
|
||||
util = stats["avg_deployed"] / cash * 100
|
||||
max_util = stats["max_deployed"] / cash * 100
|
||||
roi = delta / stats["avg_deployed"] * 100 if stats["avg_deployed"] else 0.0
|
||||
print(f"{label:52} {cash:10.0f} {delta:10.2f} {stats['return_pct']:6.2f}% "
|
||||
f"{stats['max_dd_pct']:5.2f}% {util:7.2f}% {max_util:7.2f}% {roi:7.2f}% "
|
||||
f"{sum(1 for f in result['fills'] if f.kind == 'base'):4} "
|
||||
f"{sum(1 for f in result['fills'] if f.kind == 'add'):4}")
|
||||
130
labs/analysis/etf/vs_hold.py
Normal file
130
labs/analysis/etf/vs_hold.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""ETF 网格策略 vs 同期买入持有(同一区间、同一份日线)。
|
||||
|
||||
用法: py -3.14 -B analysis/etf/vs_hold.py
|
||||
"""
|
||||
|
||||
import math
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from backtest import START_CASH, SYMBOLS, analyze, fetch_daily, simulate # noqa: E402
|
||||
|
||||
data = {code: fetch_daily(code) for code in SYMBOLS}
|
||||
result = simulate(data)
|
||||
stats = analyze(result)
|
||||
curve = result["curve"]
|
||||
start_day = curve[0][0]
|
||||
cash0 = result["start_cash"]
|
||||
grid_final = stats["final_equity"]
|
||||
|
||||
# ---- 买入持有:在回测首日按各标的收盘价等权买入,持到期末 ----
|
||||
bars_by_date = {code: {b["date"]: b for b in data[code]} for code in SYMBOLS}
|
||||
first_stamp = start_day.strftime("%Y%m%d")
|
||||
entry = {c: bars_by_date[c][first_stamp]["close"] for c in SYMBOLS}
|
||||
last_stamp = curve[-1][0].strftime("%Y%m%d")
|
||||
exit_ = {c: bars_by_date[c][last_stamp]["close"] for c in SYMBOLS}
|
||||
|
||||
per_symbol_cash = cash0 / len(SYMBOLS)
|
||||
hold_shares = {c: per_symbol_cash / entry[c] for c in SYMBOLS} # 含零股,忽略整手限制
|
||||
hold_curve = []
|
||||
for day, _, _, _ in curve:
|
||||
stamp = day.strftime("%Y%m%d")
|
||||
value = sum(hold_shares[c] * bars_by_date[c].get(stamp, {"close": entry[c]})["close"]
|
||||
for c in SYMBOLS)
|
||||
hold_curve.append((day, value))
|
||||
hold_final = hold_curve[-1][1]
|
||||
|
||||
|
||||
def profile(curve_points):
|
||||
"""从权益曲线算总收益、最大回撤、日波动、夏普与卡玛。"""
|
||||
values = [v for _, v in curve_points]
|
||||
peak, max_dd = -math.inf, 0.0
|
||||
for value in values:
|
||||
peak = max(peak, value)
|
||||
max_dd = max(max_dd, (peak - value) / peak)
|
||||
rets = [values[i] / values[i - 1] - 1 for i in range(1, len(values))]
|
||||
mean = statistics.fmean(rets) if rets else 0.0
|
||||
sd = statistics.pstdev(rets) if len(rets) > 1 else 0.0
|
||||
total = values[-1] / values[0] - 1
|
||||
days = len(values)
|
||||
annual = (1 + total) ** (252 / days) - 1 if days else 0.0
|
||||
return {
|
||||
"total_pct": total * 100,
|
||||
"annual_pct": annual * 100,
|
||||
"max_dd_pct": max_dd * 100,
|
||||
"daily_sd_pct": sd * 100,
|
||||
"sharpe": (mean / sd * math.sqrt(252)) if sd > 0 else float("nan"),
|
||||
"calmar": (total / max_dd) if max_dd > 0 else float("nan"),
|
||||
}
|
||||
|
||||
|
||||
grid_curve = [(day, equity) for day, equity, _, _ in curve]
|
||||
|
||||
print("=" * 108)
|
||||
print(f"区间 {start_day} ~ {curve[-1][0]}({len(curve)} 个交易日),账户 {cash0:,.0f} 元")
|
||||
print("=" * 108)
|
||||
print(f"{'策略':26} {'期末权益':>12} {'总收益':>9} {'年化':>8} {'最大回撤':>9} "
|
||||
f"{'日波动':>8} {'夏普':>7} {'卡玛':>7}")
|
||||
for label, points in (("ETF 网格(现配置)", grid_curve), ("等权买入持有", hold_curve)):
|
||||
p = profile(points)
|
||||
print(f"{label:26} {points[-1][1]:12,.2f} {p['total_pct']:8.2f}% {p['annual_pct']:7.2f}% "
|
||||
f"{p['max_dd_pct']:8.2f}% {p['daily_sd_pct']:7.3f}% {p['sharpe']:7.2f} {p['calmar']:7.2f}")
|
||||
|
||||
print()
|
||||
print("== 逐标的买入持有(同一区间)==")
|
||||
print(f"{'标的':11} {'期初':>8} {'期末':>8} {'涨跌':>9} {'期间最大回撤':>12}")
|
||||
for code in SYMBOLS:
|
||||
bars = [b for b in data[code] if b["date"] >= first_stamp and b["date"] <= last_stamp]
|
||||
closes = [b["close"] for b in bars]
|
||||
peak, dd = -math.inf, 0.0
|
||||
for value in closes:
|
||||
peak = max(peak, value)
|
||||
dd = max(dd, (peak - value) / peak)
|
||||
print(f"{code:11} {closes[0]:8.3f} {closes[-1]:8.3f} "
|
||||
f"{(closes[-1] / closes[0] - 1) * 100:8.2f}% {dd * 100:11.2f}%")
|
||||
|
||||
print()
|
||||
print("== 关键口径 ==")
|
||||
print(f" 网格:平均资金占用 {stats['avg_util_pct']:.2f}%(峰值 {stats['max_deployed'] / cash0 * 100:.2f}%),"
|
||||
f"买入名义 {stats['buy_amount']:,.0f}(换手 {stats['turnover_x']:.2f} 倍),"
|
||||
f"佣金 {stats['fees']:.2f}")
|
||||
print(f" 网格:占用部分的收益率(权益变动 ÷ 平均占用)= "
|
||||
f"{(grid_final - cash0) / stats['avg_deployed'] * 100:.2f}%")
|
||||
print(f" 买入持有:资金 100% 占用(从未空仓),无佣金/无交易")
|
||||
print(f" 网格:完整轮次 {len([f for f in result['fills'] if f.kind == 'exit'])} 次,"
|
||||
f"胜率 100%(只在盈利 ≥{stats['params']['min_profit_pct']}% 时才卖)")
|
||||
|
||||
# ---- 同风险口径:把网格放大到与买入持有相同回撤,比收益 ----
|
||||
print()
|
||||
print("== 同回撤口径(把网格单档股数放大到回撤≈买入持有)==")
|
||||
from dataclasses import replace # noqa: E402
|
||||
from backtest import SYMBOL_PARAMS, SYMBOLS as _SYMS # noqa: E402
|
||||
|
||||
hold_p = profile(hold_curve)
|
||||
print(f" 目标回撤:买入持有 {hold_p['max_dd_pct']:.2f}%")
|
||||
print(f"{'放大倍数':>8} {'权益变动':>12} {'总收益':>9} {'最大回撤':>9} {'平均占用':>9} {'夏普':>7} {'卡玛':>7}")
|
||||
for factor in (1, 4, 10, 20, 35):
|
||||
params = {
|
||||
c: {**SYMBOL_PARAMS[c],
|
||||
"buy_shares": max(100, int(SYMBOL_PARAMS[c]["buy_shares"] * factor)),
|
||||
"max_shares": max(1000, int(SYMBOL_PARAMS[c]["max_shares"] * factor))}
|
||||
for c in _SYMS
|
||||
}
|
||||
res = simulate(data, symbol_params=params)
|
||||
st = analyze(res)
|
||||
pts = [(day, eq) for day, eq, _, _ in res["curve"]]
|
||||
p = profile(pts)
|
||||
print(f"{factor:8}× {st['final_equity'] - cash0:12,.2f} {p['total_pct']:8.2f}% "
|
||||
f"{p['max_dd_pct']:8.2f}% {st['avg_util_pct']:8.2f}% {p['sharpe']:7.2f} {p['calmar']:7.2f}")
|
||||
print()
|
||||
print("注:放大是外推(同一段历史、同一批机会等比放大),不是新样本的验证;")
|
||||
print(" 放开股数后实际能否成交/是否滑点恶化,日线回测无法回答。")
|
||||
Reference in New Issue
Block a user