拆开 Vibe-Trading 开源项目的因子引擎:算子层、注册表与批量跑分

2026-08-05

本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。

HKUDS 在 GitHub 上开源的 Vibe-Trading,它的因子模块里真正值得抄走的不是那几百条公式本身,而是「算子层定死语义 → 元数据当准入凭证 → 输出被强制体检 → 批量跑分只负责调度」这条四段式流水线。 公式是公开的数学,谁都能实现;难的是让四百多个独立文件在同一套契约下互不干扰地跑完一轮,并且任何一个坏掉的文件都不能把整轮拖崩。这篇只讲这条流水线怎么搭,不讨论任何一个因子有没有用——历史表现不代表未来,本文只讨论工程实现。

先说清楚命名:Vibe-Trading 是 HKUDS 放出的开源项目名(仓库许可证为 MIT,Copyright (c) 2026 Vibe-Trading Contributors),和「凭感觉交易」这类泛指说法没有关系。

站内已有几篇相邻话题,分工是这样的:让模型帮你写一次性数据分析脚本,看让 AI 写数据分析脚本;批量跑分背后「评测集怎么攒」的方法论,看Agent 评测集构建;Python 侧抽象层怎么分层,看ECC 的 Python 模型抽象。本篇只讲 Vibe-Trading 因子引擎这一块的具体结构。

一、这块代码要解决什么问题

打开 agent/src/factors/ 会看到 482 个受版本控制的文件,其中绝大部分躺在 zoo/ 下面。zoo/ 有五个子目录:academicalpha101fundamentalgtja191qlib158。每个子目录里是一堆同构的 .py 加一个 __init__.py,一个文件对应一条 alpha。至于总规模,仓库 agent/SKILL.md 自己的描述是「462 pre-built alphas across qlib158 / alpha101 / gtja191 / academic / fundamental」——这里只是引用仓库的说法,本文不把它当作自己核过的结论,读代码的人真正要关心的也不是这个数字。

来源必须说清楚。仓库根目录的 NOTICE 写明:qlib158 打包的是 Microsoft Qlib 的特征定义,走 Apache 2.0;alpha101 的公式来自 Kakushadze (2015) 的 arXiv:1601.00991;gtja191 来自国泰君安 2014 年那份短周期交易 alpha 因子研究报告;academic 一组对应 Fama-French 五因子、Carhart 动量、Hou-Xue-Zhang q-factor。NOTICE 里明确写了:源文献的正文、表格和图不复制,只把公式当作数学事实重新实现。四个子目录下各有一份 LICENSE.md。所以这不是「项目自研的因子库」,是公开公式的工程化重实现;能不能商用,以许可证原文为准,本文不提供法律意见。

四百多个文件、五种来源、各自要的行情列还不一样——问题就变成了:怎么让它们长得足够一致,一致到可以被一个循环无差别地调用。

二、算子层:把语义定死在 base.py

agent/src/factors/base.py 是全部因子模块唯一允许从本仓库导入的东西。它的模块 docstring 一上来就把数据契约写死:所有算子作用在宽表 pd.DataFrame 上,index 是交易日的 DatetimeIndex,columns 是标的代码;计算结果必须是同样形状的原始分值。

三条约定值得单独拎出来:

第一条,NaN 一律传播,不做静默 fillna(0)zscore 遇到标准差为零或 NaN 的行返回 NaN;scale 遇到绝对值和为零的行返回 NaN;safe_div 在分母恰好为零时返回 NaN 而不是 inf 或 0;ts_corr 碰到窗口内常数序列返回 NaN。这条约定的价值在下游才体现——一个静默补零会伪装成有效信号一路混到统计里,而 NaN 会被后面的校验逮住。

第二条,禁止未来函数写在类型签名上。delta(df, d) 强制 d >= 1,传 0 或负数直接 ValueError,报错信息里就写着 lookahead ban;文档还专门说明:负向 shift 的 Ref(df, -n) 形式是故意不提供的。这是把「别偷看未来」这条纪律从人的自觉降级成了 API 层面写不出来。

第三条,性能后端是可插拔且结果等价的。_backend.py 懒加载 bottleneck,读 VIBE_TRADING_DISABLE_BOTTLENECK 决定要不要用;ts_argmax / ts_argmin 在有 bottleneck 时走 C 编译的滑窗,没有就退回 pandas 的 rolling().apply(),注释里明确写了两条路径结果一致、只是快慢不同。这里还留了个很实在的坑记录:bn.move_argmax 返回的是距窗口末端的距离,代码里用 (n - 1) - raw 转成从窗口起点计的 0 基下标;而 bn.move_rank 算的是 Spearman 秩相关、跟这里要的百分位秩不是一回事,所以 ts_rank 宁可自己用 numpy 的 sliding_window_view 手写。

vwap() 是市场差异被显式建模的地方。它接一个 Market 枚举(equity_us / equity_cn / equity_hk / equity_in / equity_kr / crypto / futures),A 股走 (amount * 1000) / (volume * 100 + 1)——因为 Tushare 的 daily.amount 单位是千元、daily.vol 单位是手,注释里连当初拿哪只标的探针核对量纲都写进去了;其余市场在 panel["vwap"] 缺席时退回 (O + H + L + C) / 4。缺列不静默兜底,直接抛 KeyError

三、注册表:元数据即准入凭证

agent/src/factors/registry.py 是这套设计里最有借鉴价值的一层。它的核心判断是:加载一个因子不需要执行它

每个 alpha 的 .py 里有一个 __alpha_meta__ 字典字面量,这是唯一的真相来源。load_alpha_meta_from_py()ast.parse 静态解析文件,遍历顶层 Assign 找到这个名字,再用 ast.literal_eval 取值——全程不 import。取出来的字典喂给 pydantic 的 AlphaMeta,配置是 extra="forbid", frozen=True:多写一个键就是校验失败。字段包括 idthemeformula_latexcolumns_requiredextras_requiredrequires_sectoruniversefrequencydecay_horizonmin_warmup_bars 等。columns_required 有自定义校验器:要么落在固定的价格列集合里,要么以 fund: 前缀开头走基本面开放命名空间,别的一律 ValueError

模块路径是推导出来的,不是读来的:f"src.factors.zoo.{zoo_id}.{alpha_id_short}",两段 token 都要匹配 ^[a-z][a-z0-9_]{0,31}$。docstring 里有一句很值得抄:注册表从不采信任何数据文件里写着的 py_module 字段。这是一条防止数据驱动导入被投毒的硬边界。同类的还有 _MAX_PY_BYTES / _MAX_YAML_BYTES 两道体积上限,以及 YAML 只走 yaml.safe_load。顺带一提,_meta.yamlexport-manifest 生成给外部消费者看的产物,不是加载路径。

真正 import 发生在 Registry.compute(alpha_id, panel) 里,而且在 import 之前先做前置检查:columns_required 里有列不在 panel 中,抛 SkipAlphaextras_required 缺失同理;requires_sector 为真但 panel 里没有 sector,也是 SkipAlphaSkipAlphaRegistryError 是两类语义完全不同的异常——前者是「这个因子在这个数据上本来就不该跑」,后者是「它坏了」。这个区分决定了上层能不能把「跳过」和「失败」分开统计,这一点跟 Agent 的失败分类设计是同一个思路。

算完还要过 _validate_output():返回值必须是 DataFrame;形状必须和 panel["close"] 一致;数组里不许有 ±inf;NaN 占比超过 95% 直接判失败。前面 base.py 那条「NaN 传播、不静默补零」的约定,就是靠这里兜住的。

最后是加载器的双模式。当 zoo_root 就是内置目录时走 importlib.import_module 按包名导入(复用 import 缓存、支持相对导入);指向别处(测试、插件)时改用 spec_from_file_location 按文件路径加载,调用方不必折腾 sys.path。另有 get_default_registry() 提供带 threading.Lock 的进程内单例,注释解释得很直白:构造一次要 AST 扫几百个文件,API 每个请求都扫一遍是浪费。

四、批量跑分与分析核心

agent/src/factors/bench_runner.py 的定位是调度,不是数学。它的 docstring 第一句就说明来历:这套流程原本长在 agent/scripts/w4a_run_benches.py 里,抽出来是为了让 CLI 驱动和 Web UI 后台 worker 共用同一条管线,「数学没变,只是换了载体」。

run_bench(zoo, universe, period, top, on_progress, registry, only) 的流程是:先 reg.list(zoo=zoo) 拿到 id 列表,only 参数可以把范围缩到几个指定 id(alpha compare 就靠它,避免比三个因子却把整个 zoo 跑一遍);再调 _load_universe_panel(universe, period) 装载行情面板、_compute_forward_returns(panel) 算前向收益——这两个函数都在 agent/src/tools/alpha_bench_tool.py 里,前向收益用的是 close.pct_change(fill_method=None).shift(-1),注释写明是把下一根 bar 的收益对齐到当前因子时间戳上。

并行这段处理得很克制。worker 数取 VIBE_TRADING_BENCH_WORKERS(读不到就用 os.cpu_count()),并被 max(1, min(n_workers, n_total)) 夹住;只有在调用方没有注入自定义 registry、worker 数大于 1 且任务多于 1 时才启用 ProcessPoolExecutor——注入 registry 就强制串行,因为自定义对象未必能安全跨进程。大对象不随任务走:initializer=_init_bench_worker 把 panel 和 return 矩阵一次性塞进每个 worker 的模块级全局,之后每个任务只传一个 alpha id 字符串。上下文优先取 fork,取不到就静默退回默认。这类「一次初始化、多次复用」的编排取舍,和并发编排里的资源共享是同一类问题。

隔离做到了每一层:worker 内部先捕获 SkipAlpha / RegistryError / RuntimeError / KeyError / ValueError 记成 typed 跳过,再兜一个宽泛 except 记成 unexpected;主进程侧 fut.result() 再包一层,worker 进程崩了也只是往 skipped 里追一条 worker crash,整轮继续。on_progress 回调本身也被 try 包住,回调抛异常只写日志、不影响跑分。

结果结构是固定字段集:statuszoouniverseperiodn_alphas_testedn_skippedby_themetop5_by_irdead_examplesrowsskippedmetawall_secondscategorise() 把每行按三个写死在模块里的常数(IC 均值、IC 为正的比例、t 统计量绝对值)分进 alive / reversed / dead 三个桶,theme_breakdown() 再按 theme 标签聚合计数。这些只是字段与分桶机制,本文不涉及任何因子落在哪个桶、也不展示任何跑分结果。另外 panel["_meta"] 里的宇宙元信息(比如某些 loader 会打的幸存者偏差标记)会被原样透传进结果——这个设计比把它藏起来诚实得多。

同目录下还有两个配套模块:bench_runner_strict.py 在原有分桶之上加了同宇宙随机对照与训练/测试切分,其 docstring 直接点名原分桶的问题——只跟零比较,接受得了那些 IC 其实由共同的横截面 beta 驱动的因子;compare_runner.py 是 CLI、POST /alpha/comparealpha_compare 工具三个入口共用的比较内核,SORT_KEYS 限定了可排序的指标名。

数学本身在 agent/src/factors/factor_analysis_core.py,只有两个函数,短得可以一口气读完。compute_ic_series() 算逐日 Spearman 秩相关:先取日期与代码的交集,再用 notna() 交叉掩码只保留因子和收益都存在的格子,然后 rank(axis=1) 之后 corrwith(method="pearson")——秩上的 Pearson 就是 Spearman,这样整段是向量化的,不用逐日循环。_MIN_VALID_PER_DATE = 5:当天有效标的少于 5 个就丢掉这一天。compute_group_equity() 做分层:逐日按因子值 rank(method="first")pd.qcut 分组,分不出足够多的不同组时退回等宽 pd.cut,每组等权取收益均值,最后 (1 + ret_df).cumprod() 得到列名为 Group_1Group_N 的累计净值表。注意它只返回这张表,怎么解读不归它管。

五、各部分速查

组成部分它负责什么仓库位置你什么时候会碰到它
算子层定义宽表数据契约、横截面与时序算子、NaN 传播规则、禁负向 shiftagent/src/factors/base.py新写或移植一条公式时,只能从这里 import
性能后端懒加载 bottleneck,缺失或被环境变量关闭时退回 pandas 路径agent/src/factors/_backend.py跑分明显偏慢、或怀疑两条路径结果不一致时
注册表AST 扫描 __alpha_meta__、pydantic 严格校验、懒 import、输出体检agent/src/factors/registry.py新增因子加载不上、health() 里出现 failed 条目时
批量跑分列 id、装面板、算前向收益、进程池调度、失败隔离、结果聚合agent/src/factors/bench_runner.py想一次跑完一个 zoo,或要接自己的进度条时
严格跑分在原分桶之上加同宇宙随机对照与训练/测试切分agent/src/factors/bench_runner_strict.py觉得原分桶太松、想加一道对照时
比较内核按 zoo 分组、只 bench 指定 id、按指定指标排序agent/src/factors/compare_runner.py只想横向对比手选的几个因子时
分析核心逐日 Spearman IC 序列;分层等权累计净值表agent/src/factors/factor_analysis_core.py要复用这两段计算、或要核对口径时
因子仓库五个来源子目录,其中 academic / alpha101 / gtja191 / qlib158 四个各带一份 LICENSE.mdagent/src/factors/zoo/查某条公式怎么实现的、或确认许可来源时

六、边界与代价:它明确不管什么

这套设计不是免费的,放弃的东西相当明确。

放弃了灵活的因子形态。 每个 alpha 必须是单文件、纯函数、模块级只允许 import / 函数定义 / ALPHA_ID / __alpha_meta__ / docstring 这几种语句——agent/tests/factors/test_alpha_purity.py 用 AST 逐文件强制这条纯函数契约,导入白名单只有 pandas、numpy、scipy、src.factors.base 以及 __future__typingmathdataclassesos / sys / subprocess / socket / urllib / requests / httpx / aiohttp / pathlib / open / eval / exec / __import__ 一律禁用,连 getattr 拿双下划线属性都被拦。想在因子里读个本地文件、调个接口、缓存点中间结果?做不到,这是有意的。

放弃了跨截面之外的形态。 契约是「输入宽表面板、输出同形状宽表」。逐笔、事件驱动、非等长时间轴这些形态不在射程内。AlphaMeta.frequency 虽是字符串列表,但整条流水线是围绕日频宽表建的。

防未来函数是抽样式的,不是证明。 agent/tests/factors/test_lookahead.py 的做法是构造 300 行 × 10 个合成标的的面板,在 PROBE_T = 260 处快照因子值,然后从 PERTURB_FROM = 270 开始把后续所有数据换成 NaN 或哨兵值重算,断言快照不变。docstring 自己说明了容差取 1e-9 而非严格位相等的原因,以及能检出多大量级的泄漏。这是一道很有性价比的哨兵,但它是采样,不是形式化证明。

分桶阈值是硬编码的启发式。 categorise() 的常数直接写在模块里,不是配置项,而 bench_runner_strict.py 的 docstring 本身就在批评这套门槛。你要是照抄这套代码,这几个常数是你必须自己重新想清楚的地方,不是可以直接继承的结论。

它完全不管钱的部分。 因子引擎的输出只有分值矩阵、IC 序列和分层净值表。仓位怎么定、交易成本怎么算、下单走不走真实通道,这些都在别的模块里。仓库里确实有券商连接层(agent/src/trading/connectors/ 下 12 家连接器子目录,README 亦自述 12 brokers)和 16 个具体渠道实现文件,那意味着一旦你把链路接通,凭据就有了真实暴露面:密钥落在哪台机器、谁能读到、进程崩了会不会残留在日志里,都要自己回答。实盘的错单不可撤销,程序化交易的合规义务在不同司法辖区差别很大。因子引擎本身不为这些负责,它只算数。

七、上手与避坑清单

先跑 health() 再跑任何东西。 会踩的原因:注册表的扫描是「静默跳过 + 记错误」——某个文件元数据写错,它不会中断构造,只是不出现在 list() 里,你会以为跑全了。怎么避:拿到 Registry 先看 health() 返回的 loaded / failed / errors,failed 不为 0 就先把 errors 里的 reason 逐条清掉。

__alpha_meta__ 必须是纯字面量。 会踩的原因:它是被 ast.literal_eval 读的,不是被执行的——你写个 'theme': THEMES['momentum'] 或者用 f-string 拼 id,语法完全合法,但静态解析直接失败,这个因子就悄悄不存在了。怎么避:这个字典里只能出现常量。

别指望字段名写错会得到宽容。 会踩的原因:AlphaMetaextra="forbid",多一个键、拼错一个键都是 ValidationError;id 还要过 ^[a-z][a-z0-9]+_[a-z0-9_]+$,文件名和 zoo 目录名各自还有一条 token 正则。怎么避:新增因子时照着同目录里已有的文件复制骨架改,别凭记忆手写元数据。

基本面列要带 fund: 前缀。 会踩的原因:columns_required 的校验器只放行固定价格列集合和 fund: 前缀,写个裸的字段名会被判「unknown panel column」,而这个错误发生在扫描期,表现出来只是这个因子没被注册。怎么避:非价格列一律写成 fund:xxx,具体能不能填上由运行时的加载器决定。

注入自定义 registry 会让跑分退回串行。 会踩的原因:use_parallel 的条件里明确要求 registry is None,你为了测试传了个自建 Registry,然后发现速度和预期差一个数量级,还以为是环境变量没生效。怎么避:知道这是有意为之;要并行就用默认的 get_default_registry() 路径。

分不清 skipped 里的两种 kind 会误判。 会踩的原因:skipped 列表里 kindtypedunexpected 两种,前者大多是数据缺列这类正常跳过,后者才是代码问题;混在一起看会得出「跳了一大半」的错误印象。怎么避:统计时按 kind 分开算,只对 unexpected 报警。

别把 bottleneck 装没装当成无关变量。 会踩的原因:装与不装走的是两条实现路径,虽然注释声明结果一致,但你排查数值差异时如果不知道有这条分叉,会白花很多时间。怎么避:出现跨机器数值不一致时,先确认两边 bottleneck 的安装状态和 VIBE_TRADING_DISABLE_BOTTLENECK 的取值。

A 股量纲不要想当然。 会踩的原因:vwap()equity_cn 用的是千元与手的换算,如果你换了数据源却沿用这个分支,量纲会整体偏 1000 倍,而结果不会报错——它只是错。怎么避:换数据源时回去读 base.pyvwap() 那段注释,确认新源的 amount 和 volume 单位。

收尾

把这套东西读一遍,你会发现它的强度不在数学上——factor_analysis_core.py 统共一百来行。强度在于它把「四百多个陌生文件要能被同一个循环无差别调用」这件事,拆成了四道互相独立的闸门:算子层管语义、注册表管准入、输出校验管数据质量、跑分层管调度与隔离。任何一道闸门失守,坏的都只是一个因子,不是整轮。

你要复用它的话,给自己一份自检清单:我的算子层有没有把 NaN 政策和防未来函数写进签名;我的注册表是不是在不执行代码的前提下就能判断该不该加载;我的输出校验拦不拦得住形状不符、inf 和过度稀疏;我的批量执行层是不是每一层都有独立的异常兜底。

接下来该读哪个文件,取决于你想深挖哪一段:想看纯函数契约怎么被强制,读 agent/tests/factors/test_alpha_purity.py;想看防未来函数的哨兵怎么构造,读 agent/tests/factors/test_lookahead.py;想看这套引擎怎么暴露给 CLI 和 REST,读 agent/src/factors/cli_handlers.pyagent/src/tools/alpha_bench_tool.py;想确认许可边界,读根目录 NOTICE 和各 zoo 子目录下的 LICENSE.md,以许可证原文为准。

最后重复一遍前提:本文只讨论这个开源项目的工程实现,不评价任何因子、策略或标的,不构成任何投资建议;历史表现不代表未来。至于这套东西能不能接实盘、能不能用于对外提供服务,一律以你所在司法辖区的监管要求与券商协议为准。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 拆解开源项目 Vibe-Trading 的券商抽象层与凭据保管读懂 Vibe-Trading 开源项目的因子库来源:一份 NOTICE 与四份子目录许可证

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。