开源项目 Vibe-Trading 的定时研究:把每天自动跑一遍做成带存储的可执行对象

2026-08-05

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

定时 Agent 塌得最狠的一处,永远是「这一轮跑完了」这句话由谁定义。 这里说的 Vibe-Trading 是 HKUDS 放在 GitHub 上的那个开源交易 Agent 项目(仓库自带 MIT 许可证),不是「凭感觉交易」这类泛指说法。它的调度器把作业标成 COMPLETED,含义是「这次派发成功入队了」,不是「研究做完了」——这一点仓库自己在 agent/src/api/scheduled_routes.py 的派发函数里写得很直白:send_message 排队一个 agent attempt 后就返回,不等那次 agent 运行到达终态。你如果照着字面理解那个状态字段去做监控告警、去做「上一轮成功才跑下一轮」的串行控制,看到的会是一片虚假的绿色。这篇就沿着这个坑,把这个开源项目的定时研究模块和它那条 OpenBB 外部数据线拆开。

站内已有几篇讲相邻问题:Agent 的日常运维 讲的是人怎么值班盯它、常驻部署与守护 讲进程本身怎么活下来、重试与幂等 讲一次失败该怎么安全重放;本篇不重复这三件事,只盯一个具体样本——Vibe-Trading 把「定时」落成了什么数据结构,以及这个结构在崩溃、并发、时区三种压力下的表现。

另外先把口径说死:本文只讨论工程实现,不讨论任何标的、任何策略的好坏。凡涉及因子、回测、扫描的段落,历史表现不代表未来,本文一律只看代码怎么组织。

一、这个模块在解决什么问题

「每天早上自动跑一遍研究」这句话,写成 crontab 一行就能跑,为什么还要在 agent 里做一个模块?区别在于这一行有没有身份。crontab 里的那行是无状态的:它跑没跑、上次为什么挂了、下次什么时候、连着挂了几次、要不要放弃,全都不在系统里。

Vibe-Trading 把它做成了一条持久化记录。agent/src/scheduled_research/models.py 里的 ScheduledResearchJob 是个 dataclass,字段包括 idprompt(研究提示词)、schedulenext_run_atstatuscreated_atlast_run_atconsecutive_failureslast_errorfailure_kindconfigtimezone。这十二个字段就是它对「一个定时研究任务」的完整定义——注意这里面一半是失败语义,说明设计时的假设不是「它会跑成功」,而是「它会反复失败,我得记住失败到什么程度」。

schedule 只接受两种形式:一个纯正整数字符串表示间隔毫秒,或者一个简化的五字段 cron 表达式(分 时 日 月 周),每个字段允许 **/n,或逗号分隔的数字与 low-high 区间。星期用 cron 惯例,Sunday 记作 0,7 不被当作周日别名——这是我在 models.pyCRON_BOUNDS 注释里读到的显式声明,别按别的 cron 实现的习惯去猜。

对外是三个 HTTP 端点:POST /scheduled-runs 创建或替换、GET /scheduled-runs 列表(可按 status 过滤)、DELETE /scheduled-runs/{job_id} 删除。整个后台执行默认是关的,agent/src/config/env_schema.pyVIBE_TRADING_ENABLE_SCHEDULER 默认为 false。仓库文档 agent/src/skills/strategy-dev-manager/references/scheduled_decay_scan.md 把这个后果写得很清楚:不带这个开关启动,/scheduled-runs 端点照样收下并记录作业,但什么都不会触发。

VIBE_TRADING_ENABLE_SCHEDULER=1 vibe-trading serve --port 8899

「能创建但不执行」这个组合是很多人第一天踩的坑:你 POST 成功了、拿到了 job id、GET 也能看见它 pending,于是以为跑起来了。

组成部分它负责什么对应仓库位置你什么时候会碰到它
作业模型与校验定义 job 字段、校验 schedule 语法与时区形状、序列化agent/src/scheduled_research/models.py创建作业被 422 拒掉、排查字段类型报错时
持久化存储崩溃安全的原子写、损坏文件隔离、增删查agent/src/scheduled_research/store.py机器断电重启后、store 文件解析失败时
执行器轮询、判定到期、派发、推进下次时间、失败退避agent/src/scheduled_research/executor.py作业不触发、连续失败后不再跑时
HTTP 路由与派发回调三个端点、把作业变成一次 session 派发agent/src/api/scheduled_routes.py对接前端或脚本、想搞清 COMPLETED 含义时
OpenBB 请求适配器把工作台的一次查询接到 agent 上并流式回传agent/src/openbb_bridge/adapter.py从 OpenBB Workspace 侧发起提问时
工作台上下文注入把附件、工具结果、组件元数据折进提示词并限额agent/src/openbb_bridge/context_injector.py附件被截断、模型看不到组件数值时

二、状态机:它防的到底是哪几种崩

executor.py 里最值得逐行读的是 is_due。它只有三行逻辑,但每一行都对应一类线上事故:

if job.status in {JobStatus.CANCELLED, JobStatus.RUNNING, JobStatus.FAILED}:
    return False
return job.next_run_at <= now_ms

CANCELLEDFAILED 被当作终态。函数注释解释了为什么 FAILED 也要排除:失败的作业保留旧的 next_run_at(推进时间这件事本身可能就是失败的原因),如果不在这里挡掉,每一个 tick 都会重新派发它一次,变成一个死循环。这是个很典型的坑——「失败了就重试」的直觉,遇上「推进下次时间的那步失败了」,会退化成无限重试。

RUNNING 被排除是为了不重复派发在飞的作业。但进程被 SIGKILL 时,磁盘上会留下永远是 RUNNING 的僵尸记录,按上面的规则它永远不再到期。所以有 recover_stale_running:执行器实例启动时跑一次,把所有 RUNNING 改回 PENDING。关键在「每个实例只跑一次」这个约束——恢复之后再变成 RUNNING 的,就是真正在飞的活儿,不能再动它。

派发失败走的是有上限的指数退避:consecutive_failures 累加,退避时间由 VIBE_TRADING_SCHEDULER_RETRY_BASE_DELAY_MSVIBE_TRADING_SCHEDULER_RETRY_MAX_DELAY_MS 两个环境变量控制,达到 VIBE_TRADING_SCHEDULER_MAX_CONSECUTIVE_FAILURES 后作业转 FAILED 变终态。派发成功则 consecutive_failures 归零。失败原因用 failure_kind 区分 "dispatch"(派发/会话侧失败)与 "schedule"(时间推进失败),存进 last_error 前先过 redact_textredact_internal_paths 做脱敏,并截断到有上限的长度——因为这个字段会被 API 原样吐给调用方,异常信息里带着内部路径或凭据片段是很常见的事。

真正精细的是并发防护。派发是 await 的,一次 tick 里排在后面的作业,可能在等待期间被用户 DELETE 或者被一次 POST 覆盖了。执行器的做法是两次重读加身份比对:派发前 _run_job 重新 store.get(job.id),派发完 _persist_completion 再重读一次,两次都用 _same_record 比对 idcreated_at。为什么是 created_at 而不是别的?因为同 id 的替换 POST 会盖上新的 created_at,即使 schedule 一模一样也能区分出来。记录没了就是用户取消了,不复活;记录换人了就让新定义自己管自己的生命周期。

顺手还有一个容易忽略的细节:tick 里对每个作业的 _run_job 单独包了 try,注释说明理由是「一个作业的意外异常不能让排在它后面的作业每个 tick 都被饿死」。以及 asyncio.CancelledError 在所有 except 里都被显式重新抛出,不当普通异常吞掉——这是 asyncio 代码里的常见 bug 源,抄的时候别漏。

三、时间:cron 一旦带上时区就不再是纯函数

这块是我认为最值得单独读的部分。next_due 的签名是 (schedule, after_ms, tz),返回严格大于 after_ms 的第一个到期时刻。两个设计选择很关键:

第一,间隔调度在时区校验之前就返回。代码注释写明了理由:间隔型作业必须在存储的时区键在本机解析不了时也继续推进。也就是说时区解析失败只影响 cron 型作业。

第二,搜索按天走,不按分钟走_next_cron_due 先在目标时区的本地日历上逐天筛,命中的那天再枚举小时和分钟。注释给的理由是:这样一个不可能的日期(比如 2 月 31 日)会快速失败,而不是在事件循环上扫过好几年的分钟。搜索窗口常量 _CRON_SEARCH_LIMIT_DAYS 定义为 6 * 366 + 1,注释解释这个余量是为了吸收「一个年度作业连续几年都落在夏令时跳变的空洞里」这种极端情况。

夏令时的处理策略写在 _local_wall_time_to_epoch_ms:把本地墙钟时间转成 UTC 再转回来,如果对不上,说明这个墙钟时间在该时区不存在(春季跳变的空洞),返回 None 让这次触发被跳过。注释点名了 PEP 495——如果不做这个检查,ZoneInfo 会静默地把它映射到跳变之后并照跑。秋季回拨造成的重复时刻则用 fold=0 取第一次,保证只跑一次。

日与周两个字段的关系也按标准 cron 来:两个字段都被限定时是 OR,任一为通配符时另一个说了算。这个规则在 _day_matches 里有单独注释。这类语义分歧是跨系统迁移 cron 表达式时最容易出错的地方,迁进来之前先按这份实现验一遍。

还有一个产品层的判断藏在路由里:创建作业时如果没给 next_run_at,默认就是「现在」,即刻首次触发;但如果这是一个带时区的 cron 作业,默认值改成第一个符合表达式的时刻。路由里的注释解释了取舍——带时区的 cron 作业,它的契约是作者写下的那个墙钟时间,不是创建的那一瞬间。

四、存储:为崩溃写的代码长什么样

store.py 的整个价值可以浓缩成一句话:在任何一个瞬间被 SIGKILL,磁盘上要么是完整的旧文件,要么是完整的新文件。实现是同目录建临时文件、写、os.fsyncos.replace、再 fsync 父目录。临时文件名带 pid,权限 0o600。父目录 fsync 失败时只记 debug 日志不抛错,因为不是所有文件系统都支持。

另一半是对损坏的态度:文件不存在是唯一的干净空结果,返回空 dict;文件存在但解析不了,则把它改名隔离(文件名后缀带 UTC 时间戳),然后抛 CorruptStoreError,绝不静默返回空列表。这个选择方向很明确——宁可让调用方炸掉,也不要让一次解析失败伪装成「你本来就没有任何定时任务」。做定时系统的人应该能立刻体会到这两者的差别有多大。

存储位置也做了约束:_default_store_pathget_runtime_root(),默认落在 ~/.vibe-trading,注释里明确写了「绝不放进仓库工作树里」,和 live runtime、swarm 配置、持久化记忆共用同一个根。

时区校验在这里做了分层,是个挺聪明的设计:store.upsert 只调 validate_timezone_shape(只检查是 None 或非空字符串),不做解析;解析版 validate_timezone 留给创建接口和执行器。理由写在函数注释里——一个在 A 机器上能解析的时区键,在 B 机器上可能因为时区数据库版本不同而解析不了,如果持久化层较真,这台机器上整个 store 的写入都会瘫掉。分层之后,解析失败只降级成单个作业的 schedule 失败。from_dict 同理只做类型检查,注释说得直接:加载时解析时区,会让一个解析不了的键把整份 store 文件送去隔离。

代价也要说清楚:这个 store 每次 upsert/delete 都是 load() 全量读、save() 全量写,get 也是加载全部再取一个。作业量级小的时候这是最省心的选择,量级上来就是明确的瓶颈,而且没有跨进程锁——同时跑两个执行器进程是没有定义的行为。

五、OpenBB 桥接:外部数据进提示词的那条线

openbb_bridge 解决的是另一个方向的问题:让这个 agent 作为自定义 agent 挂进 OpenBB Workspace。契约是两个端点,GET /agents.json 返回清单用于发现,POST /v1/query 是流式查询。权限设计有个细节值得学:清单端点是不鉴权的(它只有静态元数据,通过它够不到任何用户、会话或运行),而 /v1/query 因为能触达完整的 agent 注册表,挂了和发消息接口同一个 require_auth 依赖。

适配器最反直觉的决定是每个请求新建一个一次性 sessionadapter.py 顶部的注释把推理链写全了:OpenBB 的 QueryRequest 不带会话或线程标识(注释里列出了它验证过的字段清单),OpenBB 的契约本身就是无状态的、每轮重传全部历史;那么服务端要想有身份,就只能从消息内容里推导,而内容会在无关会话之间撞车——两个都以「hello」开头的对话会共用一个 session,历史互相泄漏。所以干脆什么都不推导,改成把对方传来的历史重放进新 session。

历史重放也没走 send_message,而是直接写 store 的 append_message。理由有两条:以 user 角色发送会启动第二次 attempt;而且把一次性重放的历史喂进索引,会用工作台的对话副本污染跨会话搜索。这类「同一个动作有两条路径,走哪条取决于副作用」的判断,是接第三方协议时最耗神的地方。

上下文注入那块,context_injector.py 把三类来源折进提示词前缀,优先级递减:用户显式附加的 context 条目(表格、产物、解析过的 PDF,这是唯一真正带数据的一类)、历史里 tool 角色消息带回的载荷、以及组件与仪表盘的元数据。第三类要特别注意——OpenBB 不会把组件的数值放进这些字段,取值需要一次 get_widget_data 函数调用往返,而这个桥接没实现。它的应对不是假装有数据,而是在提示词里把这件事写给模型看:只有名字和参数可用,数值没有附带,用你自己的数据工具去取、不要猜。清单里对应的 widget-dashboard-search 特性标志因此保持关闭,注释里的说法是「不去宣传一份 agent 根本不会取的数据」。这种「把能力缺口显式写进提示词」的做法,比默默留白要可靠得多。

字符预算是共享的、硬性的:

MAX_DATA_CHARS = 8000
DATA_TRUNCATION_MARKER = "... [truncated: attached data exceeded the 8000-character budget]"

注释解释了为什么必须硬截:agent 自己有五层压缩兜底,但无边界的表格或 PDF 转储会在第一次 LLM 调用之前就把请求撑爆。附件优先花这份预算,工具结果其次,截断处会插入显式标记而不是悄悄丢掉。整个 inject 外面包了一层 try,注释是「绝不因为上下文处理失败而让一次查询挂掉」,失败就退回原始消息。

事件方向上,event_mapper 把会话总线的事件翻译成 OpenBB 的 SSE 对象,attempt.completed / attempt.failed / attempt.cancelled 三个终态之一到达就收流。取消也被当作终态处理,注释写明了理由:不这样的话流会一直发心跳直到客户端放弃。流结束后在 finally 里清掉这个 session 的事件总线缓冲——session 本身留在磁盘上供审计,但没人会再连回这个一次性的流,长跑的服务不该为每一次工作台提问都攒一份回放缓冲。

六、边界与代价:它明确不管的事

第一,COMPLETED 不是研究做完了。 前面说过,派发回调 send_message 返回即视为成功。要判断研究本身的结果,得去看那次 agent 会话,调度层不管。

第二,FAILED 是彻底终态,没有自动复活。 连续失败达到上限后作业不再被 is_due 选中,API 也没有提供「重启一个作业」的操作,只有 POST 覆盖或 DELETE 删除。你需要自己在外面盯 statusconsecutive_failures

第三,没有并发控制、没有跨进程锁。 store 是全量读写的单文件,多进程同时跑执行器不在设计范围内。

第四,跳过的触发不会补跑。 落进夏令时空洞的那次直接跳过,进程停了几小时期间该跑的那几次也不会追补——next_due 算的是严格大于「现在」的下一个时刻,不是「从上次到现在应该跑几次」。要 catch-up 语义得自己加。

第五,OpenBB 桥接不实现组件数据取值往返,工作台上的数值不会自动进入提示词。

第六,也是最需要认真对待的一条:仓库里有 agent/src/trading/connectors/ 这个方向,README 亦自述接入 12 家券商,目录下确实是 12 个连接器子目录。定时触发一旦接到真实下单能力上,代价的性质就变了——凭据放在服务器上就多出一份暴露面(连 last_error 这种诊断字段都要专门做脱敏,可见风险面有多细);下错的单不可撤销,不像一次跑歪的研究可以重跑;程序化交易在多数市场有申报或备案义务,且各司法辖区规则不同。能不能这么用,以你所在司法辖区的监管要求与你的券商协议为准。

关于因子库还要如实说一句:agent/src/factors/zoo/ 下的几组因子不是这个项目自研的。仓库根目录 NOTICE 声明得很清楚——Microsoft Qlib 的特征定义按 Apache 2.0 引入,另有几组公式分别来自 Kakushadze 的 101 Formulaic Alphas、国泰君安 191 短周期因子研报,以及 Fama-French 五因子、Carhart 动量、Hou-Xue-Zhang q-factor 等学术模型,仓库把它们当作数学事实重新实现,各子目录下另有 LICENSE.md。这些是公开公式的工程化重实现,仅此而已。本文不提供法律意见,能不能商用以许可证原文为准。

七、上手与避坑清单

开关默认关着,而端点照常收单。 会踩是因为 POST 返回 201、GET 看得见 pending,一切迹象都像跑起来了。避法:启动时带上 VIBE_TRADING_ENABLE_SCHEDULER,并且第一件事是等一个最短周期的作业真的把 last_run_at 写上,再上真实作业。

status == completed 当作研究成功的信号。 会踩是因为这个词在别的调度系统里通常意味着任务执行完毕。避法:把它读成「入队成功」,研究结果的成败去 agent 会话侧确认;做告警时同时看 consecutive_failuresfailure_kind

照搬别处的 cron 表达式。 会踩在两个点:星期字段 7 不是周日别名;日与周同时被限定时是 OR 关系。避法:迁移前先用同一份表达式跑一次 next_due,肉眼核对头几次触发时刻。

给 cron 作业配了时区,却按创建即触发去预期。 会踩是因为不带时区的作业默认立刻首触发,带时区的则默认推到第一个符合表达式的时刻——同一个接口两种行为。避法:创建后立刻看返回体里的 next_run_at,别凭直觉。

在两台机器上共用一份 store 文件,或同时启两个执行器。 会踩是因为它看起来只是个 JSON 文件。避法:把「单进程持有」当成硬约束;确实要多实例,得自己在外面加锁。

看到 CorruptStoreError 就想删文件重来。 会踩是因为报错信息看着像脏数据。避法:先去看被隔离出来的那个 .corrupt-<时间戳> 文件,绝大多数情况是能手工救回来的,那里面是你全部的定时任务定义。

把大附件丢进 OpenBB 的上下文,期待模型看到全部。 会踩是因为截断是静默发生在提示词层的。避法:认这个共享字符预算,输出里出现截断标记就说明数据没进全,该改成让 agent 用自己的数据工具去取。

把重试退避当成幂等保证。 会踩是因为退避只保证「隔一会儿再试」,不保证「这次动作重放是安全的」。避法:作业的 prompt 本身要写成可重复执行的语义,副作用型动作的幂等要在工具层解决,这块的思路见 失败重试与幂等定时任务的落地方式

收尾

这个模块最值得抄走的不是代码,是它对失败的态度:十二个字段里一半在描述失败、终态坚决不自动复活、损坏文件宁可抛错也不返回空、时区校验按「持久化层不较真、执行层较真」分层。这些选择加起来才让「每天自动跑一遍」变成一件敢放着不管的事。

给自己的定时 Agent 做体检,可以按这四问:崩溃在写文件的中途,我的作业定义还在吗?进程被 kill 时正在跑的那条,重启后是复活、卡死,还是被重复派发?我的「成功」状态到底断言了什么——入队成功、执行开始,还是结果可用?失败第 N 次之后谁来兜底,是自动放弃还是无限重试?

接着读代码的话,顺序建议是:agent/src/scheduled_research/models.py 先看清数据结构,再 store.py 看崩溃安全怎么落地,然后 executor.pyis_due_run_job 往下读并发那几处重读;OpenBB 那条线从 adapter.py 的模块级注释开始,它把「为什么每次新建 session」的推理链完整写在了那里。想看这个调度器在业务侧怎么被用,agent/src/skills/strategy-dev-manager/references/scheduled_decay_scan.md 是仓库自带的一个例子。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 策略仓库:注册、衰减跟踪与退役的生命周期设计拆解开源交易 Agent Vibe-Trading:工具、技能与风控三层

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