从数据到报告的完整研究笔记本项目,附针对前视偏差和过度拟合的代码审查清单
把一项完整的策略研究组织成可复现的项目,从原始数据一直运行到书面结论,并用一份能发现“美化回测”错误的清单审查代码。
你将学到什么
- 一种文件夹和笔记本的布局,让六个月后的你或任何其他人都能复现这项研究。
- 工作顺序:数据、清洗、特征、信号、回测、绩效指标、样本外检验、报告。
- 一份逐行检查前视偏差、幸存者偏差和过度拟合的代码审查清单。
- 如何诚实地撰写结果,包括尝试过但没有奏效的内容。
讲解
样本外测试、参数平台以及大量试验的代价已在课程《系统化交易与回测》(第 6 课和第 7 课)中讲过。本课关注的是让这些理念真正落地的工程实践:项目结构、固定的工作流程,以及一次他人可以重复的审查。
项目布局
project/data/raw/ # 下载的数据,从不手动编辑data/clean/ # 由 01_data.ipynb 生成的 parquet 文件notebooks/ # 01_data, 02_features, 03_backtest, 04_reportsrc/ # indicators.py, engine.py, metrics.py(第 6、8、9 课)tests/ # test_metrics.py, test_engine.pyconfig.yaml # 代码、日期、参数、成本假设requirements.txt # 锁定的版本:pandas==2.x, numpy==…
复用的代码放在 src/ 中;笔记本只负责调用函数和展示结果。锁定软件包版本,因为一次 pandas 更新就可能改变某个默认设置(频率字符串、重采样标签),从而改变你的数字。为所有随机过程设定种子,并在报告中记录数据的下载日期。
按顺序进行的工作流程
- 先提出假设。在接触数据之前,先写下一句经济学逻辑和确切的规则(课程《系统化交易与回测》,第 2 课)。现在就确定样本内 / 样本外的划分,并在第 7 步之前不要打开样本外文件。
- 数据。下载、保存原始数据,记录数据来源和复权方法(第 4 课)。用一份“今天的指数成分股”名单回测过去十年,就是幸存者偏差。
- 清洗与验证。缺口、重复、零价格、时区;保存到
data/clean/。 - 特征与信号使用
src/中经过测试的函数;第 t 根 K 线上的每个特征只使用截至 t 的数据。 - 回测使用第 8 课的引擎,并包含成本模型(课程《系统化交易与回测》,第 8 课)。
- 绩效指标与图表(第 9 课和第 10 课),在样本内期间上计算,使用一个小型参数网格,而不是去搜索最好的那一格。
- 样本外只运行一次,参数保持冻结。无论结果如何都要记录下来。
- 报告:假设、数据、规则、成本、两张结果表、图表、尝试过的变体数量、决策。
代码审查清单
把你的笔记本当作别人的来审查;每一行都是一个“是或否”的问题。
| 领域 | 检查项 | 典型修复 |
|---|---|---|
| 时序 | 每个信号在设定仓位之前是否都位移了一根 K 线? | pos = signal.shift(1) |
| 时序 | 滚动窗口是否使用 center=False(默认值)且没有 bfill()? |
去掉居中设置和向后填充 |
| 时序 | 标准化处理(z 分数、排名、缩放器)是否只在过去的数据上拟合? | 使用扩展窗口或滚动窗口拟合 |
| 数据 | 品种范围是否按每个日期当时所知的情况来定义? | 使用时点成分股名单,或给出诚实的说明 |
| 数据 | 是否在相关场景中用复权价格计算收益率、用未复权价格计算止损距离? | 同时保留两列 |
| 成交 | 跳空是否按开盘价成交,而不是按止损价或信号价格成交? | min(open, stop) |
| 成本 | 佣金、点差和滑点是否按每笔交易、每个方向计入? | 在配置中设置成本模型 |
| 过度拟合 | 尝试过多少组参数、多少条规则和多少个品种范围?报告中是否写明了这个数量? | 记录每一次运行 |
| 过度拟合 | 相邻的参数是否给出相似的结果(平台)? | 绘制参数网格热力图 |
| 过度拟合 | 样本外数据是否被动用过不止一次? | 如果是,它现在已属于样本内;寻找新的数据 |
| 可复现性 | “Restart kernel and run all”(重启内核并运行全部)能否复现报告中的数字? | 修复隐藏状态和单元格顺序 |
关于显著性的经验法则:年化夏普比率的 t 统计量大约等于 夏普比率 × √年数。四年期间 0.8 的夏普比率得出 1.6,低于通常约为 2 的门槛,而这还没有考虑尝试过的变体数量;在二十次试验中,最好的那一次预计仅凭运气就会显得不错。
实例演算
假设示例,所有数字均为编造。一位交易者在 30 只大盘股上测试“在 100 日突破时买入,以 3 × ATR 移动止损离场”,期间为 2014–2025 年,每个方向成本 0.1%。
- 划分。样本内为 2014–2021 年(8 年),样本外为 2022–2025 年(4 年),在第一次运行之前就已确定。
- 网格。突破长度 50、100、150 × ATR 倍数 2、3、4 = 9 个变体,均有记录。样本内夏普比率介于 0.6 到 1.1 之间,最好的一格为 (100, 3) = 1.1;其相邻格为 0.9 和 1.0,形成一个平台,因此保留这一选择。
- 审查。清单检查发现,ATR 列使用了
rolling(14).mean(),并基于当天的真实波幅来设定当天的止损。引擎随之改为使用前一根 K 线的 ATR(第 8 课)。样本内夏普比率从 1.1 降至 1.0;最大回撤保持 −22% 不变。 - 样本外。运行一次:夏普比率 0.6,CAGR 7.2%,最大回撤 −19%,118 笔交易。显著性:0.6 × √4 = 1.2;样本内数值为 1.0 × √8 = 2.8。样本外结果为正,但单独来看在统计上较弱。
- 报告中的决策。“该规则在样本外保留了约 60% 的样本内夏普比率(0.6 / 1.0),处于一个平台上,共尝试 9 个变体,修复了一个前视偏差错误。证据有限;下一步是使用第 12 课的脚本进行六个月的模拟交易,而不是投入真实资金。”已对手续费建模;不计税费和融资成本。
常见错误
练习活动
在纸上完成:审查下面这段笔记本代码以及随后的结果。
df['z'] = (df.close - df.close.mean()) / df.close.std()df['signal'] = df.z < -1.5df['pos'] = df.signal.astype(int)df['ret'] = df.close.pct_change() * df.pos
6 年样本内夏普比率为 2.4;2 年样本外夏普比率为 −0.3。(a) 找出代码中的两个前视偏差问题。(b) 计算样本内和样本外夏普比率的近似 t 统计量。(c) 改写这四行代码,使其不存在前视偏差(如果你更喜欢用文字,也可以描述改动)。
显示答案
(a) 第一,df.close.mean() 和 df.close.std() 使用了包括未来价格在内的整个样本,因此任何日期的 z 分数都“知道”未来的平均值。第二,pos 没有位移:今天的收益是依据今天收盘价计算出的信号赚取的。
(b) 样本内:2.4 × √6 = 5.9;样本外:−0.3 × √2 = −0.4。样本内的高数值是由这些错误造成的,这就是它崩溃的原因。
(c) 使用滚动窗口或扩展窗口,并进行位移:
m = df.close.rolling(250).mean(); s = df.close.rolling(250).std()df['z'] = (df.close - m) / sdf['signal'] = df.z < -1.5df['pos'] = df.signal.shift(1).fillna(False).astype(int)df['ret'] = df.close.pct_change() * df.pos
局限性:消除前视偏差只会让测试变得诚实,而不会让它盈利;修正后的规则仍然需要计入成本、使用带止损的引擎,以及一个全新的样本外期间。
要点回顾
- 把可复用的代码连同测试放在
src/中,锁定版本,并让“全部运行”能够复现报告。 - 在第一次回测之前确定假设、规则以及样本内 / 样本外的划分;样本外只运行一次。
- 用清单审查时序、数据、成交、成本和试验次数;记录你尝试过的每一个变体。
- 以 夏普比率 × √年数 粗略报告显著性,同时报告变体数量和决策,包括“证据不足”。
下一步
至此,课程《面向交易者与投资者的 Python》全部结束;下一门课程《自营交易公司、资助账户与作为一门生意的交易》将探讨考核规则、回撤类型,以及把交易作为一门生意时的实务问题和德国税务问题,而像你在这里构建的这样一套经过测试、脚本化的流程,正是其起点。
自测
回答问题后核对答案。你可以随意重试多次。
供您自行复习的自动自测。您的答案在浏览器中核对,我们不会查看或评估。这不是考试,也不是资格证书。
您的进度
“已完成”仅表示您将本课标记为已学完,并不是对您理解程度的测试。 学习进度和笔记仅保存在此设备的此浏览器中,而非账户中。