通用 Backtest 模块代码审查报告
审查对象为 Factor Pricing Research 项目的 src/backtest。重点判断该模块作为所有 notebook 共用的回测基础设施,是否存在会改变研究结论的显著性问题,以及接口、健壮性和可维护性上的改进空间。
一、执行摘要
该模块的职责边界是合理的:signal 函数负责策略决策和策略特有的可行性筛选,通用引擎负责执行、现金/持仓记账、估值、指标和绘图。代码短小,接口容易在 notebook 中调用;但当前实现仍更接近“研究原型”,还没有把通用回测引擎最重要的时间、账务和输入不变量落实为可验证的机制。
二、发现总表
| 等级 | 位置 | 问题 | 影响 |
|---|---|---|---|
| 高 | returns.py:56-100__init__.py:21-28 | 同一 t 用收盘价产生 signal 并成交,接口没有强制执行延迟。 | signal 若使用该日收盘信息,会产生前视偏差;这是能直接改变收益结论的口径问题。 |
| 高 | returns.py:173-176 | Sharpe 以“年化收益减年化 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 也应提供共同样本长度、缺失期数量和对齐起止时间。
四、设计评价
做得合理的地方
- 用
SignalFunction = Callable[[Any, pd.DataFrame, dict], dict]将策略决策与通用执行分离,适合多个 notebook 复用。 - 使用
default_factory=CostConfig,避免 dataclass 实例共享同一个默认成本对象。 run()返回包含现金、持仓和逐笔成交的结果,具备基本审计材料;使用深拷贝避免历史行被后续状态修改。- 分析、绘图分层,绘图函数支持传入
ax,便于 notebook 组合。 - 交易成本在买入和卖出现金流中分别扣除,表达形式直观。
设计上应加强的地方
- 状态接口过于宽松:普通
dict同时承担输入状态、结果记录和可变运行状态。建议拆出不可变的PortfolioState、订单类型和成交记录,至少用 dataclass/TypedDict 描述字段。 - 职责边界还缺一层通用执行风控:signal 可以决定涨跌停和整手,但引擎仍应统一验证正数、现金、持仓、重复订单、时间和价格。策略特有规则与账务安全规则应分开。
- 结果缺少审计事件:应记录 submitted, executed, rejected、拒绝原因、估值价格来源和执行时点,才能解释跨 notebook 的差异。
- 配置没有不可变和校验:
CostConfig可改写且无类型/范围检查。建议使用 dataclass,构造时验证费率,并把政策(commission、stamp、slippage、minimum fee)显式化。 - 统计层应返回元数据:除标量外返回样本数、缺失数、频率来源、起止日期、benchmark 对齐范围、rf 口径和估值 policy。
五、推荐改进路线
- 第一阶段:结果可信性。引入 decision/execution 时点或明确 t-1 信息约束;为 NAV 和收益写固定样例;将 Sharpe 改为逐期超额收益计算;要求日频显式指定年频。
- 第二阶段:账务安全。集中做订单 schema 校验;加入确定的资金不足 policy;记录拒单;验证卖出数量、买入现金、持仓非负和每期 NAV 可解释。
- 第三阶段:数据契约。验证长表主键、价格域、calendar 和估值缺失;把停牌沿价做成显式 policy;基准统一使用共同样本。
- 第四阶段:研究工程化。补齐 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、单点序列 | 图形稳定返回或给出可理解错误,不产生静默伪数据 |
七、范围与限制
- 审查对象限定为
/home/ubuntu/projects/factor-pricing-research/src/backtest四个文件及其公开接口说明;没有审查各 notebook 内具体 signal 的选股逻辑和策略特有可行性判断。 - 本项目目录当前没有可用 Git 元数据,无法可靠核对“昨天更新”的提交范围;文件系统中该项目主要文件显示为 2026-08-27,报告按当前工作区代码审查。
tests/test_returns.py、pyproject.toml和docs/methodology.md当前为空,因此无法依赖现有自动化测试确认预期口径。- 本报告未修改回测源代码,只提供审查结论和改进路线。是否立即改变历史 notebook 结果,应在先冻结新旧口径并做对比回放后决定。