一、回测系统为什么总会失真
很多团队都认同回测重要,但真正把回测系统做好并不容易。
原因并不复杂:
绝大多数回测系统从设计第一天开始,就和线上主流程分道扬镳了。
常见路径通常是这样的:
- 线上有一套完整流程
- 为了验证策略效果,再写一套离线脚本
- 离线脚本只复现大概步骤
- 后续线上变更继续迭代,离线脚本靠人工追赶
短期看这种方式很快,长期看问题非常严重:
- 线上与回测结果经常不一致
- 新增节点要维护两套接入
- 同一 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 判断
这样做的结果是,回测不再是旁路脚本系统,而成为引擎的正式运行模式之一。
它既能共享线上成熟能力,又能保持实验环境隔离。
因此,如果要用一句话概括回测系统设计的正确方向,最准确的表达应当是:
不要维护两套风控逻辑,而要让同一套风控引擎在不同运行时条件下工作;差异落在适配层,不可重放的节点在步骤定义层声明跳过,一致性留在主流程骨架。
只有做到这一点,回测结果才会真正可解释、可复现、可被信任。


[...]Temporal风控(一)Temporal 在风控系统中的最佳实践:Workflow、Activity、Query、Search Attributes 的落地经验Temporal风控(二)如何用 Registry + DAG 重构一个历史风控引擎Temporal风控(三)Redis Context 作为工作流中间态存储的优缺点分析Temporal风控(四)多国家共享内核的插件式架构设计Tempor[...]