开源 Vibe-Trading 的 Alpha Zoo 因子库怎么调用

2026-08-05

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

HKUDS 开源的 Vibe-Trading 里,Alpha Zoo 这一层真正值得工程师抄走的东西,不是那些因子公式,而是它把「一堆散落的 .py 文件」收敛成「一个可过滤、可体检、可懒加载的注册表」时立下的那几条硬约束——每一条都换来了确定的代价。 公式本身是公开论文和研报里的数学事实,任何人都能自己实现一遍;难的是让它们在一个 Agent 系统里可枚举、可校验、出错时不互相拖垮。

先把话说死:历史因子表现不代表未来,本文只讨论工程实现。下面出现的 IC、IR 之类都只作为字段名出现,本文不讨论任何数字的高低,也不涉及任何标的、仓位或买卖判断。

这篇和站内几篇相邻内容分工不同。让模型帮你写一次性的数据处理脚本,是 AI 写数据分析脚本 的题目;给 Agent 本身打分的方法论在 Agent 评测方法;而注册表这种「先用结构化元数据把候选筛到很小,再对幸存者做重计算」的手法,和 RAG 元数据过滤 讲的是同一类工程思路。本篇只钉在 Alpha Zoo 这一层:调用路径长什么样,结果字段怎么读。

顺便消个歧:Vibe-Trading 是 HKUDS 这个开源项目的项目名(MIT 许可证,Copyright 2026 Vibe-Trading Contributors),不是「凭感觉交易」这类泛指说法。后文出现的都是专有名词。

一、这一层要解决的问题:几百个文件怎么变成一条命令

打开 agent/src/factors/zoo/,里面是五个子目录:academicalpha101fundamentalgtja191qlib158。想知道到底装了多少条因子,第一反应通常是去翻文档,但这恰恰是这个仓库最不该信文档的地方——README 的 CLI 示例注释、agent/src/factors/__init__.py 的模块 docstring、registry.py 底部那段单例注释,三处都写了一个总数,而三个数并不相等。这不是谁写错了,是它们各自写在不同的时间点,之后目录还在长。仓库这样描述是一回事,运行时到底注册进去多少条是另一回事,后者只有 health() 报出来的 loaded 说了算。这个「文档数字与运行时数字必然漂移」的现象,后面的坑清单里还会撞见两次。

模块的骨架是统一的:从 src.factors.base 导入一批算子、一个 __alpha_meta__ 字典字面量、一个 compute(panel) 函数——注册表认的只有这三样里的中间那样。骨架之外的部分并不整齐:四个从公开公式移植过来的 zoo(academicalpha101gtja191qlib158)在文件头带一段中文说明注释,格式是「中文名称 / 简要说明 / 典型用途」三行,再接一段写明论文或研报出处的英文 docstring;fundamental 下的几个模块只有一行英文 docstring,没有中文头。另外你会在一部分模块里看到 ALPHA_ID 这个模块级常量,但它不是每个文件都有,注册表也从头到尾没读过它——真正的元数据来源只有 __alpha_meta__。看到 ALPHA_ID 别以为它是接口的一部分,它只是纯函数闸门额外放行的一个赋值语句。

一个具体的例子:agent/src/factors/zoo/fundamental/roe.pycompute 只有一行,返回 zscore(panel["fund:roe"]);它的 __alpha_meta__columns_required 就写着 ["fund:roe"]theme["quality"]。因子实现本身可以短到这个程度,复杂度全被推到了元数据和注册表那一侧。

问题就出在数量上:几百个模块各自 import 一堆东西,如果注册表用「挨个 import 一遍再读属性」的朴素做法,任何一个模块写错、任何一个依赖缺失,整库就废了;而且启动开销会直接砸在每一次 Agent 调用上。

二、注册表怎么扫:静态读元数据,不执行代码

agent/src/factors/registry.py 的做法是 AST 静态解析。load_alpha_meta_from_py(path)ast.parse 拿到语法树,遍历模块顶层的 Assign 语句,找到目标名为 __alpha_meta__ 的那一条,再用 ast.literal_eval 把它求值成字典。全程不执行模块代码。

围绕这条主路径,源码里堆了一圈防御:

  • 文件大小上限 _MAX_PY_BYTES = 200_000,超了直接抛 RegistryError,扫都不扫。
  • _ID_RE = ^[a-z][a-z0-9_]{0,31}$zoo_id 和文件名去掉后缀得到的 alpha_id_short 都要过这个正则。
  • 模块路径是推导出来的:f"src.factors.zoo.{zoo_id}.{short_id}"。文件头的 docstring 里有一句很值得抄——「We never honour a py_module field from any data file」。也就是说,元数据里就算写了模块路径,注册表也不认,导入目标只由目录结构决定。
  • 元数据用 pydantic 模型 AlphaMeta 校验,配置是 extra="forbid", frozen=True。多写一个键就报错,不是静默忽略。
  • themeuniverse 都是 Literal 枚举:theme 共 11 个取值(momentum、reversal、volume、volatility、quality、value、liquidity、microstructure、sentiment、growth、leverage),universe 共 7 个(equity_us、equity_cn、equity_hk、equity_in、equity_kr、crypto、futures)。
  • columns_required 有独立校验器:要么落在价格列白名单 {open, high, low, close, volume, vwap, amount} 里,要么以 fund: 前缀开头,否则报 unknown panel column。这等于把「固定的价格列」和「开放的基本面命名空间」分成了两套规则。
  • decay_horizon 限定在 0 到 512,min_warmup_bars 非负。

最关键的一条是失败处理:_try_register 里任何一步抛 RegistryError,都不会向上冒泡打断扫描,而是往 self._load_errors 里追加一条 _LoadError(alpha_id, reason) 然后 return。重复的 alpha id 同样只记一条 duplicate alpha id。以 _ 开头的目录、__pycache__、以 _ 开头的 .py 文件全部跳过——__init__.py 就是这么被排除掉的。

对你的意义很直接:一个坏文件只损失它自己;元数据写错在扫描期就被抓住,而不是等到某次计算跑到一半才炸。

三、这一层由哪些零件拼成

组成部分它负责什么对应仓库位置你什么时候会碰到它
算子层横截面与时序算子(rank / zscore / ts_rank / decay_linear / delta 等),统一的 NaN 传播策略agent/src/factors/base.py读某个因子实现看不懂算子语义时
因子模块一个 alpha 一个文件,含 __alpha_meta__ 字面量与 compute(panel)agent/src/factors/zoo/<zoo>/<id>.py想看某条公式怎么落地成代码
注册表扫描、校验、懒加载、输出体检agent/src/factors/registry.py排查加载失败、或要挂自定义 zoo 目录
Agent 工具把注册表包成一个带 action 判别式的只读工具agent/src/tools/alpha_zoo_tool.py让模型自己查库时
技能说明告诉模型什么场景该用哪个工具agent/src/skills/alpha-zoo/SKILL.mdAgent 选错工具、或过滤条件写错时
命令行alpha list / show / bench / compare / export-manifestagent/src/factors/cli_handlers.py人工排查,绕开模型直接看
REST 路由参数白名单校验 + 任务态管理agent/src/api/alpha_routes.py接前端或外部系统时
纯函数闸门用 AST 强制因子模块的 import 白名单与语句白名单agent/tests/factors/test_alpha_purity.py自己往 zoo 里加因子时
出处与许可各因子库的上游来源与许可声明根目录 NOTICE,以及 academicalpha101gtja191qlib158 四个目录各自的 LICENSE.md判断许可边界时

四、调用路径:从一次 tool call 到一列因子值

AlphaZooTool 的名字就叫 alpha_zoorepeatable = Trueis_readonly = True。它没有拆成三个工具,而是用一个 action 判别式收敛成三种操作:list_alphasget_alphahealth。文件头的 docstring 把理由写得很清楚:保持模型的工具目录紧凑,避免教它三套长得几乎一样的接口。这个取舍在 Agent 工具设计 里是个反复出现的老问题,Vibe-Trading 的答案是「合并 + 枚举收窄」。

调用链条是这样的:

  1. 模型发出 tool call,execute 转手交给模块级的 run_alpha_zoo(**kwargs)。之所以留一个模块级入口,源码注释说是为了对齐 run_alpha_bench,让命令行处理器能直接拿到解析好的字典而不用绕一圈 JSON。
  2. 先校验 action 是否在三个枚举里,不在直接返回错误信封。
  3. _get_registry() 在函数体内部 import Registry——这是刻意的局部导入,注释写明「this tool’s import never triggers a zoo scan until the agent actually calls it」。
  4. list_alphas 分支处理 limit:默认 50,转不成 int 报错,小于等于 0 报错,然后 min(limit, 500) 硬压。
  5. _action_listregistry.list(zoo=..., theme=..., universe=...) 拿到全量 id,再对前 limit 个逐个调 _alpha_summary
  6. 结果统一包成 {"status": "ok", "result": ...},错误则是 {"status": "error", "error": ...}

Registry.list 的过滤逻辑朴素到没有任何模糊匹配:zoo 是字符串相等比对,themeuniverse 是「值是否在元数据的列表里」,最后 sorted 返回。三个条件都是精确匹配,写错就是空列表,不是报错。

_alpha_summary 的字段裁剪是这一层最该注意的设计。它先放 idzoo,然后按 _META_FIELDS_EXPOSED 这个白名单元组挑 11 个字段:nickname、theme、formula_latex、columns_required、extras_required、requires_sector、universe、frequency、decay_horizon、min_warmup_bars、notes。源码正文不在里面,文件头注释给的理由是 payload 会变大,源码走专门的检视路径。想拿源码得用 Registry.get_source(alpha_id)(同样有 200_000 字节上限),或者命令行的 alpha show <id>(带 --brief 则只出元数据)。这种「工具返回值按白名单裁剪」的做法,和 工具返回值设计 里说的是一回事:返回给模型的东西越少越可控。

真正的计算走的是另一条路——Registry.compute(alpha_id, panel),它不在 Agent 工具的暴露面里:

  • 先比对 columns_requiredextras_required 是否都在 panel 里,requires_sector 为真时 panel 是否有 sector;缺任何一项抛 SkipAlpha,这是一个独立异常类,语义是「前置条件不满足」而不是「出错了」。
  • 然后才 _load_module 真正导入模块。默认 zoo 目录走包名 import;zoo_root 被换成别的路径时走 spec_from_file_location 的文件加载器,这个分支由构造函数里 self._use_filesystem_loader = self._zoo_root != default_root.resolve() 决定。
  • 拿到 compute 函数,没有就抛 RegistryError
  • 最后是 _validate_output 的四道体检:返回值必须是 DataFrame;形状必须和 panel["close"] 一致;不许出现正负 inf;NaN 占比超过 0.95 直接判失败。

agent/src/factors/base.py 顶部的 docstring 补齐了语义前提:算子都作用在宽表上,index 是交易日、columns 是标的代码;NaN 一律传播,没有任何静默的 fillna(0)ts_corr 遇到窗口内常数序列返回 NaN 而不是 0。这一整套「宁可 NaN 也不要假值」的取向,是上面那些体检能成立的前提。

五、结果该怎么读

health() 的返回是三个键:loaded(成功注册数)、failed(失败数)、errors(一个由 {alpha_id, reason} 组成的列表)。这是你判断「库到底装好没有」的唯一可信来源,比任何文档里的自述数字都靠谱。SKILL.md 里还特意交代了一句:loaded=0 应当理解为「zoo 模块还没落地」,不要当成 bug 去查。

list_alphas 的返回除了 items,还有 total(过滤后的总数)、returned(本次返回数)、truncated(布尔)和 filters(把你传进来的三个过滤条件原样回显)。filters 这个回显很有用——过滤没生效时,你一眼能看出是自己传的值没进来,还是传进来了但没匹配上。

元数据字段的读法:formula_latex 是公式本身;notes 承载的是实现层面的坦白。agent/src/factors/zoo/alpha101/LICENSE.md 里说得很明确,原论文公式引用市值或行业分类时,实现要么代入降级值、要么用 requires_sector=True 把这个因子挡在门外,每一处降级都逐条记在该因子的 notes 里。所以读一个因子先读 notes,比读公式更能知道它在这套实现里到底是什么。

decay_horizonmin_warmup_bars 是两个不同的时间尺度,前者是元数据声明的衰减视野,后者是计算前需要的预热 bar 数;frequency 是频率列表;columns_required 直接告诉你要准备哪些面板列。这四个字段合起来就是「跑这个因子你得先有什么」的完整清单。

至于评估结果,那是 alpha_bench 那条路的产出,不在 alpha_zoo 里。SKILL.md 的 Constraints 一节写明:不向 Agent 暴露任何逐票逐日的因子值,报告只给聚合统计。本文不讨论这些统计量的高低——历史表现不代表未来。

六、边界与代价:它放弃了什么

放弃了元数据的动态性。 因为走 ast.literal_eval__alpha_meta__ 必须是纯字面量字典。你不能用循环批量生成 191 份元数据,不能写 "universe": DEFAULT_UNIVERSE,不能拼字符串。换来的是「读元数据永远不执行代码」。

放弃了因子的自主性。 agent/tests/factors/test_alpha_purity.py 是一道 AST 闸门,逐文件参数化跑:import 白名单只有 pandas、numpy、scipy、__future__、typing、math、dataclasses,加上仓库内唯一允许的 src.factors.base;禁用名单包括 os、sys、subprocess、socket、urllib、requests、httpx、aiohttp、pathlib、Path、open、eval、exec、compile、__import__,连 getattr 的字符串参数是双下划线开头都要拦;模块级语句只允许 import、函数定义、ALPHA_ID 赋值、__alpha_meta__ 赋值和 docstring。代价是因子不能自己去拉数据、不能读文件、不能带模块级状态——所有输入必须由 panel 传进来。这套把能力面收窄到一张清单的思路,和给 Agent 分配工具权限时的做法是同源的。

放弃了负向位移。 base.py 的 docstring 明写:delta(df, d) 强制 d >= 1,负位移的 Ref(df, -n) 形式是有意不实现的。这条约束让整个算子面不可能写出未来函数,代价是你想做前视性质的诊断实验得完全绕开这层算子。

放弃了数据结构的灵活性。 输出形状必须严格等于 panel["close"],也就是这套东西只服务于「宽表面板」这一种形态。

它明确不管的事: 数据从哪来不管(SKILL.md 提到某些 universe 的加载器可能还没接上,命中时会返回 universe loader 未实现的提示);因子好不好不管;alpha_zoo 本身完全只读,连临时文件都不写。

顺着仓库往下走时的代价要单独说清。 因子这一层不碰任何下单动作,但同一个仓库里 agent/src/trading/connectors/ 下有 12 家券商/交易所连接器子目录(README 也自述 12 brokers)。一旦你从「算因子」走到「连账户」,代价的性质就变了:凭据一旦落到本地配置或环境变量里就多了一份暴露面,下错的单在市场上不可撤销,程序化交易本身的合规义务因司法辖区而异。这一段和 Alpha Zoo 的工程设计没有关系,能不能这么用,以你所在司法辖区的监管要求与券商协议为准。

许可要按出处分开看。 仓库根目录 NOTICE 写得很细:qlib158 是 Microsoft Qlib 特征目录的适配,走 Apache 2.0,上游 pin 的 commit 和文件路径都记在 agent/src/factors/zoo/qlib158/NOTICE 里;alpha101gtja191academic 三组公式分别来自 Kakushadze 2015 的论文、国泰君安 2014 的研报、以及 Fama-French 与 Carhart 等学术模型,仓库的立场是「数学公式属于事实性内容」,因此只做重实现,论文与研报的正文、表格、图表一概不复制。这四个目录下各有一份 LICENSE.md,写清各自的出处、显示名与实现边界;fundamental 目录里没有这份文件,因为它不是外部公式的移植,而是仓库自己围绕 fund: 面板列写的一小组基本面因子。所以描述这几个库时别笼统说成「项目自研的因子库」——五个 zoo 里有四个是公开公式的工程化重实现,出处与许可各不相同,只有 fundamental 那一组是仓库自己写的。本文不提供法律意见,任何关于用途与再分发边界的判断,以许可证原文为准。

七、上手与避坑清单

一、zoo 名写成 kakushadze101 会静默查空。 为什么会踩:agent/src/skills/alpha-zoo/SKILL.md 的 Zoo Inventory 表格里写的是 kakushadze101alpha_zoo_tool.py 里 zoo 参数的 description 举的例子也是 (e.g. gtja191, kakushadze101);但磁盘上的目录名是 alpha101agent/src/api/alpha_routes.py 里的 _VALID_ZOOS 集合写的也是 alpha101。而 Registry.list 是精确字符串比对,不匹配就返回空列表,不报错。怎么避:先不带 zoo 参数跑一次 list_alphas 看真实取值,或直接读 _VALID_ZOOS

二、同一份技能文档里的 classical 在磁盘上叫 academic,而 fundamental 根本没进那张表。 为什么会踩:技能文档写给模型看,更新节奏和目录结构不一定同步。怎么避:把 health()_VALID_ZOOS 当索引,别把 SKILL.md 的清单当目录树。

三、theme / universe 写近似值查不到。 为什么会踩:过滤是「值是否在元数据列表里」的精确匹配,cnchinaA股 一律不匹配 equity_cnSKILL.md 的 Common Pitfalls 也点了这条。怎么避:抄 registry.py 顶部那两个 Literal 定义,theme 11 个、universe 7 个,就这些。

四、拿 ls | wc -l 当因子数会多算。 为什么会踩:目录里混着 __init__.pyLICENSE.mdNOTICE,而注册表会跳过 _ 开头的文件。怎么避:数 *.py 再减 __init__.py,或者干脆以 health()loaded 为准。

五、指望 get_alpha 顺手拿到源码。 为什么会踩:_META_FIELDS_EXPOSED 白名单里压根没有源码字段,工具文件头注释解释过是为了控制 payload。怎么避:走 Registry.get_source(alpha_id),或命令行 vibe-trading alpha show <id>

六、limit 传大数以为能全量拉。 为什么会踩:min(limit, _MAX_LIMIT) 会把它压到 500,而且压的时候不会额外提示。怎么避:看返回里的 truncatedtotal,别看 returned

七、在服务端热路径上反复 Registry() 为什么会踩:alpha_zoo_tool.py_get_registry() 每次调用都新建一个 Registry(),也就是重新 AST 扫全库;而 registry.py 底部专门放了带线程锁的进程内单例 get_default_registry(),注释写明是给 API 和 bench 这类热路径准备的。怎么避:自己写服务端集成时用单例,只有需要自定义 zoo_root(测试、插件)才直接 Registry()

八、自定义 zoo_root 后导入行为变了却没察觉。 为什么会踩:构造函数用 zoo_root != default_root.resolve() 这一个布尔量切换加载策略,默认目录走包名 import,其它路径走文件路径 spec;两种模式下相对导入的可行性不一样。怎么避:自定义 zoo 里严格照纯函数契约来,只 import src.factors.base

九、把 SkipAlpha 当成 bug 去查。 为什么会踩:它和 RegistryError 长得像,但语义是「这个因子的前置条件在你的 panel 上不满足」,属于预期内跳过。怎么避:看异常消息里列的到底是缺列、缺 extras 还是缺 sector 标签,那就是你要补的输入。

结尾:一份自检清单

动手前把这几条过一遍,能省掉大半排查时间:

  • 先跑 action=health,确认 loaded 不是 0,errors 里没有你关心的那个因子。
  • 过滤值全部从 registry.pyTheme / Universe 定义里抄,zoo 名从 _VALID_ZOOS 抄。
  • 拿到结果先看 totaltruncated,再看 items
  • 读某个因子先读 notes,再读 formula_latex,最后看 columns_required 对不对得上你手里的面板。
  • 要源码走 get_source 或命令行,别指望 Agent 工具给。

接下来该读哪个文件,按目的分三条路:想吃透算子语义,读 agent/src/factors/base.py,尤其是 NaN 策略那几段 docstring;想知道纯函数契约是怎么被强制的,读 agent/tests/factors/test_alpha_purity.py 顶部那段说明,白名单和禁用名单都列在那里;想绕开模型直接用,读 agent/src/factors/cli_handlers.pyadd_subparser 那一段,五个子命令的参数定义写得比文档全。

再说一遍:本文只拆这套 Agent 工程的实现方式,不构成任何投资建议;因子的历史表现不代表未来。涉及能不能把它接到真实账户上这类判断,以你所在司法辖区的监管要求与券商协议为准。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源仓库的回测层:多引擎共用一个 runner开源项目 Vibe-Trading 的 30 份多智能体编制怎么调

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