Engineering & Quantitative Review

通用 Backtest 模块代码审查报告

审查对象为 Factor Pricing Research 项目的 src/backtest。重点判断该模块作为所有 notebook 共用的回测基础设施,是否存在会改变研究结论的显著性问题,以及接口、健壮性和可维护性上的改进空间。

审查日期:2026-09-08范围:src/backtest 全部 Python 文件方式:静态审查 + 行为推理结论:需要修订后再用于正式结论

一、执行摘要

该模块的职责边界是合理的:signal 函数负责策略决策和策略特有的可行性筛选,通用引擎负责执行、现金/持仓记账、估值、指标和绘图。代码短小,接口容易在 notebook 中调用;但当前实现仍更接近“研究原型”,还没有把通用回测引擎最重要的时间、账务和输入不变量落实为可验证的机制。

总体判断:在修复高严重度问题前,输出的年化收益、Sharpe、基准比较和部分净值曲线不应直接作为正式研究结论。尤其需要先明确“用哪个时点的信息、在什么价格成交”的时间契约。
3高严重度结果风险:执行时点、Sharpe 口径、负数量可反向交易
5中严重度工程/统计风险:时间频率、缺失价格、静默跳单、成本参数、绘图口径
4主要文件:returns.py、costs.py、plots.py、__init__.py

二、发现总表

等级位置问题影响
高returns.py:56-100
__init__.py:21-28
同一 t 用收盘价产生 signal 并成交,接口没有强制执行延迟。signal 若使用该日收盘信息,会产生前视偏差;这是能直接改变收益结论的口径问题。
高returns.py:173-176Sharpe 以“年化收益减年化 rf”除以年化波动率。不是标准逐期超额收益 Sharpe;rf 没有转换为逐期收益,指标可能系统性偏高或偏低。
高returns.py:18-32,63-100数量、价格、费用参数没有统一验证;负数量可以反向增加持仓或现金。非法 signal 不会失败,而会生成看似合法的结果,破坏账务不变量并掩盖 notebook 错误。
中高returns.py:63-101订单按 dict 顺序逐笔执行,资金不足/持仓不足的订单静默跳过。结果依赖输入顺序;研究者无法从结果知道哪些目标未成交,复现和解释困难。
中高returns.py:134-145价格矩阵、结果时点、重复键和缺失价格没有显式契约或错误信息。缺时点可能 KeyError;缺价格可能使 NAV 变成 NaN;重复长表键会直接失败。
中returns.py:109-116,162以自然日中位间隔自动推断年频。交易日序列通常推成约 365,而不是 244/252;默认指标会被错误年化。
中returns.py:195-198;plots.py:48-59,121-131分析和绘图对 benchmark 缺失期采用不同处理。统计丢弃缺失期,图形填 0 收益;同一份回测在图和数字间出现口径差异。
中costs.py:1-23成本模型只接受比例乘法,没有参数范围、最小佣金、成交额/市场规则的表达。小额交易成本可能失真;极端配置可使卖出净现金为负;模型适用范围不清楚。
中plots.py:42-45,74-79,98-100空 NAV、全 NaN 基准、非正 NAV 等输入没有稳定处理。绘图可能抛异常或对数坐标失真;分析和可视化的失败行为不一致。
中tests/test_returns.py(空文件)核心回测测试缺失,统计口径没有回归保护。上述问题容易在 notebook 修改后重新出现,且没有可执行的接口契约。

三、详细技术发现

F-01|执行时点可能造成前视偏差(高)

证据:run() 在 returns.py:56-61 以 t 调用 signal,随后在 :63-99 使用 signal 返回的价格成交。包文档又明确约定成交价是该行的 C(t)(__init__.py:21-28)。

为什么严重:如果 signal 在 t 使用了 t 收盘价、当日成交量、当日排名或由 t 收盘计算的因子,那么引擎等价于“看到收盘结果后仍按收盘价成交”。即使某个 notebook 当前自律地避免了这点,通用接口也没有在类型或数据流上阻止误用。

建议:把执行价格/时点变成显式配置,例如 signal 在 decision_time=t 产生订单,订单在 execution_time=next(t) 使用下一期开盘或可配置价格字段成交;若必须保持现有 close 代理,应将 signal 可见数据截断到 t-1,并在 API 文档和测试中固定该约束。不要只靠 notebook 注释防止前视。

F-02|Sharpe 公式与 rf 频率不一致(高)

证据:returns.py:173-176 先用净值总收益计算 ann_ret,再计算 ann_vol,最后使用 (ann_ret - rf) / ann_vol。

问题:标准 Sharpe 应先将年化 rf 转换为逐期 rf(例如 (1+rf)**(1/ppy)-1),形成逐期超额收益,再使用超额均值和标准差年化。年化复合收益率不是逐期平均收益,两者在波动存在时不等价。当前指标名称叫 Sharpe,但数学口径会让使用者误解。

建议:计算 rf_period = (1 + rf)**(1 / ppy) - 1,然后 excess = r - rf_period,返回 excess.mean() / excess.std(ddof=1) * sqrt(ppy)。同时保留 rf 的明确单位;如需兼容旧结果,应另命名旧指标而不是无声改变。

F-03|负数量可绕过风险检查并制造反向交易(高)

证据:is_valid_trade() 只检查卖出数量是否大于持仓,以及买入总成本是否大于现金(returns.py:24-31)。没有检查 quantity > 0、价格有限且为正。于是负 buy 会减少持仓并增加现金,负 sell 会增加持仓并减少现金。

影响:这不是策略可行性检查应承担的责任,而是通用账务层必须保证的基本不变量。一个 notebook 中的 sign 错误可能被静默记账,最后表现为异常但不明显的超额收益。

建议:在进入执行循环前对 signal 结构、instrument、quantity、price 做统一校验;非法输入抛出包含 time/instrument/side 的异常。至少保证数量和价格为有限正数,买卖集合格式正确,成本配置非负且卖出净额非负。是否整手、涨跌停、停牌仍可由 notebook 提供,但不能替代这些通用不变量。

F-04|静默跳过订单且结果依赖顺序(中高)

证据::64-65 和 :84-85 对无效订单直接 continue;买单又在 :83-100 按字典顺序逐单扣现金。

影响:资金不足时,先出现的买单成交,后出现的买单被丢弃。改变 signal 构造字典的顺序就可能改变组合。结果中虽然有 trades,但没有 rejected orders 和原因,审计者不能区分“策略不想买”和“引擎没钱买”。

建议:定义明确的订单分配政策:全有或全无、按权重按比例缩放、或按确定排序截断。默认至少把拒单记录为 rejected_trades,并允许严格模式直接报错。对同一期订单先做总额检查或规范化,避免 Python dict 顺序成为隐含投资组合规则。

F-05|估值输入的缺失与键约束不足(中高)

证据:analysis() 在 :134-145 用 pivot() 建价格矩阵,并直接访问 px.loc[t,c];只做了时间前向填充,没有检查结果时点是否在价格矩阵中、持仓标的当期是否有可用价格、长表键是否唯一。

影响:结果中多一个 calendar 时点会触发 KeyError;退市前后或首次上市前的价格缺失可能让 NAV 变 NaN。对停牌“沿上一价”的处理也没有区分真实停牌、缺失数据和上市前无价格,可能把数据问题隐藏为估值。

建议:在构造 Backtest 或运行前验证:(time,instrument) 唯一、price 有限且非负、calendar 已排序且属于可估值时点。对缺价格提供明确 policy(报错、沿用上一价、剔除资产),并在结果中记录估值状态;不要用无条件 ffill() 代替数据质量决策。

F-06|自动年频推断不适合交易日数据(中)

证据:infer_ppy() 在 returns.py:109-116 以相邻日期的自然日中位间隔计算 365.25 / median。普通交易日多数相邻日期间隔为 1,结果接近 365。

影响:年化收益指数、波动率、Sharpe 和年化换手都被影响。文档虽建议日频显式传 244/252,但“None 自动推断”的默认接口仍会让调用者得到看似合理的错误数字。

建议:不要从日期间隔猜交易频率;要求调用方显式传 periods_per_year,或接受明确的频率枚举并映射到 12/252 等值。若保留推断,只能作为提示并在输出中标记 frequency_source='inferred',不能静默用于正式指标。

F-07|基准缺失的分析和绘图口径不一致(中)

证据:分析部分在 returns.py:195-198 用 dropna() 丢掉基准缺失期;绘图部分在 plots.py:48-54 和 :121-131 用 fillna(0.0) 将缺失期当作零收益。

影响:统计超额序列和图中基准净值不是同一数据集。用户可能把图上的完整曲线与数字指标一起引用,导致解释不一致。

建议:统一 benchmark policy:严格对齐并只在共同样本绘图和计算,或明确显示断点;不要把缺失数据伪装为 0 收益。对 excess return 也应提供共同样本长度、缺失期数量和对齐起止时间。

四、设计评价

做得合理的地方

设计上应加强的地方

五、推荐改进路线

必须先做:固定并测试执行时点;修正 Sharpe;拒绝负/非有限订单;对缺失价格和 calendar 做显式验证。
  1. 第一阶段:结果可信性。引入 decision/execution 时点或明确 t-1 信息约束;为 NAV 和收益写固定样例;将 Sharpe 改为逐期超额收益计算;要求日频显式指定年频。
  2. 第二阶段:账务安全。集中做订单 schema 校验;加入确定的资金不足 policy;记录拒单;验证卖出数量、买入现金、持仓非负和每期 NAV 可解释。
  3. 第三阶段:数据契约。验证长表主键、价格域、calendar 和估值缺失;把停牌沿价做成显式 policy;基准统一使用共同样本。
  4. 第四阶段:研究工程化。补齐 pytest 配置和测试;将口径写入非空 methodology 文档;输出 run metadata 和版本标识,确保 notebook 结果可追溯。

六、最低测试矩阵

类别必须覆盖的情形断言重点
时间t 产生 signal、t+1 成交;月频与交易日日频;不规则 calendar成交价和可见数据严格符合 policy;日频不会默认为 365
账务买卖各一笔、卖空数量、全卖后再买、资金不足、数量为 0/负数现金和持仓不为非法值;非法输入抛出明确异常或按 documented policy 拒绝
估值缺时点、停牌 NaN、上市前 NaN、退市后持仓、重复 time/instrument不出现无解释的 NaN/KeyError;policy 可观测
成本默认成本、零费率、极端费率、小额交易买卖现金流、fee 符号和卖出净额符合合同
统计1、2、12、252 个周期;零波动;负收益;benchmark 缺失年化、波动率、Sharpe、超额和样本范围口径一致
可视化空序列、全 NaN benchmark、非正 NAV、单点序列图形稳定返回或给出可理解错误,不产生静默伪数据

七、范围与限制