Vibe-Trading 影子账户:从交易记录抽规则到生成回测代码的流水线
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
这条流水线真正值钱的地方,不是它最后吐出一份报告,而是它强迫「人的隐性规则」在四个环节里各显式化一次——先变成结构化字典,再变成可被 ast.parse 校验的 Python 源码,再变成回测产物里的数值字段,最后变成模板里的固定小节。 每过一道关口,原本模糊的「我大概就是这么做的」就少一分模糊。这套做法跟交易本身关系不大,跟你手上任何一个需要把老专家的经验固化成系统的项目关系很大。
先把命名说清楚:Vibe-Trading 是 HKUDS 放出的开源项目的项目名,不是「凭感觉交易」这类泛指说法。它整体是一套把金融研究工作流拆成技能、工具与多智能体团队的 Agent 工程,许可证 MIT(Copyright 2026 Vibe-Trading Contributors)。本文只看其中一个模块目录 agent/src/shadow_account/。历史表现不代表未来,本文只讨论工程实现。
站内已经有几篇相邻的文章:让 AI 写测试 谈的是让模型产出可执行代码这件事本身,结构化输出不稳怎么办 谈的是约束模型吐出合法结构,Agent 评测方法 谈的是怎么衡量一个 Agent 做得对不对;本篇的分工是看一个真实开源项目把这三件事串成一条有产物落盘的流水线时,各段的接口是怎么切的。
一、这条流水线在解决什么问题
一个长期交易的人,账户里躺着几百条成交记录,但他说不清自己的规则。你问他,他会给你一段模糊的自述。这段自述没法回测,没法复现,也没法被别人审。
shadow_account 的切法是:不问人,只读记录。整条流水线的输入是一份券商导出的交割单文件,输出是一份分节报告,中间强制经过四个不可跳过的形态转换。extractor.py 的模块 docstring 把这条链路写得很直白:
trades_df → FIFO pair → filter (pnl > 0) → feature engineer
→ KMeans cluster (k auto 2-5) → per-cluster decision tree (max_depth=3)
→ path extraction → structured entry_condition dict
→ LLM-light natural-language translation (template fallback if no LLM)
注意这条链路里模型出现的位置:只在最后一步,而且是可选的。extract_shadow_profile 的 llm_translator 参数默认是 None,此时走 _translate_rule 里的 f-string 模板兜底。也就是说规则本身是算出来的,模型只负责把结构化字典翻译成一句人话。这个取舍值得记住——在需要审计的链路上,把模型放在表达层而不是决策层。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 提取器 | 交割单 → ShadowProfile(含若干条 ShadowRule) | agent/src/shadow_account/extractor.py | 想把历史行为压成结构化规则时 |
| 代码生成 | 规则 → signal_engine.py 源码 + config.json | agent/src/shadow_account/codegen.py | 需要规则可执行、且要挡住模板注入时 |
| 回测驱动 | 铺 run_dir、调回测、解析产物、做差值分解 | agent/src/shadow_account/backtester.py | 需要把外部进程的产物收敛成稳定结构时 |
| 报告渲染 | 组装分节数据、画图、出 HTML/PDF | agent/src/shadow_account/reporter.py | 需要产物既能给人看又能给前端吃时 |
| 数据契约 | 四个 frozen dataclass + PRICE_FEATURES | agent/src/shadow_account/models.py | 想知道各段之间到底传什么时 |
| 落盘布局 | ~/.vibe-trading/ 下的三个目录 | agent/src/shadow_account/storage.py | 排查「上次跑的结果去哪了」时 |
| 工具封装 | 四个 BaseTool,暴露给 Agent 调用 | agent/src/tools/shadow_account_tool.py | 接 Agent 编排、做参数校验时 |
二、提取器:规则是算出来的,不是问出来的
extract_shadow_profile 拿到路径后先走 parse_file 与 records_to_dataframe,再交给 pair_trades_fifo 做先进先出配对,得到一组回合记录。然后只保留 pnl > 0 的回合——这一步就是「影子」的全部立意:只学这个人做对的时候是什么样子。
样本不足会直接抛错。MIN_PROFITABLE_ROUNDTRIPS = 5 是硬门槛,不够就 raise ValueError,不降级、不编造。对应技能文件 agent/src/skills/shadow-account/SKILL.md 里那条红线:「样本不足必报错」。这个设计比它看起来重要——大多数「从数据里学规律」的模块崩在这里,样本少的时候硬凑一个结论出来,后面所有环节都在给噪声镀金。
特征分两层。第一层是从交割单本身就能算出来的 _NUMERIC_FEATURES:holding_days、pnl_pct、entry_hour、entry_weekday,加上分类特征 market。这层永远可用,离线也能跑。第二层是 PRICE_FEATURES,也就是 entry_rsi14 和 prior_5d_return,需要额外拉行情。这层做了三重降级:
- 市场标签在
_MARKET_KEY_MAP里查不到映射(比如other)就跳过取数; _fetch_price_history通过resolve_loader走回测侧的 loader 注册表,捕获NoAvailableSourceError与兜底Exception后返回None;- 取不到就留
NaN,_promoted_numeric_features再判断该特征非空行数是否达到min_support,不达标就不进特征矩阵。
结果是:行情源挂掉时,整条流水线的行为退化成「只用交割单特征」的基线,而不是报错或者静默给错数。这种「主路径不依赖外部 I/O、增强路径可整段脱落」的分层,是任何要给别人用的模块都该抄的。
再看时间边界。_as_of_index 会把买入当天那根日线也排除掉,注释写明理由是日线收盘价在盘中入场那一刻并不存在。_compute_rsi 用 Wilder 的指数加权写法,docstring 明确标注「causal by construction」。这两处是同一件事的两面:任何用历史数据推规则的模块,必须把「当时能看到什么」写进代码而不是写进文档。
聚类那段是 _auto_cluster:z-score 标准化后跑 KMeans,k 从 2 试到 min(max_rules, 5),用 silhouette_score 挑最优,random_state=42 固定随机性;异常时 fallback k=2。行情特征可能带 NaN,这里用中位数填补让 KMeans 能跑,但注释特意交代了:填补只影响分组,_cluster_to_rule 从不读行情特征做边界,所以中性中位数不会污染规则区间。
规则本身由 _cluster_to_rule 生成——取每簇的 p10–p90 分位作为区间,主市场取众数。函数 docstring 直说这比决策树轻,在小样本下更可解释,「v2 特征变宽时再换 DecisionTreeClassifier」。这跟顶部 docstring 里写的决策树路径存在措辞差,读代码时以实际实现为准。最后 _translate_rule 把结构化条件译成一句话,截断长度 RULE_TEXT_MAX = 80。
三、代码生成:把「可执行」和「可校验」绑在一起
codegen.py 的输入永远是 ShadowProfile,输出是两样东西:一份 signal_engine.py 源码字符串,一份 config.json 字典。它自己不写盘,写盘的责任在 write_run_dir。
这一段有两个细节值得单拎出来。
第一,注入防线。 模板走 Jinja2,但所有动态值都过一个自定义 filter py_literal:
def _python_literal(value: Any) -> str:
"""Render ``value`` as source text for a safe Python literal."""
literal = repr(_literal_safe_value(value))
try:
ast.literal_eval(literal)
except (SyntaxError, ValueError) as exc:
raise ValueError(f"Invalid generated Python literal: {literal!r}") from exc
return literal
_literal_safe_value 只放行 None、str、bool、int、有限 float、以及它们组成的 list/tuple/dict,其余类型直接 TypeError;非有限浮点也拒绝。渲染完再用 ast.literal_eval 反向验一遍。规则里的市场标签、样本代码这些字段一路是从用户文件里流进来的,这道防线堵的就是「用户文件内容变成生成代码的一部分」。仓库里有 agent/tests/test_shadow_codegen_security.py 专门盯这条路径。
第二,形状校验。 validate_generated 做三件事:ast.parse 能过、模块里有 class SignalEngine、这个类有 generate 方法且除 self 外至少一个位置参数。返回 (ok, error_msg) 而不是抛异常,由 write_run_dir 决定是否 raise。这是很朴素但很有效的一层——它不保证语义对,只保证下游按约定去 import 和调用时不会当场炸。生成代码这件事的常见翻车姿势就是「跑一半才发现类名不对」,把校验前移到写盘之前,成本几乎为零。
模板 templates/signal_engine.py.j2 里还藏着一个很典型的工程判断。规则里的 entry_hour 是从盘中成交记录挖出来的,但日线数据每根 bar 只有一个时间戳:
intraday = len({pd.Timestamp(ts).time() for ts in index}) > 1
只有确认是日内数据时才启用小时窗口过滤,否则跳过。注释解释得很清楚:日线 bar 通常打在午夜,硬套这个窗口会把每一根 bar 都拒掉。从 A 数据粒度挖出来的条件,套到 B 粒度上要先问一句「这个条件在 B 里还有信息量吗」——这类跨粒度陷阱在特征工程里非常常见。
render_config 那边则简单得多,就是拼一个字典:source、codes、start_date、end_date、interval、initial_capital、engine、shadow_id,外加一个 extra 兜底合并位。
四、回测驱动与报告:把外部产物收敛成稳定结构
run_shadow_backtest 的职责是编排。它先用 select_multi_market_codes 从 _LIQUID_BASKETS 里按市场取一组预置代码,flatten_codes 去重展平,write_run_dir 铺目录,然后调 run_backtest。注意 run_backtest_fn 这个参数——它是为测试留的注入点,默认才落到真实入口。这是让「有子进程/有网络的模块」保持可测的标准做法。
真正的脏活在解析。_load_metrics 按 metrics.json / metrics / metrics.csv 三个 key 依次找,找不到就 run_dir.glob("**/metrics.*") 全目录扫;_coerce_numeric 只保留标量数值字段,bool 显式排除。_load_equity_curve 更宽容,日期列在 date/datetime/timestamp 里认,净值列在 equity/equity_curve/value/net_value 里认,都认不出就退回第一列和最后一列。
_summarize_artifacts 里有一条判断特别能体现写这段代码的人踩过坑:只有在确实一个指标都没拿到且状态非 ok 时才把 combined 写成 {"error": ...}。注释说明,状态不 ok 但指标可用,通常意味着某个数据源临时抽风,此时如实呈现已有数据比整体报错更贴近用户的真实处境。
差值分解 _compute_attribution 是纯算术的,模块 docstring 明说「no LLM, no simulation rebuild」,目的是让数字可审计可复现。它把差值拆成 noise_trades_pnl、early_exit_pnl、late_exit_pnl、overtrading_pnl 四项,剩下解释不掉的全归 missed_signals_pnl 残差项,另外按 |impact| 排序取前五条放进 counterfactual_trades。留一个显式残差项,比把所有差异强行归因到某个好听的名目上诚实得多。 这跟做 Agent 复盘时的心法一致,可以对照 Agent 中间产物落盘 一起看。
reporter.py 这边,_build_sections 组装模板要的严格结构,并用 _split_metrics 把数值字段和字符串字段分开——数值给模板做格式化,字符串(比如上游塞进来的 error)单独放进 combined_error 让模板显式呈现。这是个小设计但很关键:类型混杂的字典直接喂给格式化模板,迟早在渲染层炸。
_render_charts 三张图各自包在 try 里,任何一张失败只是从 map 里消失,不影响整体。PDF 走 weasyprint,导入失败或渲染失败都降级成 html-only,HTML 产物始终存在。落盘位置由 storage.py 统一管:~/.vibe-trading/ 下的 shadow_accounts/、shadow_runs/、shadow_reports/,其中 hash_journal 用 SHA1 算文件内容哈希,配合 find_by_journal_hash 做幂等,同一份交割单不必重复提取。这套「产物路径可预测 + 内容哈希做幂等键」的组合,跟 幂等与重试 里讲的是同一件事。
五、边界与代价:它明确不管什么
这个设计放弃了不少东西,而且大多是主动放弃的。
它不管跨市场的分市场归因。 _per_market_breakdown 的 docstring 写得很坦白:回测运行器只吐一份合并指标文件,所以每个市场那一行其实是合并指标的副本,「faithful but intentionally lossy」。你看到的分市场表格在 v1 里并不是真的分市场算出来的。这一点如果不读代码,光看报告会误判。
它不管「你的规则是不是好规则」。 整条流水线只做一件事:把这个人盈利时的共性画像提取出来并重放。技能文件里写得很直接——规则不是必赚公式,而是用户盈利时的共性画像。样本本身有幸存者偏差(只学赚钱的回合),历史窗口之外的适用性没有任何保证,报告第 8 节 Confidence & Caveats 里也把「style decay」列成显式条目。
它不管下单。 技能文件的红线一条是「不落单」,报告第 8 节最后一条同样写明这份报告只用于研究、永不路由到任何实盘系统。仓库里另有 agent/src/trading/connectors/ 这个方向的 12 家连接器子目录(README 亦自述 12 brokers),那是完全独立的另一条链路。一旦你自己去碰那条链路,代价必须自己承担:凭据一旦落到本地配置或环境变量里就有了暴露面,下错单在多数市场是不可撤销的,程序化交易的申报与合规义务因司法辖区而异。能不能这么用,以你所在司法辖区的监管要求与券商协议为准。
它不管别人的策略。 红线第二条是「不复制他人策略」,规则只从用户自己的记录里出,不从社区或公开策略里抓。
小样本下它会退化成一条规则。 _extract_rules 在样本少于 min_support 时直接走 _heuristic_single_rule,聚类结果全部不达标时也是同一条兜底路径。此时你拿到的「规则集」其实是整体分位数,信息量和多簇结果完全不是一回事。
六、上手与避坑清单
别拿刚导出的原始文件直接跑,先确认格式被识别。 会踩是因为 parse_file 支持的是同花顺、东方财富、富途和通用 CSV 四种形态,券商导出模板经常改列名,识别偏了会导致买卖方向或市场标签整体错位,而流水线不会因此报错——它会一路算下去给你一份看起来正常的报告。避法是先单独跑一次解析,核对 market 与方向字段分布是否符合预期,再进提取。
别把「行情特征没进矩阵」当成故障。 会踩是因为 _promoted_numeric_features 的门槛是「非空行数 ≥ min_support」,行情源不通或市场标签不在 _MARKET_KEY_MAP 里时,entry_rsi14 与 prior_5d_return 会静默缺席,日志走的是 logger.debug,默认级别下你什么都看不见。避法是先把日志级别调到 DEBUG 跑一次,确认缺席是环境问题还是数据问题。
别在调 min_support 时只盯规则条数。 会踩是因为这个参数同时管三件事:单条规则的最小支撑、行情特征的准入门槛、以及是否直接走单规则兜底。调高它想「让规则更可靠」,可能顺手把行情特征全部踢出矩阵,聚类维度悄悄变了。避法是每次改这个参数都对照看一眼 entry_condition 里还剩哪些 key。
别假设生成的引擎在日线上会用小时窗口。 会踩是因为规则里明明有 entry_hour 区间,但模板里的 intraday 判定会在日线数据上整段跳过这个检查。避法是把生成出来的 run_dir/code/signal_engine.py 打开读一遍——它就是普通 Python 文件,读它比读文档快。
别指望没跑过回测时报告是空的。 会踩是因为 RenderShadowReportTool 在 load_cached_result 拿不到缓存时会自己补跑一次回测,补跑再失败才降级成带 error 的空结构。你以为在渲染,实际在等一次完整回测。避法是显式先跑回测工具,再渲染。
别忽略 safe_user_path。 工具层对 journal_path 一律过这道校验,越界直接返回 error 而不是抛栈。你在外面自己调 extract_shadow_profile 时是没有这层保护的,路径来源不可信时要自己补上。这类工具入参边界的处理,可以对照 工具参数校验 里的做法。
别把 llm_translator 的失败当致命错误。 _translate_rule 里对翻译器异常的处理是 logger.warning 后落回模板,规则结构完全不受影响。这是对的设计,但也意味着你可能一直在看兜底文案却以为模型在工作。
收束
如果只带走一件事:把「隐性经验显式化」拆成多段,每段用一种不同介质表达同一份规则,然后在每段接缝处加一道机器可判的校验。shadow_account 的接缝是 models.py 里那四个 frozen dataclass,校验是 ast.parse 加形状检查加类型分离。换成你自己的领域,介质和校验器会变,结构不会变。
接下来该读哪个文件,按你的关注点分:想看数据契约怎么切,读 agent/src/shadow_account/models.py,一百来行读完你就知道四个模块之间到底传什么;想看生成代码怎么防注入,读 agent/src/shadow_account/codegen.py 前六十行;想看外部产物怎么收敛,读 agent/src/shadow_account/backtester.py 的 _load_metrics 与 _load_equity_curve;想看这个模块自己认领了哪些不确定性,读 agent/src/shadow_account/templates/shadow_report.html 最后那个 Confidence & Caveats 小节。
最后重申两点:历史表现不代表未来,本文只讨论工程实现;本文不构成任何投资建议,涉及能不能这么用的判断,一律以你所在司法辖区的监管要求与券商协议为准。仓库根目录 NOTICE 里还声明了因子库各自的上游来源与许可——Microsoft Qlib 的特征定义走 Apache 2.0,另有几组公式来自公开论文与研报、仓库把它们当作数学事实重实现,各因子库子目录下另有 LICENSE.md。本文不提供法律意见,能不能商用以许可证原文为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 读懂 Vibe-Trading 开源项目的因子库来源:一份 NOTICE 与四份子目录许可证 和 Vibe-Trading 策略仓库:注册、衰减跟踪与退役的生命周期设计。