滚动前进验证:别只考一次试,让它每年都重考一遍
你大概已经知道不能拿全部历史来调参了,所以你很规矩地做了这件事:五年数据,前三年拿来找参数,后两年一次都不碰,留着验证。后两年的成绩看着也还行,没有前三年那么亮眼,但是正的。
于是你上真钱。半年之后,又不行了。
这时候你会很困惑:我明明留了样本外啊。困惑是对的,因为你确实做了正确的动作——只是你把一场需要反复参加的考试,只考了一次。
先说人话:一次期末考,还是十二次月考
想象你要评价一个天气预报员。
一种评法:让他把 2019 年到 2021 年的历史气象资料研究个透,然后一口气预报 2022 和 2023 两整年的天气,最后对一次答案。答对率八成,不错。
另一种评法:让他用手上已有的资料预报下个月,月底对答案;然后把这个月真实发生的天气也交给他,让他重新消化一遍,再预报下下个月;如此往复,滚二十四次。最后你手上有二十四张成绩单。
两种评法都用到了「他没见过的日子」,但你心里清楚哪一种更能说明问题。第一种,他有可能只是恰好赶上那两年天气特别有规律;而且这两年里他的知识一直停在 2021 年,越到后面越过时。第二种,你看到的不只是一个平均分,还看到了他的成绩在时间上怎么起伏——是每个月都稳定七八分,还是有三个月满分、九个月不及格凑出来的平均分。
滚动前进验证(walk-forward,也常被叫作「滚动回测」「前进分析」)就是第二种评法。窗口往前滚,每次用前面一段时间的数据把系统调好,然后拿去在紧接着的那一段时间上跑,跑完把这一段也纳入已知信息,再往前滚一格。
这里的「回测」是指拿历史数据假装重走一遍、看看会赚会亏的动作,术语卡在 回测。
对你的决策意味着什么
把历史一刀切成「训练段 + 验证段」这件事本身没错,错在它只给了你一个样本。
具体哪里不牢靠,有三层:
第一层,那一次样本外考试的成绩,本身是个随机数。 你切在 2021 年年底,验证段就是 2022—2023;切在 2020 年年底,验证段换成另外两年,结论可能整个翻过来。一个只有一次观测的实验,你没办法区分「这套逻辑成立」和「这两年正好合它的胃口」。样本内与样本外的完整分界,样本内与样本外 那篇讲得更细,这里只说它的不足。
第二层,你的知识被冻结在切分点上。 一刀切的做法里,系统的参数是拿 2019—2021 年定下来的,然后就一动不动地用了两年。可现实中没人这么干——真实的使用方式是每隔一阵子拿最新数据重新看一眼。你验证的那个东西,和你实际会用的那个东西,不是同一个东西。
第三层,也是最要命的一层:那段样本外,你会忍不住反复用。 第一次验证成绩不好,你回头改改规则,再验一次;改到第五次终于好看了。这时候那段「样本外」早就被你看了五遍,它已经变成了样本内,只是你自己不承认。这条路的尽头就是 过拟合——把历史里的噪音当成了规律。
滚动前进不能消灭这三个问题,但它把第一个问题从「一次观测」变成「十几次观测」,把第二个问题直接解决掉(因为它就是按你真实的使用节奏在重演),第三个问题它也没办法,那得靠你自己的纪律。
它更诚实的地方在于:它衡量的不是「这套规则好不好」,而是「这套不断被重新调整的流程好不好」。 这两者的差别,就是「这把刀锋利吗」和「这个人磨刀的手艺好吗」的差别。你实盘里真正依赖的是后者。
窗口到底怎么排
这一节是本篇的骨头,值得慢慢看。排窗口只需要定三个东西,但每一个背后都藏着一个你必须表态的假设。
三个要素
训练窗有多长。 往回看多少数据来定参数或训模型。太短,参数被最近几个月的噪音牵着走;太长,你等于假设五年前的市场和现在是一回事。
验证窗有多长。 定好参数之后,拿去用多久才重新调整一次。这个长度必须和你实盘真实的调整频率对齐——如果你实盘打算一个季度重估一次,验证窗就该是一个季度。填一个和现实不符的数,验证出来的东西就没有对应物。
每次往前滚多远。 通常就等于验证窗的长度,这样各段验证期首尾相接、互不重叠。为什么必须不重叠?因为重叠的话,同一段行情会被算进成绩两次,你手上那十几张成绩单就不再是十几个独立的观测,而是被复制过的少数几个。
把所有验证段按时间顺序拼起来,你会得到一条连续的资金曲线——这条曲线上的每一天,都属于当时的系统没见过的数据。这才是滚动前进真正的产出物,不是那个平均收益数字。把总长度和训练窗、验证窗、步进这三个窗口长度填进 Walk-forward 切分器,就能把窗口一格一格排出来,一眼看到自己这么切一共能得到几段验证期。
滑动还是扩张:这是个关于市场的表态
窗口往前滚的时候,训练段的起点跟着走,还是钉在原地?
- 滑动窗口:训练段长度固定,整体往前挪,老数据从后面掉出去。
- 扩张窗口:起点不动,末端往前伸,训练段越滚越长,历史全留着。
qlib 的滚动任务生成器把这两种滚法直接写成了两个常量,一行注释就说清了区别:
class RollingGen(TaskGen):
ROLL_EX = TimeAdjuster.SHIFT_EX # fixed start date, expanding end date
ROLL_SD = TimeAdjuster.SHIFT_SD # fixed segments size, slide it from start date
它默认走扩张:训练段用扩张方式增长,而验证段和测试段的长度保持不变,只是整体往前平移。
这不是个技术选项,是个投资观点。选滑动,你在说「市场结构会变,太老的数据是干扰而不是信息」;选扩张,你在说「底层规律是稳的,数据越多估计越准」。这两句话都可能对,但对的场合不一样——短周期的价格行为规律更接近前者,长周期的估值与均值回复更接近后者。你至少要知道自己选了哪一句。
中间那道隔离带
训练段和验证段直接首尾相接,看起来天经地义,其实经常出事。
问题出在「答案是什么时候揭晓的」。假如你训模型时用的答案是「未来十个交易日的涨跌」,那么训练段最后那十天的样本,它们的答案其实落在验证段的开头。这些样本一旦进了训练集,模型就等于提前看过了验证段前十天的走势——这就是标准的 前视偏差,只不过它是从窗口边界渗进来的。
qlib 专门为这件事留了一个参数,函数注释写得直白:
def trunc_segments(ta: TimeAdjuster, segments, days, test_key="test"):
"""
To avoid the leakage of future information, the segments should be
truncated according to the test start_time
"""
做法就是把非测试段的尾巴按测试段起点往回截掉若干天,硬生生挖出一道空隙。这道隙的宽度不该拍脑袋,它由你的答案需要多久才能揭晓决定。
同一份代码的示例任务里还有一句注释挂在验证段上,提醒别把测试段的未来信息泄进验证段——训练、验证、测试三段的先后关系,在滚动里每滚一格都要重新守一遍。
还得往前多留一段数据
窗口不是从训练段第一天开始就能用的。绝大多数指标要攒够若干根 K 线才能吐出第一个有效值,模型的特征也一样。所以你实际需要的数据起点,比训练段起点还要更早一截。
freqtrade 的机器学习模块在文档里把这笔账算得很细:要回测某个时间段,得把数据起点往前推「训练窗长度 + 指标预热所需的 K 线数」,否则第一个窗口就是残的。这笔账很容易被漏掉,因为它不在窗口本身的三个参数里,却决定了你的数据得从哪天开始下载。
系统层:同一个词,三种不同的东西
看过三个真实系统怎么实现它,你对这个概念的理解会牢固很多——尤其是会发现,「walk-forward」这个词在不同系统里指的根本不是同一件事。
一 · 真正的滚动重训练
freqtrade 的机器学习模块把窗口切分做成了一个直白的循环。它要用户提供两个参数:训练窗的天数,以及「用多少天就滑一次窗、重训一次」的天数。切分逻辑的核心只有几行:
while True:
if not first:
timerange_train.startts = timerange_train.startts + int(bt_period)
timerange_train.stopts = timerange_train.startts + train_period_days
first = False
...
# 验证段紧接着训练段结束的那一刻开始
timerange_backtest.startts = timerange_train.stopts
timerange_backtest.stopts = timerange_backtest.startts + int(bt_period)
读一遍就明白窗口是怎么排的:训练段的起点每轮往前挪一个「验证窗长度」,终点始终是起点加上固定的训练窗长度——所以这是滑动窗口;验证段的起点严格等于训练段的终点,长度就是那一步的步长。首尾相接,不重叠。
它的文档里有两处非常诚实的说明,比代码本身更值得记住。
一处是关于成本:整段时间除以「多少天滑一次」就是需要训练的模型个数。想让验证更接近实盘,就得把滑动步长调小,而步长每缩小到十分之一,训练次数就涨十倍。文档直接承认,把自适应训练完整回测一遍会非常慢,并给出了一个反直觉的建议——真想测它,不如让它在模拟盘上一直跑着自己训。这句话的潜台词是:滚动前进验证的保真度和它的计算成本是死死绑在一起的,你只能在两者之间选位置。
另一处是关于泄漏:文档说明,目标值(也就是「答案」)是每个窗口各算一次的,所以不会看到未来;但特征的定义是在整段时间上一次性做完的,因此特征本身是否偷看未来,得由写策略的人自己保证。这是个很关键的边界——滚动的框架只能保证窗口切得对,它保证不了你在特征里干了什么。
二 · 任务生成:把一次实验拆成一串实验
qlib 的思路更抽象一层。它不直接跑回测,而是把「一个任务」按时间切成「一串任务」,每个任务自带各自的训练段、验证段、测试段,然后交给下游批量执行、批量收集结果。
它的滚动模块在类注释里对自己的定位交代得很清楚:这个模块只负责离线地把一个任务按时间切成一串任务,它和「在线服务时不断更新模型」是两回事,共用的只是任务生成那一层。
这个区分对你有实际意义:离线滚动是用来评估的,在线更新是用来运行的。评估阶段你可以随便回头重来,运行阶段每一步都不可逆。很多人把前者的结论直接当成后者的承诺,中间那道坎就是这里。
滚动步长在这里是「每次往前推多少个交易日」,代码里给了个默认值,实际用的时候当然要按你自己的调整节奏改——记住它是当前版本的默认值,可配置,不是什么推荐值。
三 · 「分段一致性检查」:名字一样,做的事不一样
Vibe-Trading 里也有一个叫 walk-forward 的东西,但它做的是另一件事,这一点必须说清楚,否则很容易误读它的输出。
它接收的是一条已经跑完的资金曲线,把这条曲线按长度均分成若干段(默认分成 5 段,可配置),逐段单独计算这一段的收益、夏普比率(一个把收益按波动大小折算过的比值,术语卡见 夏普比率)、最大回撤和胜率,最后汇总两个东西:有几段是赚钱的,以及各段之间的离散程度。
returns_list = [w["return"] for w in windows]
profitable_windows = sum(1 for r in returns_list if r > 0)
...
"consistency_rate": round(profitable_windows / n_windows, 4),
"sharpe_std": round(float(np.std(sharpes_list)), 4),
它的模块开头把自己归类为「统计校验工具」之一,和蒙特卡洛置换检验、自助法夏普区间并列——三个互相独立的工具。看清楚了:这里没有重新训练,也没有重新选参数。它切的是结果,不是流程。
这种检查有它自己的价值:一条总收益漂亮的曲线,如果拆开发现是某一段暴赚、其余全亏,那这个「漂亮」的可复制性极低。它回答的是「这条曲线的成绩在时间上均匀吗」。但它回答不了「换一段时期重新定参数,还能定出好参数吗」——而后者才是滚动前进验证的本职。
看到「walk-forward」四个字,先问它到底滚了什么。 滚了「重新调参/重新训练」的,是真正的滚动前进验证,它检验的是你那套调整流程;只滚了「把已有结果切成几段看看均不均匀」的,是分段一致性检查,它检验的是收益的时间分布。后者成本极低、也确实有用,但把它的通过当成「样本外验证过了」,是这个领域里非常常见的一次误读。
它治不了什么
它治不了数据本身的毛病。 前视偏差、幸存者偏差这类问题是长在数据里的,你把窗口滚一百遍,每一遍都带着同样的污染。滚动只重排时间,不清洗数据。
它自己也能被过拟合。 训练窗多长、多久滚一次、用滑动还是扩张——这些都是可调的旋钮。你把它们调到某个组合让整条拼接曲线最好看,那你只是把过拟合从「策略参数」这一层挪到了「验证设置」这一层,性质完全没变。判断方法也很直接:如果训练窗从两年改成两年半,结论就翻了个个儿,那这个结论本来就不成立。这和参数本身的稳健性是同一个道理,参数高原与参数尖峰 那篇讲的判据在这里同样适用。
单个窗口的数字不可当真。 窗口切得越细,每一段里的交易笔数就越少,少到十几笔的时候,那一段的胜率和夏普基本是噪音。看滚动结果要看整体的分布形状,不要盯着某一格说「你看这段多好」。小样本为什么骗人,大数定律与赌徒谬误 那篇是从概率角度讲的,结论一致。
一致 ≠ 能赚。 每一段都稳定小亏,也是完美的一致性。一致性只说明结果可复制,不说明结果是正的。这两件事得分开看。
它没法回答「什么时候该停」。 滚动结果告诉你这套流程在过去十几个窗口里表现如何,它不会告诉你第十七个窗口失效时你怎么察觉。那是实盘监控的事,属于另一套话题。
小结
- 一刀切的样本外只考了一次试;滚动前进是把这场试重复十几次,并且每次都用当时能拿到的数据重新调整一遍系统。
- 排窗口只定三件事:训练窗多长、验证窗多长、每次往前滚多远。验证窗长度必须和你实盘真实的重估节奏对齐,否则验证的不是你要用的东西。
- 滑动窗口和扩张窗口不是技术选项,是两种关于市场的假设——「旧数据是干扰」还是「数据多多益善」。
- 训练段和验证段之间要留隔离带,宽度由「你的答案需要多久才揭晓」决定,否则窗口边界会漏进未来信息。
- 保真度和计算成本是死绑的:滚得越密越像实盘,代价涨得越快。
- 同名不同物:有的系统的 walk-forward 是滚动重训练,有的只是把已有曲线切成几段看均不均匀。用之前先确认是哪一种。
- 它治不了数据污染,它自己也能被过拟合,单个窗口的数字不可当真。
一句话记住这篇:别用一次考试的成绩判断一个人,让他每年都重考一次,你才看得出他是真会还是那年题简单。
本文所引源码与文档均来自上述快照版本,是阅读代码得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 看其它概念各自在说什么,或从 量化与 AI 投研卷 顺着读;本站内容为投资教育,不构成任何投资建议,边界见 免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- freqtrade 源码快照 b3404c9(2026-08-18):freqtrade/freqai/data_kitchen.py 的 split_timerange、docs/freqai-running.md、docs/freqai-parameter-table.md ↗
- qlib 源码快照 79633dd(2026-07-23):qlib/workflow/task/gen.py 的 RollingGen 与 trunc_segments、qlib/contrib/rolling/base.py ↗
- Vibe-Trading 源码快照 3a752d5(2026-08-04):agent/backtest/validation.py 的 walk_forward_analysis ↗