单元 05:从零实现一个回测
单元 04 已经能够在每日收盘后生成目标持仓,但“希望持有”还不是“已经持有”。如果把当日目标直接乘以当日收益,策略会获得信号产生前已经发生的涨跌。
本单元将补上这段时间关系,再加入换手、交易成本和买入持有基准,完成第一条从市场数据走到策略净值的回测链路。
本单元目标
完成本单元后,你将能够:
- 说明目标持仓、回测持仓和资产收益属于哪个时间区间;
- 使用一行时间偏移避免目标持仓获得当日已发生的收益;
- 根据持仓和资产收益计算策略毛收益;
- 根据持仓变化计算单边换手与比例交易成本;
- 生成扣除成本前后的策略净值;
- 建立价格型买入持有基准并正确解释比较结果;
- 说明这个向量化回测做了哪些教学简化。
最终产物由下面的命令生成:
uv run python examples/unit05/run_backtest.py前置条件
- 已完成单元 04;
- 能解释简单收益率、净值、信号和目标持仓;
- 已执行
uv sync --locked; - 后续命令均在项目根目录运行。
场景:目标持仓不能赚取过去的收益
先看样例中的两个相邻交易日:
| 日期 | 当日收盘后得到的目标持仓 | 当日资产收益 |
|---|---|---|
| 2024-01-05 | 1.0 | +1.1858% |
| 2024-01-08 | 1.0 | +0.6836% |
2024-01-05 的 target_position=1.0 使用了当天最终收盘价 102.40 才能计算出来。此时从 2024-01-04 收盘到 2024-01-05 收盘的 +1.1858% 已经结束,策略不能回头获得这段收益。
本单元采用下面的日线 bar 模型:
交易日 t 收盘后得到 target_position(t)
↓ 延迟一根日线
下一行使用 position(t+1) = target_position(t)
↓
position(t+1) × asset_return(t+1)也就是说,某日收盘后的决策只能影响下一根日线。这个约定避免用未来数据,却仍然只是日线级近似。
日线 bar 模型不等于真实成交
程序把目标持仓延迟一行后,假设它在下一根 bar 中完整生效,没有模拟具体订单、下一日开盘价、部分成交和盘中路径。真实交易若在下一日开盘成交,close-to-close 收益中还包含开盘前的隔夜变化,不能被这个简化模型精确拆分。
本单元的目标是先保证列之间没有时间穿越。更细的执行模型需要明确订单时间并使用相应频率和价格的数据。
准备目标持仓和资产收益
程序先按单元 04 的同一规则计算 3 日均线与目标持仓,再计算单元 03 使用的 close-to-close 简单收益:
result["asset_return"] = result["close"].pct_change(fill_method=None)asset_return(t) 表示从上一交易日收盘到交易日 t 收盘的资产收益。第一行没有前一个价格,因此保持缺失。
目标持仓和资产收益虽然在同一行,却不能直接相乘:
target_position(t)在交易日t收盘后才形成;asset_return(t)在这个收盘时点已经全部发生;- 因此目标持仓必须先沿时间轴向后移动。
将目标持仓延迟为回测持仓
代码只需要一行关键操作:
result["position"] = result["target_position"].shift(1).fillna(0.0)shift(1) 把交易日 t 的目标移到下一行,使它成为下一根日线使用的回测持仓。fillna(0.0) 表示策略在第一个有效目标生效前从现金开始;它没有把预热期信号伪装成空仓信号,而是在回测层明确初始账户状态。
用两个日期检查:
| 日期 | target_position | position | 含义 |
|---|---|---|---|
| 2024-01-05 | 1.0 | 0.0 | 当日刚得到做多目标,当前行仍按此前现金状态计算 |
| 2024-01-08 | 1.0 | 1.0 | 上一行的做多目标延迟后生效 |
如果省略 shift(1),2024-01-05 的目标会获得同日 +1.1858% 的收益,这正是我们要避免的时间穿越。
未来函数不一定长得像读取未来日期
未来函数(Look-ahead Bias)泛指计算使用了决策时尚不可获得的信息。代码即使没有显式读取“明天”,只要用当日收盘价生成目标,又让目标赚取当日 close-to-close 收益,就已经泄漏了时间信息。
用持仓计算策略毛收益
设:
:交易日 的资产简单收益率; :用于交易日 这根 bar 的回测持仓比例; :未扣交易成本的策略毛收益率。
本单元只有全仓持有和现金两种状态:
代码直接逐行相乘:
result["gross_return"] = result["position"] * result["asset_return"]因此:
position=1.0时,策略获得这一行的全部资产收益;position=0.0时,策略毛收益为 0;- 本单元假设现金收益为 0,也没有杠杆和做空。
例如 2024-01-08 的持仓为 1.0,资产收益为:
所以策略毛收益也是 0.6836%。而 2024-01-10 的资产上涨 1.3645%,但回测持仓为 0.0,策略毛收益为 0。
这不是程序漏算,而是上一交易日的目标为空仓。回测的职责是忠实执行事先写下的规则,而不是事后把上涨日补进来。
根据持仓变化计算交易成本
只有持仓发生变化时才需要交易。本单元用单边换手(One-way Turnover)表示相邻两行持仓变化的绝对值:
其中
| 持仓变化 | 单边换手 |
|---|---|
| 0.0 → 1.0 | 1.0 |
| 1.0 → 0.0 | 1.0 |
| 1.0 → 1.0 | 0.0 |
| 0.0 → 0.0 | 0.0 |
代码把第一行之前的账户状态也设为现金:
previous_position = result["position"].shift(1).fillna(0.0)
result["turnover"] = (result["position"] - previous_position).abs()交易成本率设为 0.001,也就是每单位单边换手扣除 0.1%。0.1% 等于 10 个基点(Basis Points,bps),1 bps 等于 0.01%。
其中:
:本期从收益中扣除的交易成本率; :本期单边换手; :每单位单边换手的成本率,本例为 0.001。
代码为:
result["transaction_cost"] = result["turnover"] * cost_rate
result["net_return"] = result["gross_return"] - result["transaction_cost"]这个成本模型把佣金、买卖价差和滑点统一近似为一个固定比例,便于手工检查。它没有模拟最低收费、税费差异、市场冲击、成交量限制或成本随时间变化。
transaction_cost 各行相加得到的是单期成本率合计,不等于用货币计算的累计手续费,也不等于毛净值末端差异;净值还会受到每期复利影响。
生成毛净值、净净值和基准
策略毛净值和净净值继续使用单元 03 的连乘:
result["gross_wealth"] = (
1 + result["gross_return"].fillna(0.0)
).cumprod()
result["net_wealth"] = (
1 + result["net_return"].fillna(0.0)
).cumprod()- 毛净值(Gross Wealth)回答:忽略交易成本时策略如何变化?
- 净净值(Net Wealth)回答:按本单元成本假设扣费后如何变化?
- 两者的差异展示交易频率带来的成本侵蚀。
只看策略自身还不够。基准(Benchmark)提供一个回答“如果不采用这套策略,会怎样”的参照。本单元使用买入并持有(Buy and Hold):从样例第一日收盘持有到最后一日收盘,不做择时。
result["benchmark_wealth"] = (
1 + result["asset_return"].fillna(0.0)
).cumprod()这个基准等于收盘价除以首日收盘价,是不扣成本的价格型参照。它没有现金分红、初次买入成本和再投资假设,因此不是完整的可交易账户。比较时必须保留这项口径说明。
运行回测
执行:
uv run python examples/unit05/run_backtest.py程序会打印完整的 10 行回测表。先核对这些关键行:
| 日期 | 目标持仓 | 回测持仓 | 资产收益 | 换手 | 成本 | 策略净收益 |
|---|---|---|---|---|---|---|
| 2024-01-05 | 1.0 | 0.0 | +1.1858% | 0.0 | 0.0000% | 0.0000% |
| 2024-01-08 | 1.0 | 1.0 | +0.6836% | 1.0 | 0.1000% | +0.5836% |
| 2024-01-09 | 0.0 | 1.0 | -0.4850% | 0.0 | 0.0000% | -0.4850% |
| 2024-01-10 | 1.0 | 0.0 | +1.3645% | 1.0 | 0.1000% | -0.1000% |
| 2024-01-11 | 1.0 | 1.0 | +0.7692% | 1.0 | 0.1000% | +0.6692% |
| 2024-01-15 | 1.0 | 0.0 | +1.2476% | 1.0 | 0.1000% | -0.1000% |
汇总结果应为:
持仓调整: 4 次
累计单边换手: 4.0
单期成本率合计: 0.4000%
策略毛收益: 0.3880%
策略净收益: -0.0115%
买入持有收益: 4.6627%
回测明细: outputs/unit05/backtest.csv
净值图表: outputs/unit05/backtest_wealth.png单元 04 有 5 次目标持仓切换,这里只有 4 次实际持仓调整。原因是样例最后一日才产生新的做多目标,已经没有下一根日线让它延迟生效。
backtest.csv 的核心字段为:
| 字段 | 含义 |
|---|---|
target_position | 当日收盘后生成的目标持仓 |
position | 延迟一行后用于本行收益的回测持仓 |
asset_return | 上一收盘到当日收盘的资产收益 |
turnover | 相邻回测持仓的单边变化 |
transaction_cost | 本行按换手扣除的成本率 |
gross_return | 扣费前策略收益 |
net_return | 扣费后策略收益 |
gross_wealth | 扣费前策略净值 |
net_wealth | 扣费后策略净值 |
benchmark_wealth | 不扣成本的买入持有价格净值 |
解读净值图
打开 outputs/unit05/backtest_wealth.png。上图是延迟后的回测持仓,下图比较策略毛净值、策略净净值和买入持有基准。
上图的每段阶梯线表示相邻两个收盘之间使用的持仓,圆点对应回测表中该日期行记录的 position。
可以逐段读图:
- 策略在 2024-01-08 才首次持有,因此没有获得此前上涨;
- 持仓不变时没有新增成本,毛净值与净净值按同一收益继续变化;
- 每次进出都会让红色净净值相对蓝色毛净值再下降一点;
- 2024-01-10 和 2024-01-15 处于现金,却各发生一次调仓成本;
- 绿色买入持有基准在这 10 天明显更高。
最终策略毛收益为 0.3880%,扣除四次 0.1% 成本后净收益为 -0.0115%。这说明一个在价格图上看似合理的规则,经过时间延迟和成本后可能没有收益。
但 10 个交易日不能证明移动平均策略长期无效,也不能证明买入持有永远更好。这个短样本只用于验证回测列之间的计算关系。
这个向量化回测模拟了什么
向量化回测(Vectorized Backtest)把每日数据、持仓和收益表示为对齐的列,再一次完成整列计算。它适合规则简单、频率固定且容易用表格核对的研究。
本单元已经表示:
- 信号延迟一根 bar;
- 全仓或空仓;
- 持仓变化和比例成本;
- 策略毛收益、净收益和买入持有基准。
本单元没有表示:
- 具体订单类型和成交价格;
- 开盘跳空与盘中价格路径;
- 部分成交、停牌、涨跌停和流动性;
- 最小交易单位、资金取整和现金余额;
- 分红、税费细则、融资利息和现金利息;
- 成本随订单规模和市场状态变化。
因此这里的 position 是模型假设下的回测持仓,不是交易账户的真实成交记录。
常见陷阱
忘记延迟目标持仓
直接计算 target_position * asset_return 会让信号获得生成前已经发生的收益。结果通常会被系统性高估。
对价格做了 shift,却没有对持仓做 shift
移动价格或收益列可能暂时让数字看起来对齐,却很难解释每一行代表什么。保持资产收益口径不变,只把决策延迟到可使用的下一行,更容易审计。
把目标切换次数当成实际交易次数
样本最后一行产生的新目标没有下一行可以生效。统计交易应该基于回测持仓变化,而不是目标持仓变化。
只计算买入成本,忘记卖出成本
从 0 到 1 和从 1 到 0 都有单边换手 1.0。本单元的成本率对买卖方向采用同一简化值。
用收益相减代替净值比较
交易成本在不同日期发生,后续还要参与复利。最终毛净值减净净值,不应简单等同于各期成本率之和。
把基准当成投资承诺
基准只是参照,不代表可无成本获得,也不表示未来会重复样例中的上涨。
验证结果
完成标准:
- 2024-01-05 的目标持仓为 1.0,但回测持仓仍为 0.0;
- 2024-01-08 的回测持仓为 1.0,并获得当行资产收益;
- 2024-01-10 的回测持仓为 0.0,没有获得当行资产上涨;
- 4 次持仓调整产生 4.0 的累计单边换手;
- 每次单边持仓调整按 0.1% 成本率扣减;
- 策略净值始终不高于相同持仓路径的毛净值;
- 买入持有末端净值等于
105.50 / 100.80; - 能解释日线 bar 模型与真实成交之间的差异。
小结
本单元完成了第一条最小回测链路:
收盘价
↓
SMA → 信号 → 目标持仓
↓ shift(1)
回测持仓
↙ ↘
× 资产收益 持仓变化 → 换手 → 成本
↘ ↙
毛收益 → 净收益
↓ 连乘
毛净值 / 净净值
↓ 比较
买入持有基准下一单元会在这份回测结果上计算总收益、年化收益、波动率、最大回撤、夏普比率等绩效指标,并学习每个指标能够说明什么、不能说明什么。
练习
基础练习:核对一笔完整进出
沿着 2024-01-05 到 2024-01-10 的数据回答:
- 哪一天产生第一个做多目标?
- 哪一天回测持仓真正变为 1.0?
- 持仓期间获得了哪两行资产收益?
- 哪一天回测持仓回到 0.0?
- 买入和卖出分别扣除了多少成本率?
进阶练习:比较不同成本假设
分别使用 cost_rate=0、0.0005 和 0.001 运行回测,记录:
- 策略毛收益是否变化;
- 策略净收益如何变化;
- 持仓调整次数和累计换手是否变化。
先写出预期,再运行程序核对。
延伸练习:设计下一开盘成交模型
不必实现代码,先写清楚:
- 信号在何时生成;
- 订单在何时提交;
- 使用下一日哪个价格作为成交价;
- 持仓在隔夜和日内分别如何变化;
- 需要哪些额外字段才能拆分隔夜收益与日内收益。
如果无法为某段收益找到明确持仓,就说明时间模型还不完整。