回测偏差地图:好看的历史成绩,是在哪一道工序上做出来的
假设你已经把偷看未来的问题查干净了。工具跑过,报告是绿的,代码里没有一处引用到明天的数据。你重新跑了一遍——年化还是很好看。
然后你上真钱,还是亏。
这时候大多数人会开始怀疑人生,或者怀疑市场。但更值得怀疑的是另一件事:「回测偏差」从来不是一个东西,它是一族东西。 它们分别卡在流程的不同位置上,症状高度相似(历史好看、现实难看),成因却毫不相干。你修好了其中一种,剩下六种一动不动地待在原地。
这篇不新讲某一种偏差,而是把它们排回各自的原位——看清楚哪一道工序会掺进什么水,你才知道下一次该往哪儿查。
先说人话:满意度 98% 的那家餐厅
一家餐厅门口挂着牌子:顾客满意度 98%。
这个数字可以在四个完全不同的地方被做出来,而牌子上只写着一个数:
- 问了谁。 只问了这个月还在来的老顾客。吃过一次再没来的那些人,压根不在样本里。
- 什么时候问的。 上完甜点、结账之前,趁着心情最好的那五分钟问的。
- 问了几轮。 这个月做了五十次小调查,把最好看的那一次印上了牌子,其余四十九次扔了。
- 算没算全成本。 满意度里不含停车费、不含排队四十分钟、不含服务费。
四个问题,四个不同的动手脚位置。你只盯着「98% 这个数是不是算错了」,永远查不出问题——因为算术全对。问题不在算术,在于这个数被生产出来的每一道工序上。
回测就是这么一条生产线。你看到的那条向上的资金曲线,是它的最终产品。
投资层:这条生产线一共有几道工序
先把话说清楚:回测就是把一套事先定死的规则拿到历史数据上重走一遍,看「如果当初这么做,账户会变成什么样」(术语卡见 回测,这里只用它最朴素的意思)。想更完整地理解这一步到底在算什么,可以看 回测到底在算什么。
拆开看,它至少有这么八道工序,缺一不可:
| 工序 | 它在干什么 | 这一道会掺进的水 | 错了会怎样 |
|---|---|---|---|
| 一、圈样本 | 决定哪些标的进入检验范围 | 幸存者偏差 | 名单本身就是赢家名单 |
| 二、备数据 | 把每个字段填进每一天 | 前视偏差、数据修订 | 用了当时拿不到的数字 |
| 三、算指标 | 从价格算出均线、波动这类中间量 | 启动期偏差 | 回测算出的值和实盘算出的不是同一个 |
| 四、出信号 | 把指标变成买卖决定 | (本身不掺水,是上游的下游) | 前三道的错误在这里第一次变成钱 |
| 五、撮合 | 判断这一笔能不能成、成在什么价 | 成交假设、滑点、流动性 | 现实里根本轮不到你成交 |
| 六、扣成本 | 手续费、税、冲击 | 成本假设 | 交易越频繁,误差越致命 |
| 七、出统计 | 算收益、回撤、跟谁比 | 基准选择 | 赢了大盘还是赢了通胀,两回事 |
| 八、回头调参 | 改一改再跑一遍 | 过拟合、数据窥探 | 这一道在流水线之外,它放大前七道所有的噪音 |
这张表最值得记的是最后一行。前七道是「这一次运行内部」的问题,第八道是「你一共跑了多少次」的问题——它不属于任何一次运行,所以任何一次运行内部的检查都发现不了它。后面会专门说。
系统层:每道工序在真实系统里长什么样
工序一 · 圈样本:名单是不是赢家名单
这是最早发生、也最容易被跳过的一道。你要检验「买沪深 300 成分股会怎样」,得先有一份成分名单。问题是,哪一天的名单。
Vibe-Trading 的因子评测工具在这件事上做得挺露骨——它不藏,直接把「这份样本有毛病」写进了输出的元数据里:
panel["_meta"] = {
"universe": "sp500",
"survivorship_bias": True,
...
}
配套的注释交代得很清楚:它拿到的标普 500 名单是当前的成员表,不是历史某一天的快照;那段时间里退市、被并购、被调出指数的公司,是被静默排除掉的,因此统计结果会被系统性地抬高。
它对沪深 300 那条路径处理得更细:如果能拿到带日期的成分归属表,就按每一天的真实成员去裁剪横截面;拿不到,就退回一份静态名单,同时把 survivorship_bias 标成真、把这次运行标成「降级」。而这个标记会一路往上传,最后出现在评测摘要里给用户看。
这个设计值得单拎出来讲一句:它没有假装问题不存在,也没有把警告塞进日志末尾让你自己去翻,而是把「样本有偏」做成了报告结构的一部分。 一个成绩单如果不告诉你它的样本是怎么圈的,那这个成绩单就是不完整的——这句话对量化系统成立,对你在雪球上看到的任何一张收益截图同样成立。展开看 幸存者偏差。
工序二 · 备数据:每个数字,那天真的拿得到吗
第二道是前视偏差的主战场,前面已经单独写过一整篇,这里只补它在地图上的位置感。
要点是:这一道的错误不一定发生在你的代码里。财报的报告期和公布日差着一两个月、数据库只保存被后来反复修订过的最终版、指数成分表被今天的版本覆盖了历史——这些污染在数据到你手上之前就已经完成了。qlib 用时点数据库(Point-In-Time 数据)来堵这个口子,思路是让数据层只肯给出「查询时点之前最后一次公布的那一版」。
放在地图上看,它的特殊之处在于:它是唯一一道你可以完全不写错一行代码、却照样中招的工序。
工序三 · 算指标:回测算出的值,实盘算不出来
这一道最容易被完全忽略,因为它听起来不像偏差,像技术细节。
问题出在递归上。有一类指标的每一个值都依赖它前一个值——比如指数移动平均(EMA,一种给近期数据更高权重的均线,见 均线与 EMA),今天的值是「昨天的值」和「今天的价格」加权而来。这意味着它没有一个干净的起点,历史喂得越长,值越收敛。
freqtrade 的文档举了个傻到不能再傻却一看就懂的例子:一个叫 steps 的量,第一行是 0,之后每行加一。喂一千根 K 线(K 线就是一根一根的价格柱),最后一根算出来是 999;只喂五百根,同一根 K 线上算出来是 499。同一个位置、同一个公式,值不一样。
麻烦在于回测和实盘拿到的数据长度天然不同:回测按你指定的时间段一次性吃下整段历史,实盘只能拿到交易所愿意给的最近那一截。于是同一个指标,在两个场景下就不是同一个数了。
freqtrade 为此单独做了一个检查命令,做法是拿不同长度的「预热数据」各算一遍指标,然后比对最后一行的差异,输出一张方差表。它的文档说了一句很实在的话:目标不是把方差压到绝对为零——因为像 EMA 这种天生递归的指标,要零方差得预热到荒谬的长度——而是压到小到不会改变你的进出场判断为止。
这一道的偏差方向是双向的,这是它和前两道最大的区别。它不必然让你的历史成绩变好,它只是让「回测里发生的事」和「实盘里会发生的事」不是同一件事。别小看这个区别:只往好处错的偏差你会舍不得查,双向错的偏差你会觉得「误差不大算了」——两种心态,同样致命。展开看 指标的启动期。
顺带记一句这类检查的边界:它只比对最后一行的指标值,至于这点差异会不会真的改变某一笔交易,它不回答。工具给的是线索,不是判决。
工序五 · 撮合:你以为的成交,是一串默认假设
(第四道出信号本身不掺水,跳过——它是前三道的下游,错误在这里第一次变成账面上的钱。)
回测最大的信息缺口在这里:它不知道一根 K 线内部发生了什么。 一根日线只告诉你开、高、低、收四个数,中间那六个多小时价格怎么走的、什么时候走到哪儿、有没有量,全是空白。可撮合恰恰需要这些信息。
信息不够,就只能做假设。freqtrade 的文档很难得地把自己的假设列成了一张清单,摘几条最有代表性的(原文更长,建议直接去看它的回测文档):
- 入场按当根 K 线的开盘价成交,除非你自己写了定价逻辑;
- 只要请求价落在这根 K 线的高低区间之内,就视为按请求价全额成交,不计滑点;
- 信号触发的出场,按下一根 K 线的开盘价成交;
- 止损恰好在止损价成交,哪怕这根 K 线的最低价比止损价还低;
- 一根 K 线内部,假定「先到低点、后到高点」,因为这样对止损更保守,优先保住本金。
看完这几条,你应该有点后背发凉。这里面的每一条单独看都合情合理,合在一起却构成了一个比现实友好得多的世界:没有滑点、想成交就成交、止损永远精准生效。
必须说句公道话:这些假设不是 bug,是没有 K 线内部信息时的无奈取舍,而且它们中的一部分(比如低点先于高点)是刻意往保守方向偏的。同一份文档也很坦白地写着:回测永远不能替代先用模拟盘跑一遍。真正危险的不是这些假设存在,而是你不知道自己已经默认了它们。
同一份文档里还藏着一个更妙的例子:交易所对最小下单量和价格精度是有硬限制的,而回测拿到的是今天的限制,历史上的限制拿不到。于是在价格远高于今天的那段历史里,用今天的最小下单量折算出来的最小金额会被算得虚高——一条本该无关紧要的现实约束,也可能是错的。这个细节的教育意义在于:回测里「现实的部分」,同样是一份快照。
A 股这边还多一层。Vibe-Trading 的 A 股撮合模块要先算出当天的涨跌停价带才能判断能不能成交,而这个价带必须从严格属于历史的价格推出来——用当天的收盘价去推,就成了偷看未来。同一个价格,差一天,性质完全变了。
工序六 · 扣成本:它不是一个数,是一族假设
qlib 的交易所模块把成本拆成了几个独立参数:开仓一档费率、平仓另一档费率,外加一个最低成本兜底(都是当前默认值,可配置)。
为什么买和卖的费率要分开?因为它们本来就不对称——印花税在 A 股是卖出单边征收的(印花税就是卖出时按成交额缴的一笔税),所以卖出那一侧天然更贵。最低成本兜底则对应券商的最低佣金:单子太小的时候,费用不按比例走,按下限走,小单会被吃掉得离谱。
把这三件事写成三个参数,本身就是在说一件事:成本不是「乘一个万分之几」这么简单。 而且交易越频繁,这一族假设的误差被放大得越厉害——一套一年调两次仓的规则,成本假设错一倍无伤大雅;一套一天进出三次的规则,成本假设错一点点就能把全部超额吃光。展开看 回测里的交易成本假设。
工序七 · 出统计:赢了,可是赢了谁
这一道最短,但漏掉它,前面七道白做:年化 20% 到底算好还是不好,取决于同期你不动脑子买指数能拿多少。基准选错了,整份成绩单的含义就变了。见 基准与超额收益。
工序八 · 回头调参:唯一一道不在流水线上的
前面七道都在问「这一次运行有没有算错」。第八道问的是另一件完全不同的事:这份数据,你一共问了多少次。
你跑了一遍,年化 12%。把参数从 14 改成 20,跑第二遍,18%。再改成 22,跑第三遍,23%。你留下了 22 那一版,前两版随手扔了。
注意这里没有任何一次运行是「算错」的——三次都算得完全正确。可你最后交出去的那个 23%,是从三次尝试里挑出来的最大值,它比这套规则的真实水平高。挑得越多,虚得越厉害。这就是数据窥探:反复用同一份历史找答案,找到的很可能只是这份历史独有的巧合。而当你把这种挑选做到极致——参数多到能把历史上每一个小波折都照顾到——就成了过拟合:规则记住的不是市场的规律,是这段历史的噪音。
有意思的是,freqtrade 的参数搜索模块把「别给自己太多自由度」直接写进了搜索空间的设计:小数参数被限制在有限的精度上,理由写在文档里——比这更精细的取值,通常只会得到过拟合的结果。它宁可让你搜不到那个更漂亮的数,也不让你有机会骗自己。
这一道是整张地图上最贵的一道,因为:
- 没有任何工具能扫出它。 查前视的工具读的是一次运行的结果,而这里的问题存在于「你扔掉的那四十九次」里,那些运行早就没了。
- 它的入口是你的手,不是你的代码。 你每按一次回车,都在往里加一点。
- 它伪装成勤奋。 反复调参在感觉上像是在认真工作。
对付它的办法全是流程纪律,不是代码:留出一段从不参与调参的数据(样本内与样本外)、让检验窗口滚着往前走(滚动前进验证)、看参数附近是一片平地还是一根孤峰(参数高原与参数尖峰)、把成交顺序打乱重算很多遍看结果散不散(蒙特卡洛检验)。展开看 过拟合 和 数据窥探,术语卡在 过拟合。
为什么必须当成一张图,而不是一份清单
排完八道工序,真正的收获在这里。
第一,它们的「错误方向」不一样。 幸存者偏差、前视偏差、过拟合、数据窥探这四种,只往好处错——它们从不让你的历史成绩变难看。成交假设和成本假设是系统性偏乐观——默认无滑点、默认按请求价成交、默认成本是个固定比例。启动期偏差是双向的。这个分类不是学术洁癖,它直接决定查错的优先级:一份好得反常的成绩单,优先怀疑「只往好处错」的那四种;一份和实盘差距忽大忽小、方向不定的成绩单,先去看启动期和撮合。
第二,修好一种,剩下的纹丝不动。 时点数据库能让你拿不到未来的财报,但挡不住你跑一百遍挑最好那次;查前视的工具能告诉你哪一列偷看了,但对「你的样本只剩赢家」一无所知;把成本调到很保守,也救不了一个成交假设本身就不成立的策略——现实里那一笔压根轮不到你成交,成本设多高都没意义。每一道工序需要一把自己的钥匙。
第三,它们是乘起来的,不是加起来的。 样本只留赢家(第一道)+ 成交默认无滑点(第五道)+ 从三十次尝试里挑最好(第八道),三个各自「不算太离谱」的偏差叠在一起,能把一套毫无价值的规则,包装成一条漂亮得让你睡不着觉的曲线。而报告上只有一个数字,看不出它是三样东西的乘积。
第四,越靠前的工序,错了越贵。 第一道圈错样本,后面七道做得再精细也是在一个不存在的世界里做题;第八道调参调过头,至少前七道的结构还在,换一份数据重来还有救。所以自查的顺序应该是从前往后,而不是从你最熟悉的那道开始。
「我已经查过前视了」是这张地图上最常见的自我安慰。 前视偏差因为有现成的工具、有醒目的名字,成了最容易被查、也最容易被当成终点的一种。查完前视只是过了第二道工序。样本是怎么圈的、指标预热够不够、成交凭什么算成、成本怎么扣的、这份数据你一共问了多少次——五个问题一个都没回答,成绩单的可信度并没有提高多少。
一份按顺序来的自查表
顺序有讲究:从最便宜、最能一票否决的问起。前面的问题答不好,后面的问题就不必问了。
- 这份样本是怎么圈出来的? 名单是哪一天的名单?期间退市、被并购、被调出的标的,还在不在里面?
- 每个字段,那一天真的拿得到吗? 尤其是财报这类会被修订的数据——你手上的是当年那一版,还是被后来擦洗干净的最终版?
- 指标预热够不够? 有没有递归型指标?回测喂进去的历史长度,实盘拿得到吗?
- 成交假设你能不能说出来? 按什么价成交、凭什么认为能成交、滑点算了没有、遇到涨跌停或停牌怎么处理。说不出来,等于你在用一套自己不知道的规则。
- 成本是怎么扣的? 买卖两侧一样吗?有没有最低佣金?换手率越高,这一条越要命。
- 跟谁比? 同期指数拿多少?
- 这份数据你一共问了多少次? 诚实回答。有没有一段数据从头到尾没参与过调参?
别把「回测本来就不准,看个大概」当成结论。 这句话听着谦虚,实际上是放弃:如果你不知道自己的成绩单在哪一道工序上被美化了多少,那这份成绩单既不能证明规则可行,也不能证明它不可行——它什么都没告诉你,你却因为看过它而更有信心了。一个不能证伪的检验,比不检验更危险。
失效边界与常见误用
以为偏差都会让成绩变好。 启动期偏差、A 股撮合的价带判断,方向都是双向的。「这个偏差让我的成绩变差了,所以真实情况更好」——这个推论同样站不住,因为你不知道另外几道工序往哪边偏了多少。
把成本调高当成万能保险。 成本保守和成交假设保守是两件事。你可以把手续费设得很高,同时仍然默认「只要价格在区间内就一定能全额成交」——后者才是大多数策略在现实里破功的地方。
把工具的绿灯当成通行证。 每一个检查工具都有它明确的覆盖边界:查前视的只验它实际跑到的信号,查启动期的只比对最后一行的指标值。没查出来,不等于没有。
修完偏差就以为规则成立了。 去掉所有水分之后的成绩才是起点。一套干净但不赚钱的规则,仍然是不赚钱的规则;而更常见的情况是,水分去掉之后,那份漂亮的收益也一起没了——因为水分本来就是它的来源。
只在电脑里查,不在脑子里查。 你复盘一次失败的交易,只挑那些印证了你结论的历史片段来看,这是数据窥探的人类版;你回忆自己的历史战绩,记住的多半是赚钱那几笔,这是幸存者偏差的人类版。这层和心理账户是连着的,行为金融学 那篇讲得更系统。
小结
- 回测偏差不是一个概念,是一族卡在不同工序上的问题:圈样本(幸存者)、备数据(前视)、算指标(启动期)、撮合(成交假设、滑点)、扣成本、出统计(基准)、回头调参(过拟合、数据窥探)。
- 第八道「调参」不在流水线内,它问的是「你一共跑了多少次」,因此任何一次运行内部的检查都发现不了它,只能靠流程纪律。
- 各种偏差的错误方向不同:幸存者、前视、过拟合、数据窥探只往好处错;成交与成本假设系统性偏乐观;启动期是双向的。成绩单好得反常时,先查前四种。
- 它们乘起来:三个「不算太离谱」的偏差叠在一起,足以把无价值的规则包装成漂亮曲线。
- 自查从前往后:样本怎么圈的 → 字段哪天可得 → 指标预热够不够 → 成交假设说得出来吗 → 成本怎么扣 → 跟谁比 → 这份数据问了多少次。
- 修好一种不代表修好了别的。每道工序有自己的钥匙。
一句话记住这篇:一份漂亮的历史成绩,可以在七八个不同的地方被做出来;你只查了其中一个,就等于什么都没查。
本文所引源码与文档均来自上述快照版本,是阅读代码与文档得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 逐个看这几种偏差各自怎么发生,或从 量化与 AI 投研卷 顺着读;本站内容为投资教育,不构成任何投资建议,边界见 免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- freqtrade 源码快照 b3404c9(2026-08-18):docs/backtesting.md(Assumptions made by backtesting、Trading limits)、docs/recursive-analysis.md、docs/lookahead-analysis.md、docs/strategy-customization.md(Common mistakes)、docs/hyperopt.md ↗
- qlib 源码快照 79633dd(2026-07-23):qlib/backtest/exchange.py、docs/advanced/PIT.rst ↗
- Vibe-Trading 源码快照 3a752d5(2026-08-04):agent/src/tools/alpha_bench_tool.py、agent/src/factors/bench_runner.py、agent/backtest/engines/china_a.py ↗