Vibe-Trading 开源仓库的回测层:多引擎共用一个 runner
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
Vibe-Trading 是 HKUDS 在 GitHub 上开源的量化研究工具仓库(不是「凭感觉做交易」那个泛指说法),它的回测层里最值得看的一处工程决策,是把「市场规则」和「执行流程」彻底拆开了:所有市场共用同一个逐 bar 执行循环,各市场之间的差异全部压缩成四个抽象方法。 这意味着接一个新市场不需要复制一遍回测逻辑,只需要回答四个问题——这笔单子允不允许成交、下多少手要怎么取整、费用怎么算、滑点怎么打。
先说清楚这篇的范围。本文只讨论这个开源仓库的代码是怎么组织的、怎么调用的,不涉及任何策略优劣、任何收益表现、任何投资判断。历史表现不代表未来,本文只讨论工程实现。
站内已有三篇相邻的文章:Agent 回归测试怎么做 讲的是给 Agent 本身建回归集,Agent 评测方法 讲怎么给 Agent 的输出打分,让 AI 写数据分析脚本 讲的是一次性分析脚本的写法。这篇不重复它们,只做一件事:把 Vibe-Trading 这个具体仓库的回测层结构拆开给你看,当作一份「多规则共存的执行引擎」的现成参考实现。
一、这一层到底在解决什么麻烦
回测代码写着写着变成一坨的原因,通常不是算法难,而是规则多。A 股要处理 T+1、涨跌停、100 股一手、印花税只在卖出方收;加密永续要处理资金费率和强平;期货要乘合约乘数;外汇要按点差算成本。这些规则如果直接写进主循环,主循环里就会堆满 if market == ...,而且每加一个市场,所有既有市场的行为都有被改坏的风险。
Vibe-Trading 的处理方式是:主循环只写一份,放在 agent/backtest/engines/base.py 的 BaseEngine.run_backtest() 和 _execute_bars() 里;规则差异全部下沉到子类。BaseEngine 用 @abstractmethod 强制子类实现四个方法:
can_execute(symbol, direction, bar):市场规则允不允许这笔交易;round_size(raw_size, price):按最小交易单位取整;calc_commission(size, price, direction, is_open):费用结构;apply_slippage(price, direction):滑点模型。
另有几个非强制的钩子可以按需覆写:on_bar() 做每根 bar 的市场规则处理,before_rebalance_bar() / after_rebalance_bar() 分别插在下单前后并可以返回 True 提前终止,execution_open() / valuation_open() 分别给出成交价和估值价,_calc_pnl() / _calc_margin() / _calc_raw_size() 留给需要合约乘数的品种。
拿 agent/backtest/engines/china_a.py 的 ChinaAEngine 看这套抽象怎么落地:can_execute() 里三段判断依次是「方向为 -1 直接拒绝(不做卖空)」「方向为 0 时比对 pos.entry_time 的日期,同日不许卖出(T+1)」「用 _blocked_by_limit() 检查价格是否撞到涨跌停带」;round_size() 就是 max(int(raw_size / 100) * 100, 0);calc_commission() 里佣金取 max(名义金额 * commission_rate, commission_min),再叠加过户费,卖出方向额外叠加印花税;apply_slippage() 是 price * (1 + direction * self.slippage_rate)。整个文件不到 200 行,因为它不需要管资金曲线、不需要管产物落盘。
二、runner.py 是一个路由器,不是一个执行器
agent/backtest/runner.py 的 main(run_dir) 是命令行入口,用法在模块 docstring 里写得很直白:python -m backtest.runner <run_dir>。它做的事按顺序是这么几件。
第一件是把 run_dir 关进白名单。 它从 src.tools.path_utils 引入 safe_run_dir(),先把传进来的路径过一遍允许的运行根目录(可以通过环境变量 VIBE_TRADING_ALLOWED_RUN_ROOTS 追加)。代码注释里写了动机:没有这道关,python -m backtest.runner /tmp/attacker_path 就能从磁盘任意位置加载 signal_engine.py。
第二件是校验配置。 BacktestConfigSchema 是一个 pydantic 模型,字段包括 codes、start_date、end_date、source、interval、engine、initial_cash、fundamental_fields、event_feeds。校验规则值得抄的地方在于它们都是「把非法值挡在边界」的类型:interval 只接受 1m/5m/15m/30m/1H/4H/1D;engine 只接受 daily/options;source 必须落在 backtest.loaders.registry 的 VALID_SOURCES 里(这个集合的注释明说是配置模型和 Agent 工具的唯一真源,避免两边漂移);initial_cash 声明成 Field(gt=0, allow_inf_nan=False),注释解释了原因——结果要除以它,非正值会让整轮跑出非有限的数。这个思路和 Agent 参数校验 里说的一致:模型能生成的字段,就是你要校验的字段。
第三件是扫描策略源码。 策略以 <run_dir>/code/signal_engine.py 的形式落盘,由 _load_module_from_file() 用 importlib 动态加载。加载前先跑 _validate_signal_engine_source():用 ast.parse() 解析,先拒绝导入期就会执行的东西(顶层语句、装饰器、非字面量默认值、可执行的类体语句,以及 from signal_engine import ... 这种自引用循环导入),再跑 _scan_runtime_reachable() 走一遍真正会执行的代码——从 SignalEngine 类的每个方法出发,顺着裸函数调用把模块级辅助函数也拉进工作队列,在这条可达路径上拒绝网络库导入、os 的 shell 类属性、eval/exec 这类内建、以及写模式的 open()。源码里明确写了这是纵深防御而不是内核级隔离,也解释了为什么只扫可达路径:仓库自带的技能示例里有独立的抓数辅助函数和 __main__ 演示块带着网络库,整文件封杀会误伤。
第四件才是路由。 _create_market_engine() 的分支顺序是有讲究的:先按符号形态判市场,多市场直接给 CompositeEngine;然后是期货(再按交易所后缀分中国与全球)、外汇;接着单独把印度和韩国拎出来,注释解释了原因——它们的有效数据源在早期分支里没有对应项,不提前拦会掉进默认分支;最后才是按数据源分的那批。engine 配成 options 时走的是另一条路,调用 backtest.engines.options_portfolio 里的 run_options_backtest() 函数,而不是引擎类。
三、各层的分工与仓库位置
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 入口与路由 | 校验 run_dir、校验配置、扫描并加载策略源码、挑引擎 | agent/backtest/runner.py | 每次跑回测都经过 |
| 共享执行循环 | 信号对齐、逐 bar 下单、产物落盘、run card | agent/backtest/engines/base.py | 想改执行语义时 |
| 市场规则子类 | 覆写四个抽象方法表达该市场的规则 | agent/backtest/engines/china_a.py 等同目录文件 | 接一个新市场时 |
| 数据模型 | Position / TradeRecord / EquitySnapshot 三个 frozen dataclass | agent/backtest/models.py | 自己写引擎或读产物时 |
| 权重约束 | max_weight / min_weight / group_exposure | agent/backtest/constraints.py | 想限制单名或分组占比时 |
| 组合优化器 | 把信号矩阵换算成组合权重 | agent/backtest/optimizers/ | 配置里填了 optimizer 时 |
| 指标计算 | 由净值序列与成交记录算出结果字段 | agent/backtest/metrics.py | 读 metrics.csv 时 |
| 稳健性检验 | monte_carlo / bootstrap / walk_forward | agent/backtest/validation.py | 配置里填了 validation 时 |
| 数据加载 | 按 source 或 auto 取数,带回退链 | agent/backtest/loaders/ | 换数据源、排查缺数据时 |
| Agent 侧工具 | 起子进程跑 runner、收产物、回传 JSON | agent/src/tools/backtest_tool.py | Agent 自己调 backtest 工具时 |
agent/backtest/engines/ 目录下除公共文件外,落地的引擎类包括 ChinaAEngine、GlobalEquityEngine、CryptoEngine、ForexEngine、ChinaFuturesEngine、GlobalFuturesEngine、IndiaEquityEngine、KoreaEquityEngine、CompositeEngine,加上抽象基类 BaseEngine 和给期货共用的 FuturesBaseEngine。这些都是在同一个 engines/ 目录下数得出来的。
四、约束、指标、验证为什么要各自成模块
这三块被切开的理由不一样,值得分开说。
约束层是「只在优化器输出上生效」的一层。 constraints.py 定义了 MaxWeight、MinWeight、GroupExposure 三个类,load_constraints(config) 从配置里的 constraints 列表构造,apply_constraints_frame(frame, constraints) 逐行作用在权重矩阵上——先取符号和绝对值,按配置顺序依次施加,再把符号乘回去,所以多空账本也能用。模块 docstring 里写清了它的定位:这一层重新分配已经活跃的名字,不新开也不平掉任何仓位。更关键的是 base.py 的 _load_optimizer() 里那句提示:没配 optimizer 却配了 constraints,会打印一条 [WARN] 说明约束只作用在优化器输出上,然后忽略。这种「配错了要出声」的设计,比默默不生效强太多。
指标层是纯函数。 metrics.py 暴露的是 calc_metrics、bar_returns、by_symbol_stats、by_exit_reason_stats、calc_trade_turnover_series、calc_bars_per_year 这类接口,输入是净值序列和成交记录,不碰引擎状态。年化因子由 calc_bars_per_year(interval, source) 算出来;跨市场的情况在 runner.py 里被特判成 bars_per_year=None,注释说明这时改用自然日口径。
验证层是可选的、按配置开关的。 run_validation() 只看 config["validation"] 里有哪些键:有 monte_carlo 就跑 monte_carlo_test(),有 bootstrap 就跑 bootstrap_sharpe_ci(),有 walk_forward 就跑 walk_forward_analysis(),每项各自带默认参数(比如次数、置信度、seed、窗口数)。结果通过 write_validation_json() 写到 artifacts/validation.json,这个写入函数会把非有限数值序列化成 null 而不是裸的 NaN——base.py 的注释专门解释了这一点,因为裸 NaN 不是合法 JSON。同一个模块还带一条独立 CLI 路径,可以对已经跑完的 run 目录单独补跑。
产物这边的约定也很固定:_write_artifacts() 往 <run_dir>/artifacts/ 写每个标的的 ohlcv_<code>.csv、equity.csv、positions.csv、trades.csv、metrics.csv;rebalance_notes.json / .md 和 risk_xray.json / .md 由对应模块单独写;最后 run_card.py 的 write_run_card() 落一份运行卡,数据来源取自 _run_card_data_sources()。数据来源这块有个细节挺见功力:fetch_data_map() 里如果请求的 source 不可用、实际由回退链上的另一个 loader 供数,记录的是真正供数的那个名字,注释直言「声称用了 A 而实际是 B 供数的运行卡是在撒谎」。
五、边界与代价:它明确不管的事
这套设计不是免费的,几处取舍写在代码和注释里,你接手前应该知道。
执行语义被钉死成「次日开盘」。 _align() 里信号统一 shift(1),成交价取当前 bar 的 open(没有 open 就退到 close)再打滑点。想做盘中触发、想按收盘价成交、想拆单成交,都不是改配置能解决的,要动共享循环本身。
权重会被强制归一。 _align() 末尾有 scale = pos.abs().sum(axis=1).clip(lower=1.0) 再整体相除,也就是绝对权重之和不会超过 1。杠杆走的是另一条路(_leverage_for_symbol() 和 target_notional 的乘法),而不是靠权重放大。
停牌与缺数据靠有限次前向填充兜底。 ffill_limit 单市场是 5、跨市场是 10,注释说明跨市场取更大值是因为春节这类长假;超出限度的空档不会被填。全程为 NaN 的标的会被直接剔除并打日志,一个都不剩时抛 ValueError。这套规则对长期停牌是有偏差的,它不假装能处理。
跨市场模式下拿不到历史基准价。 historical_base_price() 的 docstring 写明:CompositeEngine 的子引擎是无状态的规则书,没有自己的价格面板,所以跨市场运行时涨跌停带可能算不出来——这时函数返回 None,调用方 _blocked_by_limit() 明确规定「拿不到基准价就不许伪造拦截」。宁可放行也不瞎拦,这是个立场,不是 bug,但你得知道。
策略源码扫描不是沙箱。 上面第二节说过,_scan_runtime_reachable() 只覆盖从 SignalEngine 方法可达的代码,源码注释自己把残留风险写在案上。如果你要把这套东西开放给不受信任的输入,得在进程/容器层面再加一道,参考 最小权限怎么设计 的思路。
它完全不管下单。 回测层只写文件、只打印 JSON。仓库里的 agent/src/trading/connectors/ 下有 12 家券商/交易所连接器子目录(README 也自述 12 brokers),那是另一条链路。一旦你把回测结果接到真实下单上,代价就完全变了:API 凭据多一处存放就多一份暴露面、真实委托发出去不可撤销、程序化交易还有申报与合规义务,且这些义务因司法辖区而异——能不能这么用,以你所在司法辖区的监管要求与券商协议为准。
另外说一句因子库的来源,免得误会。runner.py 里的 _selected_factor_specs() 会在配置给出 alpha id 时去 src.factors.registry 取元数据。这些因子库不是项目自研的:按仓库根目录的 NOTICE,其中 Microsoft Qlib 的特征定义按 Apache 2.0 许可打包进来,另有几组公式来自公开论文与券商研报,仓库把它们作为数学事实重新实现,各子目录下另有 LICENSE.md。本文不提供法律意见,能不能商用以许可证原文为准。
六、上手与避坑清单
一、先跑 python -m backtest.runner <run_dir>,别一上来就从 Agent 侧调。 会踩是因为 Agent 侧的 backtest 工具(agent/src/tools/backtest_tool.py)是通过 Runner(timeout=300).execute() 起子进程跑 runner 的,返回的 stdout/stderr 还会被截到最后 2000 字符,真实报错很容易被切掉。直接跑命令行能看到完整回显,定位完再回到 Agent 链路。这也是 工具返回值该怎么设计 里的老问题:截断是必要的,但排错阶段你要绕过它。
二、run_dir 一定放在允许的根目录下。 会踩是因为 safe_run_dir() 在命令行入口和工具入口都会拦,路径不合规时你拿到的是一行 JSON 报错、退出码 1,很容易被误读成「配置有问题」。要么把 run 目录建在默认允许的位置,要么用 VIBE_TRADING_ALLOWED_RUN_ROOTS 显式追加。
三、SignalEngine.__init__ 不要留必填参数。 会踩是因为 _validate_signal_engine_class() 用 inspect.signature 检查,只要有一个没默认值的参数就直接报错——runner 是按 SignalEngine() 无参实例化的。同理,generate 必须存在且可调用。
四、generate() 的返回值类型要卡死。 会踩是因为 run_backtest() 里做了两道检查:返回值不是 dict 报一次错,字典里任何一个值不是 pd.Series 再报一次错。两处都是直接 sys.exit(1)。让模型写策略时,把这个契约写进提示词里比事后修便宜。
五、别在策略文件顶层写可执行语句。 会踩是因为 AST 结构检查在导入前就跑,顶层的 print、顶层的赋值计算、函数装饰器、非字面量默认值都会被拒。想做初始化就放进方法体里。
六、constraints 必须搭配 optimizer 才生效。 会踩是因为不配优化器时它只打一条 [WARN] 就跳过,日志淹掉了你会以为约束生效了。配完之后建议对着 positions.csv 核一遍权重列。
七、source 填了具体名字不代表数据就是它给的。 会踩是因为 loader 不可用时会走回退链,日志里是 [WARN] 一行。要确认真实来源,看运行卡里的数据来源字段,那里记的是实际供数方。
八、跨市场和单市场的年化口径不同。 会踩是因为 codes 里混进一个别的市场的符号,bars_per_year 就会变成 None 走自然日口径,前后两次运行的结果字段不可直接比较。要比就固定标的集合。
收个尾
如果你要照着这套结构做自己的东西,顺序建议是:先读 agent/backtest/models.py(三个 dataclass,五分钟读完,是全层的通用语),再读 agent/backtest/engines/base.py 的 run_backtest() 那九个编号步骤(它就是全流程的目录),然后挑 china_a.py 这种最短的子类看抽象方法怎么落地,最后回到 runner.py 看路由和校验。
自检三条:你的执行循环里还有没有 if 市场 == ...;你的配置错误是在边界被拒还是跑到一半才炸;你的产物里有没有一处如实记录了「数据实际由谁提供」。这三条过了,市场再加几个也不会失控。
再重复一次前面的话:以上全部是对一个开源仓库工程结构的描述,不构成任何投资建议;涉及实盘的部分,以你所在司法辖区的监管要求与券商协议为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源项目 Vibe-Trading 工具层:72 个文件与上下文预算 和 开源 Vibe-Trading 的 Alpha Zoo 因子库怎么调用。