点下「开始回测」之后,那个程序内部到底在忙什么
- 说得出一个回测引擎从数据到报告之间有哪几道工位,每道工位大致在做什么形态的事
- 讲清楚「哪些部分可以一次性算完、哪些部分必须逐根往前推」的分界线在哪,以及为什么这条线不能挪
- 知道回测报告上所有指标其实都是从同一张成交清单反推出来的,因此该先看哪里
你写好了策略,配置填完了,点下那个按钮。进度条走了几十秒,然后屏幕上刷出一张表:总收益、最大回撤、交易笔数、胜率、平均持仓时长。
问题是——这几十秒里,那个程序到底干了什么?
你大概知道标准答案是「它把历史重放了一遍」。可这句话的信息量,跟说「汽车靠烧油前进」差不多:方向没错,但你没法用它判断任何事。你没法判断为什么换个参数要重跑一遍全部数据;没法判断为什么明明只有几万行数据却要跑那么久;更没法判断当结果和你的手工估算对不上时,该去哪一段找原因。
这一篇要做的就是把那个黑盒切开,看看里面排着几道工位。至于回测这个东西本身在回答什么问题、它替你做了哪些你没同意过的假设,回测到底在算什么 那篇已经讲透了,这里不重复。这一篇只看工程结构,而且全篇会围着一个问题转:
它为什么必须一根 K 线一根 K 线地往前爬?
这个问题听着像实现细节,其实是整件事的骨头。想明白它,上面那几个「没法判断」会一起解开。
先说人话:一个替你管零花钱的人,和一本已经写完的日记
想象你出门旅行一个月,把一笔钱和一件差事交给邻居。
差事是这样的:你给他一本已经写完的天气日记,一天一页,从月初到月底全都写好了。你还给他一条死规矩——下雨天买一把伞,晴天把手上的伞卖掉。你要他照这条规矩,从第一页做到最后一页,最后告诉你钱变多了还是变少了。
现在看他怎么干这件事。
有一部分活,他可以一口气做完。 比如「哪些天下雨」这件事,他完全可以先把整本日记翻一遍,在每一页角上画个勾或者画个叉,一次搞定,不用管钱不管伞。因为「那天下没下雨」只取决于日记本身,跟他兜里有多少钱毫无关系。
但另一部分活,他只能一天一天往下推。 第十七天他能不能买伞?得看第十六天结束时他兜里还剩多少钱。而第十六天剩多少,又得看前面十五天他买了几把卖了几把。这条链子没法跳着算,因为每一天的可能动作,都被前面所有天的结果框死了。
这就是整个回测引擎的分水岭,也是这一篇最核心的那句话:
看法可以一次算完,账户只能一步一步走。
再补一个约束,比上面这条更硬:他不许提前翻页。 第十七天做决定的时候,他只能看第十七天以前写了什么。你要是允许他翻到第十八页看一眼明天是不是晴天,那这本账做出来就一文不值——不是错一点,是整个结论作废。而他手上偏偏握着整本日记,翻页的成本是零。唯一能防住这件事的办法,就是逼他老老实实按顺序走,而且规定他每一天能看到的边界。
逐根循环这个笨拙的结构,就是从这两条约束里长出来的。它不是程序员没想到更快的写法,是更快的写法会算出假账。
投资层:这个结构决定了你的交易清单,而不只是你的观点
上面那个分水岭,翻译到投资上是一句很多人没意识到的话:你的策略好不好,和你实际能拿到哪些交易,是两件被这条分界线隔开的事。
「下雨买伞」是你的观点,它属于可以一次算完的那半边。你有多少个信号、信号准不准,跟你有多少钱没关系。
可你真正赚到或亏掉的钱,来自另外半边——那半边是有限资源在时间上的分配过程。同一批信号,落到不同的账户状态上,会长出完全不同的交易清单:
- 钱被占住了。 第五天出了一个信号,可你的钱三天前就全买成别的了。这个信号在统计里存在,在你的账户里不存在。
- 格子满了。 你规定最多同时持有几笔,第六个机会再好也进不来。而哪六个先来,纯粹是时间顺序说了算。
- 先出来的那笔,决定了后面能进去的那笔。 一笔仓位早平掉两天,腾出来的钱刚好接住下一个信号;晚平两天,那个信号就永远错过了。
所以经常有人问:为什么我和别人跑同一套逻辑,结果差这么多?很多时候答案不在信号那一侧,在资金和仓位这个状态机上——两个人的资金量不同、允许同时开几笔不同、上一笔什么时候平掉不同,于是同一批信号被筛出了两份完全不同的成交清单。
这也解释了另一个常见的困惑:为什么信号的「胜率」看起来不错,账户曲线却很难看。 因为胜率统计的是那半边可以一次算完的东西——所有信号一视同仁;而你的账户只吃到了其中一个子集,而且这个子集不是随机抽的,是被资金占用规律地筛出来的。极端情况下,你吃到的恰好是持仓时间最长、周转最慢的那一批。
把这层想清楚,你就知道拿到一份回测报告该先看哪里了:先看成交清单,再看汇总指标。 汇总指标是从清单上算出来的二手产物,清单才是原始凭证。这一点后面还会再回来说。
系统层:切开看,里面排着七道工位
现在进代码。注意,下面讲的是结构,不是某个框架的用法——具体到函数名、参数名那一层,不同框架完全不同,而且下个版本就可能改,记了没用。真正在各家之间高度一致的是这几道工位的顺序和职责边界。
Vibe-Trading 的回测引擎在模块开头一句话就把这条流水线交代了:加载数据 → 生成信号 → 预先算好目标仓位 → 逐根 K 线执行并施加市场规则 → 出指标。把它展开得再细一点,大致是七道。
工位一:把数据装进来,并且钉在同一个时钟上
第一步不是「读文件」,是「对齐」。
如果你只测一个品种,这一步几乎没有存在感。可只要你测的是一篮子品种,麻烦立刻出现:它们的交易日历不一样、上市时间不一样、中间缺的行不一样。你需要一个统一的时间轴,而不是几十条各走各的时间线。
freqtrade 的做法是让循环走一个统一的时间生成器:外层是时间,内层才是品种。每推进一格时间,就去每个品种里取「这一刻对应的那一行」;某个品种在这个时刻还没有数据,就跳过它。你能在代码里看到专门处理这类错位的逻辑——比如某个品种的数据晚开始,就得等时间轴走到它头上;比如动态的品种清单会把一个品种踢出去过一阵又加回来,重新加回来时它的行游标还停在老位置,得快进到当前时刻,否则它会把过去那段又重演一遍。
这些补丁本身不重要,重要的是它们透露出的设计判断:回测里只能有一个时钟。 因为钱只有一份。你没法让 A 品种的循环跑到年底、B 品种的循环还在年初,然后把两份成交记录拼起来——那样两边会花同一笔钱两次。
这一步还有一个容易被忽略的动作:掐头。指标是需要预热的,一条长周期均线在数据最开始的那一段根本算不出有效值。引擎会按策略声明的预热长度,把开头那一段整体切掉。切少了,你的回测头几笔是拿半成品指标做的决定;切多了,白白浪费数据。这一层单独展开在 指标启动长度。
工位二:指标和信号,整段一次性算完
这是唯一一道不逐根的工位,也是很多人对回测的直觉出错的地方。
引擎会把某个品种的整段历史丢给你的策略,让你一次性把所有指标列、所有信号列全算出来。你写的通常是那种整列整列做运算的向量化代码——一行算完十年的均线。这一步做完,内存里躺着的是一张宽表:每一行是一根 K 线,每一列是一个指标或者一个「这里该不该买」的标记。
为什么这部分可以一次算完? 回到零花钱那个类比:因为「那天下没下雨」只取决于日记本身。指标是价格序列的纯函数,第 500 行的均线值,只和它前面那些行的价格有关,和你有没有持仓、还剩多少钱一点关系都没有。没有状态,就可以并行;有状态,就只能排队。
紧接着有一个动作,是这道工位上最要命的一步:信号列整体往后挪一格。
freqtrade 在做这件事的地方留了一句注释,我认为是整份代码里最值得抄下来的一句:
# Row is treated as "current incomplete candle".
# entry / exit signals are shifted by 1 to compensate for this.
意思是:循环里当前这一行,被当成「正在走、还没走完的那根 K 线」。既然还没走完,它的收盘价你此刻并不知道,那么由这根 K 线算出来的信号,你此刻也不该知道。所以引擎把所有信号列整体往下推一格——循环走到第 N 行时,读到的是第 N−1 行算出来的结论。
这一格,就是「信息按时间到达」这件事在代码里的全部实现。它看着像个平移操作,实际上是在重建一条你原本已经打破的因果链:你在向量化算指标的时候,是站在上帝视角把整段历史一次算完的;这一格把你重新按回到时间里面去。少了它,你就是在用今天收盘的结果决定今天的动作。这类错误的完整形态和它的其他变种,见 前视偏差。
这道工位末尾还有个不起眼但很说明问题的动作:算完之后,引擎会把这张 pandas 宽表转成普通的 Python 列表再进循环,注释里明写理由是为了性能——按行遍历 DataFrame 太慢。这个细节值得记住,因为它给了你一个很实在的判断:接下来那个循环是全程最热的地方,热到值得为它把数据结构换掉。
工位三:逐根往前爬
循环本体。外层时间,内层品种,一格一格往前推。
每推进一格,引擎手上有三样东西:当前时刻、这个品种此刻能看到的那一行数据、以及账户的当前状态(有哪些持仓、有哪些挂着没成交的委托、还剩多少钱、还剩几个格子)。前两样是从数据里读的,第三样是它自己一路攒下来的。
「爬」这个字用得很实:它不能跳,不能倒回去,也不能并行。整个回测的耗时,绝大部分花在这个循环里。
顺便回答开头那个「为什么几万行数据要跑那么久」:因为工作量不是数据量,是数据量乘以品种数乘以每格要做的判断数。 更要命的是参数搜索——你要试一百组参数,这个循环就得从头跑一百遍,而且这一百遍之间几乎没法复用中间结果,因为账户状态从第一根就开始分叉了。参数搜索的代价是乘法而不是加法,根源就在这里。
工位四:在每一格上问一句「按规矩现在该干什么」
循环走到某一格,动作是有固定顺序的,而这个顺序本身就是设计。
大致是:先料理已有的仓位(该不该平、止损碰没碰到、要不要加减仓),再料理挂着没成交的委托(超时了要不要撤、要不要改价),最后才看要不要开新仓。而开新仓之前还有一道闸——格子还有没有空。
这个「先存量、后增量」的顺序不是随手排的,它和实盘执行循环里的顺序是同一套逻辑,交易机器人开机之后,它一整天到底在忙什么 那篇从实盘那侧讲过一遍。放在回测里它还多一层意义:顺序直接决定成交清单。 同一格上,先平后开和先开后平,会得出不同的可用资金,进而得出不同的交易。
还有一件事在这道工位上发生,而且经常被误解:判定出「该买」和「买到了」是两件事。 引擎在这里产出的是一个意图,接下来才轮到把这个意图变成一笔成交。这条缝里塞着一整套问题——按什么价、能不能成、成多少,全都归 信号和订单是两回事 和 回测引擎凭什么认为你成交了 管,这里就不越界了。
工位五:撮合
意图变成成交的那一步。这是整个引擎里假设最密集、也最容易骗人的地方——一根 K 线里价格到底怎么走的,数据里根本没有,程序只能按一套写死的约定填。
这道工位单独成篇,回测引擎凭什么认为你成交了 从头讲。你只需要知道它在流水线上的位置:它夹在「判定」和「记账」中间,是唯一一处需要凭空造假设的工位。 前面的数据和信号是算出来的,后面的记账是推出来的,只有这里是定出来的。
工位六:持仓和资金,往前推一步
成交发生了,账要跟着动:持仓数量变了,可用资金变了,占用的格子变了,费用扣掉了。
这道工位就是整个循环之所以必须是循环的原因。 它是个状态机——当前状态加上这一格发生的事,得出下一格的状态。前面几道工位换个写法都还有救,唯独这一道,它的输入里含着自己上一步的输出,这条链没法拆开并行。
qlib 那边把这个回环写得特别显眼。它的主循环形状大致是:策略根据上一步的执行结果生成一个交易决策,把决策交给执行器去执行,执行器返回结果,策略再拿这个结果去生成下一个决策,如此往复直到日历走完。决策和执行之间是一个闭环,而不是一条单向的流水线。 这个形状比 freqtrade 那种「一个大循环里顺次调用」更能看出状态的往返。
qlib 还有一层设计值得单独提一句:它的执行器是可以嵌套的。外层按天走的执行器里可以套一个按分钟走的执行器,外层给出一个「今天要调成这个仓位」的决策,内层再把这个决策在日内一分钟一分钟地执行掉。这个结构对应的是一件很现实的事——做决定的时间尺度和执行的时间尺度往往不是同一个。 你按周调仓,但那一笔单子是在某天的某几分钟里成交的。大多数简单回测把这两个尺度压成了一个,于是「决策」和「执行」之间那段真实的时间差就凭空消失了。
至于每一笔要扣多少钱、扣哪几项,归 成本模型该怎么设才不骗自己 管。
工位七:收尾和统计
循环爬到最后一根,还没结束,有两件事要做。
第一件是处理还开着的仓位。 数据到头了,可你手上还捏着三笔没平。这些怎么算?引擎会统一按某种约定把它们收掉,在成交清单里标出来。这批交易值得你专门看一眼,因为它们的了结方式不是你的规则决定的,是数据的边界决定的——如果你的收益里有很大一块来自这批「被数据到头强行结束」的仓位,那这份报告的可信度要打折。 持仓周期越长的策略,这块越大。
第二件才是算指标。 而这一步的真相,是本篇最想让你记住的一点:
报告上那几十个数字,全部是从同一张成交清单反推出来的。 每笔交易的开平时间、开平价格、数量、费用——就这几列。把它们按时间排开,累成一条资金曲线,然后所有指标都从这条曲线和这张清单上算:收益是终点比起点,回撤是曲线上的峰谷距离,胜率是清单上正负两类的笔数比,平均持仓是开平时间差的均值。
这意味着两件事。
一是统计这道工位几乎不会出错,公式是公开的,谁算都一样。所以当结果不对劲时,别去怀疑统计,往上游找——去看清单,去看撮合假设,去看数据。
二是汇总指标丢掉了大量信息,而清单没有。年化收益是一个数,它背后可能是三百笔平均分布的小赚,也可能是两笔暴赚扛住了两百九十八笔小亏。前者是个方法,后者是运气或者一个你还没发现的 bug。这两种情况在汇总指标上长得一模一样,在成交清单上一眼就能分辨。
所以拿到报告的正确姿势是:把汇总那一屏先跳过去,直接翻清单。数一数笔数够不够、看一看收益是不是集中在极少数几笔、查一查那几笔的日期附近有没有什么特殊情况。这几个动作花不了十分钟,但它们能拦掉大多数「看起来很美」的结果。
回到那个问题:为什么不能一次算完
把上面七道工位串起来,这个问题的答案已经浮出来了,而且是三层的。
第一层,状态是路径依赖的。 钱只有一份,格子只有几个。第 N 格能做什么,被前面 N−1 格做过的事框死。这类计算天然不可并行——不是没优化好,是数学上就没法拆。
第二层,信息必须按时间到达。 你手上握着整段历史,而模拟里的那个「你」不该知道明天。逐根循环加上那个后移一格,是唯一能把这条边界钉死的结构。一旦你为了快而改成整段一起算,边界就会以各种你察觉不到的方式漏水——最典型的就是拿一个「整段历史都算完之后才知道」的统计量,去做历史中间某一天的决定。
第三层,决策会改变自己后面的选项。 这一层最容易被忽略。你今天买了,明天的可选动作里就多了「卖」而少了「买」;你今天把钱花光了,后天再好的机会也只能看着。这是一个反馈回路,不是一条流水线。 qlib 把它写成「上一步的执行结果喂给下一步的决策」,就是在把这个回路显式化。
顺带说一句判断题:只要一套计算里出现了这三层中的任何一层,它就必须是顺序的。 反过来,如果你写的某段东西不含这三层——比如算一堆指标、算一堆因子值——那它就该被一次性算完,逐根循环去算指标纯属白白慢十倍。分清这条线在哪,是写回测代码时最值钱的一个判断。
顺着这个结构,你能自己做的几件事
知道了里面排着七道工位,下面这些事就不用靠猜了。
结果不对时,按工位从后往前排查。 统计不会错,那就往前:清单对不对 → 撮合按什么价 → 判定读的是哪一行 → 信号有没有后移 → 数据本身干不干净。这条链上每一环都能单独验证,比对着一堆数字干瞪眼强得多。数据那一环的常见坑(复权、停牌、退市这些)本卷另有专篇,这里不展开。
看一个陌生框架时,先找它的主循环。 找到那个「while 没走完」或者「for 每一格」的地方,从那里往两头读:往上是数据和信号怎么准备的,往下是成交和记账怎么落的。主循环是任何一个回测框架的地图原点。 具体怎么下手,怎么读一个开源回测框架的源码 给了一套顺序。
自己动手写一个玩具引擎,别一上来就求全。 按这七道工位,每道先做最笨的版本:数据只支持一个品种,信号只支持一列,撮合只按下一根开盘价,记账只记现金和持仓。跑通之后再一道一道加复杂度。这个过程最大的收获不是那个引擎,是你会亲身撞见每一处「这里必须做个假设」的地方——而这些地方恰恰就是你以后读别人报告时该问的那几个问题。
别指望换个框架能解决问题。 七道工位是同一套骨架,换框架换掉的只是每道工位的默认假设。如果你的困惑是「我不知道这个结果为什么长这样」,换框架只会让你在一套新的默认值下继续不知道。
本文是对指定快照版本源码与文档的结构性阅读笔记,不是运行结果,也不保证在你手上那个版本、那套配置下同样成立——具体行为请以你自己的核对为准。文中不涉及任何具体标的、不推荐任何框架或数据源,不构成投资建议。把一个引擎的内部结构搞清楚,只能帮你判断一份报告哪里可能出问题,它不会让回测结果变得可靠,历史表现也不预示未来收益。 完整边界见 免责声明。
小结
- 一个回测引擎大致排着七道工位:装数据并对齐到同一个时钟 → 整段一次性算完指标和信号 → 逐根往前爬 → 每格判定该做什么 → 撮合 → 更新持仓和资金 → 收尾并统计。
- 全程只有第二道是一次算完的,因为指标是价格的纯函数,不含状态;从第三道开始全部必须顺序执行。这条分界线就是「有没有状态」。
- 必须逐根的三个理由:状态路径依赖(钱只有一份)、信息按时间到达(不许提前翻页)、决策改变自己后面的选项(这是个回环,不是流水线)。
- 「信号列整体后移一格」是把上帝视角算出来的信号重新按回时间里的关键一步,少了它,你就是在用当天的结果决定当天的动作。
- 报告上所有指标都是从同一张成交清单反推的。统计那步几乎不会错,出问题一定在上游;而清单里的信息远比汇总指标多——先看清单,再看那一屏数字。
- 结尾那批「因为数据到头才被平掉」的仓位要单独看一眼,它们的了结方式不是你的规则决定的。
一句话说完这篇:回测引擎慢慢往前爬,不是因为写得笨,是因为你兜里的钱只有一份、而且它不许自己偷看明天——凡是和这两件事无关的计算,它其实都一口气算完了。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。