Vibe-Trading 开源交易 Agent 的四道下单闸门:从强制执行层到日频计数

2026-08-05

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

这套闸门真正值钱的地方不是”检查得多”,而是把每一次不确定都倒向拒绝——读不到持仓、拿不到报价、算不出名义金额、抢不到提交锁,全都判 DENY,而不是放行后再补救。绝大多数 Agent 项目在写工具权限的时候,默认分支是”没匹配到规则就放过去”;HKUDS 开源的 Vibe-Trading 这个仓库(不是”凭感觉交易”那种泛指说法,是一个具体的开源项目)在 agent/src/live/ 这一层反过来,默认分支是挡住。这个方向一旦选反,后面加多少检查都补不回来。

先说清楚本文的位置。站内已经写过 Agent 权限的台阶式收缩(权限该分几级)、Hermes 的安全审批链路(人怎么介入审批)和 Agent 最小权限设计(权限边界怎么划),那三篇讲的是”该不该给权限”;这篇只讲一件事——权限已经给了、Agent 真的会调用下单接口时,代码里还剩几道物理闸,它们分别写在哪个文件的哪个函数里。历史表现不代表未来,本文只讨论工程实现。

一、这几道闸要防的是什么

一个能读行情、能写研究报告的 Agent,和一个能调 place_order 的 Agent,失效模式完全不是一回事。前者错了你重跑一遍;后者错了,订单已经进了券商的撮合队列,撤不回来。

从代码与注释里能反推出四类要防的场景:模型被提示注入或绕进死循环、连续发出下单调用;券商工具的参数模式项目方管不到,同一个 place_order 在不同券商那里字段名都不一样,解析错一个字段就是数量级偏差;行情源临时不可用,本地算不出这笔单值多少钱;多个进程同时跑,各自读到的当日计数都还没超限。

这四类问题对应四段独立代码。它们的共同前提写在 agent/src/live/enforcement.py 的模块文档里:每一项检查都是 fail-closed——任何无法解析的输入、缺失的行情、含糊的字段,都拒单而不是放行。

二、强制执行层:一个纯函数,九步固定顺序

agent/src/live/enforcement.py 里的 check_mandate 是整条链路的判定核心。它是个纯函数:吃进 Mandate(用户授权契约)、OrderIntent(归一化后的订单意图)、持仓、余额、当日计数,吐出 None(放行)或者一个 BreachEvent(描述第一条被违反的规则)。

检查顺序是写死的,先失败的那条就是判决:

  1. 排除清单 exclude_symbols——优先级高于其它一切 universe 规则;
  2. 工具类型白名单 allowed_instruments——注释里明确写了空集等于全拒;
  3. 资产类别桶 asset_classes
  4. 单笔名义金额 max_order_notional_usd
  5. 成交后的总敞口 max_total_exposure_usd
  6. 成交后的毛杠杆 max_leverage
  7. 当日下单笔数 max_trades_per_day
  8. 资金上限 account_funding_usd(纵深防御,注释说明券商侧的资金上限才是真正兜底的那一道);
  9. 最后才是昂贵的 universe 底线检查(市值、流动性),因为这两项要走数据加载器联网。

把”贵的检查放最后”是个很实在的工程取舍——前八步全是本地算术,能挡掉的就不必付网络往返的代价。

判定结果的类型区分同样有讲究。BreachEvent 带一个 kind 字段,取值是 universe / instrument / quantitative 三者之一。前两者是结构性违规(比如标的在排除清单里、工具类型没被授权),文档里写得很直白:没有任何”放宽”能允许它,除非改契约本身,而 Agent 永远没有改契约的路径,所以直接 DENY。只有 quantitative(超额度类)才会走 PAUSE_FOR_REAUTH,把完整的 BreachEvent 抛给上层,让人来决定要不要放宽。

几个细节值得单独拎出来。_post_trade_gross_exposure 是按 symbol 逐个算带符号敞口的,不是简单地把订单金额从组合总市值里减掉——这样一笔卖单只能把已有多头减到零,超出的部分变成空头敞口,而不是继续冲销整个组合。第八步的资金检查只对买单生效,注释里写明”never block a sell on this”。而 _resolve_order_notional 会把非正数和 NaN 一起拒掉。

三、下单守卫:真正被 Agent 调到的那一层

check_mandate 是纯函数,谁调它?agent/src/live/order_guard.py 里的 LiveOrderGuardTool。它继承 MCPRemoteTool,只被实例化在券商那些被判为 WRITE 或 UNKNOWN 的远程工具上;READ 工具保持原样,走没有闸的普通路径。

它的 execute() 按这个顺序跑:加载契约(无效或 schema 版本不认识就拒)→ 检查过期(expires_at 解析不出来也算过期)→ 检查熔断标志 → 用券商专属的 extractor 解析订单意图(解析不出就拒)→ 名义金额归一化 → 读持仓和余额 → 进入提交锁跑 check_mandate

这里面有两个设计点特别值得抄。

第一个是 _normalize_intent_notional 一笔订单可能同时带 notional_usdquantity,也可能只带 quantity。如果只按显式金额判,塞一个很小的金额配一个很大的数量就能把所有上限绕过去。这个方法的做法是:只要带了 quantity,就去取实时报价(先问券商自己的报价工具,取不到再退回项目的数据加载器),算出数量隐含的金额,然后取显式金额和隐含金额里较大的那个盖到 intent 上。取不到报价怎么办?返回 None,直接拒单。

第二个是计数消耗的时机。 注释里点破了一个容易踩的坑:MCPServerAdapter.call_tool 在券商或网络失败时不抛异常,它返回一个 {"status": "error"} 信封。所以 _allow 必须自己去看转发回来的载荷——非错误信封才递增当日计数并记 order_placed / accepted;错误信封则记 order_rejected / outcome=error,且不消耗计数,因为一次失败的转发根本没下成单。_is_error_envelope 还把”解析不出来的响应”也当成错误处理。

另外,repeatable = False 这个类属性对应的是”实盘订单绝不能被静默重发”。这条约束和站内讲的 重试与幂等的边界 是同一个问题的两面:交易调用不幂等,所以整条链路上没有任何自动重试。

每一次判决——放行、拒绝、暂停——都会写一条审计事件。agent/src/live/audit.py 里的 write_live_action 先用 redact_payload 脱敏,再把同一份记录扇出到最多三个 sink:固定的 audit.jsonl 账本(总是写)、本次运行的 trace writer、以及界面用的事件回调。返回的工具结果里还会带上这条脱敏记录,键名是冻结的 live_action,这样 SSE 中继不用碰 agent 主循环就能推事件出去。这种把审计做成结构化事件而非日志字符串的做法,可以对照 Agent 可观察日志怎么写 一起看。

四、熔断:一个文件的存在就是停止信号

agent/src/live/halt.py 的设计前提写在第一段:熔断必须独立于模型是否配合。所以它不是内存标志,不是配置项,而是一个文件。

halt_flag_set() 做的事情只有一件——看 <runtime_root>/live/HALT 这个文件在不在。全局哨兵永远优先:只要它存在,任何 broker 都返回已停。还支持每个 broker 单独的哨兵文件,允许只停一个通道。trip_halt() 写文件是原子的(同目录临时文件 + os.replace),里面装一个小 JSON 记录触发时间、触发来源和原因;但文档写得很清楚——文件的”存在”才是熔断,JSON 只是归因元数据,内容读不出来或者格式坏了照样算已触发。用户或者外部看门狗直接 touch 这个文件也生效。

熔断在这个项目里其实有三层落点:

  • 注册期agent/src/live/registry.pywrap_live_broker_tools 在组装工具列表时如果发现已熔断,直接把 WRITE/UNKNOWN 那些下单工具从列表里删掉,模型根本看不到它们;
  • 调用期LiveOrderGuardTool.execute() 顶部的那次检查,覆盖”列表构建之后才触发熔断”的时间窗;
  • 抢先动作register_halt_action / on_halt_action 这对函数让 runner 注册一个回调,观察到触发时去撤挂单、按契约决定要不要平仓,而不只是拒绝下一笔。

第三层的拆分方式很克制。trip_halt 故意自动触发这个动作,因为用户可能绕过 trip_halt 直接建文件;把”写标志”和”执行清扫”解耦,才能保证不管标志是谁写的,观察到的 runner 都会执行同一套动作。agent/src/live/runtime/flatten.py 里的 flatten_and_cancel 是这个动作的实现,函数文档把顺序写死为「先撤掉所有挂单,再按契约决定要不要平仓」,理由写的是先让订单簿静下来;平仓那一步还额外受契约里 flatten_on_halt 这个开关约束,读不到就默认只撤单不平仓,同样是往保守方向倒。文档里另有一句同样重要:这条路径上每一次券商调用和每一次报错都先写审计,出错的调用一律不重试。

五、日频计数:真正难的不是计数,是并发

agent/src/live/daily_count.py 只有一百多行,但把两件不同的事分开了。

计数本身很朴素:trade_counter.json 里存 {"date": ..., "count": ...},按 UTC 自然日滚动,日期对不上就当今天是 0。写入用同目录临时文件加 replace,临时文件名里带进程 ID 和线程 ID,避免同机多写者互相覆盖。这个文件的读取是 fail-open 的——模块文档明说”计数是纵深防御,券商才是真实上限,任何读失败读作 0(只在计数上 fail-open,订单上永不)”。

真正吃劲的是 daily_order_lock 这个上下文管理器。它在 broker 目录下开一个 .order_submit.lock,POSIX 走 fcntl.flock,Windows 走 msvcrt.locking非阻塞。抢不到就抛 DailyOrderLockUnavailable,上层直接拒单。两条下单路径(MCP 的 order_guard 和直连 SDK 的 sdk_order_gate)都把”最终计数检查 → 券商提交 → 计数落盘”整段包在这把锁里。

选非阻塞而不是排队等待,是个明确的取舍:宁可把并发的第二笔单挡回去让上层重新决策,也不要让它排队等一会儿再基于过期的持仓快照打出去。

六、这几块分别在哪、什么时候会碰到

组成部分它负责什么仓库位置你什么时候会碰到它
check_mandate纯函数判定,返回 BreachEventNoneagent/src/live/enforcement.py想改任何一条额度口径或检查顺序时
LiveOrderGuardTool包住 MCP 远程下单工具,跑完整条闸agent/src/live/order_guard.py接一个通过 MCP 暴露的券商时
execute_live_order同一套仪式的函数版,给直连 SDK 用agent/src/live/sdk_order_gate.py接一个走 Python SDK 的券商时
halt_flag_set / trip_halt文件哨兵,存在即停agent/src/live/halt.py需要一个不依赖模型配合的停止键时
daily_order_lock / 计数UTC 日计数 + 跨进程非阻塞提交锁agent/src/live/daily_count.py排查”为什么这笔被拒了”时
classify_tool工具读写三级阶梯,UNKNOWN 按 WRITE 处理agent/src/live/classification.py券商冒出一个没见过的工具名时
write_live_action脱敏后扇出到账本 / trace / 事件总线agent/src/live/audit.py要复盘”它到底做了什么”时

表里的 classify_tool 是”默认拒绝”在工具发现层的落点:MCP 工具自带的 annotations 只能把工具往”读”的方向降级,维护者的人工映射表永远压过它,两者都没覆盖到的落进 UNKNOWN、下游当 WRITE 处理。注释里直接引用了 mcp.types.ToolAnnotations 自己的文档——客户端绝不应基于来自不可信服务器的注解做工具使用决策。

七、边界与代价:它明确不管的那些事

它不保证订单一定不出错,只保证越界的那些被挡住。 契约里的额度是用户自己填的,填错了闸门照样放行——它检查的是”是否越过你设的线”,不是”这条线设得对不对”。

它放弃了可用性换确定性。 数据加载器不可用、券商报价工具没响应、锁被别的进程占着,结果都是拒单。行情源抖一下,那段时间的所有按数量下的订单都会被挡。这在实盘上是对的选择,但你得接受”闸门本身会成为不可用来源”。

几处能力是明确留白的。 market_cap_usd 只对美股和美国 ETF 走 yfinance 的 .info 尽力而为,其它资产类别直接返回 None——一旦设了市值底线,非美标的会一律被拒。A 股在 _ASSET_CLASS_MARKET 里根本没有映射,注释写明这是有意为之的 fail-closed。sdk_order_gate.py 的货币说明也很坦白:连接器报价是本币,契约上限是美元,港币/离岸人民币按美元处理会高估敞口,于是上限收得更紧——过度拒绝,不会放松。另外 agent/src/live/extractors/__init__.pyBROKER_EXTRACTORS 目前只注册了一个 broker 键,MCP 路径上没有对应解析器的券商会被直接拒单。

下面这些它一点都不管。 你所在司法辖区对程序化交易的申报与备案义务;券商协议里关于自动化下单是否被允许的条款;API 凭据的保管——OAuth 令牌缓存和契约文件都落在本地 <runtime_root>/live/ 下,目录建的时候带 0700,但只要这台机器被别人拿到,暴露面就是实打实的;以及最要紧的一条——订单一旦被券商接受就是不可撤销的,任何闸门都只能挡住”还没发出去”的那些。能不能这么用,以你所在司法辖区的监管要求与券商协议为准。

八、上手与避坑清单

别把两条路径当成同一份代码。 MCP 走 order_guard.py,直连 SDK 走 sdk_order_gate.py,两边跑的是同一个 check_mandate,但外围仪式各写了一份。踩点在于:order_guard.py_normalize_intent_notional 重建 OrderIntent 时没有带上 asset_class 字段,而 sdk_order_gate.py_normalize_notional 显式传了。也就是说带数量的订单在 MCP 路径上会退回”按 instrument_type 推默认桶”的行为。要改哪条口径之前,先确认自己在改的是哪条路径。

别在券商工具的 readOnlyHint 上建立信任。 这个字段是服务器自己声明的。项目的做法是给每个券商维护一份人工映射表压在它上面。你接新券商时如果偷懒不写映射表,工具会落进 UNKNOWN——好消息是那也当 WRITE 处理,不会漏出去;坏消息是读工具也被包上闸,行为跟你预期的不一样。

别忘了错误信封不是异常。 这是全篇最容易复制到自己项目里的一条:适配器返回 {"status": "error"} 而不是 raise,try/except 一个都抓不到。如果你按”没抛异常就是成功”来递增计数或写审计,会得到一份和现实对不上的账本。判断成功要显式检查载荷。

别用阻塞锁替换非阻塞锁。 看到 LOCK_NB 想改成阻塞等待很自然——反正等一会儿就好了。但等待期间持仓快照会过期,等到拿到锁再提交,判定依据已经不是当前状态。这个项目选择直接拒,让上层重新走一遍完整判定。

别把熔断做成内存标志。 agent 主循环卡死、模型在循环、事件总线断了的时候,内存标志谁来读?文件哨兵不需要任何进程配合,代价只是每笔单多一次 Path.exists()

别在没有隔离账户的前提下接实盘。 契约里的资金字段注释说得很清楚:真正兜底的是券商那边被圈定的账户余额,本地那份只是镜像出来做纵深防御的。本地这份可以被绕过(比如数据陈旧),券商那份不能。顺序是先在券商侧圈钱,再来配本地契约。

收束

这四段代码可以当成一份检查清单来用,不管你做的是不是交易 Agent:

  • 我的判定函数在拿不到数据时,默认分支是放行还是拒绝?
  • 我的”执行成功”判断,是看有没有抛异常,还是看载荷内容?
  • 我的停止键需要几个进程配合才能生效?
  • 并发的第二个请求,是排队等还是直接拒?
  • 我的审计记录,能不能把一次动作回溯到授权它的那次人类点击?

想继续往下读的话,路径大概是这样:先把 agent/src/live/enforcement.py 通读一遍拿到判定骨架,再看 agent/src/live/order_guard.py 理解调用时序,然后 agent/src/live/classification.py 补上”哪些工具会被包闸”这一块,最后 agent/src/live/registry.py 看整条链路是怎么在工具注册期装配起来的。agent/src/trading/connectors/ 下面有十二个券商连接器子目录,接新券商的活儿基本都落在那个目录里。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源项目的脱敏层:Agent 输出转发出去之前先看清它管到哪一步HKUDS 开源项目 Vibe-Trading 的 mandate:Agent 先提授权再动手

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