如何让回测系统复用线上主流程而不是维护两套逻辑


一、回测系统为什么总会失真

很多团队都认同回测重要,但真正把回测系统做好并不容易。
原因并不复杂:
绝大多数回测系统从设计第一天开始,就和线上主流程分道扬镳了。

常见路径通常是这样的:

  • 线上有一套完整流程
  • 为了验证策略效果,再写一套离线脚本
  • 离线脚本只复现大概步骤
  • 后续线上变更继续迭代,离线脚本靠人工追赶

短期看这种方式很快,长期看问题非常严重:

  • 线上与回测结果经常不一致
  • 新增节点要维护两套接入
  • 同一 bug 需要修两次
  • 策略团队越来越不相信回测

因此,回测系统真正要解决的不是“能不能重跑历史请求”,而是:

如何让实验、验证与线上执行共享同一套控制骨架,从而让回测结果具有可解释性和可信度。

二、为什么“另起一套离线逻辑”是最危险的做法

另起一套逻辑的诱惑很大,因为它看起来简单直接。
但这种方式会在三个层面造成系统性损失。

1. 语义漂移

线上流程的阶段顺序、跳步条件、失败处理、上下文读写可能不断演进。
离线脚本如果没有复用主流程,迟早会在细节上偏离。

2. 维护成本翻倍

每新增一个特征、模型或规则,不仅要更新线上,还要同步修改回测逻辑。
时间一长,团队自然会优先保证线上,回测变成次优维护对象。

3. 实验结论不再可信

当回测系统和线上系统不是同一执行骨架时,实验结果只能说明“另一套逻辑在历史数据上跑出来了什么”,而不能说明“线上真实会发生什么”。

因此,最成熟的原则应当是:

回测不是另一套风控引擎,而是同一风控引擎在另一种运行时条件下的执行方式。

三、真正该复用的,不是某几个函数,而是主流程骨架

很多人谈复用时,只想到复用数据处理函数或规则函数。
这并不够。
真正决定回测与线上是否一致的,是主流程骨架是否一致。

主流程骨架通常包括:

  • 阶段顺序
  • 依赖关系
  • 跳步逻辑
  • 失败传播
  • 结果汇总
  • 进度语义
  • 收尾动作

如果这些都不一致,那么即使底层规则函数相同,整体行为也可能不同。
反过来,只要主流程骨架一致,很多运行时差异都可以通过适配层解决。


四、回测系统的正确边界:复用编排,替换运行时

要让回测复用线上主流程,关键在于界定什么必须共享,什么可以替换。

1. 必须共享的部分

通常包括:

  • 流程编排层
  • 步骤执行顺序
  • 节点依赖图
  • 状态查询语义
  • 结果组装协议

这些部分决定系统如何理解决策过程,不能轻易分叉。

2. 可以替换的部分

通常包括:

  • 输入来源
  • 外部依赖调用
  • 数据抓取方式
  • 回调与落库行为
  • 运行环境与资源配置

这些部分属于运行时适配层,更适合在回测环境中替换。

3. 核心原则

更准确的做法不是“让回测模仿线上”,而是:

让线上与回测共享同一条编排协议,再用不同的执行适配去面对不同运行环境。

五、为什么 Activity 适合作为回测适配层

如果系统采用工作流架构,那么回测与线上差异最适合落在 Activity 或执行适配层,而不是 Workflow 骨架。

原因很简单。

1. Workflow 负责控制语义

它描述的是:

  • 如何推进流程
  • 阶段之间怎么衔接
  • 进度如何暴露
  • 错误如何处理

这些在回测场景中通常不应改变。

2. Activity 负责与外部世界交互

回测与线上最大的差异,恰恰大多发生在外部世界:

  • 线上调用真实依赖
  • 回测读取历史快照
  • 线上执行真实回调
  • 回测可能跳过写回或改写目标

因此,把差异控制在 Activity 层,能够最大化复用主流程。

3. 回测适配层的实现思路

Activity 层的回测替换,核心思路是:用相同的 Activity 名称注册一套回测实现,在回测专用 Worker 中覆盖线上实现,使 Workflow 骨架对此无感知。

差异集中在数据阶段:线上从真实服务拉取用户当前数据,回测则从数据仓库读取历史快照,并以相同的键名写入上下文。
这样,后续的特征、模型、规则步骤所读取的上下文结构与线上完全一致——数据来源变了,读取协议没有变。

其他阶段按需简化:依赖历史快照已覆盖的阶段可以空转,规则执行阶段保持完整,结果回调和数据落仓则改为进程内返回或静默跳过。
每个阶段的简化程度取决于它是否有可用的历史替代数据,而不是统一处理。

4. 这样做的收益

  • 编排层不用维护两份
  • 进度查询天然共用
  • 回测结果更接近真实执行路径
  • 新增节点时只需要补对应适配

这是”复用骨架,替换运行时”的最自然实现方式。


六、不可重放步骤的处理:skip_if_backtesting

回测适配层能解决绝大多数问题,但有一类步骤是例外:

某些步骤依赖的是实时外部服务,历史上根本不存在可用的快照,回测时无法提供任何有意义的替代数据。

典型例子是 AI 人脸活体检测(liveness detection)——它需要一张当时拍摄的人脸图片,历史请求中不存在可重放的输入。
如果强行在回测中调用,要么报错,要么消耗真实费用,要么得到与当时完全不同的结果。

对于这类步骤,正确的处理方式不是在 mock Activity 里写特殊逻辑,而是:

在步骤定义上声明 skip_if_backtesting=True,让执行层在回测模式下自动跳过它。

1. 工作原理

回测请求会在 risk_input 中携带 _backtest: True 标志,这个标志由控制面在发起回测时注入。

check_skip Activity 中,执行层会同时检查请求是否携带回测标志、以及当前步骤是否声明了 skip_if_backtesting
若两个条件同时成立,步骤直接被跳过,不会尝试任何 mock 或真实调用。

2. 为什么要作为步骤级属性,而不是 mock Activity 内部的 if 判断

如果把”回测时跳过”写在 mock Activity 内部:

  • 执行层和观测体系看不到”跳过”发生了,只看到”Activity 被调用并空返”
  • 回测进度展示会显示该步骤”已执行完成”,而不是”被跳过”
  • 指标无法区分”跳过”与”正常执行”

skip_if_backtesting=True 配合执行层的统一 check_skip 机制:

  • 跳步在步骤评估阶段发生,比 Activity 调用更早
  • 状态被明确记录为 “skipped”
  • 观测体系、进度展示、指标都能正确反映这次路径

3. 哪类步骤适合声明 skip_if_backtesting

适合的步骤通常同时满足以下两点:

  • 依赖实时输入(照片、实时 API 返回值等),历史快照不存在或无意义
  • 即使跳过,回测的主要目标(验证规则逻辑和决策路径)仍然成立

不适合的步骤:

  • 数据、特征、模型步骤——这些有历史快照,mock Activity 能正常替代
  • 核心规则步骤——跳过会导致回测结论失去价值

4. 与普通 skip 函数的关系

skip_if_backtesting 是一个静态声明,不依赖上下文判断,只要是回测请求就触发。
skip 函数是动态谓词,每次执行时根据上下文实时评估。

两者都合法,不互斥:同一个步骤可以同时声明两者,执行层优先检查 skip_if_backtesting,再检查 skip 函数。


七、回测系统为什么必须共享同一套依赖图

回测一致性并不只取决于规则实现,还取决于依赖展开是否一致。
如果线上和回测依赖图不一致,即使看起来执行了同样的规则,最终中间结果也可能完全不同。

1. 依赖图决定了什么先发生

特征依赖哪些数据、模型依赖哪些特征、规则依赖哪些模型,这些关系一旦不同,最终结果就会漂移。

2. 依赖图决定了什么会被复用

有些节点可以跨步骤复用,有些需要强制重算。
如果回测另写一套逻辑,很容易在复用路径上出现偏差。

3. 依赖图决定了关键路径

回测系统不仅要看结果,还常常要观察性能与长尾。
共享同一依赖图,才能保证关键路径分析具有参考价值。

因此,回测若想真正可信,必须共享同一套节点注册与图解析体系。


八、Query 对回测系统的意义:不要再额外维护一套进度状态机

回测任务通常不是瞬时操作。
如果希望提供可交互体验,最自然的需求就是实时显示执行进度。

很多系统会做出这样的设计:

  • 回测服务自己维护一张状态表
  • 后台定时刷新进度
  • 前端轮询数据库

这套方案的问题在于,它又造了一套影子状态机。
如果主流程本身已经具备 Query 能力,那么回测系统完全没必要重新维护同类状态。

更成熟的方式是:

  • 启动主流程
  • 通过 Query 读取当前进度
  • 将进度实时推给前端

这样做的好处非常直接:

  • 状态源头唯一
  • 不需要额外对账
  • 进度字段天然和线上一致
  • 回测平台变得更轻

这也是为什么共享主流程骨架会显著提升回测系统质量。


九、回测环境的隔离原则

虽然回测应复用线上主流程,但这不意味着两者应完全混跑。
相反,越是复用同一骨架,越需要在运行环境上保持清晰隔离。

通常应隔离:

  • 任务队列或执行资源
  • 命名空间
  • 外部依赖凭证
  • 回调目标
  • 缓存保留策略

原因很简单。
回测往往具备批量性、实验性和高可变性,如果与线上完全共享资源,很容易产生竞争与污染。

因此,正确设计应是:

  • 共享编排协议
  • 隔离运行资源

这两点并不矛盾,反而应同时成立。


十、如何设计回测输入层:重放历史请求,而不是重写临时入参

回测系统如果想有说服力,输入层设计必须严肃。
它不应只是让用户手工拼一个大请求对象,而应尽量还原真实线上输入语义。

更成熟的做法通常包括:

  • 按业务标识拉取历史请求
  • 恢复原始入口元信息
  • 加载历史上下文或快照
  • 允许注入少量实验变量

这样做的价值在于:

  • 更接近真实线上环境
  • 更适合做差异对比
  • 更容易解释结果变化来源

若回测输入严重脱离真实请求,哪怕主流程复用了,也仍会得到低可信度结果。


十一、回测输出层应该服务什么目标

回测输出不是简单返回一个通过或拒绝结果。
一个成熟回测系统至少服务以下目标:

  • 看最终决策
  • 看中间阶段进度
  • 看关键特征与模型结果
  • 看规则命中路径
  • 看新旧策略差异

因此,输出层最好兼顾:

  • 最终结果摘要
  • 过程态可视化
  • 关键中间态对比
  • 异常与失败信息

如果只返回一个最终分数,回测对策略迭代的帮助会非常有限。


十二、为什么共享主流程会显著降低维护成本

当回测与线上共用主流程骨架后,系统演进会获得明显收益。

1. 新增节点只做一次接入

数据、特征、模型、规则节点只需要按标准方式接入一次,线上和回测都会受益。

2. 流程变更只改一处

阶段顺序、跳步逻辑、失败传播规则改变时,无需同步维护另一套回测编排。

3. 问题修复更集中

修复主流程缺陷时,不会再担心离线版逻辑是否漏改。

4. 排障链路更统一

线上和回测共享同一套状态语义、命名空间和执行协议,排查问题的思路基本一致。

这类收益在系统初期不一定明显,但在长期演进中极其重要。


十三、反模式:复用”结果格式”,却不复用”执行协议”

有些系统也会说自己复用了线上逻辑,但实际上只是:

  • 复用了一些规则函数
  • 复用了结果结构
  • 复用了部分工具方法

而真正的执行顺序、状态语义、失败处理、依赖展开仍然是另一套。
这种做法本质上还是双系统维护,只是看起来相似。

真正需要复用的是:

  • 编排协议
  • 依赖图
  • 上下文模型
  • 进度语义

而不是只复用一些表面代码片段。


十四、如何评估回测系统是否真的复用了线上主流程

可以用几个非常实际的问题判断。

1. 新增一个节点时,是否需要改两套编排

如果需要,说明还没有真正共享骨架。

2. 回测进度是否直接来自主流程状态

如果回测自己维护另一套状态机,说明还没有真正复用。

3. 依赖展开是否共用同一套注册与图解析

如果回测另有一份依赖逻辑,结果一致性迟早出问题。

4. 线上问题是否能在回测环境中用同一骨架复现

如果不能,说明回测只是另一个近似系统。

5. 流程变更时,回测是否需要大量人工追改

如果需要,说明复用还停留在表面层。

这些标准比“代码看起来像不像”更有说服力。


十五、总结:回测系统的最高级复用方式是什么

回测系统最高级的复用方式,不是把一些函数抄过去,也不是保留几个共同模块。
真正高级的复用,是:

  • 复用同一条主流程骨架
  • 复用同一套依赖图与上下文协议
  • 复用同一套进度查询语义
  • 把差异限制在运行时适配层(mock Activity 替换真实数据来源)
  • 把"不可重放步骤"通过 skip_if_backtesting 在步骤定义层声明,而不是散落在各处的 if 判断

这样做的结果是,回测不再是旁路脚本系统,而成为引擎的正式运行模式之一。
它既能共享线上成熟能力,又能保持实验环境隔离。

因此,如果要用一句话概括回测系统设计的正确方向,最准确的表达应当是:

不要维护两套风控逻辑,而要让同一套风控引擎在不同运行时条件下工作;差异落在适配层,不可重放的节点在步骤定义层声明跳过,一致性留在主流程骨架。

只有做到这一点,回测结果才会真正可解释、可复现、可被信任。

声明:Hello World|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - 如何让回测系统复用线上主流程而不是维护两套逻辑


我的朋友,理论是灰色的,而生活之树是常青的!