# 第六章:看清好看的回测:别被自己骗了 第五章我们给策略做了一次全面体检——基准对比、逐年拆解、收益分布、参数敏感性。现在手上有一套完整的评估方法论。 但所有体检结果都基于一个前提:**回测结果是真实的。** 真的吗? 回顾第五章的发现:TopNRanking 策略在 2021 年亏了 10%,换遍所有动量窗口都救不回来——这是动量策略的结构性弱点。但同样的策略在 2023-2025 的好市场上表现不错。这引出一个危险的念头:**如果我们在好市场上精心挑选参数,找到“收益最好”的组合,信心满满地投入资金——未来再遇到差市场会怎么样?** 回测可以调参数让收益翻倍——这是真的好,还是自欺欺人? ### 路线图 **选什么标的(第二章)→ 每个买多少(第三章)→ 什么时候买卖(第四章)→ 怎么验证有效(第五章)→ 如何避免自欺欺人(第六章)** **这一章你仍在飞轮的“归因层”,但主练动作切到了“疑”——本书口号“做要规则、看要指标、还要怀疑你的指标”中“还要怀疑你的指标”,就是这一章要让你练成肌肉记忆的事。** 第一章你已经亲手撞过一次墙——参数扫描里“冠军”在样本外掉到水下;这一章我们给你**四把铲子**,让那种墙以后看见就能挖开:样本内/外、Walk-forward、交叉验证、规则负担。本章解决第五步——识别过拟合陷阱,学会检验策略的真实有效性。四步对应的问题与方法如表 6-1 所示。 **表 6-1 第六章四节问题与方法对照** | 步骤 | 问题 | 方法 | |------|------|------| | Step 1 | 能不能找到收益最好的参数? | 参数优化 + 样本内/样本外 | | Step 2 | 市场在变,参数也该跟着变吧? | Walk-forward 分析 | | Step 3 | 一条路径够可靠吗? | 交叉验证 | | Step 4 | 规则越多回测越好——是好事吗? | 规则负担 | 铁律不变:**先猜后验,数据说了算。** (操作流程见前言“怎么使用这本书”。) --- ## 6.1 能不能找到收益最好的参数? **和第一章 1.5 节的关系:** 第一章末尾你扫过 24 个一维参数(均线窗口),用 60-40 切分做样本外测试,已经撞过一次过拟合的墙。这一章接着上一次的脚印继续走——**同一种切法,但量级跃迁**:参数从 1 维涨到 4 维(动量 / 波动 / 调仓 / 止损),组合数从 24 涨到 490。维度多了之后,过拟合的样子会变什么样?这一节给你答案。 第五章用了默认参数——动量窗口 20 天、波动率窗口 20 天、调仓频率 10 天。但这些是随便选的,不是天经地义的。 直觉告诉我们:**多试几组参数,选收益最高的就行了。** 几百种组合里总有比默认值更好的吧? 这个想法太自然了。就像买手机——把市面上所有型号的参数列出来,逐项比较,选评分最高的那款。参数也一样,把所有组合都试一遍,选出表现最好的——这叫**参数优化**,也叫**网格搜索(Grid Search)**——一个参数组合就像是一个格子,挨个格子试一遍,总能找到最优解。 真的这么简单吗?试试看。 ### 动手实验 1:参数优化与样本内/样本外 这个实验用到 open-xquant 的参数优化工具:`GridSearch`(网格搜索——把所有参数组合都试一遍)和 `WalkForward`(前推分析——模拟“边走边调”的真实场景)。 我们一起把这份 spec 写出来。这次重点看三件新东西:**维度跃迁**(从一维到四维参数)、**时段语义切分**(好市场挑参数 / 差市场验证)、**业务约束的可读字符串写法**。 #### 起草上下文 + 任务 第一章 1.5 节用一行“前 60% / 后 40%”切了一刀——这是结构性切分。这一节不一样:每段时间要带金融含义。 > **上下文**:本章探讨参数优化和过拟合。第五章看了默认参数(动量 20 / 波动率 20 / 调仓 10)的 plateau vs peak,但学员从没试过“从一堆参数里找最好的”。承接第五章的叙事——TopNRanking 在 2017-2020 好市场上参数选得好,2021 年差市场会怎么样? > > **任务**:在 `q6-avoid-overfitting.ipynb` 用 `oxq.optimize.GridSearch` 跑参数网格搜索,用样本内 / 样本外方法检验过拟合。 > 📌 **要点**:数据切分要带“市场含义”。第一章的 60/40 是按比例切的,这里换成“2017-2020 好市场 / 2021-2023 差市场”——后者把每段对应的市场行情说清楚,学员才能看懂“为什么是这两段”。**任何切分型 spec,起止日期都要四个全锁死(IS_START / IS_END / OOS_START / OOS_END),否则今年跑、明年跑切出来不一样,过拟合证据就站不住。** #### 起草要求:维度跃迁与业务约束 第一章扫过 24 个一维参数(均线窗口)。这一节直接跳到 4 维——动量 / 波动率 / 调仓频率 / 止损阈值。 > **要求**: > > 1. 数据切割:样本内 `2017-01-01 ~ 2020-12-31`、样本外 `2021-01-01 ~ 2023-01-31`,四个端点全锁死 > 2. 用 `ParameterSet` 定义参数空间:动量 / 波动率窗口 `[10, 15, 20, 25, 30]`、调仓频率 `[5, 10, 15, 21]`、止损阈值 `[0.03, 0.05, 0.07, 0.10, 0.15, 0.20]` > 3. 分两组跑:无止损组(3 维)+ 含止损组(4 维),约束后总组合数约 490 > 4. 业务约束用可读字符串写:`add_constraint("RebalanceFrequencyRule.interval_days <= mom.period")`、`<= vol.period` > 5. 调仓频率不应超过信号窗口——这是领域知识,不是技术约束 > 6. 用 `GridSearch` 跑两组参数空间,在样本内、样本外各跑一次 > 📌 **要点**:维度跃迁后要分组管理。一维扫描随便扫,四维直接扫会爆——5×5×4×6 = 600 还要约束过滤。**分两组跑(无止损 70 组合 + 含止损 420 组合)既能控制总数,又能让止损这一维在结果里被单独看清——也为后面“规则负担”那一节埋了伏笔。** > 📌 **要点**:业务约束用可读字符串,不要用 lambda。`"RebalanceFrequencyRule.interval_days <= mom.period"` 这种 DSL 字符串比 `lambda p: p["RebalanceFrequencyRule.interval_days"] <= p["mom.period"]` 更适合 0 基础学员看。**约束的“为什么”要写到 spec 注释里——“信号 20 天更新一次,调仓 60 天才看一次 = 在用过期信号做决策”——这是把领域知识嵌入 spec 的标准写法。** #### 起草结果呈现 > **结果呈现**: > > 1. 打印 Top 20 表(按样本内夏普排名)+ 样本外对比 + 全局相关系数 + 衰减分布 > 2. 散点图(样本内 vs 样本外夏普,y=x 对角线虚线,Top 20 红色高亮) > 3. Top 20 柱状图(每组双柱:样本内 / 样本外) > 4. 总结打印按实际数据动态描述:Top 20 平均衰减、对角线下方点的占比、过拟合定义 完整 spec 在 [`specs/spec-01-parameter-optimization.md`](https://github.com/xingwudao/xquant-learning/blob/main/q6-avoid-overfitting/specs/spec-01-parameter-optimization.md)——复制给 AI,弹窗选「允许」。 AI 助手执行完毕后,你的 notebook 里应该出现了网格搜索的完整结果。我们来看看代码做了什么。 数据被切成了两段:**样本内(In-Sample)** 用 2017-2020 四年做优化,**样本外(Out-of-Sample)** 用 2021-2023 做验证。为什么选这么早的数据?因为样本内需要足够长(至少 3-4 年),才能覆盖牛熊周期,避免只在单一行情下调参。时间顺序也必须正确——先优化,后验证,就像真实投资中你只能用历史数据做决策,然后面对未来。 **样本内**就是你看着答案复习的模拟考试卷;**样本外**就是真正的考试——你从没见过的题目。 四个维度的参数组合——动量窗口 5 种 × 波动率窗口 5 种 × 调仓频率 4 种 × 止损阈值(有/无),加上约束过滤(调仓频率不能超过信号窗口——用过期信息做决策没有意义),最终产生 490 种有效组合。 `GridSearch` 把 490 种参数组合逐个跑回测,按夏普比排名。然后用同样的参数在样本外再跑一遍,看“真正的考试”成绩如何。 ### 运行结果 490 种组合全部跑完,先看样本内 Top 20 的“样本外真相”——样本内夏普 Top 20 及其样本外表现(部分)如表 6-2 所示。 **表 6-2 样本内夏普 Top 20 及其样本外表现(部分)** | # | mom | vol | freq | sl | 样本内夏普 | 样本外夏普 | 衰减 | |---|-----|-----|------|----|-----------|-----------|------| | 1 | 15 | 10 | 10 | 0.03 | 1.39 | 0.50 | -0.88 | | 2 | 15 | 15 | 10 | 0.03 | 1.34 | 0.49 | -0.85 | | 3 | 15 | 25 | 10 | 0.03 | 1.30 | 0.38 | -0.92 | | 4 | 15 | 20 | 10 | 0.03 | 1.29 | 0.39 | -0.90 | | 5 | 30 | 10 | 5 | 0.03 | 1.25 | 0.02 | -1.23 | | 7 | 30 | 10 | 10 | 0.03 | 1.24 | -0.07 | -1.31 | | 14 | 15 | 10 | 10 | 无 | 1.18 | 0.01 | -1.17 | **样本内 Top 20 统计:** 样本内夏普均值 1.22(范围 1.15 ~ 1.39),样本外夏普均值 0.20(范围 -0.09 ~ 0.50),平均衰减 -1.02。 **全部 490 个组合统计:** 样本内/样本外夏普相关系数 0.04,样本外比样本内差的比例 100%,夏普衰减均值 -0.82、中位数 -0.88。 散点图与 Top 20 柱状图如图 6-1 所示。 ![490 个参数组合:样本内 vs 样本外夏普比](images/q6-avoid-overfitting/01-is-vs-oos-scatter-and-top20.png) ### 结果解读 第一个认知升级:**参数优化是一把双刃剑。** 先看数字。样本内 Top 20 的平均夏普 1.22——这在量化里属于很拿得出手的成绩。但拿到样本外(2021-2023,没见过的数据),平均夏普跌到了 0.20。衰减了 83%。 再看散点图。每个点代表一种参数组合,对角虚线是“样本外 = 样本内”的理想状态。如果所有点都在对角线上,说明样本内的好成绩在样本外也能兑现。但现实是:**100% 的点都在对角线以下**——没有一种参数组合的样本外表现达到样本内水平。 更扎心的是:样本内夏普和样本外夏普的相关系数只有 0.04——几乎没有关系。样本内排名第一的组合,样本外并不一定最好。 这就是**过拟合(Overfitting)**——你的策略只会死读书,课后习题全都会做,结果高考考砸了。它“记住了”过去数据的特征(噪音),而不是发现了真正的规律。 柱状图更直观:蓝色柱子(样本内)高高在上,红色柱子(样本外)矮了一大截。有些组合样本外甚至为负——在“真正的考试”中亏了钱。 **数据说了算:参数优化后,必须在没见过的数据上验证。样本内的好成绩不算数。** **这就是第一把铲子:样本内 / 样本外切分。** 它不能直接帮你赚钱——但它能让你在第一时间看到“漂亮回测背后到底有没有真东西”。第一章那次“被自己骗”的肉身记忆,到这里第一次有了一件可以挂在墙上的工具。 怎么办?我们把数据切成了“优化”和“验证”两段,只验证了一次。但只验证一次够吗?市场在变,参数也该跟着变吧? --- ## 6.2 市场在变,参数也该跟着变吧? Step 1 把数据切成了“优化”和“验证”两段,发现了过拟合问题。但我们只切了一次、只验证了一次。 市场每年都在变。2020 年选的参数,2023 年还适用吗?能不能模拟“每隔一段时间重新优化参数”的过程? 直觉告诉我们:**市场每年不一样,参数当然应该定期更新。** 用最近的数据重新优化,应该比死守一组参数更好。 这个想法叫 **Walk-forward(前推验证)分析**——“边走边调”。在历史数据上模拟“每隔一段时间重新优化参数”的过程:用过去两年数据选参数,在接下来半年验证;然后窗口往前滑半年,再选参数、再验证……如此反复。 有两种滑动方式: - **滚动式(Rolling)**——固定数据窗口宽度,窗口左边位置不断往右移动。好处是只看最近的市场,坏处是丢弃了更早的数据。就像只翻最近两年的课本复习 - **锚定式(Anchored)**——固定数据窗口左边位置,窗口宽度不断拉长。好处是利用了所有历史数据,坏处是旧数据可能不再适用。就像从高一课本开始全部复习 试试看——用两种不同的方式“边走边调”。 ### 动手实验 2:Walk-forward 分析 我们一起把这份 spec 写出来。这次重点看两件新东西:**循环型 spec 的端点全锁**(窗口起止 + 长度 + 步长四个参数都不留浮动)、**对照实验式 spec 的并列结构**(让 Rolling / Anchored 自己跑出对比)。 #### 起草上下文 + 任务 > **上下文**:在 `q6-avoid-overfitting.ipynb` 中已有 490 个组合的网格搜索结果 + 样本内/样本外对比,过拟合现象已暴露。当前问题:数据只切了一次。市场在变,2023 年选的参数 2025 年还适用吗?能不能模拟“定期重新优化”的过程? > > **任务**:在 notebook 新建单元格,用 `oxq.optimize.WalkForward` 实现 Walk-forward 分析,对比 Rolling 和 Anchored 两种方式。 #### 起草要求:循环窗口的四个端点 循环型 spec 比单次 spec 脆弱得多——单次 spec 漏掉一个日期可能没事,循环型 spec 漏掉就会让“折数”在不同时间跑出不同结果。 > **要求**: > > 1. 复用 Step 1 的参数空间(无止损组 70 组合)和约束(`RebalanceFrequencyRule.interval_days <= mom.period` / `<= vol.period`) > 2. 数据范围:`DATA_START="2017-01-01"` 到 `ANALYSIS_END="2026-05-06"`——四个端点全锁死,不要用 `pd.Timestamp.now()` > 3. Rolling Walk-forward:训练窗口 `"2Y"`、测试窗口 `"6M"`、`anchored=False` > 4. Anchored Walk-forward:同上,`anchored=True`(训练窗口从起点不断扩大) > 5. `train_period="2Y"` 的依据:覆盖一个完整中期周期;`test_period="6M"` 的依据:足够长以观察策略表现,又足够短以让多个折落进数据范围 > 📌 **要点**:循环型 spec 的窗口端点要四个全锁死——**起始日 + 结束日 + 窗口长度 + 步长**。漏掉任何一个,折数都会随时间漂移。如果用 `pd.Timestamp.now()` 当结束日,半年后跑出来折数从 15 变成 16,参数稳定性的结论可能就反过来了。**任何带 `for` 循环的 spec 都要回头检查这四个端点。** > 📌 **要点**:循环窗口的“为什么”也要写。`"2Y" / "6M"` 不是随手挑的——背后有“覆盖一个完整中期周期”和“折数 ≥ 5 才有统计意义”两个判断。**领域知识里的窗口长度选择,跟参数选择本身一样重要——把判断写进 spec 注释,别让 AI 揣摩。** #### 起草结果呈现 > **结果呈现**: > > 1. 用 `to_dataframe()` 打印每折详情:训练时段、验证时段、最优参数、样本内/样本外夏普 > 2. 汇总:`oos_sharpe_ratio()`、`deterioration()`、各折参数集合 > 3. 对比图(figsize 14×6,上下两栏):上栏每折样本外夏普(Rolling 红 / Anchored 蓝两条线),下栏参数稳定性线图(mom.period / interval_days 各折取值) > 4. 标题「Walk-forward 分析:Rolling vs Anchored」 > 5. 打印分析按实际数据动态描述:Anchored 是否锁定参数、Rolling 是否乱跳、衰减对比 完整 spec 在 [`specs/spec-02-walk-forward.md`](https://github.com/xingwudao/xquant-learning/blob/main/q6-avoid-overfitting/specs/spec-02-walk-forward.md)——复制给 AI,弹窗选「允许」。 AI 助手执行完毕后,你的 notebook 里应该出现了 Rolling 和 Anchored 两种 Walk-forward 的完整结果。 训练窗口 2 年,验证窗口 6 个月。每次用 2 年数据选出最优参数,在接下来 6 个月验证。然后窗口滑动,重复这个过程。最终得到 15 折结果——15 次“选参数 → 验证”的记录。 ### 运行结果 两种方式的对比如表 6-3 所示。 **表 6-3 Rolling vs Anchored 对比** | 方式 | 样本外夏普 | 衰减 | 唯一参数组合 | |------|-----------|------|-------------| | Rolling | 0.14 | -36.2% | 7/15 | | Anchored | 0.13 | -23.5% | 5/15 | Anchored 参数更稳定。 关键发现:Rolling 在 15 折(每次滑动算一折,一共滑了 15 次)中选了 7 种不同的参数组合——平均两折就换一种。而 Anchored 只有 5 种,且其中 7 折选了相同的参数(动量窗口 20、波动率窗口 15、调仓频率 5)——单一组合占了近一半折数。 Anchored 的逐折参数一览如表 6-4 所示。 **表 6-4 Anchored Walk-forward 逐折参数与样本外夏普** | Fold | mom | vol | freq | 样本外夏普 | |------|-----|-----|------|-----------| | 1 | 15 | 15 | 5 | 2.50 | | 2 | 15 | 15 | 5 | 0.71 | | 3 | 20 | 15 | 5 | -0.13 | | 4 | 15 | 10 | 5 | 0.68 | | 5 | 30 | 15 | 5 | -2.49 | | 6 | 20 | 15 | 5 | -1.83 | | 7 | 20 | 15 | 5 | 1.42 | | 8 | 20 | 15 | 5 | -0.67 | | 9 | 20 | 15 | 5 | 1.91 | | 10 | 20 | 15 | 5 | -1.75 | | 11 | 30 | 15 | 5 | 1.99 | | 12 | 20 | 20 | 5 | 1.22 | | 13 | 30 | 15 | 5 | 1.23 | | 14 | 20 | 15 | 5 | 1.31 | | 15 | 30 | 15 | 5 | 0.98 | (mom=20, vol=15, freq=5)这组参数前后出现了 7 次,是 Anchored 的最频繁选择。其余四种组合——(15, 15, 5)、(30, 15, 5)、(15, 10, 5)、(20, 20, 5)——各出现 1 到 4 次。换句话说:动量窗口在 15-30 之间换、波动率窗口大多停在 15、调仓频率 15 折全选 5。Rolling 的波动更宽——动量窗口在 15-30 之间跳、波动率窗口从 10 一直到 30,没有明确偏好。 每折样本外夏普走势与参数稳定性对比如图 6-2 所示。 ![Walk-forward 分析:Rolling vs Anchored](images/q6-avoid-overfitting/02-walk-forward-rolling-vs-anchored.png) ### 结果解读 第二个认知升级:**参数稳定性比参数最优更重要。** 先看上图。两条实线是样本外夏普——Rolling(红色)和 Anchored(蓝色)都有起伏,但起伏的方向大体一致。下图更关键:Rolling 的参数线在 mom=15-30、vol=10-30 间跳来跳去,Anchored 的两条线被压在更窄的区间内(mom 多在 20、vol 多在 15)。 **参数稳定性(Parameter Stability)**——好的参数组合应该在不同时间窗口中保持一致。如果每换一个窗口参数就变,说明“最优参数”取决于你看哪段市场,这个参数可能只是在拟合噪音。 Anchored 在 15 折中 7 折都选了(mom=20, vol=15, freq=5)这一组——15 次里 7 次是同一个答案;剩下的折在四组里跳,但波动率窗口大多稳在 15、调仓频率 15 折全是 5。不管训练集从 2 年扩大到 9 年,参数选择都被限制在一个窄区间——这是一个好信号。而 Rolling 在 15 折里选出 7 种不同组合,每两折就换一次,说明它在追逐短期市场特征,而不是捕捉长期规律。 样本内到样本外的衰减比例:Rolling 衰减 36.2%,Anchored 衰减 23.5%。Anchored 衰减更小,因为它用了更多数据训练,不容易被噪音骗。 Walk-forward 的核心价值:不是找到一个“永远最优”的参数,而是检验你的优化方法在历史上是否靠谱。如果历史上每次优化后验证都不错,你对下一次优化会更有信心。 **数据说了算:参数稳定 = 更可信。如果最优参数总在变,你怎么知道现在选的参数下个月还是最优?** **这就是第二把铲子:Walk-forward。** 它把“换段时间还稳定吗”从一种焦虑变成了一项可以滚动跑的检查——参数稳定的策略你可以拿得动,参数到处变的策略你可以早早放弃。 但 Walk-forward 只用了一种切法——如果数据切的方式不同,结论还一致吗? --- ## 6.3 一条路径够可靠吗? Walk-forward 模拟了“边走边调”,但我们只用了一种切法。如果换一种切数据的方式,结论还一样吗? 做科学实验有个基本原则:**一次实验不够,要重复多次。** 只做一次实验就下结论,可能只是碰巧。同样的道理——只验证一次(Step 1)或只用一种切法(Step 2)都不够可靠。 直觉告诉我们:**好策略不管怎么切都好。** 如果 5 次验证里 4 次都选了同样的参数,比只验证 1 次可信得多。 这就是**交叉验证(Cross Validation)**——用多种数据切分方式验证同一个策略,看结论是否一致。 但金融数据有一个特殊的限制,先用一个生活类比说清楚:**做菜可以“先看明天的菜单再决定今天买什么菜”——但炒股不行**,因为你今天根本不知道明天会发生什么。 把这个限制翻译成两个量化术语: - **时间序列属性(Time-Series Nature)** —— 金融数据有先后顺序:今天比昨天晚发生,比明天早发生。顺序不能打乱。 - **时间穿越 / 数据泄露(Look-Ahead Bias / Data Leakage)** —— 在训练或验证时,不小心让模型“看到了未来”。比如本应该用 2020 年的数据训练,结果你混了一点 2024 年的数据进来——这等于让你的策略在考试前“偷看了答案”,回测会异常漂亮,但放到实盘上一笔笔真金白银地亏。 所以做交叉验证时**每折必须保持时间顺序**——这就是 **时间序列分割(Time-Series Split)**:训练集永远在测试集之前,绝不让“未来”出现在“过去”的训练里。 试试看。 ### 动手实验 3:交叉验证 我们一起把这份 spec 写出来。这次重点看两件新东西:**时序切分而非随机切分**(金融数据的硬约束)、**接续型 spec 的减法**(不复述前面已经讲过的概念,专心做一件新事)。 #### 起草上下文 + 任务 > **上下文**:在 `q6-avoid-overfitting.ipynb` 中已有参数优化 + Walk-forward 分析。Walk-forward 只用了一种切法——如果换一种切法,结论还一样吗?一次验证和多次验证,可信度差多少? > > **任务**:在 notebook 新建单元格,用 `oxq.optimize.TimeSeriesCV` 实现时间序列交叉验证。 #### 起草要求:时序切分的硬约束 > **要求**: > > 1. 复用 Step 2 的参数空间和约束(无止损组 70 组合) > 2. 数据范围:`DATA_START="2017-01-01"` 到 `ANALYSIS_END="2026-05-06"`——与 Step 2 同步锁死,保证 5 折切分点在任何时间跑都一致 > 3. 用 `oxq.optimize.TimeSeriesCV`(不是 sklearn 的 `KFold`):`n_splits=5`、`expanding=True` > 4. 传入 `paramset`,让 `cross_validate()` 自动在每折训练集上做 GridSearch > 5. `n_splits=5` 的依据:ML 教科书默认值,7 年数据上每折约 1.4 年验证集,足够长 > 6. `expanding=True` 的依据:训练集逐折扩大——Step 2 的 Anchored 比 Rolling 更稳定,本步跟随该结论 > 📌 **要点**:金融数据要用时序切分,不能用随机切分。普通 ML 用 sklearn 的 `KFold` 是随机切的(可设 `random_state`);金融场景必须用 `TimeSeriesSplit`——**训练集永远在测试集之前,绝不让“未来”出现在“过去”的训练里**。否则就是把答案偷给了考试中的你——回测看起来漂亮,实盘上一笔笔真金白银地亏,是这条硬约束最常见的代价。这是 q6 比通用 ML 课程多出的一条硬约束。 > 📌 **要点**:接续型 spec 应该做减法。spec-03 比 spec-01 / spec-02 都短得多——它不重新定义参数空间(直接复用 Step 2 的 `wf_paramset`)、不重讲一次什么是过拟合,专心做“5 折结果对比”一件事。**承接型 spec 应当克制重复——前面 spec 讲过的就别再讲;只引入本 spec 真正新的东西。** #### 起草结果呈现 > **结果呈现**: > > 1. 用 `cv_result.to_dataframe()` 打印汇总表,再 for 循环逐折打印(训练时段、验证时段、最优参数、样本内/样本外夏普、样本外收益) > 2. 汇总:`mean_oos_metric()` ± `std_oos_metric()` + 范围 [min, max] + 唯一参数组合数 > 3. 柱状图(figsize 12×6):每折样本内 / 样本外夏普双柱 + 样本外均值水平线 > 4. 标题「5 折交叉验证:每折的样本内 vs 样本外夏普比」 > 5. 打印分析按实际数据动态描述:5 折中几折选了相同参数、变异系数大小、与 Step 2 Anchored 结论是否一致 完整 spec 在 [`specs/spec-03-cross-validation.md`](https://github.com/xingwudao/xquant-learning/blob/main/q6-avoid-overfitting/specs/spec-03-cross-validation.md)——复制给 AI,弹窗选「允许」。 AI 助手执行完毕后,你的 notebook 里应该出现了 5 折交叉验证的完整结果。 `TimeSeriesCV` 把数据切成 5 折。每折用前面的数据训练(训练集逐渐扩大),紧跟着的一段做验证。每折都独立做网格搜索——选出该折的最优参数,再到验证集上检验。 ### 运行结果 5 折交叉验证的逐折详情如表 6-5 所示。 **表 6-5 5 折交叉验证逐折详情** | Fold | 训练时段 | 验证时段 | 最优参数 | 样本内夏普 | 样本外夏普 | 样本外收益 | |------|----------|----------|----------|-----------|-----------|-----------| | 1 | 2017-01-01 ~ 2018-07-22 | 2018-07-23 ~ 2020-02-11 | mom=15, vol=15, freq=5 | 0.87 | 0.69 | 14.37% | | 2 | 2017-01-01 ~ 2020-02-11 | 2020-02-12 ~ 2021-09-02 | mom=15, vol=10, freq=5 | 0.72 | 0.44 | 10.82% | | 3 | 2017-01-01 ~ 2021-09-02 | 2021-09-03 ~ 2023-03-25 | mom=20, vol=15, freq=5 | 0.66 | 0.28 | 4.33% | | 4 | 2017-01-01 ~ 2023-03-25 | 2023-03-26 ~ 2024-10-14 | mom=20, vol=15, freq=5 | 0.53 | 1.56 | 37.83% | | 5 | 2017-01-01 ~ 2024-10-14 | 2024-10-15 ~ 2026-05-06 | mom=20, vol=20, freq=5 | 0.72 | 1.79 | 67.22% | **TopNRanking CV 汇总:** 样本外夏普 0.95 ± 0.68,样本外范围 [0.28, 1.79],唯一参数组合 4/5。 5 折中 Fold 3 与 Fold 4 选择了相同的参数(mom=20, vol=15, freq=5),其余三折各不相同——这组结果该怎么读? 每折样本内 vs 样本外夏普对比如图 6-3 所示。 ![5 折交叉验证:每折的样本内 vs 样本外夏普比](images/q6-avoid-overfitting/03-cross-validation-5fold.png) ### 结果解读 第三个认知升级:**多次验证比单次验证更可靠。** 先看参数一致性。5 折里只有 Fold 3 和 Fold 4 选了同一组参数(mom=20, vol=15, freq=5),其余三折各不相同——单看 5 折,共识不算强。但有意思的是:这组(mom=20, vol=15, freq=5)也正是 Step 2 里 Anchored Walk-forward 在 15 折里出现 7 次的最频繁参数。两种独立的切法(一种 15 折滑动、一种 5 折时序切分)都把概率最高的位置指向了同一组参数——切法换了,结论没变。 再看表现波动。样本外夏普的范围从 0.28 到 1.79,标准差 0.68。变异系数(标准差/均值)约为 0.72,仍小于 1——说明样本外表现整体相对一致,但波动不容忽视:最好的折(1.79)比最差的折(0.28)高了 6 倍以上。参数对了不代表什么时候都有正收益——策略对市场环境仍然敏感。 交叉验证的价值不是给你一个“最优参数”,而是告诉你**你的参数选择有多可靠**。5 折下来如果共识强,可以更有信心;共识弱,就该接受“参数选择本身有不确定性”,而不是盯着某一折的结果。 **数据说了算:多次验证给出更立体的画像——共识强可以更进一步,共识弱就该承认不确定性。即使参数对了,也别期望每年都有正收益。** **这就是第三把铲子:交叉验证。** 它让你在多种切法下独立验证同一个结论——一次可能是巧合,多次都指向同一个答案就更可信。前两把铲子告诉你“是不是过拟合”,这一把告诉你“你有多大把握”。 到这里,我们从三个角度检验了参数选择——一次验证(Step 1)、动态验证(Step 2)、多次验证(Step 3)。但还有一种过拟合更隐蔽:不是参数选错了,而是**规则太多了**。 --- ## 6.4 规则越多回测越好——是好事吗? 前三步检验的都是参数选择——找参数、更新参数、换切法验证参数。但还有一种过拟合更隐蔽。 回顾第四章:我们给策略加了止损、止盈、移动止损等交易规则。当时的结论是“不是所有规则都有用”。但那时我们没有做样本内/样本外验证。如果在样本内不断加规则、调参数,回测收益一路上升——这是真的在改进策略,还是只是在“凑”出好看的数字? 直觉告诉我们:**规则越多保护越全面,效果应该越好。** 止损防亏损、止盈锁利润、移动止损跟踪趋势——每一条都有道理。 真的吗?试试看——从最简单的策略开始,一层一层加规则,看样本内和样本外分别怎么变。 ### 动手实验 4:规则负担 我们一起把这份 spec 写出来。这次重点看两件新东西:**规则数的定义**(“规则数”和“自由度”是两个不同的轴)、**业务约束链的金融语义**(止损 < 止盈 / 移动止损 ≥ 固定止损 / 回撤 ≥ 止损都是领域知识)。 #### 起草上下文 + 任务 > **上下文**:在 `q6-avoid-overfitting.ipynb` 中已有参数优化 + Walk-forward + 交叉验证。前三步检验“参数选得对不对”。本步检验另一种过拟合——规则太多。每加一条规则,就多了一个可调旋钮;旋钮越多,越容易在样本内“调出”好看的结果。 > > **任务**:在 notebook 新建单元格,做两个堆叠实验展示规则负担。 #### 起草要求:4A 规则堆叠 + 4B 自由度堆叠 把“加规则”和“加自由度”分两个实验跑,**学员才能看出“是规则本身的问题还是自由度的问题”**。 > **要求**: > > 1. 编写 `run_layer(layer_name, rules, paramset, description)` 辅助函数,复用 Step 1 的样本内/外切分 > 2. **4A 规则堆叠**(6 层):基础策略 mom=20/vol=20/freq=10 固定,逐层添加交易规则 > - Layer 0:基础策略(无额外规则) > - Layer 1:+ 优化调仓频率 > - Layer 2:+ 优化止损 > - Layer 3:+ 优化止盈(约束 sl < tp) > - Layer 4:+ 优化移动止损(约束 trail >= sl) > - Layer 5:+ 最大回撤风控(约束 max_dd >= sl) > 3. **4B 自由度堆叠**(5 配置):从 1 自由度逐步加到 6 自由度 > - A:仅动量窗口(1 自由度) > - B:+ 波动率(2) > - C:+ 调仓频率(3) > - D:+ 止损阈值(4) > - E:+ 止盈 + 移动止损(6 自由度,粗网格 [10,20,30] 防止组合数爆炸) > 4. 每层在样本内选最优 → 在样本外验证 > 5. 约束链按金融语义编码:止损 < 止盈、移动止损 ≥ 固定止损、回撤阈值 ≥ 止损 > 📌 **要点**:q6 第一次显式区分“规则数”和“自由度”两个轴。**4A 数的是“加了几条交易规则”**(止损是一条规则,即便它只有一个 threshold 参数);**4B 数的是“有几个可优化的旋钮”**(一条规则可能贡献 1 个或多个自由度)。两个轴不一样——通用 ML 课程很少这么分,但金融领域必须分:止损是一条独立的业务规则,它在 4A 里占一层;但它的 threshold 在 4B 里只贡献 1 自由度。**学员看到两个实验的曲线分别走出 plateau 拐点,才能确认“既不是规则数的问题,也不是单纯自由度的问题,是两者都到了 plateau”。** > 📌 **要点**:业务约束链要用金融语义,不是技术约束。`sl < tp`(止损不能高于止盈,否则止损永远先触发)、`trail >= sl`(移动止损不能比固定止损更严,否则后者无意义)、`max_dd >= sl`(组合层风控阈值不应小于个仓止损,否则单仓止损还没触发组合就停了)——三组约束本身就是领域知识。**spec 里的约束链 = 嵌入领域知识的标准位置;学员看 spec 学的不只是参数,还有“为什么这些参数之间该有先后关系”。** #### 起草结果呈现 > **结果呈现**: > > 1. 两张汇总表:4A 六层 + 4B 五配置(含自由度、组合数、样本内/样本外夏普、衰减) > 2. 两张图(各 figsize 12×6,双折线):4A「规则堆叠:样本内 vs 样本外」+ 4B「自由度越多 ≠ 越好」 > 3. 打印分析按实际数据动态描述:4A 找出“超过这一层的规则都是规则负担”的最佳层、4B 找“自欺欺人开始”的拐点 > 4. 引用 Brian Peterson 两段原话:「Rule burden is a form of overfitting」+「Too many rules will make a backtest look excellent in-sample, and may even work in walk forward analysis, but are very dangerous in production.」 完整 spec 在 [`specs/spec-04-rule-burden.md`](https://github.com/xingwudao/xquant-learning/blob/main/q6-avoid-overfitting/specs/spec-04-rule-burden.md)——复制给 AI,弹窗选「允许」。 AI 助手执行完毕后,你的 notebook 里应该出现了两组实验的完整结果。 **4A:逐层添加规则。** 从最简单的基础策略出发,每层多加一条规则,每层都在样本内做优化。约束条件先把不合理的组合(比如止损大于止盈、移动止损小于固定止损)过滤掉——剩下的层差异才不会被荒谬组合污染。 **4B:逐步增加参数维度。** 从只调 1 个参数(动量窗口)开始,逐步增加可调参数的数量。**自由度(Degrees of Freedom)**——可以调节的参数和规则的数量。自由度越多,优化器的“调节空间”就越大。 ### 运行结果 4A 规则堆叠的样本内/样本外表现如表 6-6 所示。 **表 6-6 4A 规则堆叠:每层规则的样本内/样本外夏普与衰减** | Layer | 描述 | 组合数 | 样本内夏普 | 样本外夏普 | 衰减 | |-------|------|--------|-----------|-----------|------| | 0 | 基础(固定参数) | 1 | 0.94 | 0.52 | -0.43 | | 1 | + 优化调仓频率 | 3 | 1.06 | -0.07 | -1.13 | | 2 | + 优化止损 | 18 | 1.11 | 0.36 | -0.75 | | 3 | + 优化止盈 | 57 | 1.33 | 0.15 | -1.18 | | 4 | + 优化移动止损 | 183 | 1.33 | 0.15 | -1.18 | | 5 | + 最大回撤风控 | 825 | 1.33 | 0.15 | -1.18 | 4B 指标堆叠(按可优化的自由度分档)的对比如表 6-7 所示。 **表 6-7 4B 指标堆叠:自由度递增下的样本内/样本外夏普** | 配置 | 自由度 | 组合数 | 描述 | 样本内夏普 | 样本外夏普 | 衰减 | |------|--------|--------|------|-----------|-----------|------| | A | 1 | 5 | 动量窗口 | 1.07 | -0.17 | -1.24 | | B | 2 | 25 | + 波动率窗口 | 1.18 | 0.01 | -1.17 | | C | 3 | 70 | + 调仓频率 | 1.18 | 0.01 | -1.17 | | D | 4 | 420 | + 止损阈值 | 1.39 | 0.50 | -0.88 | | E | 6 | 323 | + 止盈 + 移动止损 | 1.56 | -0.07 | -1.62 | 随层数 / 自由度增加,样本内一路向上、样本外却平甚至下降,对比如图 6-4 所示。 ![4A 规则堆叠 与 4B 自由度越多不等于越好](images/q6-avoid-overfitting/04-rule-burden-4a-4b.png) ### 结果解读 第四个认知升级:**规则越多 != 策略越好,简单就是美。** 这是本章最重要的认知冲击。 先看 4A。样本内夏普从 Layer 0 的 0.94 爬升到 Layer 3 的 1.33——每加一条规则,优化器都能在样本内找到更好看的结果。但样本外呢?Layer 0 (基础策略,完全没有额外规则)的样本外夏普是 0.52——这是所有层中最高的!加了调仓频率优化后(Layer 1),样本外直接变成了 -0.07。止损(Layer 2)挽回了一些(0.36),但之后的止盈、移动止损、回撤风控全部无效——样本外停在 0.15 不再提升。 注意 Layer 3-5 三行的样本内、样本外、衰减数字一字不差(IS 1.33 / OS 0.15 / 衰减 -1.18)——这不是抄错。Layer 3 引入了 `sl < tp`、Layer 4 又叠 `trail >= sl`、Layer 5 再叠 `max_dd >= sl`,三条约束把搜索空间一层层锁死。再增加的旋钮被前面的约束吃掉,最后什么都调不动。**这是规则负担的另一面:旋钮多了,相互之间的约束也多,优化器其实根本没机会真正“利用”那几个新自由度。** 也就是说:最简单的基础策略,反而是样本外表现最好的。 再看 4B。从配置 D 到配置 E,自由度从 4 增加到 6,样本内夏普从 1.39 升到 1.56(好了 12%),但样本外从 0.50 跌到 -0.07——**自欺欺人的拐点**出现了。多出来的两个参数让优化器在样本内找到了更好看的结果,但在样本外完全不兑现。 为什么会这样?每条新规则都增加一个**自由度**——可以调节的旋钮。旋钮越多,优化器就有越多的空间去“凑”出好看的回测结果。这不是在发现真规律,而是在记忆噪音。这就是**规则负担(Rule Burden)**——规则本身就是一种过拟合的来源。规则太多会让回测看起来很漂亮,甚至在前推验证中也可能过关,但放到实盘就很危险。 这个判断不是我们一拍脑袋——R 语言量化框架 quantstrat 的作者 Brian Peterson 早就说过:“规则负担本身就是一种过拟合(Rule burden is a form of overfitting)。规则太多会让回测在样本内异常漂亮,甚至在前推验证里也能过关,但放到实盘就非常危险。”每条规则都是给优化器多一个旋钮——旋钮越多,凑出好看回测的余地就越大,对真实未来的把握反而越小。 **铁律:能用 2 个参数解决的问题,不要用 6 个。每增加一条规则,都要问——样本外也改善了吗?如果没有,删掉它。少即是多。** **这就是第四把铲子:规则负担。** 它告诉你少即是多——不是“加规则越多策略越好”,而是“每加一条规则都要在样本外验证它真的帮上忙”。这把铲子和前三把不同:前三把帮你检查已有的策略,这一把帮你**在设计阶段就少挖坑**。 --- ## 6.5 回头看:你刚才做了什么? **6.5 是“做”——四把铲子的操作流程总图,把四步实验铺成你以后每次接到一份回测都能照着走的检查路径。** 四步下来,我们从四个角度回答了同一个核心问题——“这个参数组合是真的好,还是运气好?” - **Step 1: 参数优化** → 490 种组合,样本内很好 → 样本外全面衰减 → 过拟合 - **Step 2: Walk-forward** → 模拟“边走边调” → 参数稳定才可信 - **Step 3: 交叉验证** → 5 折独立验证 → 共识强可以更进一步,共识弱就该承认不确定性 - **Step 4: 规则负担** → 规则越多样本内越好 → 样本外不买账 → 简单就是美 从今以后,拿到任何策略的回测结果,可以用一套防过拟合检查清单核对一遍真实性,如表 6-8 所示。 **表 6-8 防过拟合检查清单** | 检查项 | 怎么做 | 好的信号 | 坏的信号 | |--------|--------|----------|----------| | 样本外验证 | 在没见过的数据上跑 | 衰减小 | 衰减大 | | Walk-forward | 分窗口优化+验证 | 参数跨窗口稳定 | 参数每次都变 | | 交叉验证 | 多折验证 | 各折结果一致 | 各折差异巨大 | | 规则负担 | 从简到繁,逐层加 | 样本外同步改善 | 样本内改善但样本外不变 | | 参数敏感性(第五章) | 扫描参数 | 高原型 | 山峰型 | 加上第五章的参数敏感性,你手上已经有五把检验策略的武器。第一章那次“被自己的回测骗”的肉身记忆,到这里被沉淀成了一份可以挂在墙上的检查清单——这就是“疑”从感觉变成方法的过程,也是飞轮归因层在你手里第一次完整闭环的一小步。下次再接到一份“漂亮”的回测,你不会再赤手空拳,而是知道该问哪几个问题、用哪几把铲子去挖。 更重要的是:这四把铲子不只是让你“能识破自己的过拟合”,它们也让你**敢于做更激进的策略实验**——因为你知道有工具可以兜住底。怀疑回测不是为了畏手畏脚,是为了能放心动手。这就是“疑”和“做”在飞轮里的真正关系:**疑得越深,做得越敢。** --- ## 6.6 本章总结 **6.6 是“概念速查 + 带走的问题”——和 6.5 严格分工:6.5 写“怎么做”,6.6 写“用到了什么 / 还有什么没回答”。** ### 策略进化路径 第五章产出:完整的策略评估方法论 + 体检清单 → Step 1: 参数优化(样本内很好但样本外衰减,过拟合) → Step 2: Walk-forward(模拟定期重新优化,参数稳定性) → Step 3: 交叉验证(多次验证更可靠,但表现差异大) → Step 4: 规则负担(规则/参数越多越危险,简单就是美) → 第六章产出:四把防身工具 + 防过拟合检查清单 + “怀疑回测”的态度 ### 概念速查表 本章涉及的核心概念汇总如表 6-9 所示,方便随时回查。 **表 6-9 第六章核心概念速查** | 概念 | 含义 | 类比 | |------|------|------| | 参数优化 / 网格搜索 | 把所有可能的参数组合都试一遍,选出表现最好的 | 从几千所大学里挑分数最高的 | | 样本内(训练集) | 用来优化参数的那段数据 | 模拟考试的试题 | | 样本外(测试集) | 用来验证参数的另一段数据,优化时没见过 | 真正的考试 | | 过拟合 | 样本内表现很好但样本外大幅衰减 | 背了真题成绩好,但没学会真知识 | | Walk-forward 分析 | 在历史上模拟“定期重新优化”的过程 | 每学期重新选课本复习 | | 滚动式 Walk-forward | 训练窗口固定大小,向前滑动 | 只翻最近两年的课本 | | 锚定式 Walk-forward | 训练窗口从起点开始,越来越大 | 从高一课本开始全部复习 | | 参数稳定性 | 好参数在不同窗口中保持一致 | 不管怎么考,你的强项科目都一样 | | 交叉验证 | 用多种切法验证同一个策略 | 做实验要重复多次才可信 | | 时间序列分割 | 保持时间顺序的交叉验证 | 不能用未来考题“复习”过去 | | 规则负担 | 每条新规则增加过拟合风险 | 考试作弊手段越多,被抓的风险越大 | | 自由度 | 可以调节的参数和规则数量 | 作弊的旋钮越多,越容易凑出高分 | ### 本章核心认知 走完四步实验后,本章最值得带走的八条认知如表 6-10 所示。 **表 6-10 第六章核心认知** | 认知 | 来源 | |------|------| | 参数优化是双刃剑——优化后必须在新数据上验证 | Step 1 | | 样本内的好成绩不算数 | Step 1 | | 参数稳定性比参数最优更重要 | Step 2 | | 好参数跨窗口稳定,坏参数到处变 | Step 2 | | 多次验证比单次验证更可靠 | Step 3 | | 不同验证方法指向同一个参数 → 可信度高 | Step 3 | | 规则越多 != 策略越好 | Step 4 | | 能用 2 个参数解决的问题,不要用 6 个 | Step 4 | ### 带走的问题 我们已经知道怎么评估策略(第五章)、怎么防过拟合(第六章)。回测世界的问题基本解决了。 但回测终究是模拟。在回测里,你可以按任何价格、在任何时间成交。真实世界里呢? - 你想买的时候,市场上有足够的卖盘吗? - 你下单到成交之间,价格会不会已经变了? - 回测假设的手续费,和真实交易一样吗? **从回测到实盘,还有多少坑在等着你?** → 第七章 **你在第一章撞过那堵墙,第五章手里有了体检表,这一章把怀疑变成了肌肉记忆。下面一章,回测的世界要让位给真实成交的世界——你将带着这四把铲子,走进实盘。** > 本章所有代码的可运行版本见 `notebooks/q6-avoid-overfitting.ipynb` --- ## 反馈与读者群 如果你发现本章有错别字、概念不清或实验结果不一致,欢迎 [提交 GitHub Issue](https://github.com/xingwudao/xquant-beginner/issues/new/choose)。 课程、正式书和后续更新见 [xquant.shop/courses](https://xquant.shop/courses)。 添加作者微信 `xquanter` 进入读者群。请备注:`XQuant 读者`。