ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

回测有信号实盘却消失?从特征管道到黄金样本的排查实战

回测有信号实盘却消失?从特征管道到黄金样本的排查实战

回测里信号图刷得满满当当,一上线实盘信号却像人间蒸发。这种"回测有预测、部署没信号"的现状我见得太多了,每次都有朋友第一反应是重训、改网络结构、调阈值,其实方向从一开始就跑偏了。模型部署后没信号,问题大概率不在模型本身,而在训练/回测与部署之间那些你以为"理所当然一致"的细节:特征管道、时间对齐、状态持久化、甚至日志里不起眼的超时丢弃。这篇文章就是把我踩过、排查过、最后被一个黄金样本测试拯救了的完整思路整理出来,希望能让同样被这个问题卡住的人少走几小时弯路,把精力花在真正该动的代码上。

1. 先别动训练脚本:回测与部署的差距通常不在模型本身

1.1 回测跑出来的信号到底是什么

回测里的"信号"从来不是单纯模型输出一个分数,它是一整条流水线的最终产物。以我常用的lightgbm回归模型预测下一个周期价格区间为例,完整链条大概是:K线数据进入特征工程,经过滑动窗口均值、波动率、时序滤波,再标准化,然后喂给模型,模型输出预测值,最后经过阈值判断和后处理,才变成你看到的买卖点信号。

问题在于,回测平台(比如backtrader)和在线推理服务对这条流水线的处理方式有本质差别。回测时数据是一次性全部加载的,pandas里一个rolling窗口、一个ewm平滑,甚至一个fillna,都是对一个完整序列做向量化计算。你在回测脚本里写的是"从第0根K线跑到底",特征函数的输入是完整的过去和现在。而在线部署时,程序启动那一刻手里只有从历史数据库捞回来的一堆历史bar,之后每来一根新bar才追加一次,本质上是流式处理。这两种模式下,同样的函数名、同样的参数,结果往往对不上。

所以我一直跟团队说,部署后没信号的第一步排查思路,不是问"模型哪里坏了",而是问"哪一环的输入输出和回测时不一样了"。

1.2 为什么部署后的"没信号"往往不是模型退化

模型退化真实存在,但它的典型表现是"有信号但盈亏不稳定"或者"信号质量持续变差",罕见地会直接变成完全"哑火"。如果你在回测里还能看到预测值分布、信号点,一旦部署就完全没信号,绝大多数情况是信号压根没有被生成出来,而不是模型不想生成。

举个很实在的例子。回测里模型预测的是下一个周期的收益率,假设阈值设为0.005。训练集和回测期的预测值大致集中在-0.01到0.01之间,超过阈值就能产生信号。但部署后,如果特征管道里某个标准化参数取错了,模型输入值的分布整体偏移了0.5个标准差,预测值可能全部落在-0.003到0.004之间,于是连续几百根bar都没有一个超过0.005,信号自然全军覆没。

这种场景下你重训一百次也没用,因为问题出在"喂进去的东西不对"。模型还是那个模型,输入数据已经不是那个输入数据了。除非你的训练数据和线上数据分布差异大到离谱,否则部署后没信号,优先怀疑管道,而不是怀疑模型权重。

1.3 我的首轮排查顺序(一个固定清单)

被坑过几次之后,我现在遇到"回测有信号、部署没信号"会按下面这个顺序排查,一般半小时内能锁定八成问题:

  1. 检查线上日志里有没有"预测超时""模型繁忙""结果丢弃"之类的记录,先排除推理被系统主动扔掉的可能。
  2. 把线上单根bar的原始特征值打出来,和回测里同一时刻的特征值做逐列对比,看第一波数据是不是已经对不上了。
  3. 检查特征工程里所有用到全量序列的算子:rolling、ewm、shift(-1)、全局标准化、截面排名,一个个确认它们的窗口参数和处理顺序。
  4. 确认过滤/平滑模块在服务启动时有没有正确预热,状态是延续的还是从零开始的。
  5. 检查信号时间戳和K线时间戳的对应关系,重点看是不是偏移了一根bar。
  6. 用一份"黄金样本"做逐点回放比对,定位第一个出现差异的位置。

这套清单我后面会逐条展开讲,但其实每一条背后都对应着一个我曾经花过好几个小时才发现的坑。先把心态摆正:先别急着重训,先把"为什么部署端没有信号"从数据流上找出来。

2. 特征管线的"前后不一致":最常被忽略的隐形杀手

2.1 训练/回测时的批量处理,部署时的流式处理差异

特征管线前后不一致,是我遇到过的最常见原因,没有之一。回测时你大概率是这样写特征的:

df["ma_20"] = df["close"].rolling(20).mean() df["std_20"] = df["close"].rolling(20).std() df["momentum"] = df["close"] / df["close"].shift(5) - 1

这几行代码在回测里跑得飞起,结果也完全符合预期。但注意,这里的rolling(20)在回测时已经对每根bar用了它之前19根加当前这根的数据来算均值,这是正确的。可部署时如果你把同样的代码扔进在线服务,第一根bar进来时rolling(20)窗口里只有1个值,均值就是这根bar的价格本身,和回测里那一根bar的窗口均值差了十万八千里。

你以为写的是同一个特征,实际上部署端在"窗口还没填满"的状态下输出了另一套特征。模型接收到这种特征,预测值自然不正常。最坑的是,很多模型对噪声敏感,窗口没填满时特征极端值会把softmax或回归输出压到一侧,于是预测值长期在一个无信号区间徘徊。

所以在部署代码里,特征计算必须严格按"增量更新 + 初始预热"的方式重写:服务启动时先从数据库读取至少max(所有窗口长度) + 1根历史K线完成预热,之后每根新bar到来时只更新增量部分。不要直接复用回测脚本里那套向量化写法。

2.2 滑动窗口滤波模型的状态在部署端没被初始化

这个坑具体到我最近一次排查的项目,就是热词里那个"滑动窗口滤波模型"。我在做价格序列滤波时用了某种指数加权滑动平均类的滤波器,回测里信号干净利落,部署后愣是一根毛刺都没有。

问题出在滤波器的初始化状态上。回测时数据是从2020年一路跑到2023年,滤波器状态在2020年就从零开始慢慢收敛,到了2023年早已经是"见过几千根bar"的状态,输出自然平滑。但部署服务启动时,如果滤波模块的内部状态被重置为初始值,那么它对最近bar的输出完全依赖开头那几根bar的数值,形状和回测时完全不同。

这就像一辆车在高速上跑到了120码,你说的是路灯下的实时速度;部署端相当于每次点火都从零起步,还没提到120码信号就已经该被触发又没被触发完了。错误的表现不是滤波器失效,而是输出序列被整体"压缩"或"平移"到了模型误判的区间。

正确处理方式是:部署服务的滤波器状态应该从回测期最后一段数据的状态恢复,或者在服务启动时用历史bar做一次完整的"预热前向传播",让滤波状态先收敛到和回测尾部一致的水平,再开始处理新bar。如果你用的是带状态的前向传播滤波器(比如指数加权、卡尔曼滤波、自定义状态机),务必把"状态持久化"纳入部署设计,不要默认从零开始。

2.3 标准化与归一化的参数泄漏

另一个高频坑是标准化参数用错了。我做lightgbm回归模型时,回测脚本里喜欢直接对整个训练集做:

scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

这在训练、验证、回测上是正确的做法。但部署时如果我直接把这段代码复制到线上,transform的参数却不知道从哪来——有的朋友图省事,直接在线上调用时fit了整个实时数据流,于是标准化统计量每来一根bar就变一次,特征被动态归一化到完全不可控的范围内,模型预测自然忽上忽下,最终信号被阈值判定过滤得一干二净。

回测和训练阶段你还可以用"全样本统计量"来算,但部署阶段只能使用训练阶段固定下来的mean和std,而且要把它序列化到模型文件旁边或者数据库里,部署服务启动时加载固定参数。我见过很多项目的标准化参数是写死的常量数组,一旦训练集更新、参数更新了但部署端没同步,隐藏偏差就滋生了。

顺带提一个和"参数泄漏"直接相关但经常被忽略的点:如果你在回测里用了全样本均值做标准化,那本质上你已经用了未来数据。回测时这样处理会让模型"看到"全序列的分布,部署时却没有未来数据可用,预测值会系统性偏低或偏高,信号自然跑不出来。所以标准化严格来说只能用滚动统计量或仅用训练集统计量,一旦用了全样本,说明回测本身就存在数据泄漏,部署后没信号反而是"虚拟信号消失"的表现,重训解决不了这种问题。

3. 时间对齐问题:回测能收到信号,实盘信号发给了错误的瞬间

3.1 收盘价与下一根K线:T+1逻辑到底有没有落地

特征和标准化对上了还不够,时间对齐是我见过第二频发的信号消失原因。它有时候不直接影响预测值,但会直接影响信号有没有被放在正确的位置上。

回测里最常规的写法是:用当前bar的收盘价计算特征,预测下一根bar的收益,信号在下一条bar开盘执行。这个逻辑在backtrader里看起来天经地义,因为数据永远是"已经客死的bar"。但部署时如果信号生成逻辑偷懒复用了"实时收盘价"来算特征,那就出事了。你在盘中实时行情下调用模型,此时这根bar还没收盘,特征值还在随价格跳动,模型预测的是一个"半成品bar"的未来收益,和回测时"完整bar"的预测完全不同。它不只是噪声大,而是系统性的偏差,预测值往往会被压扁到阈值以下。

时间线对照大概是这样:

时点回测逻辑部署常见错误逻辑
信号生成当前bar收盘后,用收盘价算特征盘中实时用不完整bar算特征
预测目标下一根bar的开盘/收盘同一根bar的后续价格变化
执行下一根bar开盘按信号交易当前bar盘中实时交易

所以部署时一定要明确:系统应该使用已收盘的K线来计算特征和信号,而不是实时跳动的最新价。你可以在代码里维护一个"当日bar状态",只有bar真正闭合后才进入特征计算流程。用未闭合bar跑模型,回测里从没发生过这种事,你自然看不到信号。

3.2 多股回测的截面排名与部署时的逐股信号

回测阶段推"多股回测"是另一种时序陷阱。backtrader支持多股票同时回测,这时如果你用了截面因子(排序、百分位排名、相对强弱),回测里这些因子是同时用全部股票在同一时刻的数据计算出来的。也就是说,第t天股票的排名,用的是所有股票第t天的横截面信息,这在回测平台上很容易实现。

但部署时数据是逐股票、逐bar到达的,同一时刻你可能已经收到了股票A的最新bar,股票B还停在上一根bar。如果你按"当前能拿到的所有数据"实时做截面排名,那排名对象和回测里那一层排名根本不是同一批数据。最敏感的情况是:某只股票在回测里排名靠前,模型因此触发信号;部署端实时排名时它因为"部分股票数据没更新"被排到很后面,信号直接出不来。

处理方式有两种。一是把信号逻辑改成非截面依赖的形式,只用个股自身时序特征;二是必须用截面的话,在部署端对"每个交易时刻采样的股票池快照"做严格对齐——等到该时刻所有股票都确认了收盘价,再计算排名,宁可信号迟一根bar也不要在数据不齐的情况下算截面。

3.3 时间戳偏移一根K线而已,信号却能完全消失

还有一种很隐蔽的情况:信号其实生成了,但生成的时间戳比回测里晚了一根bar,导致你看到的"信号点"和K线图对不上,直观感觉是"没信号"。

我排查过一个lstm预测项目,回测里第200根bar出信号,部署端第201根bar才输出预测。原因是特征代码里用了shift(1)把特征滞后了一根,回测时这个滞后正好让模型看到了完整的历史。部署时如果数据源多了一个"确认bar闭合"的延迟,那么特征实际计算时多滞后了一根bar,模型对整个序列的"相位"产生了偏移。

模型如果依赖特定形态(比如检测"突破""拐点"),相位偏移就足以让预测值从正值翻成负值,或者从超过阈值变成低于阈值。排查方法很简单:把部署端生成的信号时间戳打印出来,和回测里信号的时间戳差分一下,如果系统性偏移了一根或以bar为周期,那基本就是时间戳对齐的锅,跟模型无关。

4. 部署环境的隐形差异:同样的权重,不同的推理结果

4.1 模型文件到底加载了没有:版本与序列化格式的坑

有一个最蠢但最常发生的问题:线上服务跑的模型文件,根本不是你回测时用的那个版本。如果你在本地jupyter里训练好模型,保存成pickle或joblib,然后上传到部署环境,而部署环境的Python包版本和你本地不一样,比如lightgbm从3.3升到了4.1,反序列化时某些字段的默认值可能变了。更常见的是你本地保存的是"最新调参后的模型",上传到线上时不小心传成了"三周前那个旧模型",权重完全不同,预测输出自然天差地别。

我的习惯是部署前做一个强制动作:记录模型文件的MD5哈希,把它写进部署配置和日志。上线后服务每次启动第一件事就是计算加载到的模型文件哈希,并在日志中输出;回测脚本结束时也输出训练模型的哈希。两边哈希对不上,说明加载了错误的文件,不需要继续排查别的。

另外要注意,有些模型序列化格式在跨平台(Windows训练/Linux部署)时会有数值精度的细微差异。虽然lightgbm这类树模型影响很小,但如果你用了自定义的numpy算子或深度学习模型,CPU指令集差异可能导致推理结果有微小浮点误差。这些误差在大多数情况下不致命,但如果你的信号阈值卡得很死,比如预测值为0.005001,部署端算成0.004999,信号就这么没了。所以不要把你的决策阈值设置得太贴近小数点后第三位的边缘。

4.2 批处理与单样本推理的数值差异

回测平台为了效率,普遍采用向量化/批处理的方式调用模型。pandas或numpy对整列数据做运算时,遇到NaN、无穷值、除零等情况的处理和你用循环逐行推理时是不一样的。举个具体例子,回测时特征列里如果有个别NaN,pandas的fillna会把这个NaN处理掉;但在线单点推理时,如果某根bar的某个特征计算得到NaN,常见框架的默认策略可能是直接返回空预测,或者把整条样本视为无效丢弃,于是这根bar就"没信号"了。

我排查过的一个case里,回测阶段特征计算产生了无数个NaN,但因为后面接了一个bfill,所以模型输入看起来"干净",实际是靠未来数据补的洞。部署时特征产生NaN后没有做同样的处理,或者在线代码直接跳过了NaN样本,于是一到数据抖动大的波动率极高时段(恰恰是最该出信号的时候),信号反而消失了。

所以部署代码里对NaN/无穷值的处理逻辑必须和回测时完全一致,最好把"发现NaN即丢弃样本"这一步的前后行为都打点记录到日志。宁可让信号晚一根bar、通过填充策略补齐,也不要无提示地静默丢掉。

4.3 "模型繁忙"与超时对信号的隐性影响

部署环境不是实验室,你的模型推理是有耗时的。回测时你调用模型,算多久都行,反正不着急。但线上服务通常有严格的响应时间预算,尤其是做实时交易信号时,每根bar能留给推理的时间窗口可能在几十毫秒到几百毫秒之间。

很多团队上线时会配一个"推理超时保护":模型推理时间超过阈值,返回空结果或者丢弃该信号。当服务负载高、或者模型推理代码因为某个数据分支变慢时,超时触发,信号被系统静默丢弃。日志里如果不记录"因为超时丢弃了多少次预测",你根本不知道信号不是没算出来,而是算出来后被扔掉了。

排查这个问题的唯一办法就是在日志里给每次推理都加耗时记录,并统计"推理完成率"。我建议至少记录三个指标:推理次数、平均耗时、超时丢弃次数。回测里没有超时这个概念,所以部署端出现大量超时丢弃时,回测自然不会呈现这种"信号消失"现象。别小看这个原因,有时候你追了一整天特征和时序问题,最后发现只是线上用了一个垃圾收集器停顿时间过长导致连续几十根bar全部超时。

5. 如何用一次回归测试把信号找回来(并防止复发)

5.1 造黄金样本:把回测的最后一段时间变成"可回放的实时流"

排查到这一步,如果问题还没定位到,就轮到"黄金样本"出手了。这个方法是我从软件工程的回归测试里借鉴来的,直接搬到了信号系统里。思路极简单:取回测最后200根K线(或者你觉得有代表性的那段区间),把这200根bar的原始数据全部存下来,作为"黄金样本库"。然后在部署环境用在线推理服务逐根bar回放这200根bar,记录每一步的输出,和回测脚本里同一步的输出做对比。

为什么是回测最后一段?因为这段数据既是模型从未直接见过的未来数据(相对于训练集),又和当前市场状态最接近,最能暴露"回测和部署在近期数据上是否一致"。

具体做法我一般分三步:

  1. 回测脚本里对最后200根bar,把每根bar的原始特征、标准化后特征、滤波后特征、模型输出、最终信号,全部dump成一个json或parquet存档。
  2. 部署服务里写一个"回放模式",输入相同的200根bar,逐根执行在线特征计算和模型推理,同样dump一份输出。
  3. 逐层做diff。第一层diff原始特征,第二层diff标准化特征,第三层diff模型预测值,第四层diff最终信号。哪一层开始不一致,问题基本就在哪一层之间。

5.2 分层比对的实操步骤

我用一个表格展示分层比对的心法,越往上层,越容易看出问题是否由下层导致的:

层级回测输出部署输出允许误差常发现的差异
原始特征ma_20、std_20等同左1e-8窗口未预热、rolling算法不同
标准化特征z-score后的值同左1e-6标准参数未加载
滤波后特征平滑后的数值同左1e-4滤波状态未初始化
模型预测回归分数同左1e-4模型版本不一致
最终信号买/卖/无同左完全一致时间戳偏移、阈值边界

我强烈建议不要只比对最终信号,而是每一层都落库。因为如果只比对最终信号,两边都是"无信号",你根本看不出来到底是哪一步把信号弄丢了。分层比对能在一条线上精确定位,省去大量猜测。

我记得有一次黄金样本测试结果显示:原始特征、标准化、滤波全部通过,模型预测层开始有1e-3级别的差异,最终信号全灭。后来一查,是线上服务用ONNX导出模型时把一个轻量分支剪掉了,而那个分支恰好负责处理价格剧烈波动时的极端值。你看,不比对到模型预测层,根本不可能想到是这个原因。

5.3 部署线的防御性设计:信号丢失要能"喊出声"

定位问题并修复之后,更要紧的是防止它再次发生。我的经验是:部署线要设计成"信号丢失会喊出声"的系统,而不是静默吞掉异常。

具体来说,我会在部署端给信号做三道防御:

  1. 信号降级告警:如果连续N根bar都没有产生任何信号,就自动输出一条告警并通知到人。N根据你的交易频率设定,比如日频策略N=5,分钟频策略N=30。没有这个告警,你可能一周后才发现系统已经哑了。
  2. 输入质量监控:对每根bar的特征统计量(均值、方差、缺失率)做实时监控,和训练期的统计量做一个滚动对比。特征分布突然漂移,往往比模型漂移早几个交易日发生。
  3. 黄金样本回归自动化:每次更新代码或模型文件后,自动跑一遍黄金样本回放,比对分层diff结果。不是人工跑,而是做成CI流程的一环,输出一份"回放比对报告",允许误差范围内的才通过。

我承认这套东西刚搭起来确实有点费功夫,但对比"线上信号静默消失一周才发现"的损失,这些成本实在微小。尤其当你同时维护多个策略模型的时候,自动化的黄金样本回归是最后一道兜底网,至少能让你在别人说"你那个模型废了"之前,先检查一遍到底是不是模型的问题。

写在最后

现在我的排查习惯已经固化成了一套肌肉记忆:部署后没信号,先花半小时把数据流、状态、时间戳、模型版本挨个过一遍,再用黄金样本做一次分层比对。九成问题都出在回测到部署的管道细节,而不是模型本身。慢着,如果你看到日志里出现"特征缺失率突然上升"或者"滤波状态被重置"这类信息,几乎可以闭着眼睛定位了。重训模型往往是最后才考虑的手段,因为模型可能没病,你只是没给它喂对数据。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进