YAOTU INSIGHTS

量化策略部署后信号丢失?从数据到执行的全链路排查指南

量化策略部署后信号丢失?从数据到执行的全链路排查指南
1. 这个问题的本质不是模型坏了是链路断了做过量化实盘的人大概率都遇到过这个场景回测里模型每天都能稳定输出预测信号收益率曲线画出来漂漂亮亮夏普比率看着也像那么回事。结果一部署到实盘环境信号栏空空如也或者偶尔蹦出来一两个信号跟回测里的频率完全对不上。第一反应通常是“模型过拟合了”“数据泄露了”“得重训”然后花几天时间重新跑训练、调参、换特征折腾一圈回来发现问题依旧。我踩过这个坑不止一次后来复盘发现绝大多数“回测有信号、部署没信号”的情况根因根本不在模型本身而在从回测到部署这条链路上。模型只是一个函数输入数据、输出预测它不会因为换了运行环境就突然变傻。真正变的是喂给它的数据、调用它的方式、以及信号从预测到下单之间经过的那些中间环节。这篇文章就是把这个链路拆开一层一层排查。适合正在做量化策略部署、遇到信号丢失问题的朋友也适合刚接触回测框架、想提前避坑的新手。我会用backtrader多股回测、滑动窗口滤波模型、LightGBM回归模型这些常见组合作为例子来讲但排查思路是通用的换成任何框架、任何模型都适用。核心观点先摆出来部署后没信号先做链路排查最后才考虑重训。重训是成本最高的操作把它放在排查清单的最后一位。2. 回测与部署的链路差异拆解2.1 回测环境和实盘环境的五个关键差异很多人把回测和实盘想象成“同一套代码换个数据源”实际上这两个环境的差异比想象中大得多。我把常见的差异归纳成五个维度每一个都可能导致信号丢失。数据时序差异。回测里你拿到的是完整的历史数据可以随意用未来数据做特征虽然这是数据泄露但很多人无意中就这么干了。实盘里数据是逐条到达的当前时刻你只能看到过去的数据。如果特征计算里用了shift(-1)或者滚动窗口的centerTrue回测时信号正常实盘时因为拿不到未来数据特征全是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]是当前barself.data.close[-1]是上一根bar。如果你在next()里用了self.data.close[1]那是未来数据回测能跑但实盘会报错或返回NaN。3.2 频率重采样bar的边界在哪里多股回测时不同股票的bar时间戳可能不一致。backtrader的resample功能可以把分钟线合成日线但barbound参数决定了bar的边界。默认是barbound0表示从第一个tick开始算。如果你实盘用的数据源时间戳有偏移重采样后的bar可能和回测对不上。举个例子回测里日线bar的时间戳是每天15:00实盘数据源的时间戳是15:01重采样后bar的时间戳变成15:01模型的特征计算里如果有基于时间戳的逻辑比如“当天最后一根bar”就会错位。排查方法打印回测和实盘重采样后的bar时间戳对比前10根和后10根。如果时间戳不一致调整barbound或者统一数据源的时间戳格式。3.3 缺失值实盘数据的常态回测数据通常是清洗过的缺失值很少。实盘数据里停牌、涨跌停、数据源延迟都会导致缺失。如果模型对缺失敏感信号就会丢失。LightGBM虽然原生支持NaN但有个坑如果某个特征在训练时从未出现过NaN模型可能没有学习到处理NaN的分裂方向。实盘时这个特征突然出现NaN预测会走默认分支可能偏离预期。处理缺失值的常见做法前向填充df.fillna(methodffill)适合价格类特征均值填充适合波动率类特征但会引入偏差标记缺失加一列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. 一些踩坑后的个人体会排查“回测有信号、部署没信号”这个问题最大的体会是不要急着怀疑模型先怀疑链路。模型是死的链路是活的活的地方容易出问题。另一个体会是日志要加够但不要加太多。关键环节加日志比如数据加载、特征计算、模型预测、信号生成。每个环节记录输入输出的统计量不要记录每一条数据否则日志太大没法看。还有一点回放测试是最有效的排查工具。把实盘数据存下来用回测框架重跑能快速定位问题在哪一层。这个习惯我坚持了好几年帮我省了很多排查时间。最后灰度发布是必须的。新策略上线先用小仓位跑一段时间对比实盘和回测的信号频率。一致了再加仓不一致先排查。这个流程能避免大部分生产事故。如果非要给一个排查顺序我的建议是数据层→特征层→模型层→信号层→执行层。从上游到下游逐段隔离。大部分问题在前两层就能找到。