指标的启动期:同一根 K 线,为什么回测和实盘算出的数不一样
你把同一套规则,在同一个软件里,对着同一只标的、同一根 K 线跑了两遍。第一遍是回测,程序读的是五年历史;第二遍是模拟盘,程序刚启动,手里只有交易所刚发过来的那一小段数据。
两遍算出来的均线值,不一样。
不是差很多,可能小数点后第三位才开始分岔。但你那条规则写的是「快线上穿慢线就买」,而这一根 K 线恰好卡在两条线贴在一起的位置——回测里它上穿了,实盘里差了那么一丁点,没穿。于是回测的成交记录里有这一笔,实盘没有。往后所有的持仓、所有的资金曲线,从这一刻起就分了岔。
你翻遍代码找不出错,因为确实没有错。问题出在一个几乎没人提的地方:你的指标是有记忆的,而两次运行给它喂的记忆长度不一样。
先说人话:一锅越熬越浓的老汤
想象一家店的卤汤。每天关门前把汤留着,第二天加水加料接着熬。这锅汤今天是什么味道,不只取决于今天放了什么,还取决于它已经熬了多少天——熬了三天的和熬了三年的,就算今天投的料一模一样,尝起来也不是一个味。
有一类指标就是这么算出来的。它今天的值,等于昨天的值加上今天的一点新料。昨天的值又来自前天,前天来自大前天……一路往前,没有尽头。这种「本期靠上期」的算法有个专门的名字叫递归。
freqtrade 的文档里举了一个笨得刚刚好的例子。假设有个指标叫 steps,规则是:第一行的值是 0,往后每一行等于上一行加 1。
现在问你:最后一根 K 线上,steps 是多少?
答不出来。因为这个问题少了一个条件——你从哪一根开始算的。从往前数第 1000 根开始算,最后一根是 999;从往前数第 500 根开始算,同样是最后一根,值变成 499。同一根 K 线,同一个公式,两个数。
这就是启动期问题的全部内核。steps 是个夸张的例子,它的记忆永不衰减,差多少根就差多少。真实指标温和得多,但性质完全一样:它记得的东西比你以为的多。
它在投资里意味着什么
你和别人看到的「同一个金叉」,可能不是同一个
最常见的场景是这样的:你在行情软件上把图表的起始日期往前拖了半年,回头一看,MACD 那排柱子的高度变了,甚至有一两根从红变绿。很多人第一反应是软件有 bug。
不是 bug。MACD 是由两条 EMA(指数移动平均,一种给近期价格更高权重的均线)相减得来的,EMA 就是典型的「老汤」——它今天的值里,混着理论上无限久远之前的那一点点残味。你把图表往前多加载半年数据,等于往锅里多熬了半年,出来的味道当然会变一点点。EMA 的原理本身在 均线 MA 与 EMA 那篇讲得更细,这里只关心一件事:它的值依赖于「从哪开始算」,而这个起点在回测和实盘里几乎从来不一样。
对你的实际影响,取决于差多少和差在哪里。指标值差千分之一,绝大多数时候什么都不会发生。可如果你的规则是「穿越」「交叉」「突破某条线」这种在临界点上翻脸的判断,千分之一的差别就足以让一笔交易凭空出现或凭空消失。
回测天生喂得多,实盘天生喂得少
这个不对称是结构性的,不是谁偷懒。
回测的时候,历史数据摆在硬盘上,你想读多少读多少。实盘就不一样了:程序启动那一刻,它得向交易所或行情商要数据,而对方一次能给多少条是有上限的,连续批量索要的次数也有上限。也就是说,实盘手里能拿到的历史长度存在一个天花板,你在回测里随便喂两万根,实盘可能压根喂不到。
freqtrade 的文档专门为这个天花板写了警告,并且老实交代:这个上限由交易所定,随时可能变,不会提前通知。所以这里不存在一个能抄下来的数字,只有一条机制——你在回测里用的历史长度,必须是实盘也拿得到的长度,否则你测的那套东西根本不可能上线。
哪些指标有这毛病,哪些没有
分界线很清晰:看它的公式里有没有「上一期的自己」。
没有记忆的:简单移动平均(把最近 N 天价格加起来除以 N)。窗口之外的数据对它零影响,你多喂一年还是多喂十年,结果一模一样。当日振幅、成交量这类当期算完就了结的量也一样干净。
有记忆的:EMA,以及一切建立在 EMA 之上的东西——MACD 首当其冲。另外,RSI、ATR、ADX 这些指标的经典算法里都含有一层平滑(业内常称 Wilder 平滑),本质上也是递归的一种,同样会拖着长长的尾巴。具体到你手上那个技术指标库是怎么实现的,得看库的文档,不同库对同一个指标可能给出不同的平滑方式,这也是同一个 RSI 在两个软件里对不上的原因之一。
顺带说一句:递归衰减是指数级的。这意味着影响永远不会真正归零,但会掉得很快——多喂的那部分历史越靠前,它对今天这个值的贡献越接近于零。这个性质决定了后面所有工程处理的思路:不是消灭它,是把它压到看不见。
系统层:一个真实系统怎么处理这件事
freqtrade 把这件事拆成了三步走,思路值得单独看一看,因为它不是「修一个 bug」,而是「给一个不可能消除的误差划一条可接受的线」。
第一步:让策略自己申报「我要预热多少根」
策略里可以写一个属性叫 startup_candle_count,意思是「我这套指标,需要多少根 K 线才能算稳」。
比如策略里用了一条周期 100 的 EMA,光喂 100 根是不够的——刚起步那几根的值还带着浓重的初始化痕迹。文档里对这个例子给的建议是喂到几百根的量级,让初始化的影响衰减掉。
这个申报不是走形式。源码里有一道硬校验:如果这个数小于 1,程序直接拒绝跑,并且给出的报错原文就点名了原因——这会在某些指标上引发递归问题。
if self._strat_scc < 1:
raise ConfigurationError(
f"The strategy defines invalid startup candle count of {self._strat_scc}. "
f"This will lead to recursive issues on some indicators. ..."
)
值得玩味的是这句报错的措辞:它不说「参数非法」,它说「这会导致递归问题」。写这行代码的人清楚地知道自己在防什么。
第二步:多喂进来的那一段,算完就扔
申报之后,回测引擎的动作是这样的:你说要测一月份,它实际加载的数据从一月一号再往前推若干根开始;用这段加长的数据把指标全部算完;然后把预热的那一段整段砍掉,只从一月一号开始撮合交易。
这一步是整套设计的关键,值得停下来想一秒:多喂的历史只用来把指标「熬稳」,绝不参与交易统计。 如果不砍掉,你的回测就会包含一段指标还没稳定的时期,那段时间产生的信号是垃圾,却会实打实地计入你的收益率。
文档里还补了一个诚实的旁注:如果预热所需的数据压根不存在(比如你要测的时段就是这份数据的最开头),引擎会把回测的起始时间往后推,而不是硬着头皮用不足的数据开跑。
第三步:用「递归分析」量出这个数该设多大
前两步解决了机制,但留下一个问题:预热多少根才算够?
freqtrade 给的答案不是查表,是实测。它有个 recursive-analysis 命令,做法直白到有点朴素:
- 先用一段很长的数据算一遍指标,把结果当作基准(喂得足够多,可以认为它已经熬稳了);
- 然后换若干个不同的预热长度,各算一遍;
- 最后只取每一遍最后一行的指标值,逐列和基准比,报出差了百分之几。
源码里的核心比对就是一行 pandas,和它做前视检查时用的是同一个套路:
base_last_row = self.full_varHolder.indicators[pair_to_check].iloc[-1]
part_last_row = part.indicators[pair_to_check].iloc[-1]
compare_df = base_last_row.compare(part_last_row)
偏差百分比也算得毫不花哨——(短的那个 − 基准) ÷ 基准,逐个指标存进一张表。默认试的那几个预热长度是一组奇数(当前默认值,可配置),策略自己申报的那个数会被自动插进这个列表一起测。
用奇数是有讲究的,理由挺工程化:程序从行情接口拿回一批 K 线,最后一根是「当前这根」,前面的才是预热用的。如果你把预热数量设成一个整数批次的大小,就会因为多要那么一根而触发多一次分页请求,白白多下一整批数据,还平白拖慢程序。
输出是一张表,文档里给的示例长这样:
| indicators | 20 | 40 | 80 | 100 | 150 | 300 | 999 |
|--------------+---------+---------+--------+--------+---------+---------+--------|
| rsi_30 | nan% | -6.025% | 0.612% | 0.828% | -0.140% | 0.000% | 0.000% |
| rsi_14 | 24.141% | -0.876% | 0.070% | 0.007% | -0.000% | -0.000% | - |
横着的一排是不同的预热长度,格子里是相对基准的偏差。这张表有三个地方值得看懂:
nan% 是算不出来。 周期 30 的 RSI,只给二十来根数据是算不出值的,不是偏差大,是根本没有。
偏差随着预热变长而收敛。 看 rsi_14 那一行,从两位数的百分比一路掉到千分之一以下——这就是前面说的指数衰减在数字上的样子。
- 表示完全没有差异。 也就是熬到头了。
为什么「零偏差」不是目标
这是本篇最想让你带走的一层。
按直觉,看到这张表你会想把预热长度一直加大,加到整行都是 - 为止。文档明确说这不是最佳选择:某些指标要求的预热长度会长到不切实际,而实盘那边还压着一个数据量天花板。
它给出的判断标准是另一句话——看这点偏差会不会真的改变你的进出场。
这是一句很成熟的工程判断。指标值差千分之一,对一个「均线向上就持有」的规则毫无影响;对一个「刚好卡在交叉临界点」的规则可能致命。所以该问的不是「偏差是不是零」,而是「在我这套规则的判断逻辑下,这么大的偏差够不够把一个信号翻个面」。
顺着这句话往下想,你会发现一个更朴素的结论:对启动期越不敏感的规则,越结实。 一套需要靠小数点后第三位来决定买卖的规则,就算你把预热喂到底,它也会在别的地方(数据源、撮合、报价精度)以别的形式漏。这一点和回测与实盘之间那道更宽的鸿沟是同一件事,回测与实盘的差距 那篇把整条链路铺开讲了。
递归分析报告干净,不代表策略没问题。 上游文档自己列了几条边界:它只比对最后一行的指标值,不告诉你这点偏差对进出场有没有实际影响;它只计算写在指标计算环节里的指标,你要是把某个指标的计算塞进了下单信号那一段,它压根不会去算,自然也报不出问题。把这份报告当成体检合格证,等于把「没查」当成「没有」。
它和前视偏差不是一回事
这两个很容易混,但方向正好相反。
前视偏差 是用了未来——在做某个时刻的决策时,用上了那个时刻还不可能知道的信息。它有个很坏的性质:只往好处错,历史成绩只会被它抬高。
启动期偏差是用了过量的过去。回测里那锅汤熬得比实盘久,算出来的数更「熟」。它对成绩的影响是双向的,可能让回测好看,也可能让回测难看,完全看运气。
正因为它不是单向作弊,它比前视「无害」,也因此更容易被放过。但它破坏的东西同样要命:回测结果的可复现性。回测的意义建立在「实盘会重演回测里那套计算」这个前提上,启动期偏差直接掀掉了这个前提——不是结论错了,是两次算的压根不是同一道题。这类让历史和现实对不上的机制,回测偏差总览 那篇按类别排了一遍。
有意思的是,freqtrade 的递归分析命令顺手还做了一个小小的前视检查:它拿一段极短的数据算一遍指标,再和全量数据在同一个时间点上的值对一对,对不上就点名是哪个指标。这只覆盖指标层,完整的前视排查是另一套工具。两件事共用同一个技术动作(截一段、重算、比对),但查的是完全不同的病。
失效边界与常见误用
把预热长度设得超过实盘能拿到的量。 回测跑得欢,上线就没数据。申报这个数之前先问:实盘那一头,真的喂得进这么多吗。
只在低价、低波动的品种上测。 文档特意提醒要挑价格较高、有一定波动的品种来做这个分析,否则小数舍入本身就会把结果搅浑,你看到的偏差可能是舍入噪音而不是递归。
以为换个指标库就没这问题了。 递归是公式的性质,不是某个库的实现缺陷。只要指标里含「上一期的自己」,换谁来写都一样。
把它和样本内外混为一谈。 样本内与样本外 讲的是「这段数据有没有参与过调参」,是统计问题;启动期讲的是「同一时刻的同一个数算不算得准」,是计算问题。两者都会造成回测与实盘的落差,但一个都治不了另一个。
手工看图时也中招。 你在软件上拖动图表起始点研究历史信号,如果拖动会改变指标值,那你「复盘」出来的那些漂亮信号,和你实盘当时屏幕上看到的可能就不是一回事。这一层和 回测到底在算什么 是一个道理:你必须先搞清楚屏幕上那个数是怎么来的。
小结
- 有一类指标是「有记忆」的——今天的值由昨天的值推出来,一路往前没有尽头。EMA、MACD,以及 RSI、ATR、ADX 的经典平滑算法都属于这一类;简单均线不属于。
- 对这类指标,「往前喂了多少根 K 线」是一个隐藏的输入参数。回测天生喂得多、实盘受接口上限约束喂得少,于是同一根 K 线上算出两个数。
- 差别通常极小,且随着喂进去的历史变长而指数衰减。真正的风险不在数值本身,而在判断临界点:穿越、交叉、突破这类规则,一丁点差异就能让信号有无翻面。
- 工程解法是三段式:策略申报需要多少预热 → 引擎多加载这段数据、算完指标后整段丢弃、不计入交易 → 用递归分析实测不同预热长度下的偏差,选一个够用的。
- 目标不是零偏差,是偏差小到不影响进出场。 追求绝对零往往要付出实盘拿不到的数据量代价。
- 它不是前视偏差:前视是用了未来且只往好处错,启动期是用了过量的过去且双向都可能错。它伤的不是收益率,是回测的可复现性。
一句话记住这篇:你的指标记得的事,比你以为的多——所以先问清楚它到底看过多少历史,再去信它算出来的那个数。
本文所引源码与文档均来自上述快照版本,是阅读代码与文档得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 看其它概念,或从 回测 这张术语卡补基础;本站内容为投资教育,不构成任何投资建议,边界见 免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。