Vibe-Trading 策略仓库:注册、衰减跟踪与退役的生命周期设计

2026-08-05

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

大多数「知识库」类模块只做了一半:写得进去,查得出来,然后就没了。Vibe-Trading 的 strategy_store 把另一半补上了——每一条被存进去的东西都带着状态,状态会被定期重新评估,评估结果坏到一定程度它会被自动挪出可用池。 这半边才是长期资产真正稀缺的部分,也是这篇要拆的东西。

先把口径说清楚:Vibe-Trading 是 HKUDS 放出的开源项目名,不是「凭感觉交易」这类泛指说法。本文只看它的代码结构,不讨论任何投资判断;历史表现不代表未来,本文只讨论工程实现。仓库使用 MIT 许可证(Copyright 2026 Vibe-Trading Contributors)。

一、这块代码要解决的问题:存进去不等于还能用

agent/src/strategy_store/ 目录下一共 7 个 Python 文件:__init__.py_shared.pydecay.pymetrics.pymodels.pysqlite_store.pystore.py。这 7 个文件干的事,本质上是给「研究产出物」建档。

它把研究产出物统一叫 artifact,只分两类:ArtifactType.FACTORArtifactType.STRATEGY。建档的入口是工具 sdm_register,必填三个参数:artifact_typenameuniverse。注册成功后拿到一个 art_ 开头的 ID。

关键设计在于,注册时状态并不是「可用」。sdm_register_tool.py 里写死了 status=ArtifactStatus.CREATED——刚进库的东西是「已创建」,不是「已生效」。要走到生效,它得经过一条明确的路径。models.pyArtifactStatus 枚举一共六个值:

createdbenchingactivemonitoringdecayeddisabled

这条链条的意思很直白:新建、正在评测、生效中、盯着看、已衰减、已停用。任何一条策略在任何时刻只能停在其中一个格子里,而且格子之间怎么走是代码规定的,不是人随口说的。

这就是「注册—跟踪—退役」三段式的骨架。注册解决身份问题,跟踪解决「它现在还灵不灵」的问题,退役解决「不灵了之后谁把它拿下来」的问题。绝大多数自建资产库死在第三段:没有人负责下架,于是库里躺着一堆没人敢删、也没人敢用的东西。

二、状态机长什么样:四档信号 + 连续读数

跟踪这段的实现全在 decay.py,这个文件的模块 docstring 自己写了定位:pure logic for evaluating factor/strategy health。类 DecayEvaluator 的注释更直接——Pure logic — no I/O, no database access. Feed it metrics and get decisions. 整个文件不碰数据库、不碰网络,输入指标,输出决定。

它的输出分两层。第一层是健康信号 DecaySignal,四档:HEALTHYWARNINGDECAYEDCRITICALevaluate_decay() 最多接四个指标参数(ic_ratioiric_positive_ratiosharpe),每个指标各自对照三条降序阈值分档,然后取所有指标里最差的那一档作为整体信号。阈值本身放在 DecayThresholds 这个 frozen dataclass 里,字段名成对出现,例如 ic_ratio_healthy / ic_ratio_warning / ic_ratio_decayed。具体数值请自己去仓库看,本文不复述任何指标数值。

第二层是状态迁移 should_transition()。这里有个容易被忽略的设计:单次信号不改变状态,改变状态的是连续读数。 DecayThresholds 里另有三个计数字段控制这件事,默认值是:warnings_for_monitoring = 3warnings_for_decayed = 2critical_for_disabled = 3。对应的迁移规则写在 should_transition 的 docstring 里:

- active → monitoring: WARNING or worse for ``warnings_for_monitoring``
  consecutive readings.
- monitoring → decayed: DECAYED or worse for ``warnings_for_decayed``
  consecutive readings.
- monitoring → active: HEALTHY for 1 reading (recovery).
- decayed → disabled: CRITICAL for ``critical_for_disabled``
  consecutive readings.

把这四条连起来算一笔账,但别急着做加法。一条处于 active 的东西,就算每一次评估都是最差档,也要先攒够 3 次读数才降到 monitoring,再满足 2 次才到 decayed,再满足 3 次才可能到 disabled。反直觉的地方在于:扫描工具传给 should_transition() 的是「历史信号全量 + 最新这一条」,状态变更时并不重置这段历史,所以后一段判定复用的是前一段已经攒下的尾巴。按每次都 CRITICAL 推演一遍——第 3 次扫描进 monitoring;第 4 次时尾部两条已经都是 CRITICAL,直接进 decayed;第 5 次尾部三条满足,到 disabled。五次扫描走完全程,而不是把 3、2、3 加起来的八次。这个差别在你估算「最坏情况下多久能自动摘掉一条资产」时会直接影响结论。而恢复只需要 1 次 HEALTHY_check_monitoring_transition() 里第一件事就是判断最新一条读数是不是 HEALTHY,是就直接回 ACTIVE

这个降级慢、恢复快的不对称是有意的,它把单次噪声挡在状态变更之外,代价是真出问题时反应也慢。你在自己的系统里抄这套骨架时,这两个方向的松紧要分开调,别用同一个阈值。

还有个细节值得单独记:_check_active_to_monitoring() 的判定条件是 all(s != DecaySignal.HEALTHY for s in tail)——只要不是 HEALTHY 就算数。也就是说从 active 出发时,WARNINGCRITICAL 是等价的,不存在「一次崩到底就立刻停用」的快车道。

三、指标是从哪儿来的:compute_decay_metrics 的口径

decay.py 只负责判断,指标怎么算在 metrics.py。这个文件只有两个公开函数,但它决定了整套跟踪机制的可信度。

compute_decay_metrics(bench_history) 接收一段评测历史。函数注释交代得很清楚:入参预期是最新在前(store 就是这么返回的),函数内部先 list(reversed(bench_history)) 翻成时间顺序再算。返回一个固定七键的 dict:baseline_ic_meanrolling_ic_meanic_ratiorolling_iric_positive_ratiorolling_sharpebaseline_sharpe

基线和滚动窗口的取法是硬编码的切片:

ic_values = [r.ic_mean for r in chronological if r.ic_mean is not None]
...
baseline_ics = ic_values[:5]
rolling_ics = ic_values[-5:]

前 5 条当基线,后 5 条当滚动窗口,ic_ratio 是两者均值之比。门槛不是一道而是两道:IC 这一组要求 len(ic_values) >= 3,Sharpe 那一组另有 len(sharpe_values) >= 3,两组各自判断、互不牵连——只记了 IC 没记 Sharpe 的条目,Sharpe 那两个键照样留 None,反之亦然。哪一组不够门槛,就把哪一组的键整组留在 None 上。

这里有个必须自己算一遍才看得见的后果:当历史条数在 3 到 5 之间时,[:5][-5:] 取到的是同一批数据,两个均值相等,比值恒等于 1。也就是说样本刚过门槛那几次扫描,比值这一路指标是不会报警的——它在结构上就不可能偏离。要让基线和滚动窗口完全不重叠,历史至少得有 10 条。另外 ic_positive_ratio 的分母是 ic_values 全量而不是滚动窗口,它衡量的是整段历史而非近期。

另一个函数 has_decay_inputs() 是道守卫,它的 docstring 把理由写明了:四个输入全是 None 时,evaluate_decay 会因为没有任何信号可比而默认返回 HEALTHY,所以调用方必须先检查、并改报 insufficient_data。这是我在这个模块里最欣赏的一处——「没数据」和「数据正常」被显式区分开了。很多自建监控就栽在这儿:采集断了,看板一片绿。

四、这些零件分别在哪儿

组成部分它负责什么对应仓库位置你什么时候会碰到它
数据模型与枚举定义 ArtifactBenchResultDecaySnapshot 三个 frozen dataclass 与 ArtifactStatus 等枚举agent/src/strategy_store/models.py想加字段、想改状态取值时
衰减状态机DecayEvaluatorDecayThresholds,纯逻辑判档与迁移agent/src/strategy_store/decay.py调阈值、调降级/恢复快慢时
指标口径compute_decay_metricshas_decay_inputsagent/src/strategy_store/metrics.py怀疑信号不合理、要核对窗口口径时
存储接口 + 内存实现StrategyStoreProtocol 协议与 InMemoryStrategyStore 参考实现agent/src/strategy_store/store.py写测试、或要换自己的后端时
SQLite 后端SqliteStrategyStore,WAL、外键、PRAGMA user_version 迁移agent/src/strategy_store/sqlite_store.py关心数据落在哪、表结构长什么样时
进程内单例get_store(),三个工具共用同一个 store 实例agent/src/strategy_store/_shared.py排查「为什么两个工具看到的数据不一致」时
三个 Agent 工具sdm_register / sdm_status / sdm_decay_scanagent/src/tools/sdm_register_tool.py 等三个文件从 Agent 侧调用整条链路时
技能文档阈值表、扫描排程、报告模板agent/src/skills/strategy-dev-manager/想知道设计者原意时

存储层这条线的分工做得比较干净:store.py 里先用 Protocol 把接口定死(artifact 增删查改、评测历史、衰减快照三组方法),再给一个 dict 撑起来的 InMemoryStrategyStore 当参考实现和测试替身;sqlite_store.py 才是实际用的那个。文件顶部的注释说明了后端选型还没定,交给社区在 GitHub Issue #455 里讨论——所以先有协议、再有两个实现,是有意为之。

落盘位置也值得记一笔。sqlite_store.py 里默认路径是 Path.home() / ".vibe-trading" / "strategy_store.db",可以用环境变量 VIBE_TRADING_STRATEGY_STORE_DB_PATH 覆盖。表结构里 artifactsUNIQUE(name, universe) 约束,bench_historydecay_snapshots 都带 ON DELETE CASCADE 外键,状态字段还有 CHECK 约束把六个合法取值钉死。

至于扫描怎么被触发,技能文档 references/scheduled_decay_scan.md 里写了三种:手动调工具、用 ScheduledResearchExecutor 挂定时任务、以及跟着评测跑被动更新。定时那条路默认是关的,文档给的开关是 VIBE_TRADING_ENABLE_SCHEDULER=1,任务通过 /scheduled-runs 端点创建,schedule 字段支持毫秒间隔和五段 cron 两种写法。

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

这套设计放弃的东西同样清晰,照抄之前得先认下来。

它不管数据从哪儿来。 判档的原料是 bench_history,而在这个 commit 的 agent/src/ 下,record_bench() 只有 store 自己的定义,没有任何工具调用它——写入这条历史的路径在仓库里还没接上。sdm_status 的 action 枚举只有 listdetaildisableenabledecay_check 五个,里面并没有写入评测结果的动作。所以你把这套骨架搬走时,最费劲的不是状态机,而是让「每周产生一条可比的评测记录」这件事真的稳定发生。

它不管跨状态的重扫。 sdm_decay_scan_tool.py 里,扫描目标是 ACTIVEMONITORING 两个列表拼起来的,DECAYED 不在其中。而 decayed → disabled 的判定逻辑写在 DecayEvaluator 里。这两件事对不上,意味着走到 decayed 之后想继续自动往下走,需要另有调用方——读代码时留意这处,别默认「配上定时任务就全自动了」。

它不管衰减状态的自动恢复。 should_transition() 里只有 ACTIVEMONITORINGDECAYED 三个分支有逻辑,其余状态直接返回 None;而 DECAYED 分支只判断要不要去 disabled。也就是说恢复通道只对 monitoring 开放,掉到 decayed 之后只能靠人工——sdm_status(action="enable") 会把状态直接推回 ACTIVE

它不管退役原因的留痕。 update_status() 转到 DISABLED 时会写 disabled_atdisabled_reason;但从 DISABLED 转出去时,这两个字段会被清成 None。停用理由不会作为历史保留在 artifact 记录上,只有 decay_snapshots 表里那串快照还在。做审计的话这是笔要自己补的账。

它不管信号历史的分段。 扫描工具取 get_decay_history(artifact.id, limit=10) 作为先前信号,状态迁移发生时并不清空这段历史。上一个状态里攒下的读数,会继续参与下一个状态的判定——第二节那笔「五次而非八次」的账就是这么来的。这段历史还被 limit=10 截断,判定只看得到最近十条快照;同一段代码里另算的 consecutive_warnings 字段,也是在这十条的范围内从最新往回数到第一个 healthy 为止。

基线为负时它不做区分。 metrics.py 里只挡了除零(if baseline_mean != 0),没有对基线符号做任何分支。比值这个量在基线为负时的含义和为正时不一样,这需要调用方自己心里有数。

最后一条边界属于整个项目而不只是这个模块:仓库里的因子库不是原创产物。根目录 NOTICE 写明 Microsoft Qlib 的特征定义按 Apache 2.0 引入,另有几组公式来自公开论文与研报(agent/src/factors/zoo/ 下各子目录另有 LICENSE.md),仓库把它们当作数学事实重新实现,明确声明未复制原文的行文、表格与图。讲到这块时不要笼统说成「项目自研的因子库」。本文不提供法律意见,能不能商用以许可证原文为准。

至于实盘那一侧(agent/src/trading/connectors/ 下有 12 家连接器子目录,README 也自述 12 brokers),只要涉及券商连接、资金授权和凭据保管,代价就得如实摆出来:凭据一旦落到本地配置或环境变量里就多了一个暴露面,程序化下单发出去不可撤销,而程序化交易本身的合规义务因司法辖区而异。这条链路能不能这么用,以你所在司法辖区的监管要求与券商协议为准。

六、上手与避坑清单

文档和代码对不上,以代码为准。 skills/strategy-dev-manager/SKILL.md 的 MONITOR 一节说「恢复需要连续 2 次以上 HEALTHY」,decay.py 的实现是 1 次;SKILL.md 说注册后状态是 extracted,而 models.py 的枚举里根本没有这个值,sdm_register_tool.py 写的是 CREATEDreferences/decay_thresholds.md 描述基线取「bench_type='initial' 的条目或前 5 条 periodic」,metrics.py 的实现是不区分 bench_type 直接切前 5 条。会踩是因为这类文档读起来太顺、太像规格说明;避法是凡涉及判定逻辑的句子,都回 decay.pymetrics.py 里核一遍。

别在样本刚过门槛时相信比值。 前面算过,历史条数在 3 到 5 之间时比值恒为 1。会踩是因为门槛是 3、窗口是 5,两个数字不一致,而门槛检查(len(bench_history) < 3)写在工具里、切片写在 metrics 里,隔着文件很难对上。避法是自己搬这套逻辑时把「最小样本」直接设成窗口长度的两倍,或者在结果里显式标出基线与滚动窗口的重叠条数。

别让「没数据」被算成健康。 evaluate_decay 在四个输入全 None 时返回 HEALTHY,这是它的默认行为。会踩是因为调用链上任何一环把守卫漏掉,看板就会一片绿。避法是照抄 has_decay_inputs() 这道守卫,并且让 insufficient_data 在汇总里单独占一列——扫描工具的 counts 字典就是这么做的,六个计数键里 insufficient_data 是独立的一项。

先用 dry_run 跑一遍再放开。 sdm_decay_scan 有个 dry_run 参数(默认 False)。开着它的时候,工具不写快照也不改状态,只在每条记录上放 recommended_transition 字段;关掉之后才会真的调 update_status() 并落 decay_snapshot。会踩是因为默认值是关的,直接跑就直接改库。避法是接入新数据源后的头几轮一律带 dry_run,把 recommended_transition 和你自己的判断对一遍再放开。

注册键一旦定了就别改。 (name, universe) 是唯一键,SQLite 层是 UNIQUE(name, universe),内存实现里是注册前线性查重并抛 ValueError。会踩是因为改名或换 universe 感觉只是「改个标签」,实际上等于新建了一条资产,历史评测和衰减快照都留在旧 ID 上。避法是把 name 当主键对待,展示用的标题另开字段。

两个工具看到的数据不一致时先查 store 单例。 三个 SDM 工具都从 _shared.pyget_store() 拿实例,那是个进程内全局变量,第一次调用时才实例化 SqliteStrategyStore。会踩是因为环境变量在进程启动后才改的话,已经建好的连接不会跟着换库。避法是改 VIBE_TRADING_STRATEGY_STORE_DB_PATH 之后重启进程,并且确认你以为在读的那个 .db 文件确实被写过。

配置里的字段约束不是本文的建议。 这个项目的一些配置和模板会要求模型输出目标价、仓位区间、止损位这类字段——那是配置对模型输出格式的约束,属于「仓库里的配置长什么样」,不构成本文对读者的任何建议。同理,BenchResult 里那些指标字段(ic_meanirsharpemax_drawdown 等)只是表结构,本文不展示任何取值,也不讨论好坏。

七、把这套骨架挪到你自己的长期资产上

抛开金融外壳,strategy_store 的形状是通用的:一个带状态的登记表 + 一份可比的历史记录 + 一台纯函数状态机 + 一个定期跑的扫描器。你手上任何一种会过期的资产都能套——提示词库、技能库、抓取脚本、评测集、内部文档。

套的时候有三件事得先想明白。第一,「可比的历史记录」怎么产生。这是整套机制的入口,也是这个仓库里目前最薄的一环。没有稳定产生的记录,状态机再漂亮也只是个摆设。这块和评测集的搭法是同一个问题,可以参考 Agent 评测集怎么构建

第二,判断逻辑必须是纯函数decay.py 不碰 I/O 这件事,让阈值可以离线跑、可以单测、可以回放历史信号重算一遍。真要改阈值时,你能先在旧数据上验证新阈值会不会导致大面积误降级。

第三,降级和恢复要分开调。这个项目选的是降级要连续读数、恢复只要一次,因为它更怕误伤。你的资产如果反过来更怕漏检,就把两边的松紧倒过来。

顺带说清本篇和站内几篇相邻文章的分工:Agent 回归测试怎么做 讲的是每次改动后怎么防止能力倒退,是改动触发的;评测方法怎么设计 讲的是怎么把「好不好」量化成可复算的分数,是度量口径层面的;评审位置漂移 讲的是判定标准自身在时间里跑偏;而本篇讲的是代码没改、口径没变、判定也没漂,纯粹是外部世界变了导致资产失效,以及这种失效该由哪段代码负责发现和处理。四件事互补,缺哪一件都会在别处漏。另外,扫描结果要能被人看懂、被回溯,跟 可观察日志怎么写 是同一套要求。

收个尾,给一份自检清单。把你自己的资产库对着过一遍:每条资产有没有显式状态;状态之间的迁移是写在代码里还是靠人记;有没有一份定期产生、口径一致的健康记录;「没数据」和「健康」在你的看板上是不是同一个颜色;有没有任何一条路径能让一条资产被自动挪出可用池;挪出去之后原因留没留痕。 六个问题里有两个答不上来,你那个库就已经在往「没人敢删也没人敢用」的方向走了。

想继续读代码的话,顺序建议是 models.py(先看清有哪些字段和状态)→ decay.py(判定逻辑,最短也最值得读)→ metrics.py(口径,坑最多)→ sdm_decay_scan_tool.py(看这三块怎么被串起来)。四个文件加起来不到八百行,一个下午能读透。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 影子账户:从交易记录抽规则到生成回测代码的流水线开源项目 Vibe-Trading 的定时研究:把每天自动跑一遍做成带存储的可执行对象

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