历史全在硬盘上,它凭什么让你只看见「当时那一段」
- 能说清「整段预先算好指标、再一根一根往前喂」这个结构,以及它为什么必须是这个结构
- 能指出数据层挡住越界的几道线各自挡的是什么,也能说出它们挡不住什么
- 能用「遮纸条」和「时间戳指的是开头还是结尾」这两条,去检查任何一个历史数据表格
有件事,你第一次意识到的时候会觉得有点分裂。
回测的时候,五年的行情就静静躺在你的硬盘上。每一根K线、每一个收盘价、每一次暴跌,全都在那儿,一个字节不缺。程序读得到它们中的任何一个,随时都读得到。
可这个程序要干的活,偏偏是假装自己不知道。它得走到 2023 年 3 月 14 日那一格,然后表现得就像 3 月 15 日还没发生一样——尽管 3 月 15 日的数据就躺在它手边,隔着零点几毫秒的距离。
一个能看到全部答案的程序,怎么让它老老实实只用前半页?这不是靠自觉,也不是靠道德,是靠数据层的结构安排。这篇就看一个开源交易框架在这件事上做了哪几手,以及——这才是真正值钱的部分——它为了跑得快而做的那个设计,是怎么反过来把门开着的。
一、先离开程序:整段渲染,逐格播放
想象一间体育比赛的录像复盘室。
墙上挂着屏幕,硬盘里存着整场比赛。教练组要回答的问题是:第 37 分钟那次换人,做得对不对?
要回答这个问题,规矩只有一条——只能放到第 37 分钟,不能快进。 因为你要判断的不是"这次换人事后看来效果如何",而是"当时那个人在那个信息量下,该不该这么决定"。这两个问题差着十万八千里。事后看,任何一次换人都能被解释成神来之笔或者昏招,反正结果已经摆在那儿了。
到这里都还好办。麻烦出在效率上。
复盘室配了一台设备,能算出各种分析图层:每个人的跑动热图、两队的阵型重心、传球成功率曲线。这些图层如果一帧一帧现算,慢得没法用——教练组要在两个小时里过完整场。所以剪辑师的做法是:开会之前,把全场九十分钟的所有图层一次性全部算好,缓存下来。 开会时只管调出来看,秒开。
这是完全正确的工程决定。也正是从这里,事情开始变味。
因为在"一次性全部算好"的那台机器眼里,九十分钟是一个整体。它算"全场平均跑动距离"这张图的时候,用的当然是全场的数据。然后这张图被贴到了时间轴上,第 10 分钟那一格里,也印着这个数。
于是复盘会上,有人指着第 10 分钟的画面说:你看,这个球员今天的平均跑动明显偏低,当时就该换。
这句话在逻辑上是坏的,但在屏幕上它看起来一点毛病都没有。 屏幕不会提醒你:"这个数字里掺了后面八十分钟。"它就静静地待在第 10 分钟那一格,长得和旁边那些老实数字一模一样。
把"整场"换成"整段历史",把"图层"换成"技术指标",这就是回测数据层的全部困难。
二、对你的判断意味着什么
先别急着看代码。上面那个场景已经能推出几条对手工操作也成立的判断。
一根K线上,"当时该有的"其实很少
你盯着日线图上的一根阳线,脑子里冒出的是"今天收在这里"。但请你把这句话拆开:
在今天收盘之前,"今天的收盘价"这个数字并不存在。 存在的只有一个不断变动的现价。你现在看到的那根漂亮的、上影线很短的阳线,是它走完之后凝固下来的样子;在盘中的每一刻,它可能长得完全不同——上午它还是一根阴线。
这就是为什么"这根K线收在均线上方就买"这句话,含着一个必须说清的补充:你只能在它收完之后动手。 而它收完的那一刻,你能成交的价格已经是下一根的事了。
这条约束听着像废话,但它是整个数据层最重要的一条。它意味着任何回测里,信号成立的那一格和真正动手的那一格,必须不是同一格。差这一格,很多策略的收益能差出一个数量级。
大周期的数据,要等它收完才算数
你在日线上做判断,同时想参考周线的方向。问题来了:周三这天,"本周的周线"是什么?
它还没收。它现在长什么样,跟它周五收成什么样,可能完全是两回事。你如果拿"本周这根周线是阳线"当条件,而这个判断用的是周五收盘后才确定的形态,那你在周三就用上了周五的信息。
手工看图的人几乎都犯过这个错,而且很难察觉——因为软件上那根周线一直在那儿,看上去理直气壮。一个还没走完的时间单位,在图上和一个已经走完的长得一模一样。
你复盘时也在快进
最后一条,跟程序无关,跟你有关。
你打开一张走势图研究历史信号,屏幕上是整段走势。你的眼睛在看左边那个买点的时候,余光里全是右边的走势。你自以为在还原"当时",其实你的判断被右边那一截污染得干干净净。
这就是为什么有经验的人复盘会拿张纸把右边遮住,一格一格往右挪。遮纸条这个土办法,和下面框架里那些工程手段,做的是同一件事。
三、到了系统里,这几道线长什么样
现在回到那个开源框架。它的回测在数据层大致做了这么几件事,每一件都对应上面某一条。
第一手:整段算完,把预热那一截整段砍掉
回测开始前,框架把这个品种的全部历史一次性交给策略,让它把指标全部算完。注意"一次性"和"全部"——这就是前面说的"提前渲染全场图层"。文档里明说了这个选择的理由:性能。一根一根现算,慢到不可接受。
算完之后立刻有一个动作:把开头那一段砍掉。
为什么砍?因为有一类指标是有记忆的,它今天的值由昨天的值推出来,刚开始那几十上百根算出来的值还带着初始化的痕迹,不能用。所以引擎会额外多加载一段历史专门用来"热身",热身算完就整段丢弃,绝不参与交易统计。这一段的机制、该热身多久、怎么实测,在指标的启动期那篇里已经拆得很细,这里不重复。
这里只强调一句你可能没注意的:"多加载一段、算完就扔"这个动作,本身就是一次数据边界的操作。 扔掉的那一段是"你不该拿它算业绩的数据",留下的那一段是"你该拿它算业绩的数据"。同一批数字,两种身份。数据层干的活,很大一部分就是给数字打这种身份标签。
第二手:信号整体往后挪一格
这一手是我认为整个数据层里最漂亮的一处。
指标和信号都算完之后,在把表格交给回测循环之前,框架把所有信号列整体向后平移了一格,然后把最前面那一行丢掉。
翻译成人话:第 N 根K线上产生的买卖信号,被搬到了第 N+1 根那一行。 回测循环走到第 N+1 根的时候,读到的信号是上一根产生的;它按当前这根的开盘价去撮合,天经地义。
这一下就把"信号成立在这根、动手在下一根"这条纪律,从"策略作者要记得"变成了"数据结构本身就是这样"。作者不用记,也没法不记——表格已经是错开的了。
源码里还有个很讲究的细节:平移之前先复制了一份没平移的表。为什么?因为策略的回调函数在被问到"这笔单要不要下"的时候,需要看到当时那个语境——信号连同它的标签,应该还留在它原本产生的那一格上,而不是被挪走的位置上。撮合用错开的那份,回溯语境用没错开的那份。
一个是"我该在什么时候动手",一个是"我当初为什么想动手"。这两件事需要两份不同的对齐方式,框架把它们分开存了。
第三手:给数据供应器装一个"可见上限"
前两手管的是主流程。可策略还有另一条路能碰到数据:在回调函数里,它可以主动向框架的数据供应器要一份分析结果——"把这个品种的表格给我看看"。
这条路要是不管,前面两手全白做。策略只要在回调里把整张表要过来,往后翻两页,什么都看见了。
框架的做法是:回测循环每往前走一根,就顺手把这个品种的"可见上限"往前推一格。 策略在回调里要数据,拿到的是从头截到当前这一格为止的一段(而且还带一个只保留最近多少根的上限,具体数目属于实现细节,以你手上版本的文档为准)。上限没设过的时候,直接返回一张空表——宁可给空的,不给多的。
同一个接口,在实盘模式下返回的是完整表格。听起来待遇不同,其实语义完全一致:实盘的"完整",本来就只到此刻为止。 这个接口的含义从来没变过,永远是"到现在为止你有权看到的",只是在回测里,"现在"这个词需要被人为地维护出来。
这一手值得单独记住,因为它体现了一个通用的设计观念:当你手里握着比你该知道的更多的信息时,正确的做法不是"提醒自己别看",而是把多出来的那部分从你的视野里物理地切掉。
第四手:大周期数据不是对齐,是错位挂载
前面说的"周三不能用周五的周线",在框架里对应一个专门的合并辅助函数,文档明确写着它的作用是"安全地合并,且不引入前视偏差"。
朴素的做法是按日期把两张表对上。这个做法是错的,文档也点名了这个坑:表格里每一行的时间戳指的是这根K线的开始时间,不是结束时间。 于是"周一"这一行里装着整周的数据,你在周一就拿到了整周的结果。
框架的做法是把大周期那张表的时间戳整体往后推——推到"这根大K线已经收完"的位置上,然后才去合并。中间那些没有新值的行,用上一个已知值往前填,而不是用下一个。
再琢磨一下这个动作:它把"一根K线的时间戳到底代表哪一头"这个含混的约定,改成了"能用的时间点"。数据表格上有大量这种含混:一个数写在哪一行,取决于制表的人认为这个数属于哪一刻。制表人心里的那个"属于",和你决策时需要的那个"可得",经常不是一回事。
顺带一提,源码里那个位移量还额外减掉了一个小周期的长度。原因就在第二手:反正信号整体要往后挪一格,减掉这一格,两次错位正好抵消,对齐到真正该对齐的那一刻。两处设计必须放在一起看才讲得通——这也是读源码比读单个函数有意思的地方。
第五手:承认有些数据没法做出"当时"
数据层的防线是有尽头的,框架很坦白地在文档里标了出来。
有一类接口拿的是实时值——当前的最新报价、当前的挂单簿。这类东西在历史里根本不存在,没人保存过某年某月某日某一秒的挂单簿。所以这些接口在回测里的行为是:返回现在这一刻的值。你要是不加判断就在策略里用了,整张表格的那一列会是同一个数字,而这个数字来自你跑回测的今天。
文档为此写了警告,让你按运行模式做区分。这条提醒的价值不在于教你写一个判断,而在于它划出了一条线:**能被回测的,只有那些被历史地、逐时刻地记录下来的东西。**没被记录的,不管它在实盘里多有用,都无法进入回测——你只能选择在实盘里用它、并且承认这部分能力从来没被验证过。
四、为什么"整段预先算好"这个设计,恰恰是前视最容易溜进来的门
这一节是本篇的核心,请慢一点读。
上面四道线看着挺严密。但请注意,它们全都是框架在自己能控制的地方做的:加载多少、砍掉哪段、信号错几格、接口给你看到哪里。有一块地方框架碰不到——你写在指标计算里的那些式子本身。而恰恰是"整段预先算好"这个决定,让那块地方变成了雷区。
道理其实很简单,简单到有点残酷。
设想另一种实现:程序一根一根往前走,每走到一根,就把"截止到这一根"的数据递给你,让你现算。在这个世界里,你想越界都难。你手上根本没有后面的数据,你写什么式子都出不了界。安全是默认的,越界需要刻意。
现在换成整段预先算好。程序递给你的是整张表——五年,从头到尾。你写的每一个式子,作用对象都是整整一列。
于是:
- 你写"这一列的平均值",得到的是五年的平均值,然后这个数被填进了每一行,包括第一行。第一行那个格子里,装着未来五年。
- 你写"取最后一行的值",在实盘里"最后一行"是最新那根,在回测里"最后一行"是五年后的那根。同一句话,两个意思。
- 你写平移,如果方向写反了,取到的就是下一根的值——而正负号写反这件事,代码看上去和写对时几乎一样。
- 你把小周期的数据折成大周期,折出来的那根被标上了这段时间的开头,于是这段时间的信息被搬到了它开始的那一刻。
看出共同点了吗?这四个式子,没有一个是"错误的代码"。 它们语法正确、语义清楚、在任何一本数据处理教程里都是标准写法。它们只是在错误的时间尺度上正确。
所以真正的结论是这样一句:
为了性能而选择整段预先计算,等于把"安全"从默认值改成了需要主动争取的东西。在这个模型下,越界是默认,不越界才需要刻意。
框架用四道线把它能挡的地方挡住了,但它挡不住你在一列数据上写一个整列统计量——因为那看起来完全就是一句正常的代码,而框架没有理由禁止一句正常的代码。
这也就解释了另一件事:为什么这个项目要额外花大力气造两个检测命令,去"跑起来对答案",而不是"读代码找茬"。因为在这个模型下,代码本身根本没毛病,毛病在数据的时间结构里。那两个工具怎么工作、能查出什么、承认查不出什么,是一个框架专门造工具来防的坑那一篇的事,这里不展开。
我想让你带走的是这两篇之间的那条因果:数据层的设计决定了哪种错误会变得常见,而哪种错误常见,就决定了工具链必须往哪里长。
五、这几道线管不到的,比它们管得到的更值得你操心
把上面的都做对了,是不是就干净了?不是。数据层挡的是"程序在时间上越界",它对下面这几样一点办法都没有。
你在流程上的越界。 你拿全部五年的数据反复调参,调到曲线好看为止。程序在每一次单独的回测里都规规矩矩没看未来,但你本人看了——你是用五年的完整结果,来决定该用哪一套参数的。这个问题在数据窥探那篇里有专门的讲法,它和程序层的前视是完全不同的病,但造成的假象长得一模一样。
数据本身的时间戳就是错的。 你的历史表格里,某个基本面数字标在了它所属的报告期上,而不是它被公布出来的那一天;某个指数成分是按今天的名单回填到十年前的。这类问题在数据入库那一刻就已经发生了,任何执行层的错位、切片都救不回来。这正是Point-In-Time要解决的事,而一个把它做到数据层地基里的做法,见qlib 怎么从数据层堵死偷看未来。
"当时拿得到"和"当时想得到"的差别。 就算每一格数据都严格是当时可得的,回测里的你依然带着一样东西是当时的你没有的:你知道后来发生了什么,所以你选了这套规则。 你为什么会想到用波动率过滤?因为你记得那年的暴跌。这个念头本身就是从未来带回去的。
这些机制层面的落差合起来有多大。 数据层只是其中一层,撮合、滑点、容量、延迟各有各的落差,回测与实盘的差异总览把整条链路铺开讲了。
六、不写代码的人能拿走的三条
这篇讲的是一个程序的内部结构,但下面三条是脱离程序也成立的。
第一,遮纸条法则。 看任何一张历史表格、任何一条回测曲线,随手指一格问一句:这一格里的数字,在这一格所在的那个时刻,算得出来吗? 这一个问题能筛掉绝大多数问题数据。回答"算不出来"的格子,整张表就都不可信了——因为你不知道还有多少格是这样的。
第二,问时间戳指的是哪一头。 一个数标在"2024 年 3 月"这一行,它是 3 月初就知道的,还是 3 月底才知道的,还是 4 月才公布的?这一个问题问出来,很多看着挺唬人的表格就站不住了。制表的人往往自己都没想过这个区别。
第三,别人给你看漂亮曲线时,问一句"信号和成交差几格"。 这是个很小的问题,但它非常有效:真正做过的人会立刻明白你在问什么,并给你一个具体的回答;没做过的人会含糊过去,或者反问你什么意思。这个问题问的不是技术,问的是对方有没有想过"当时"这两个字。
小结
- 回测天生分裂:全部历史都在硬盘上,程序却必须表现得像不知道后面发生了什么。这个"装作不知道"不能靠自觉,只能靠数据层的结构安排。
- 一个真实框架的做法大致是四手:多加载一段用于热身、算完整段砍掉;把信号整体错开一格,让"成立"和"动手"在结构上就不可能是同一格;给数据接口装一个随循环推进的可见上限,宁可返回空表也不多给;大周期数据按"已经收完"的时刻错位挂载,中间用上一个已知值填。
- 它还坦白承认有一类实时数据做不出"当时",在回测里只会返回现在的值——能被回测的,只有被逐时刻记录过的东西。
- 最值得记住的一层:"整段预先算好"这个纯为性能的决定,把安全的默认值反了过来。 在这个模型下,整列求平均、取最后一行、平移方向写反、重采样标在开头,都是语法完全正确、只是在错误时间尺度上正确的写法。越界成了默认,不越界才需要刻意——这正是这个坑必须靠工具"跑起来对答案"才能查的原因。
- 数据层挡不住的东西更值得操心:你本人用全部历史挑参数、数据入库时的时间戳就标错了、以及"你之所以想到这套规则,是因为你知道后来发生了什么"。
一句话说完这篇:判断一份历史数据能不能信,只需要指着任意一个格子问一句——这个数,在这一刻真的算得出来吗;程序做的那些错位、切片、砍头,说到底都是在替你把这句话问上千万遍。
想知道这个框架整体是什么、边界在哪,看freqtrade 是什么,又不是什么;想看它平时那套主循环怎么转,看交易机器人开机之后在忙什么;这里反复提到的那个概念本身,在前视偏差那篇里讲得最全。
本文基于上述源码与文档快照做概念讲解,未实际运行该程序,具体行为请以你手上那个版本为准;本站内容为投资教育,不构成任何投资建议或软件使用建议,涉及自动交易的部分尤其请注意实盘风险,边界见免责声明。回到量化与 AI 投研卷看这一卷的其他内容。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- freqtrade 源码 freqtrade/optimize/backtesting.py(回测数据准备与主循环)、freqtrade/data/dataprovider.py、freqtrade/strategy/strategy_helper.py(本地仓库快照 commit b3404c9,2026-08-18,仅阅读源码作概念注解,未实际运行) ↗
- freqtrade 官方文档 docs/strategy-customization.md(「Common mistakes when developing strategies」「Informative Pairs」「Dataframe」各小节,同一快照 b3404c9) ↗