This commit is contained in:
2026-09-19 19:45:43 +08:00
parent 7183cb45f8
commit 8131b158b4
60 changed files with 7669 additions and 909 deletions

View File

@@ -0,0 +1,354 @@
# ETF 自适应网格策略回测报告(新配置)
- 配置来源:`py-client/etc/_etf.yaml`**回测直接读取该文件,不再维护副本**
- 回测区间:**2025-12-22 ~ 2026-09-18182 个交易日)**
- 标的:`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.340.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.340.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 完整轮次
| 项 | 值 |
| --- | ---: |
| 完整轮次 | 3232 胜 0 负) |
| 单轮平均利润 | +195.81(最好 +684.48,最差 +139.36 |
| 平均持有 | 3.7 天(最长 23 天) |
| **持有 ≤1 天就了结** | **22 / 3269%** |
| 平均档位 / 最大档位 | 1.16 / 4 |
| 底仓 / 补仓次数 | 33 / 5 |
**69% 的轮次在 1 天内完成**:新配置把单档金额放大到 1.8~14 万后1% 的止盈目标对应的绝对金额
变成 18~140 元/档,达到速度没变,但**单轮利润的绝对值被放大**。
同时 **平均只用 1.16 档**(补仓 5 次),阶梯依然远未铺开。
### 3.2 资金占用
| 项 | 值 |
| --- | ---: |
| 有持仓天数 | 73 / 18240.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+1min_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,5340.31% | **+6,1111.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-18182 个交易日)、同一份日线、同一 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,2262.45%)、`×4` 得 +24,4534.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
View 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-18182 个交易日)**
- 标的:`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+1min_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% 最差(+8081.0~1.5% 最好(+1,534 / +1,4452.0% 反而回落(+783 | 太低被佣金吃掉、太高错过均值回归1.0~1.5% 是合理区间,**但 2.0% 的回落说明结论对样本路径敏感** |
| `channel_pct` | 1% 大幅变差(+37310% 收益略低(+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×(1add_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 掉到 1259%),因为"当日收盘仍在门槛之上"比"盘中触及门槛"
严格得多——**如果实盘实际上只能在收盘附近决策例如策略被限流、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 # 入场机会频率
```

View 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())

View 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_daysis_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())

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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("说明:'资金拒绝' 未单独统计;底仓/补仓次数随资金上升而增加,说明低资金时被预算挡住。")

View 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}")

View 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(" 不是精确回测(未重算后续轮次与资金复用)。")

View 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% 的确认。")

File diff suppressed because it is too large Load Diff

232
labs/analysis/etf/run.py Normal file
View 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())

View 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
View 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)

View 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}")

View 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(" 放开股数后实际能否成交/是否滑点恶化,日线回测无法回答。")