Vibe-Trading 开源项目的数据源层拆解:一个注册表、一条回退链和一份路由技能文档
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
这一层真正值钱的地方不是它接了多少个行情源,而是「哪个符号走哪条线、这条线塌了往哪儿退、什么情况下必须当场报错而不许退」这三件事被写死进了代码,还被测试钉住了对应的技能文档。 Vibe-Trading 是 HKUDS 放出的开源个人交易 Agent 项目名,跟「凭感觉做交易」这类泛指说法没有关系。它的数据源清单是能抄的,路由规则不是——你抄错一条,Agent 会在你毫不知情的时候拿到一份来路不明的数据,然后一本正经地往下算。
先说清楚这篇和站内几篇的分工:向量库怎么调召回,看 RAG 检索调优;让模型自己写数据分析脚本那条路,看 AI 写数据分析脚本;单个工具的接口形状怎么设计,看 Agent 工具设计。本篇只拆一件事:一个真实项目里,多数据源的选择权是怎么从模型手里拿走、交给代码的。
一、这一层到底在解决什么问题
金融数据这门生意的现实是:没有一个源能覆盖全部市场,能覆盖的要么收费要么要 key,免费的那批随时会按 IP 限流甚至临时封你。同一个 Agent 会话里,用户可能上一句问 A 股、下一句问美股、再下一句问某个 USDT 交易对,每个市场的可用源集合都不一样。
把这个问题原样丢给模型,结果是可预期的:模型要么记不住哪个源需要哪个环境变量,要么在一个源超时之后直接把失败当成结论回给用户。Vibe-Trading 的处理方式是把选择权收回代码层——模型只需要说「我要这几个符号从这个日期到那个日期的 K 线」,剩下的走哪条线、退到哪里,由注册表决定。
代码上这被拆成四块:一个注册表(agent/backtest/loaders/registry.py),一份 loader 契约与公共地基(agent/backtest/loaders/base.py),一层符号识别与二次兜底(agent/src/market_data.py),外加一份专门写给模型看的路由文档(agent/src/skills/data-routing/SKILL.md)。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| loader 注册表与回退链 | 收敛合法源名、按市场排出降级顺序、返回第一个可用 loader | agent/backtest/loaders/registry.py | 新增一个数据源,或想改某个市场的优先级 |
| loader 契约与公共件 | 定义 DataLoaderProtocol、OHLC 校验、带预算的重试、可选本地缓存 | agent/backtest/loaders/base.py | 自己写 loader,或排查脏 bar 与超时 |
| 具体 loader 实现 | 各家 API 的抓取与字段归一 | agent/backtest/loaders/ 目录下的各个 loader 文件 | 某一家接口变了要修 |
| 共享 HTTP 节流 | 按 host 桶控制最小请求间隔,复用连接 | agent/backtest/loaders/_http.py | 批量跑数被免费源限流时 |
| 符号识别与二次兜底 | 按符号正则挑首选源,fetch 抛错时沿链继续 | agent/src/market_data.py | 排查「为什么这个符号走了那个源」 |
| 路由技能文档 | 把源、工具、env key、优先级摊给模型看 | agent/src/skills/data-routing/SKILL.md | 让 Agent 学会自己选源 |
| 防漂移测试 | 断言文档里的源名是代码里源名的子集 | agent/tests/test_data_routing_sources_subset.py | 改了源名之后 CI 红了 |
二、注册表:一份名单、一条链、两个入口
注册用的是最朴素的类装饰器。每个 loader 模块里的类顶着 @register,装饰器把 cls.name 当键塞进全局字典 LOADER_REGISTRY。装饰器只有在模块被 import 时才会执行,所以注册表里有没有东西,取决于谁先 import 了谁——这是这类写法的经典塌方点。
它的解法是 _ensure_registered():里面硬编码了一份 _loader_modules 列表(我数了一下,24 个模块条目),逐个 importlib.import_module,并且每个都套在 try/except Exception: pass 里。这句 pass 是有意为之——akshare 这类可选依赖没装的时候 import 会炸,但那不该让整个注册表起不来,只该让那一个源不出现在名单里。resolve_loader 和 get_loader_cls_with_fallback 两个对外函数各自在开头调一次它,保证调用方永远不会撞上空注册表。
名单之外还有一份 VALID_SOURCES 集合,我数了是 25 个字符串(24 个源名,加一个 "auto" 跨市场选择器)。它的注释把定位写得很直白:这是回测配置 schema(backtest.runner.BacktestConfigSchema)和面向 Agent 的回测工具(src.tools.backtest_tool)共用的单一真相,两边不能各写一份然后慢慢漂移。仓库里确实有一条反向测试 test_valid_sources_covers_all_registered_loaders(在 agent/tests/test_engine_robustness.py),断言 set(LOADER_REGISTRY) - VALID_SOURCES 为空,也就是新加的 loader 不可能被配置校验静默拒掉。
更有意思的是 agent/src/agent/context.py 里的 _count_data_sources():系统提示词里那句「支持多少个数据源」不是手写的常量,而是 len(VALID_SOURCES - {"auto"}) 现算的,import 失败才落到一个静态兜底数。提示词跟着代码走,这个小动作省掉的是「代码加了源、提示词还停在旧数字」这类没人会去查的错。
回退链是一个 FALLBACK_CHAINS 字典,10 个市场键(a_share、us_equity、hk_equity、india_equity、kr_equity、crypto、futures、fund、macro、forex),值是有序的源名列表。下面只摘其中三条,其余七条结构完全一样:
FALLBACK_CHAINS: dict[str, list[str]] = {
"a_share": ["tencent", "mootdx", "eastmoney", "baostock", "akshare", "tushare", "local"],
"crypto": ["okx", "binance", "ccxt", "yfinance", "local"],
"forex": ["mt5", "akshare", "yfinance", "local"],
}
排序依据写在紧挨着的注释里,值得原样理解:先按封 IP 风险排,再按数据质量排。轻量、容忍限流的公共端点排前面,需要 key 的 REST 接口和容易触发限流的源排后面。这个次序跟「哪家数据最准」的直觉正好拧着,但在 Agent 场景下是对的——一次 IP 封禁会让后面几十次调用全废,而数据质量的差异通常还能靠交叉核验找补。
resolve_loader(market) 沿链走,跳过没注册的名字,try 住实例化(注释里挂着 issue 编号 #50:Tushare 这类 loader 会在 __init__ 里就调 SDK,缺凭据时直接抛,这种情况要和 is_available() 返回 False 同等对待,继续往下走),第一个 is_available() 为真的就返回。全链走完还没有,抛 NoAvailableSourceError,异常消息里带上试过哪些源。
get_loader_cls_with_fallback(source) 是另一个入口,语义不同:先按名字取类,不可用就遍历这个类自己声明的 markets 集合,对每个市场调一次 resolve_loader 拿同市场替补,并打一条 warning 日志说明发生了降级。前者是「给我这个市场能用的」,后者是「我要这个源,不行你看着办」。
三、什么时候不许退:一条被单独拎出来的例外
整份注册表里我认为最该抄走的是这十来行:
_NO_NETWORK_FALLBACK_SOURCES: frozenset[str] = frozenset({"local", "qveris"})
配套的注释解释得很清楚。local 这个 loader 读的是用户自己在 ~/.vibe-trading/data-bridge/config.yaml 里配的本地文件,它的 markets 之所以写成覆盖所有市场,只是为了让跨市场的自动解析器能够得着它,不是为了让一个「显式指定 local 但不可用」的请求悄悄降级成一次网络抓取。用户点名要本地数据却拿到网络数据,这不是容错,这是掩盖故障。所以这两个源一旦被显式请求而不可用,直接抛错,异常消息里还把配置文件路径写给用户看。
这条规则背后是一个更通用的判断:降级的合法性取决于用户的意图有没有被违背。用户说「给我 A 股日线」,你从腾讯的接口退到 baostock,意图没变;用户说「用我本地那份数据」,你退到网络源,意图已经被改写了。写重试和降级逻辑的时候,这个区分比重试次数怎么调重要得多,相关的失败分类思路可以对照 Agent 失败重试策略 一起看。
四、loader 契约与那些藏在 base.py 里的地基
base.py 用 Protocol 定义契约,只要求四样东西:name、markets、requires_auth 三个类属性,加 is_available() 和 fetch() 两个方法。fetch 的返回被规定成 {symbol: DataFrame} 的映射,DataFrame 列是 trade_date, open, high, low, close, volume。契约小到这个程度,新增一个源的成本才压得下来——agent/backtest/loaders/ 目录下我数到 38 个 .py 文件,除去注册表、公共件和几个客户端模块,绝大多数就是在填这个协议。
同一个文件里还塞了三块所有 loader 共用的地基,每一块都对应一个真实踩过的坑。
OHLC 边界校验。 validate_ohlc() 的 docstring 把动机写得很实在:loader 通常只做 dropna,于是结构上有病的 bar(high < low、高低价没能把开收盘价夹住、价格非正)会一路流进回测,最后变成 NaN 或 inf 指标,把严格模式(allow_nan=False)的 JSON 序列化搞崩。这个函数被定位成 loader 边界上的统一检查点。结构不变式永远强制,正负价那条则做成可配置的 allow_nonpositive_prices——注释举的例子是欧洲日前电力市场会合法地印出负价,与其把它静默填补成一条平滑序列,不如让一个定义明确的负价通过;而恰好为零的价格仍然拒收,因为按名义金额定仓位时零价在数学上没定义。处理策略是 drop、warn、raise 三选一。
带预算的重试。 retry_with_budget() 的设计有两处克制得很好。一是墙钟 deadline 与重试次数双重约束,两者任何一个先到都终止,且睡眠时间取 min(backoff[attempt], 剩余预算),不会在只剩半秒预算时还老老实实睡满默认退避表里的 4 秒。二是只对调用方显式声明的 transient 异常类重试,不在名单里的异常第一次出现就原样抛出——注释里那句「我们绝不重试调用方没有 opt-in 的异常类」应该被更多人抄进自己的重试封装。终态失败统一包成 TimeoutError,原异常挂在 __cause__ 上,排查时不丢现场。配套的 check_budget() 是给分页抓取用的,翻页之间检查一次,超预算就别再翻了。
可选的本地缓存。 默认关,由配置项 vibe_trading_data_cache 打开,缓存根目录默认落在 ~/.vibe-trading/cache/loaders。缓存键是内容寻址的:把源名、符号、周期、起止日期、字段列表连同一个版本号 _LOADER_CACHE_VERSION 一起 JSON 序列化后取 sha256。版本号一改,旧文件自然再也匹配不上,不用另写清理逻辑。
这里有一条我觉得所有做行情缓存的人都该抄的判断——loader_cache_range_is_final():只有 end_date 严格早于今天的区间才允许进缓存。理由是缓存键只对 end_date 寻址、不对抓取时刻寻址,如果把一个末尾 bar 还在形成中的区间缓存下来,那根未收盘的临时 bar 会被永久钉住,之后每次跑都拿到它。读写缓存的失败一律吞掉只打日志,落盘走临时文件加 os.replace 原子替换,临时文件名带 pid 和 uuid,避免同键并发写互相踩。写回时还把索引列名、索引 dtype、列轴名一起存进一份同名的 json 元数据,读回来再还原,为的是让缓存帧和现抓的帧尽量长得一模一样。
五、符号即路由:market_data.py 那 12 条正则
到这里为止的路由都是「按市场」的,但调用方手里只有符号字符串。agent/src/market_data.py 顶上那张 _SOURCE_PATTERNS 表补的就是这一段:12 条正则,从上往下匹配,命中即返回源名,全不命中则默认 tushare。
_SOURCE_PATTERNS = [
(re.compile(r"^local:", re.I), "local"),
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "tencent"),
(re.compile(r"^[A-Z]+\.US$", re.I), "yahoo"),
(re.compile(r"^\d{6}\.(KS|KQ)$", re.I), "pykrx"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "okx"),
(re.compile(r"^[A-Z]{3}/[A-Z]{3}$", re.I), "mt5"),
# 此处只摘 6 条;余下 6 条覆盖 .HK、印度 .NS/.BO、Yahoo 的 =F 与 =X、
# 四字母 /USDT(走 ccxt)以及 XXXYYY.FX(走 mt5),写法同构
]
注释里点破了这张表和回退链的关系:每条正则匹配到的源,都是它所属市场那条链的链头。所以这张表不是第二套路由规则,它只是入口点,链头不可用时后面自然还是走 FALLBACK_CHAINS。表里几条规则各自记着一次修复:印度符号允许 & 和 -(M&M.NS、BAJAJ-AUTO.NS 这类),Yahoo 的期货 =F 与外汇 =X 后缀是专门补的,注释写明没有这两条时它们会掉进 tushare 默认分支、被路由到中国市场的 loader,而那边根本解析不了;外汇的三字母斜杠格式也刻意跟四字母的 /USDT 加密对错开,避免撞车。
fetch_market_data() 是第二层兜底,粒度和注册表那层不同。注册表管的是「这个 loader 能不能构造、可不可用」,这里管的是「构造出来了、fetch() 却抛了」。它的做法是:把请求的源和这个源所在的整条链拼成一个去重后的尝试列表,截断到 max_fallback_attempts(默认 3),逐个试,抓到异常就打 error 日志继续下一个;实在都不行,符号会被收进返回结果的 _unresolved 列表,而不是让整批调用炸掉。source="auto" 时先按 detect_source 把符号分组,各组分别走各自的链。
它还有一个可选的 include_provenance 开关,打开后每个符号会附一份出处:source、requested_source、detected_source、fallback_used、currency_conversion。这几个字段是给下游人看的——一份数据到底是不是你以为的那个源给的,不该靠猜。工具返回里该塞哪些元信息,可以对照 Agent 工具返回值设计 想一想。另外它对行数也做了约束:cap_rows 在超出上限时按等步长抽样并把最后一根 bar 钉住,同时在返回里写明 truncated、policy 和一句提示怎么拿全量,不搞静默截断。
六、写给模型看的那份路由文档,以及钉住它的测试
agent/src/skills/data-routing/SKILL.md 的 frontmatter 里自称是所有数据需求的唯一 router,并要求在任何回测、取数、研究任务之前先加载它。正文是三张表加一棵决策树:
- Source Overview:每个源一行,列出覆盖市场、是否要 auth(要的话写清 env key 名,比如
TUSHARE_TOKEN、FINNHUB_API_KEY)、网络要求、对应的技能名。几个只由回测 runner 内部选择、没有独立技能页的源被标成 runner-internal。 - Capability → Tool Routing:把「我要什么数据」映射到具体工具名和所需 env key,比如 OHLCV 走
get_market_data,宏观序列走get_macro_series且需要FRED_API_KEY。 - Symbol Format Reference:各市场的符号写法与示例,正好和上一节那 12 条正则对得上。
决策树部分只有两句关键判断。回测场景:写 source: "auto",让 runner 按符号路由并自动跨同市场源回退,除非用户点名要某个源。研究场景:先查能力表拿工具名和 env key,key 缺了就明确报告缺哪个 key,而不是静默失败。
后面的 Ban-Risk 段落把运维经验直接写进了模型上下文:优先用没观察到封禁的源;Eastmoney 按 IP 限流,所有 Eastmoney 系的工具和 loader 都走共享的按 host 节流器(实现在 agent/backtest/loaders/_http.py,进程内按 host 桶控制最小间隔,还加了随机抖动避免并发 worker 齐步走,并复用同一个 session 摊薄 TCP/TLS 建连开销);单个符号失败或一次瞬时 HTTP 错误要放进返回信封里报告,不许让整批中断。
文档最后一段是数据核验纪律:会影响结论的数字要跨至少两个独立来源核对,优先原始披露而非第三方聚合;来源之间偏差超过 1% 要标成口径不一致(GAAP 与 Non-GAAP、合并与母公司、币种、TTM 与年度这些),不许悄悄挑一个;仓库里有个 financial_rigor 工具的 cross_validate 命令专门干这件事,传入按源分组的数值,返回中位数共识、逐源偏差和一个 all_consistent 标志;没核验过的数字必须标成单一来源或估算。
这份文档最容易腐烂的地方,是它列的源名跟代码里的源名对不上。仓库的处理是 agent/tests/test_data_routing_sources_subset.py:用正则把 markdown 表格第一列里那些小写标识符抠出来,排掉 get_、screen_、search_、iwencai 这些工具名前缀,剩下的必须是 VALID_SOURCES 的子集。测试文件的 docstring 把这个方向称为 code-first——文档不能凭空写出代码没注册的源。加上前面那条反向覆盖测试,两边就锁死了。技能文件本身该怎么组织、和别的机制怎么分工,可以对照 Agent 技能机制三体对比。
顺带一提规模感:agent/src/skills/ 下我数到 88 个技能目录,data-routing 只是其中一个,但它是被要求最先加载的那个。项目自己在 agent/SKILL.md 里也用「24 market-data sources」这样的说法描述数据源规模,那是仓库的自述,不是本文的实测结论;我这边能直接数出来的是模块列表 24 条、VALID_SOURCES 25 项。
七、边界与代价:这套设计放弃了什么
工程上没有白拿的东西,这一层的代价挺明确。
代码路由只覆盖 OHLCV 这一层。 回退链、符号正则、注册表,管的都是行情 K 线。资金流向、龙虎榜、财报、期权链这些需求,在 SKILL.md 的能力表里是用自然语言映射到工具名的,靠的是模型读文档,没有等价的代码级强制。这意味着 K 线之外的路由质量取决于模型有没有认真读那张表。
降级会换口径。 从一个源退到另一个源,拿到的是同一个标的,但复权方式、时区、字段精度未必一致。代码层只保证「拿到了数据」,不保证「口径没变」。include_provenance 里的 currency_conversion 字段恒为 "none",也就是这一层压根不做币种换算——跨市场比较时这个坑得你自己填。
节流是进程内的、尽力而为的。 _http.py 的 docstring 自己写明了:所有间隔控制都是 best-effort 且 process-local,不跨机器协调。你在三台机器上同时跑批,节流器互相不知道对方存在。
缓存默认关,且只缓存已收盘区间。 这是正确的默认值,但代价是你不能指望它替你省掉大批量回测的抓取时间,除非你显式打开并且接受「今天的数据永远不进缓存」。
它明确不管的事。 这一层不判断数据对不对——validate_ohlc 只查结构不变式,查不出一个数值上离谱但结构合法的 bar。跨源核验的责任被显式推给了 SKILL.md 里那段核验纪律和 financial_rigor 工具,也就是推给了模型的行为约束,而不是代码保证。要是你打算把这套搬走,得清楚自己接手的是「取数不容易断」,不是「取到的数一定对」。
碰到实盘那一侧要单独算账。 仓库 agent/src/trading/connectors/ 下有 12 个券商连接器子目录(README 也自述 12 brokers),这条路和取数完全是两码事:券商凭据一旦落在本地配置或环境变量里就有暴露面,下错的单不可撤销,而程序化交易的申报与合规义务因司法辖区而异。能不能这么接、能接到什么程度,以你所在司法辖区的监管要求与券商协议为准。
因子与回测部分只谈工程。 仓库根目录的 NOTICE 写得很清楚:qlib158 那组特征定义来自 Microsoft Qlib,走 Apache 2.0;alpha101 来自 Kakushadze (2015) 的公开论文,gtja191 来自国泰君安 2014 年的公开研报,学术那组来自 Fama-French 等公开模型——仓库把这些当作数学事实重新实现,论文与研报的正文、表格、图都不在仓库里,各因子库子目录下另有 LICENSE.md。所以讲到这些因子库时不能写成「项目自研」;能不能商用,以许可证原文为准,本文不提供法律意见。同样地,历史表现不代表未来,本文只讨论工程实现,不评价任何因子或策略的好坏,也不构成任何投资建议。
八、上手与避坑清单
别绕过注册表直接 import 某个 loader 类。 会踩是因为直接 import 拿到的是一个裸类,is_available() 为假时你自己得处理,而回退链上的其它源你一个也用不上。避法是统一走 resolve_loader 或 get_loader_cls_with_fallback,让降级逻辑只有一份实现。
新增源时别只写 loader 文件。 会踩是因为 @register 只在模块被 import 时才执行,而 _ensure_registered() 里的 _loader_modules 是硬编码列表——不加进去,你的 loader 在运行时根本不存在,而且不会有任何报错。避法是三处一起改:模块列表、VALID_SOURCES、对应市场的 FALLBACK_CHAINS,然后跑那条覆盖测试确认没漏。
改源名时记得同步技能文档。 会踩是因为源名在代码和 markdown 里各存了一份,改了一边不会有任何症状,直到模型按旧名字去请求一个不存在的源。避法是先改代码再改文档,然后让 test_data_routing_sources_subset.py 告诉你哪一边还没跟上——这条测试存在的全部意义就是替你记住这件事。
别把 local 当成一个「优先试试本地」的源。 会踩是因为它的 markets 覆盖了所有市场,看起来像个万能兜底。实际上显式指定 local 而它不可用时会直接抛错,不会退到网络源。避法是分清两种用法:想要本地优先就显式写 local 并接受硬失败,想要自动兜底就用 auto 让链自己走。
符号写法错了不会报错,只会走错源。 会踩是因为 detect_source 全不命中时默认返回 tushare,所以一个格式不对的美股符号会被安静地送去中国市场的 loader,最后表现为空结果而不是异常。避法是对着 SKILL.md 的 Symbol Format Reference 写符号,排查时把 include_provenance 打开看 detected_source 到底是什么。
在容器或 CI 里跑批之前先想清楚节流。 会踩是因为节流是进程内的,多进程多机器并发时实际请求密度是你以为的好几倍,免费源那边看到的是一次突发,然后就是限流甚至临时封禁。避法是要么串行跑,要么把相关的最小间隔环境变量调大(_http.py 的 docstring 就是这么建议的),要么干脆走需要 key 的源。
打开缓存之后别拿它当离线数据集。 会踩是因为 loader_cache_range_is_final 会把 end_date 是今天或未来的区间整个跳过,你以为缓存住了,实际每次都在打网络,还会以为是缓存实现坏了。避法是回填历史数据时把 end_date 停在昨天。
别把返回结果当成一定完整。 会踩是因为失败被设计成不中断整批:拿不到的符号进 _unresolved,行数超限的进截断信封。你要是只按符号名取值,缺的那部分会静默消失。避法是每次都检查 _unresolved,并且判断返回的是原始列表还是带 truncated 标记的字典。
收束:三个可以直接搬走的判断
这套数据源层里,跟金融关系不大、跟 Agent 工程关系很大的东西有三条:降级要区分「换个源」和「改了用户意图」,后者必须硬失败;面向模型的文档要被测试钉在代码上,否则它一定会漂;提示词里的数字要从代码现算,别手写常量。
想自己顺一遍的话,建议按这个顺序读:先 agent/backtest/loaders/registry.py 看清名单、链和两个入口,再 agent/backtest/loaders/base.py 看契约和三块公共地基,接着 agent/src/market_data.py 看符号怎么变成源名、fetch 失败怎么二次兜底,最后 agent/src/skills/data-routing/SKILL.md 看同一套规则怎么翻译成模型能读的文档,顺手把 agent/tests/test_data_routing_sources_subset.py 扫一眼,理解那份文档是怎么被锁住的。真要动它,改完记得跑注册表相关的那几条测试再提交。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源项目怎么接大模型:能力表与登录态通道 和 开源项目 Vibe-Trading 的技能体系:88 个目录如何按需加载。