1. 这个问题的本质:不是模型坏了,是链路断了
做过量化实盘的人大概率都遇到过这个场景:回测里模型每天都能稳定输出预测信号,收益率曲线画出来漂漂亮亮,夏普比率看着也像那么回事。结果一部署到实盘环境,信号栏空空如也,或者偶尔蹦出来一两个信号,跟回测里的频率完全对不上。第一反应通常是“模型过拟合了”“数据泄露了”“得重训”,然后花几天时间重新跑训练、调参、换特征,折腾一圈回来发现问题依旧。
我踩过这个坑不止一次,后来复盘发现,绝大多数“回测有信号、部署没信号”的情况,根因根本不在模型本身,而在从回测到部署这条链路上。模型只是一个函数,输入数据、输出预测,它不会因为换了运行环境就突然变傻。真正变的是喂给它的数据、调用它的方式、以及信号从预测到下单之间经过的那些中间环节。
这篇文章就是把这个链路拆开,一层一层排查。适合正在做量化策略部署、遇到信号丢失问题的朋友,也适合刚接触回测框架、想提前避坑的新手。我会用backtrader多股回测、滑动窗口滤波模型、LightGBM回归模型这些常见组合作为例子来讲,但排查思路是通用的,换成任何框架、任何模型都适用。
核心观点先摆出来:部署后没信号,先做链路排查,最后才考虑重训。重训是成本最高的操作,把它放在排查清单的最后一位。
2. 回测与部署的链路差异拆解
2.1 回测环境和实盘环境的五个关键差异
很多人把回测和实盘想象成“同一套代码换个数据源”,实际上这两个环境的差异比想象中大得多。我把常见的差异归纳成五个维度,每一个都可能导致信号丢失。
数据时序差异。回测里你拿到的是完整的历史数据,可以随意用未来数据做特征(虽然这是数据泄露,但很多人无意中就这么干了)。实盘里数据是逐条到达的,当前时刻你只能看到过去的数据。如果特征计算里用了shift(-1)或者滚动窗口的center=True,回测时信号正常,实盘时因为拿不到未来数据,特征全是NaN,模型自然不输出信号。
数据频率差异。回测用的日线数据,实盘接的是分钟线然后重采样成日线,重采样的时间戳对齐方式不同,可能导致某些bar被丢弃或重复。backtrader里resample的barbound参数如果没设对,日线bar的生成时机会和回测不一致。
缺失值处理差异。回测数据是清洗过的,缺失值要么填充了要么删除了。实盘数据经常有停牌、涨跌停、数据源延迟导致的缺失。如果模型对NaN敏感(比如LightGBM虽然能处理NaN,但某些特征全NaN时预测会退化),缺失值一多,信号就没了。
特征计算窗口差异。滑动窗口滤波模型依赖固定长度的历史窗口。回测开始时你可能有足够的历史数据预热,实盘刚启动时历史数据不够,窗口没填满,特征算不出来。这个“预热期”问题在回测里被掩盖了,因为回测直接从有数据的地方开始。
信号阈值差异。回测里你用的是历史数据的分布来确定阈值,比如预测值大于0.7才发信号。实盘数据的分布可能偏移了,预测值整体偏低,永远达不到0.7。这不是模型坏了,是阈值没做自适应。
2.2 为什么“重训”是最差的第一个选择
重训的成本很高:要重新准备数据、重新训练、重新验证、重新部署,周期可能是几天到几周。而且如果根因不在模型,重训完问题还在,你只是浪费了时间。
更麻烦的是,重训可能掩盖问题。比如你重训时用了新的数据预处理流程,恰好把某个bug绕过去了,信号恢复了,但你不知道真正的原因是什么。下次换个环境,问题又冒出来。
我的原则是:先证明模型本身没问题,再怀疑模型。怎么证明?把实盘当时的数据快照存下来,喂给回测时的同一个模型,看输出是否正常。如果正常,模型无罪,问题在链路;如果不正常,再查模型。
2.3 排查顺序:从数据到信号的全链路
我习惯按这个顺序排查,从上游到下游,逐段隔离:
- 数据层:实盘数据是否完整、时序是否正确、频率是否对齐
- 特征层:特征计算是否和回测一致、是否有NaN、窗口是否填满
- 模型层:模型加载是否成功、输入维度是否匹配、预测是否正常
- 信号层:阈值是否合理、信号生成逻辑是否触发、是否有过滤条件拦截
- 执行层:信号是否传到下单模块、是否被风控拦截
这个顺序的逻辑是:越上游的问题影响面越大,越容易排查。数据层的问题往往一眼就能看出来,模型层的问题需要对比才能发现。
3. 数据层排查:最常见的信号杀手
3.1 时序对齐:回测里的“未来函数”陷阱
这是最隐蔽也最常见的问题。回测框架为了效率,经常允许你在计算特征时访问整个DataFrame。比如用pandas计算滚动均值:
df['ma20'] = df['close'].rolling(20).mean()这行代码在回测里没问题,因为rolling默认只往回看。但如果你写了:
df['future_ret'] = df['close'].shift(-1) / df['close'] - 1回测时这一列有值,模型训练时用了它,预测时也用了它(因为回测数据是完整的)。实盘时shift(-1)拿不到未来数据,这一列全是NaN,模型输入缺失,预测直接失效。
排查方法:把实盘的特征DataFrame和回测的特征DataFrame做逐列对比,看哪些列在实盘里是NaN或者全零。重点检查所有用了shift、rolling带center参数、expanding的列。
注意:backtrader的
next()方法里,self.data.close[0]是当前bar,self.data.close[-1]是上一根bar。如果你在next()里用了self.data.close[1],那是未来数据,回测能跑但实盘会报错或返回NaN。
3.2 频率重采样:bar的边界在哪里
多股回测时,不同股票的bar时间戳可能不一致。backtrader的resample功能可以把分钟线合成日线,但barbound参数决定了bar的边界。默认是barbound=0,表示从第一个tick开始算。如果你实盘用的数据源时间戳有偏移,重采样后的bar可能和回测对不上。
举个例子:回测里日线bar的时间戳是每天15:00,实盘数据源的时间戳是15:01,重采样后bar的时间戳变成15:01,模型的特征计算里如果有基于时间戳的逻辑(比如“当天最后一根bar”),就会错位。
排查方法:打印回测和实盘重采样后的bar时间戳,对比前10根和后10根。如果时间戳不一致,调整barbound或者统一数据源的时间戳格式。
3.3 缺失值:实盘数据的常态
回测数据通常是清洗过的,缺失值很少。实盘数据里,停牌、涨跌停、数据源延迟都会导致缺失。如果模型对缺失敏感,信号就会丢失。
LightGBM虽然原生支持NaN,但有个坑:如果某个特征在训练时从未出现过NaN,模型可能没有学习到处理NaN的分裂方向。实盘时这个特征突然出现NaN,预测会走默认分支,可能偏离预期。
处理缺失值的常见做法:
- 前向填充:
df.fillna(method='ffill'),适合价格类特征 - 均值填充:适合波动率类特征,但会引入偏差
- 标记缺失:加一列
is_missing,让模型自己学习
我一般用前向填充加缺失标记的组合。前向填充保证特征有值,缺失标记让模型知道这个值是填充的。
3.4 数据预热:实盘启动时的冷启动问题
滑动窗口滤波模型需要固定长度的历史数据。比如窗口长度是60,实盘刚启动时只有10条数据,窗口没填满,特征算不出来,模型不输出信号。
回测里这个问题被掩盖了,因为回测直接从有足够历史数据的地方开始。实盘部署时,你需要先加载一段历史数据做预热,或者等窗口填满后再开始发信号。
排查方法:检查实盘启动时的历史数据长度,对比模型要求的窗口长度。如果不够,在部署脚本里加一段预热逻辑,从数据源拉取足够的历史数据。
4. 特征层排查:模型输入是否和训练时一致
4.1 特征列顺序:一个容易被忽略的细节
模型训练时,特征列的顺序是固定的。如果你用pandas DataFrame训练,列顺序是['f1', 'f2', 'f3']。实盘时如果DataFrame的列顺序变成['f3', 'f1', 'f2'],模型会把f3当成f1,预测结果完全错误。
这个问题在回测里不会出现,因为回测和训练用的是同一套代码。实盘部署时,如果特征计算和模型加载是分开的模块,列顺序很容易错位。
排查方法:在模型加载后,打印模型期望的特征列顺序(LightGBM可以用model.feature_name()),和实盘DataFrame的列顺序对比。如果不一致,用df[model.feature_name()]重新排列。
4.2 特征缩放:标准化参数是否保存
很多模型需要特征标准化,比如用StandardScaler把特征缩放到均值0方差1。训练时fit了scaler,实盘时需要transform。如果实盘时重新fit了scaler,或者用了不同的scaler,特征分布就变了,预测会偏移。
正确的做法:训练时保存scaler的参数(均值和方差),实盘时加载这些参数做transform。不要重新fit。
# 训练时 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) joblib.dump(scaler, 'scaler.pkl') # 实盘时 scaler = joblib.load('scaler.pkl') X_live_scaled = scaler.transform(X_live)排查方法:对比回测和实盘的特征统计量(均值、方差、分位数)。如果差异大,检查scaler的加载和使用。
4.3 特征计算的一致性:回测和实盘用同一套代码
这是最根本的解决方案:特征计算逻辑只写一次,回测和实盘共用。不要回测用一套pandas代码,实盘用另一套numpy代码。两套代码迟早会不一致。
我习惯把特征计算封装成一个函数,输入是原始数据DataFrame,输出是特征DataFrame。回测和实盘都调用这个函数。这样能保证一致性。
如果实盘环境不支持pandas(比如嵌入式设备),至少要把特征计算的逻辑写成可测试的单元,用同一组输入数据验证回测和实盘的输出是否一致。
4.4 特征分布漂移:实盘数据不是回测数据的复制
即使特征计算逻辑完全一致,实盘数据的分布也可能和回测数据不同。市场状态变了,波动率变了,特征分布自然漂移。如果模型对分布敏感,预测值会整体偏移。
比如回测时预测值在0.3到0.9之间,阈值设0.7能发出信号。实盘时预测值整体降到0.1到0.5,阈值0.7永远触发不了。
排查方法:画回测和实盘预测值的分布直方图,对比均值和分位数。如果偏移明显,考虑用滚动窗口重新校准阈值,或者用分位数阈值代替绝对值阈值。
5. 模型层排查:模型真的在预测吗
5.1 模型加载:文件是否完整、版本是否匹配
模型加载失败是最直接的原因,但有时候加载成功了,预测却不对。常见问题:
- 模型文件损坏:下载或传输过程中文件不完整
- 版本不匹配:训练用的LightGBM版本和实盘加载的版本不同,模型格式不兼容
- 依赖缺失:模型用了自定义损失函数或评估函数,实盘环境没导入
排查方法:加载模型后,用一组已知的测试数据做预测,对比回测时的预测结果。如果差异大,检查模型文件和版本。
5.2 输入维度:特征数量是否一致
模型训练时用了20个特征,实盘时只传了18个,或者多传了2个,预测会报错或输出异常。LightGBM在特征数量不匹配时会直接报错,但有些框架会静默处理,输出错误结果。
排查方法:打印模型期望的特征数量和实盘输入的特征数量,对比是否一致。
5.3 预测输出:概率还是标签
分类模型输出的是概率,回归模型输出的是数值。如果你把回归模型的输出当成概率用,阈值判断会出错。比如LightGBM回归模型输出的是收益率预测值,范围可能是-0.1到0.1,你设阈值0.7,永远触发不了。
排查方法:打印实盘预测值的统计量(min、max、mean、std),对比回测时的预测值分布。如果范围差异大,检查模型类型和输出解释。
5.4 模型繁忙:推理服务的并发问题
如果模型部署成推理服务(比如用Flask或FastAPI),高并发时可能出现“模型繁忙”的错误。请求被拒绝,信号自然没有。
排查方法:检查推理服务的日志,看是否有超时或拒绝的请求。如果有,增加服务实例或优化推理速度。
6. 信号层排查:预测有了,信号为什么没发
6.1 阈值设定:绝对值还是分位数
阈值设定是信号生成的关键。绝对值阈值(比如预测值>0.7)在分布漂移时会失效。分位数阈值(比如预测值在历史前10%)更稳健,但需要维护历史预测值的滚动窗口。
我一般用分位数阈值,滚动窗口取最近N个预测值,计算90分位数作为阈值。这样即使分布漂移,信号频率也能保持稳定。
排查方法:打印实盘预测值和阈值的对比,看有多少次预测值超过阈值。如果一次都没有,检查阈值设定。
6.2 信号过滤:风控和业务规则是否拦截
信号生成后,可能被风控规则拦截。比如:
- 单票仓位限制:已经持有该股票,不再发买入信号
- 行业集中度限制:该行业仓位已满,拦截信号
- 流动性过滤:成交量太低,不交易
- 涨跌停过滤:涨停不买,跌停不卖
这些规则在回测里可能没完全模拟,实盘时拦截了信号。
排查方法:在信号生成和下单之间加日志,记录每个信号是否被拦截、被哪条规则拦截。
6.3 信号混叠:多策略信号冲突
如果同时运行多个策略,信号可能冲突。比如策略A发买入,策略B发卖出,最终信号被抵消。或者多个策略的信号混在一起,无法区分。
排查方法:给每个策略的信号加标签,分别记录。如果信号冲突,检查策略间的优先级和仓位分配。
6.4 信号热力图:可视化排查
把信号按时间和股票画成热力图,能直观看出信号分布。回测的热力图和实盘的热力图对比,差异一目了然。如果实盘热力图大片空白,说明信号生成环节有问题;如果热力图有信号但下单没执行,说明执行环节有问题。
7. 实操排查清单与工具
7.1 排查清单:按顺序逐项检查
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 数据完整性 | 对比回测和实盘的数据行数、时间范围 | 实盘数据缺失、时间戳错位 |
| 特征一致性 | 对比回测和实盘的特征统计量 | 特征列顺序错、缩放参数不同 |
| 模型加载 | 用测试数据做预测,对比回测结果 | 模型文件损坏、版本不匹配 |
| 预测分布 | 打印预测值的min/max/mean/std | 预测值范围偏移、全NaN |
| 阈值触发 | 统计预测值超过阈值的次数 | 阈值过高、分布漂移 |
| 信号过滤 | 记录信号被拦截的规则 | 风控规则过严 |
| 执行链路 | 检查信号到下单的日志 | 信号丢失、下单失败 |
7.2 日志设计:让问题自己暴露
排查问题的关键是日志。我习惯在链路的每个环节加日志:
- 数据层:记录每根bar的时间戳、价格、成交量
- 特征层:记录每个特征的均值、方差、NaN比例
- 模型层:记录每次预测的输入维度、输出值
- 信号层:记录阈值、是否触发、是否被过滤
- 执行层:记录下单请求、成交回报
日志用结构化格式(JSON),方便后续分析。关键环节加告警,比如预测值全NaN时发邮件。
7.3 回放测试:用实盘数据重跑回测
最有效的排查方法:把实盘当时的数据快照存下来,用回测框架重跑一遍。如果回测能出信号,说明模型和特征没问题,问题在实盘的执行链路;如果回测也不出信号,说明数据或特征有问题。
这个方法的成本是存数据,但收益很大。我一般会在实盘环境里加一个数据快照模块,每天存一份原始数据和特征数据,方便事后回放。
7.4 灰度发布:先小仓位验证
新策略上线时,不要一上来就全仓位。先用小仓位跑一段时间,对比实盘信号和回测信号的频率、方向是否一致。如果一致,再逐步加仓;如果不一致,先排查再加仓。
灰度发布期间,重点监控信号数量、信号方向、持仓变化。如果实盘信号数量远低于回测,说明链路有问题。
8. 几个真实案例的排查过程
8.1 案例一:滑动窗口滤波模型的预热问题
有个朋友用滑动窗口滤波模型做日内策略,回测年化收益不错。实盘部署后,第一天完全没信号,第二天开始偶尔有信号,但频率远低于回测。
排查过程:检查实盘启动时的历史数据长度,发现只有20条,而模型要求的窗口长度是60。窗口没填满,特征全是NaN,模型不输出信号。第二天数据攒到40条,部分特征有值,偶尔能出信号。
解决方案:在部署脚本里加预热逻辑,启动时从数据源拉取最近60条历史数据,填满窗口后再开始发信号。改完后信号频率和回测一致。
8.2 案例二:LightGBM回归模型的特征列顺序错位
另一个案例是用LightGBM回归模型预测收益率。回测时特征列顺序是['ma5', 'ma20', 'rsi', 'volume'],实盘时特征计算模块输出的列顺序是['rsi', 'ma5', 'volume', 'ma20']。模型把rsi当成ma5,预测结果完全错误,信号方向反了。
排查过程:打印模型期望的特征列顺序和实盘输入的特征列顺序,发现不一致。用df[model.feature_name()]重新排列后,预测恢复正常。
解决方案:在模型加载后,强制按model.feature_name()排列特征列。这个操作加在特征计算和模型预测之间,保证输入顺序一致。
8.3 案例三:多股回测的信号混叠
有个多股策略,回测时每只股票独立发信号,实盘时信号混在一起,无法区分是哪只股票的。结果下单模块收到信号后,不知道该买哪只,干脆不执行。
排查过程:检查信号生成模块,发现信号没有带股票代码标签。回测时因为每只股票单独跑,没暴露这个问题。实盘时所有股票的信号汇总到一个队列,丢失了股票信息。
解决方案:信号结构里加股票代码字段,下单模块根据代码路由到对应的交易通道。改完后信号正常执行。
8.4 案例四:阈值设定导致的信号消失
有个策略用绝对值阈值0.7,回测时预测值在0.3到0.9之间,信号频率正常。实盘时市场波动率下降,预测值整体降到0.1到0.5,阈值0.7永远触发不了。
排查过程:画回测和实盘预测值的分布直方图,发现实盘分布整体左移。阈值0.7在回测里是70分位数,在实盘里是95分位数,信号频率自然低。
解决方案:改用分位数阈值,滚动窗口取最近100个预测值的70分位数作为阈值。改完后信号频率恢复。
9. 预防措施:让部署不再丢信号
9.1 统一特征计算代码
回测和实盘共用同一套特征计算代码,是避免链路差异的根本方法。把特征计算封装成独立模块,输入原始数据,输出特征数据。回测和实盘都调用这个模块。
如果实盘环境不支持pandas,至少要把特征计算的逻辑写成可测试的单元,用同一组输入数据验证回测和实盘的输出是否一致。
9.2 模型和特征的版本管理
模型文件和特征计算代码都要做版本管理。每次训练模型时,记录特征计算代码的版本号。实盘加载模型时,检查特征计算代码的版本是否匹配。不匹配就告警。
我一般用git管理代码,模型文件用日期命名,比如model_20240101.pkl。部署时检查模型日期和代码日期的对应关系。
9.3 部署前的冒烟测试
部署前跑一遍冒烟测试:用一组固定的测试数据,走完整个链路(数据加载、特征计算、模型预测、信号生成),对比输出是否和回测一致。不一致就不部署。
冒烟测试的数据要覆盖各种边界情况:数据缺失、窗口未满、预测值极端值。这样能提前发现大部分链路问题。
9.4 实盘监控和告警
实盘运行时,监控关键指标:信号数量、预测值分布、特征NaN比例、模型推理延迟。指标异常时告警。
我一般设这几个告警:
- 信号数量连续N天为0
- 预测值均值偏离回测均值超过2个标准差
- 特征NaN比例超过10%
- 模型推理延迟超过1秒
告警发到邮件或即时通讯工具,第一时间知道问题。
9.5 定期回放验证
每周或每月做一次回放验证:用实盘数据重跑回测,对比信号频率和方向。如果差异大,排查原因。这样能发现缓慢漂移的问题,比如特征分布逐渐偏移、阈值逐渐失效。
回放验证的数据要存够,至少覆盖一个完整的市场周期。我一般存最近一年的数据,每周回放一次。
10. 一些踩坑后的个人体会
排查“回测有信号、部署没信号”这个问题,最大的体会是:不要急着怀疑模型,先怀疑链路。模型是死的,链路是活的,活的地方容易出问题。
另一个体会是:日志要加够,但不要加太多。关键环节加日志,比如数据加载、特征计算、模型预测、信号生成。每个环节记录输入输出的统计量,不要记录每一条数据,否则日志太大没法看。
还有一点:回放测试是最有效的排查工具。把实盘数据存下来,用回测框架重跑,能快速定位问题在哪一层。这个习惯我坚持了好几年,帮我省了很多排查时间。
最后,灰度发布是必须的。新策略上线,先用小仓位跑一段时间,对比实盘和回测的信号频率。一致了再加仓,不一致先排查。这个流程能避免大部分生产事故。
如果非要给一个排查顺序,我的建议是:数据层→特征层→模型层→信号层→执行层。从上游到下游,逐段隔离。大部分问题在前两层就能找到。