单元 07:揭穿一个“完美”回测
单元 05 的策略净收益是 -0.0115%,单元 06 也没有从绩效指标中找到惊喜。面对这样的结果,一个危险的冲动是不断调整计算方法,直到回测变得好看。
本单元反过来做一次实验:我们主动在同一份回测里植入五类错误,观察每个错误如何制造虚假优势,再恢复诚实的时间、价格和样本假设。你会看到,几行看似合理的代码就足以把亏损策略变成“盈利策略”。
本单元目标
完成本单元后,你将能够:
- 识别信号与收益没有错开造成的未来函数;
- 量化忽略交易成本对策略结果的影响;
- 从异常收益发现价格口径断裂;
- 判断回测成交价在决策时是否可知、在市场中是否可成交;
- 区分预先设定的研究范围与看完结果后的选择;
- 使用一份清单审查回测的数据、时间、成交和展示口径。
本单元依次运行:
uv run python examples/unit05/run_backtest.py
uv run python examples/unit07/audit_backtest.py第一条命令生成诚实参照,第二条命令在它的基础上执行错误实验。
前置条件
场景:同一策略为什么能有六种结果
我们保留单元 05 的正确回测作为参照,每次只改动一个关键假设:
| 实验 | 被改坏的假设 | 诚实参照 |
|---|---|---|
| 当日偷看 | 当日信号直接赚取当日收益 | 信号形成后,从下一收益区间开始持仓 |
| 删除成本 | 毛收益被当成可实现的净收益 | 按换手扣除交易成本 |
| 混合价格口径 | 一段价格被错误缩小一半 | 全程使用同一价格口径 |
| 神谕成交 | 进场买到当日最低价,离场卖到当日最高价 | 使用决策时可知且可以执行的价格假设 |
| 事后挑选 | 看完结果后只保留最好的两个区间 | 展示预先确定的完整样本 |
这样设计不是为了构造另一套策略,而是为了隔离错误:结果发生变化时,我们知道是哪一项假设造成的。
正确策略在完整样本中的净收益为 -0.0115%。接下来不改变移动平均线参数,也不更换标的。
当日信号不能赚取当日收益
策略使用当日收盘价计算移动平均线和目标持仓。只有收盘价出现后,我们才能知道当日的目标持仓,因此它不能再回到当天开盘前,赚取当天的 close-to-close 收益。
单元 05 用一次位移表达这个时间关系:
position = target_position.shift(1).fillna(0.0)
gross_return = position * asset_return错误版本去掉了位移:
wrong_position = backtest["target_position"].fillna(0.0)
lookahead_return = wrong_position * backtest["asset_return"]这就是未来函数,也称前视偏差(Look-ahead Bias):计算在历史上的某个时点使用了当时尚不可获得的信息。代码可以正常运行,列名和数据类型也没有异常,但策略仿佛提前知道了当天收盘后的判断。
| 时间处理 | 策略总收益 |
|---|---|
| 当日目标持仓直接乘当日收益 | 4.8384% |
| 目标持仓延迟一个收益区间 | -0.0115% |
仅仅少写一个 .shift(1),结果就相差约 4.85 个百分点。
审查时间关系,不只搜索 shift
是否存在未来函数取决于数据何时可知、决策何时产生、订单何时提交、哪段收益由该持仓获得。即使代码里出现了 .shift(1),其他特征也可能泄漏未来信息;分钟数据中的时间戳含义不清,同样可能造成错位。
毛收益不是净收益
单元 05 每次持仓变化都会产生单边换手,并按 0.1% 的固定成本率扣费:
net_return = gross_return - turnover * 0.001如果删掉成本,策略总收益从 -0.0115% 变为 0.3880%。约 0.40 个百分点的差异足以改变盈亏符号。
| 成本处理 | 策略总收益 |
|---|---|
| 不扣交易成本 | 0.3880% |
| 按换手扣除成本 | -0.0115% |
差异并不恰好等于四次调仓乘以 0.1%,因为每期净收益还会继续参与复利。策略越频繁交易,遗漏成本造成的高估通常越严重。
固定比例成本仍是教学简化。真实交易还可能涉及佣金最低收费、税费、买卖价差、滑点和市场冲击。诚实回测不一定需要一开始就模拟所有细节,但必须写清楚包含了什么、遗漏了什么,并检查结论对合理成本范围是否敏感。
价格口径断裂会制造巨额收益
为了让错误足够明显,实验把 2024-01-10 之前的收盘价故意缩小一半,之后保持原值:
mixed.loc[mixed["date"] < "2024-01-10", "close"] *= 0.5
mixed["return"] = mixed["close"].pct_change(fill_method=None)这不是一次真实的拆分或分红,而是一个人为制造的口径断点。跨过断点时,价格从约 51 跳到 104,程序计算出 102.7290% 的单日收益,整个样本收益也从 4.6627% 变成 109.3254%。
| 价格处理 | 标的总收益 | 最大单日收益 |
|---|---|---|
| 混合价格尺度 | 109.3254% | 102.7290% |
| 原始样例价格 | 4.6627% | 1.3645% |
真实数据中,类似断点可能来自混用前复权、不复权和后复权价格,或者更新数据后只重算了一部分日期。复权是对价格表示尺度的换算,不是账户凭空获得收益。修复方法是让整条序列使用同一数据源、同一复权规则和同一更新时间,并记录价格口径。
异常收益先查口径
看到远超常态的单日收益时,不要立即删除,也不要立即庆祝。先核对公司行动、复权因子、单位、币种和数据拼接边界;只有确认来源后,才能判断它是真实事件还是数据错误。
日内最高最低价不是任选菜单
错误实验让策略在进场日买到最低价,在离场日卖到最高价:
entry_return = close / low - 1 - cost_rate
exit_return = high / previous_close - 1 - cost_rate这样计算后的策略收益是 3.6109%,而诚实参照仍是 -0.0115%。问题不在于 OHLC 数据是假的,而在于日线只告诉我们当天出现过哪些价格,没有告诉我们完整的盘中顺序,也不能让策略在收盘后回头选择当天最有利的成交点。
可交易性(Tradability)还要求一个理论价格能在现实中执行。例如:
- 停牌时没有正常成交机会;
- 涨停时可能有大量买单排队,却没有卖单让我们买入;
- 跌停时可能难以卖出;
- 成交量太小,订单只能部分成交,或明显推高自己的买入价格。
因此,成交假设至少要回答三个问题:价格在决策时是否已经可知、订单是否来得及提交、该价格附近是否有足够流动性。只有一个日线最高价或最低价,无法证明这些条件成立。
看完答案再选题,也会美化结果
最后一个实验先计算全部结果,再挑出收益最高的连续两个区间。程序最终选中 2024-01-05 至 2024-01-08,展示收益为 0.5836%;完整样本的策略净收益却是 -0.0115%。
这种做法属于选择偏差(Selection Bias):展示对象不是按照事先规则确定,而是因为结果好才被留下。只展示表现最好的标的、时间段、参数或指标,本质上是同一个问题。
幸存者偏差(Survivorship Bias)是常见的相关情形。比如研究十年前的股票,却只使用今天仍然上市的公司,已经退市或破产的公司便从历史样本中消失,股票池会显得比当时真正可选的范围更优秀。
修复时应在查看最终结果前确定:
- 研究标的和股票池如何形成;
- 样本的开始与结束日期;
- 可以尝试哪些参数,以及如何选择;
- 哪段数据用于开发,哪段留给后续验证。
单元 08 会继续处理样本内与样本外的分离。本单元先守住更基础的原则:不能把事后挑出的局部结果冒充完整策略表现。
运行五组对照实验
执行:
uv run python examples/unit07/audit_backtest.py终端会输出:
experiment flawed_result honest_reference difference
same_bar_lookahead 4.8384% -0.0115% 4.8500%
ignore_costs 0.3880% -0.0115% 0.3995%
mixed_price_scale 109.3254% 4.6627% 104.6627%
oracle_execution 3.6109% -0.0115% 3.6224%
best_two_periods 0.5836% -0.0115% 0.5951%程序会生成四个结果文件:
outputs/unit07/backtest_audit.csv:五组实验的汇总对照;outputs/unit07/audit_daily.csv:用于定位问题的逐日数据;outputs/unit07/backtest_review_checklist.md:可复用的审查清单;outputs/unit07/flawed_vs_honest.png:每组错误与诚实参照的对照图。
图中的五个子图各自使用独立横轴尺度,只能比较同一子图里的两根柱,不能根据不同子图的柱长比较错误大小。价格口径实验比较的是标的收益,另外四组比较的是策略收益。
把经验变成审查清单
靠记忆审查回测,很容易在换项目后漏掉同样的问题。程序生成的清单按数据、时间、成交、成本、选择和复现六个方面检查:
- ☐ 数据来源、日期、频率、单位和复权口径有明确记录。
- ☐ 研究标的或股票池在当时可知,没有只保留后来存活的资产。
- ☐ 指标和信号只使用决策时已经可获得的数据,不存在未来函数。
- ☐ 目标持仓、成交假设、实际持仓和收益区间按时间对齐。
- ☐ 成交价格在当时可以提交订单,且考虑停牌、涨跌停和流动性。
- ☐ 买入、卖出、滑点、税费和其他成本没有被遗漏。
- ☐ 策略与基准使用相同日期和一致的收益口径。
- ☐ 标的、区间和参数不是看完结果后只展示最优者。
- ☐ 样本内选择与样本外验证明确分开。
- ☐ 原始数据、配置、代码和结果能够被重复运行与追溯。
清单全部打勾不代表策略未来会盈利。它的作用是排除一批可以被系统检查的虚假优势,让剩下的结果更接近研究假设本身。
如何解读修复后的结果
五个错误版本都比对应的诚实参照更好,其中四个甚至直接改变了策略盈亏或制造出正收益。但修复全部问题后,我们应该接受 -0.0115%,而不是在五个错误数字中选择最喜欢的一个。
这揭示了回测证据的一条重要规律:结果越漂亮,越值得追问优势来自哪里。合理的策略可能获得好结果,但好结果本身不能证明时间对齐、数据口径和成交假设正确。
历史回测不是投资承诺
修复这些错误只能提高历史模拟的可信度,不能消除模型风险、市场变化和未来不确定性。历史表现不代表未来收益。
常见陷阱
多个错误可能相互掩盖
真实项目不会总是一次只出现一个错误。遗漏成本可能夸大收益,错误复权也可能制造亏损;两者叠加后的数字看似普通,并不表示回测正确。应逐项核对假设,而不是仅凭结果是否“合理”判断。
更保守不等于更准确
随意把成交价调差、把成本设得极高,可能让结果更难看,却没有让模型更接近真实交易。假设应有市场机制或数据依据,并通过多个合理情景观察敏感性。
能重复运行不等于没有偏差
一段含未来函数的代码也可以每次得到完全相同的结果。可复现解决的是“能否得到相同答案”,审查清单还要回答“这个答案是否使用了当时可获得的信息”。
不展示不利结果也是一种选择
研究记录应保留尝试过的主要规则、参数范围和失败结果。只发布最终冠军,会让读者无法判断冠军是稳定优势,还是大量尝试中偶然出现的幸运值。
验证结果
完成标准:
- 五组错误实验都能与各自的诚实参照对照;
- 当日信号直接承担当日收益时,结果从 -0.0115% 被夸大为 4.8384%;
- 忽略成本、混合价格口径和神谕成交都能在结果中留下可解释的异常;
- 事后挑选只展示最佳两个区间,而完整样本结果仍被保留;
backtest_audit.csv、逐日明细、审查清单和对照图均能重新生成;- 能说明为什么“结果更好”和“回测更可信”是两个不同判断。
小结
本单元没有发明新策略,而是让同一策略经历了五次故意破坏:
- 同日使用信号和收益,制造未来函数;
- 删除成本,把毛收益冒充净收益;
- 混合价格尺度,制造不存在的价格跳跃;
- 使用事后才知道的最高最低价,假设不可能成交;
- 看完完整结果后只展示最佳片段,制造选择偏差。
修复的共同方法是把每个数字放回它真实的时间、数据和市场约束中。以后看到漂亮回测时,先运行审查清单,再讨论策略表现。
练习
基础练习:观察成本敏感性
把成本率从 0.05% 调整到 0.20%,记录净收益对成本的敏感程度,并解释换手为什么会放大成本影响。
进阶练习:移动价格口径断点
将价格口径实验中的断点改到其他日期,观察断点前后价格水平如何影响虚假收益。
延伸练习:扩大事后选择空间
把“最佳两个区间”扩展为最佳三个区间,比较选择空间变大后结果如何变化。再为审查清单新增一项“参数尝试次数”,思考怎样记录才能暴露过度筛选。