拆解开源项目 Vibe-Trading 的券商抽象层与凭据保管
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
这层抽象真正的切口不是「把十几家券商的 API 拉平成一套函数」,而是把「用哪条通道到达券商」和「这条通道被允许干什么」拆成两个互不干扰的字段。 这里说的 Vibe-Trading 是 HKUDS 放在 GitHub 上的那个开源交易 Agent 项目(仓库 HKUDS/Vibe-Trading,MIT 许可证),不是「凭感觉做交易」这类泛指。前者在代码里叫 transport,后者是一组 capabilities 字符串加一个 readonly 布尔。这两个字段看懂了,agent/src/trading/ 下那些看起来重复的 if 分支就每一行都有理由;看不懂,你会误以为它只是没抽干净。
一、它先要解决的问题:同一件事有三种到达方式
agent/src/trading/connectors/ 下有 12 个连接器子目录(alpaca、binance、dhan、futu、ibkr、longbridge、mt5、okx、robinhood、shoonya、tiger、trading212),仓库 README 里也把这一块自述为 12 brokers。麻烦在于,这 12 家并不是「同一种东西的 12 个实例」。
有的券商压根不给你云端 API,只给一个跑在你本机的客户端,程序连上去的是本地端口。有的走的是远端 MCP 服务,认证是 OAuth,你手里没有密钥只有一个缓存下来的令牌。剩下的才是常规印象里的形态:拿 API key 和 secret 直接调 SDK 或 REST。
agent/src/trading/types.py 把这件事写成了一个三值字面量类型:
Environment = Literal["paper", "live"]
Transport = Literal["local_tws", "remote_mcp", "broker_sdk"]
READ_CAPABILITIES = (
"account.read",
"positions.read",
"orders.read",
"quotes.read",
"history.read",
)
transport 描述的是「怎么到达」,environment 描述的是「到达哪个账户环境」,两者正交。这个切法带来的直接好处是:新增一家券商时,你只需要判断它落在哪种 transport 上,而不需要重新设计一遍调用链路。IBKR 同时出现在 local_tws 和 remote_mcp 两条 profile 上,恰好说明这两个维度是真的独立的。
二、TradingProfile:九个字段撑起整张表
抽象的载体是一个 frozen dataclass,字段少得有点出人意料:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
TradingProfile 数据类 | 定义一条连接档案的全部字段:id/connector/label/environment/transport/capabilities/readonly/config/notes | agent/src/trading/types.py | 想知道某条 profile 到底允许什么时 |
各家的 profiles.py | 把该券商的若干条 profile 写成常量元组,比如 IBKR_PROFILES | agent/src/trading/connectors/<券商>/profiles.py | 想加一条自定义档案,或想查某条档案的默认端口 |
BUILTIN_PROFILES 注册表 | 把 12 家的元组拼成一张全局表,并提供 list_profiles / profile_by_id | agent/src/trading/profiles.py | 排查「unknown trading connector profile」报错时 |
| 选中态持久化 | 把当前选中的 profile id 写进 trading-connections.json,并尝试 chmod(0o600) | agent/src/trading/profiles.py | 换默认券商、或发现调用打到了意料之外的通道时 |
| 分派服务 | 按 transport 把读写操作分发到三条路径,并给返回值统一贴上元数据 | agent/src/trading/service.py | 写脚本调用账户/持仓/行情/下单时 |
各家的 sdk.py | 实现 broker_sdk 那条路径的七个统一函数与配置加载 | agent/src/trading/connectors/<券商>/sdk.py | 排查凭据从哪读、脱敏做到哪一步时 |
| 凭据隔离转发 | 可选的 TAP 转发路径,让 agent 进程手里只有策略化的代理密钥 | agent/src/trading/tap_forward.py | 你不愿意让 agent 进程持有券商密钥时 |
把各家 profiles.py 里的 TradingProfile( 数一遍,一共 39 条内置档案(Alpaca、Binance、Futu、MT5、OKX、Tiger 各 4 条,Dhan、IBKR、Longbridge、Shoonya 各 3 条,Trading212 两条,Robinhood 一条)。同一家券商为什么要好几条?看 IBKR 的定义就清楚了:
TradingProfile(
id="ibkr-paper-local",
connector="ibkr",
label="IBKR Paper · TWS / Gateway",
environment="paper",
transport="local_tws",
capabilities=READ_CAPABILITIES,
readonly=True,
config={"profile": "paper", "host": "127.0.0.1", "port": 7497, "client_id": 77},
notes="Uses the user's local TWS paper session. No IBKR credentials enter Vibe-Trading.",
),
模拟盘和实盘不是同一条 profile 上的一个开关,而是两条独立记录。Binance 的 profiles.py 把理由写进了模块 docstring:模拟和实盘用的是不同的密钥对、不同的主机,所以它们本来就是两个东西。这个设计决定了一件很实际的事——你没法「不小心把 paper 切成 live」,因为切换动作是换 id,不是改布尔值。
capabilities 这组字符串则承担了另一半表达力。只读档案挂的是 READ_CAPABILITIES 五元组;能下单的模拟档案会追加 "orders.place";而能下单的实盘档案追加的是 "orders.place.requires_mandate"——多出来的后缀是给上层看的信号,意思是这个动作必须先过用户授权闸门。Robinhood 那条远端 MCP 档案还额外挂了 "runner.manage.requires_mandate",service.py 里把它提成了常量 RUNNER_CAPABILITY,用来判断一条 profile 能不能托管长期运行的执行器。
三、分派层怎么写:不抛异常,返回结构化的拒绝
agent/src/trading/service.py 是这层抽象的唯一出口,CLI、MCP 与 agent 工具都从这里进。它的每个读操作长得都差不多:
def check_connection(profile_id: str | None = None, **overrides: Any) -> dict[str, Any]:
"""Check a connector profile without mutating broker state."""
profile = profile_by_id(profile_id)
if profile.transport == "local_tws":
...
if profile.transport == "broker_sdk":
module = _sdk_module(profile.connector)
report = module.check_status(module.build_config(profile.config, overrides))
...
return _remote_status(profile)
broker_sdk 那条分支背后是一张 _SDK_CONNECTOR_MODULES 映射表,把 10 个连接器键指向各自的 sdk 模块,运行时用 importlib 动态导入。这 10 个模块必须对外暴露同一组函数名:build_config、check_status、get_account_snapshot、get_positions、get_open_orders、get_quote、get_historical_bars。约定就写在 _SDK_CONNECTOR_MODULES 上方的注释里,没有抽象基类,也没有 Protocol——契约靠文档和测试兜着。这算不算好设计见仁见智,但它确实让新增一家券商的成本降到「照抄一个已有的 sdk.py 骨架」。
有两个细节值得单独说。
一是所有返回值都要过 _with_profile,往 payload 里补上 profile_id、connector、environment、transport 四个键。这意味着任何一次调用的结果,脱离上下文单看也能知道它是从哪条通道、哪个环境读出来的。做过 Agent 工具层的人应该有共鸣:模型拿到一坨没有出处的 JSON,很容易把模拟盘数据当成实盘来解读。关于工具返回值该带哪些元数据,可以参考 Agent 工具返回值设计 里的通用讨论。
二是能力不足时的处理。_unsupported 返回的是一个 status 为 error 的字典,而不是抛异常:
return {
"status": "error",
...
"error": f"profile '{profile.id}' does not support {capability} through the generic trading tool yet",
"capabilities": list(profile.capabilities),
}
对 Agent 场景这是对的——模型能读懂一个带 capabilities 列表的拒绝理由,却读不懂一个 Python traceback。但对写脚本的你是个陷阱:调用不会炸,你必须自己判 status。
抽象没有拉平的地方也很诚实。get_history 同时保留了两套参数词汇:duration / bar_size / what_to_show / use_rth 是 IBKR 的说法,period / limit 是所有 broker_sdk 连接器都认的通用说法,各自映射到自家 SDK 的 token。函数 docstring 把这件事直接写明了。硬要统一成一套,代价是丢掉 IBKR 那侧的表达力;不统一,代价是调用方要知道自己在跟谁说话。它选了后者。
写路径上,place_order 开头是两道结构性拒绝:transport 不是 broker_sdk 的直接拒(函数 docstring 明说 IBKR 保持只读、Robinhood 走自己的 MCP 闸门),readonly 为真的直接拒,两次都走 _unsupported。过了这两道才按环境分流:environment 是 paper 就直发券商沙盒账户,只有实盘才进 execute_live_order,先构造一个 OrderIntent,再过授权闸门。cancel_order 的注释解释了一个反直觉的取舍:撤单是降低风险的动作,所以它不被授权闸门和急停开关拦,但它仍然是一次实盘动作,必须写审计——_audit_live_cancel 整个包在 try/except 里,注释写着审计绝不能阻塞撤单。
四、凭据保管:三种到达方式,三种暴露面
到了这一层,抽象再漂亮也躲不开一个事实:程序要代表你访问券商账户,就得握着某种可以证明「是你」的东西。这个东西一旦泄露,损失是不可逆的。
站内已经有 API Key 安全管理 讲通用密钥托管原则,有 API 接入方式对比 讲不同接入形态各自的取舍,还有 Hermes 的 profile 路由 讲另一个开源项目怎么用档案做多后端路由;这篇不重复那些,只落在 Vibe-Trading 这一处的具体实现上——它的三种 transport 恰好对应三种截然不同的暴露面。
local_tws:凭据不进程序。 IBKR 那两条本地档案的 notes 写得很直白,用的是你本机已经登录好的客户端会话,密钥不进入这个项目。代价是你得开着那个客户端,而且这条路只给了读能力。
remote_mcp:程序拿到的是缓存令牌,不是密钥。 _remote_status 会去查 has_cached_oauth_token,没查到就返回 not_authorized;读操作走 _call_remote 时还要再查一遍,未授权的错误信息里直接给出了补救命令 vibe-trading connector authorize <profile_id>,并注明要在桌面会话里执行。这条路径还有一层本地白名单:如果配置里的 enabled_tools 既没有 * 也没有列出目标工具名,调用会在本地就被挡下,根本不出网。这是典型的最小权限做法,思路可以对照 Agent 最小权限设计。
broker_sdk:真的要握着 key 和 secret。 这条路最常用,也最需要你想清楚。以 Binance 连接器为例,build_config 的解析顺序是「保存的配置文件 ← profile 默认值 ← 每次调用的覆盖参数」,密钥来自 ~/.vibe-trading/binance.json;save_config 写完会尝试 chmod(0o600);对外输出时走 _public_config 脱敏,secret 换成固定占位串,key 只留前四位加星号。
Longbridge 那家的处理更细,单独拆了一个 credentials.py,把三个字段 app_key / app_secret / access_token 当成一个原子单位来解析。环境变量和运行时文件都可能提供这三个字段,它的规则是:一边只填了部分,报 credentials_partial 并列出缺哪几个;两边都齐全但内容不一致,报 credentials_conflict;都没有则是 credentials_missing。冲突比对用的是常数时间比较:
conflict_fields = tuple(
field
for field in _CREDENTIAL_FIELDS
if not hmac.compare_digest(environment[field], runtime_file[field])
)
三个字段的 dataclass 全部标了 field(repr=False),避免打印对象时把密钥带进日志。错误类型 LongbridgeCredentialError 只携带错误码和字段名,不携带值——诊断信息足够定位问题,又不会把密钥本身泄进异常栈。这套「只报字段不报值」的诊断口径,值得抄进任何要打日志的凭据解析代码。
还有一条可选路径值得单独提:agent/src/trading/tap_forward.py。配置了 TAP_PROXY_URL 和 TAP_AGENT_KEY 之后,出站的券商请求会先发到代理的 /forward 端点,真正的密钥留在代理侧,请求里只放 <CREDENTIAL:name.field> 形式的占位符,由代理在策略校验和人工审批之后替换并转发。模块 docstring 列出的三条性质是:agent 进程不持有券商密钥,只持有一个受策略约束的代理密钥;写类请求会阻塞等待人工批准;凭据上挂的 allowed_hosts 钉死了密钥可以被发往哪里,目标被篡改会在注入之前就被拒。这条路径是加性的、可选的,没配就完全不影响原有直连。
风险要说在明处。 上面这些机制降低的是「密钥被误打进日志」「被 Agent 直接读走」这类风险,不改变几个硬事实:密钥最终仍以明文躺在你自己机器的文件里,0600 只挡得住同机的其他普通用户,挡不住 root、备份程序和同步盘;实盘下单一旦被券商接受就不可撤销,撤单是另一次会失败的请求;程序化交易本身在很多地方还有申报、留痕之类的额外义务,具体要求因司法辖区、因你与券商签的协议而异。这些不是代码能替你承担的部分。
顺带说明一个容易被误读的地方:仓库 agent/src/factors/ 下那批因子库不是这个项目自研的,根目录 NOTICE 写明了各自来源与许可——Microsoft Qlib 的特征定义走 Apache 2.0,另有几组公式来自公开论文与券商研报,仓库把公式当作数学事实做了重新实现,各子目录另有 LICENSE.md。能不能商用以许可证原文为准,本文不提供法律意见。历史表现不代表未来,本文只讨论工程实现。
五、边界与代价:这套抽象放弃了什么
它没有统一数据模型。 service.py 只往返回值里塞四个元数据键,持仓、订单、行情的字段结构仍然是各家 SDK 的原样。你想跨券商聚合持仓,得自己写一层映射。这是「薄抽象」的典型代价——省下了建模成本,把差异原封不动推给了调用方。
它没有统一参数词汇。 K 线那两套参数并存就是明证。好处是每条路径都能用上原生表达力,坏处是调用方必须知道自己面对的是哪种 transport,抽象在这里是漏的。
档案是编译期常量,不是运行期数据。 BUILTIN_PROFILES 是一个模块级元组,list_profiles() 直接返回它的拷贝。落盘的 trading-connections.json 里只有一个 selected_profile 字段——存的是选择,不是凭据,也不是自定义档案。你想接一家没被内置的券商,路径是往仓库里加一个连接器目录,不是改配置。对个人使用的项目这是合理取舍,对想做多租户托管的人就是硬阻碍。
能力上限因券商而异,而且是有意压低的。 IBKR 全线只读;Robinhood 那条实盘档案的写能力挂着 requires_mandate 后缀,实际执行留在自己的 MCP 闸门后面;README 的更新记录里提到过一条规则——没有结构性模拟/实盘区分手段的券商,一律封顶在模拟加只读。这类「宁可少给能力」的决定会让一部分人觉得不好用,但它换来的是误操作面积小。
订单的市场归类是启发式的。 _order_classification 靠符号后缀(.HK、.US、.SH 之类)推断资产类别,推断不出来就返回 None。函数注释解释了为什么这样是安全的:未知情况会回落到美股默认值,而这个回落只可能导致拒绝,不可能悄悄放宽授权范围。这是个值得抄的思路——不确定时让默认值朝「更严」的方向倒。
它明确不管的事。 这层抽象不判断你该交易什么,不做资金规划,不替你评估任何一家券商,也不承诺连接可用性。它管的只有一件事:让上层用同一组函数名,安全地触达形态各异的十几个后端。
六、上手与避坑清单
默认档案是本地 IBKR 模拟盘。 profile_by_id() 不传参时会去读选中态文件,文件不存在就回落到 DEFAULT_PROFILE_ID = "ibkr-paper-local"。为什么会踩:你以为没配置就会报错,实际它会安静地去连本机 127.0.0.1:7497,然后给你一个连接失败的报告,你会误以为是券商配置错了。怎么避:第一步先跑 connector list 和 connector status,确认选中的是哪条,再动别的。
别指望改一个字段就从模拟切实盘。 为什么会踩:多数 SDK 的习惯是配置里有个 sandbox 布尔,这里没有。Binance 连接器的 docstring 说得很清楚,响应里没有任何 paper/live 字段,主机名才是权威判别依据,配置里记的 host / paper_guard 就是给你核对用的。怎么避:切换动作永远是换 profile id,切完用 check_connection 看返回的 environment 和主机对不对。
环境变量和文件同时存在会直接报冲突。 为什么会踩:很多项目的规则是「环境变量优先」,你会预期它静默覆盖。Longbridge 这里不是,两边都齐全但值不一致时返回的是 credentials_conflict,谁都不用。怎么避:定下一个来源,把另一边清干净,别留半份历史配置。
部分填写会得到 credentials_partial 而不是 credentials_missing。 为什么会踩:三个字段填了两个,你搜「missing」找不到对应报错,容易怀疑是解析问题。怎么避:直接看错误里带的字段名列表,它已经把缺的字段列出来了。
远端 MCP 档案未授权时返回的是数据,不是异常。 为什么会踩:status 为 not_authorized 的字典在脚本里长得跟正常返回一样,不判就会往下走。怎么避:所有调用统一先判 status,把 error / not_authorized 当作可预期分支处理,别只靠 try/except 兜底。
远端工具白名单没配会在本地就被拦。 为什么会踩:报错信息里说的是「remote tool 未启用」,你会跑去查远端服务,其实拦截发生在本地。怎么避:看清报错里带的 enabled_tools 列表,那是本地配置,缺就在本地补。
K 线参数别混着传。 为什么会踩:两套词汇都是 get_history 的关键字参数,传错了不会报错,只是不生效。怎么避:本地 IBKR 那条路用 duration / bar_size,其余用 period / limit,按 transport 分开写调用封装。
只读档案调下单拿到的是 error 字典。 为什么会踩:同上,它不抛异常,你的重试逻辑可能会对着一个永远不会成功的调用反复重试。怎么避:下单前先看 profile 的 readonly 和 capabilities,把「结构性不支持」和「临时失败」区分开再决定要不要重试。
收束
这层抽象的可借鉴之处不在于它接了多少家,而在于它把「怎么到达」「允许干什么」「在哪个环境」拆成了三个互相正交的字段,让每一条新增的连接都只是在这三个维度上取值,而不是往调用链里再插一个特判。凭据那一块则是另一个提醒:抽象可以把 API 差异藏起来,藏不住的是每种到达方式各自的信任边界——本地会话、缓存令牌、明文密钥、代理转发,这四种形态承担的风险完全不同,不该被同一个「已配置」的绿灯掩盖过去。
给你一份收尾自检:当前选中的是哪条 profile,它的 environment 和 readonly 是什么;密钥来自环境变量还是运行时文件,另一边有没有残留;日志和异常里会不会出现密钥值;实盘写操作前有没有一道人类可见的确认。
接下来该读哪个文件,按你的目的分:想加一家券商,从 agent/src/trading/connectors/binance/sdk.py 抄骨架,对照 agent/src/trading/service.py 里 _SDK_CONNECTOR_MODULES 上方那段函数名约定;想弄明白凭据解析的边界情况,读 agent/src/trading/connectors/longbridge/credentials.py;想把密钥从进程里挪走,读 agent/src/trading/tap_forward.py 的模块 docstring。至于这些能力在你那里能不能用于实盘,以你所在司法辖区的监管要求与券商协议为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源交易 Agent 项目 Vibe-Trading 的常驻运行时拆解 和 拆开 Vibe-Trading 开源项目的因子引擎:算子层、注册表与批量跑分。