第 8 章 · 02 向量化回测实战 本节摘要:本节演示 ML4T 工作流里最轻量的一步——向量化回测(vectorized backtest)。它不模拟订单、不撮合、不算手续费,只用 pandas 把「信号向量」和「收益向量」逐元素相乘,几行代码就能看出一个策略雏形是否值得继续投入。本节用第 7 章岭回归产生的日收益预测作为信号,讲清三件事:如何把连续预测转换成「做多 N 只 + 做空 N 只」的离散信号;如何用 算出组合每日收益并与 SP500 基准对比;以及向量化回测的天花板在哪里——为什么它只能当「快速筛子」而不能当「最终裁判」。读完本节,你能用十行 pandas 跑通一个多空策略的雏形评估。 内容来源:原项目 ,汉化并套用体系化模板。
本节摘要:本节演示 ML4T 工作流里最轻量的一步——向量化回测(vectorized backtest)。它不模拟订单、不撮合、不算手续费,只用 pandas 把「信号向量」和「收益向量」逐元素相乘,几行代码就能看出一个策略雏形是否值得继续投入。本节用第 7 章岭回归产生的日收益预测作为信号,讲清三件事:如何把连续预测转换成「做多 N 只 + 做空 N 只」的离散信号;如何用
signal * forward_return算出组合每日收益并与 SP500 基准对比;以及向量化回测的天花板在哪里——为什么它只能当「快速筛子」而不能当「最终裁判」。读完本节,你能用十行 pandas 跑通一个多空策略的雏形评估。
内容来源:原项目
08_ml4t_workflow/02_vectorized_backtest.ipynb,汉化并套用体系化模板。
⚠️ 学习提示:向量化回测忽略滑点、手续费、冲击成本与订单未成交,结果一定偏乐观。它只回答「这个信号有没有方向性的预测力」,不回答「实盘能不能赚」。
阅读完本节,你应当能够:
rank + 阈值把连续预测转成多空离散信号。signal.mul(forward_return) 算出组合每日收益。向量化回测的核心只有一行数学:
即「当天的持仓权重」乘以「次日的资产收益」。之所以叫「向量化」,是因为整段时间的权重矩阵 W 和收益矩阵 R 用 pandas 一次性相乘,没有 for 循环、没有订单对象、没有撮合引擎。
整个 notebook 严格按这条流水线展开。它的价值不在「精确」,而在「快」——你可以在几秒内扫几十个参数组合,把明显没戏的信号先淘汰掉。
💡 核心心法:向量化回测是「初筛」工具,像体检里的快速筛查,而不是确诊检查。它帮你省下「为烂策略写完整撮合逻辑」的时间,但不该用它做最终的上线决策。
notebook 先从 backtest.h5 读取第 7 章岭回归给出的预测,再用开盘价构造次日收益(forward returns):
data = pd.read_hdf('00_data/backtest.h5', 'data') daily_returns = data.open.unstack('ticker').sort_index().pct_change() fwd_returns = daily_returns.shift(-1) # 用「明天的开盘对今天的开盘」
关键细节:shift(-1) 是把「明天的收益」对齐到「今天」这一行。这样信号(今天生成)乘上 fwd_returns(对齐到今天)就表示「今天根据信号下单,明天收获的收益」。
⚠️ 时点陷阱:
shift方向搞反是向量化回测最常见的 bug,会把「未来收益」当「过去收益」乘进信号,造出完美的曲线——但这是前视偏差,实盘立刻原形毕露。务必画一张时序图,逐根 K 线确认「信号在第 t 天,收益在第 t+1 天」。
预测是连续的(每个股票都有一个收益预测值),但下单是离散的(资金有限,只能选若干只)。notebook 用横截面 rank 把每日预测排序,选出最高与最低的各 N_LONG/N_SHORT 只:
N_LONG = N_SHORT = 15 long_signals = ((predictions .where(predictions > 0) .rank(axis=1, ascending=False) > N_LONG) .astype(int)) short_signals = ((predictions .where(predictions < 0) .rank(axis=1) > N_SHORT) .astype(int))
解读这段代码:
where(predictions > 0) 把非正预测置为 NaN,只在「预测为正」的股票里挑做多。rank(axis=1, ascending=False) 按行降序排名,值越大排名越靠前(第 1 名 = 预测最高)。> N_LONG 取排名在 15 名之外的(也就是前 15 名之外为 False,前 15 名为 True)——注意这里的方向,实际效果是「挑出预测最高的若干只」。astype(int) 把布尔转成 0/1 信号矩阵。| 元素 | 含义 |
|---|---|
predictions |
每日每股票的收益预测值 |
long_signals / short_signals |
0/1 矩阵,1 表示这天这只股票被选中 |
N_LONG = N_SHORT = 15 |
每天做多 15 只、做空 15 只 |
💡 为什么用 rank 不用绝对值:预测的绝对数值会随模型尺度漂移,「预测 0.005」算大算小没共识;但「今天排第 3 名」永远清晰。rank 把不同日期、不同股票的预测拉到同一尺度,是横截面选股的标准做法。
有了信号和前瞻收益,组合日收益就是逐元素相乘再等权平均:
long_returns = long_signals.mul(fwd_returns).mean(axis=1) short_returns = short_signals.mul(-fwd_returns).mean(axis=1) strategy = long_returns.add(short_returns).to_frame('Strategy')
注意三点:
long_signals.mul(fwd_returns) 是 DataFrame 逐元素乘,效果就是「选中为 1 的股票贡献其收益,未选中为 0 的贡献 0」。.mean(axis=1) 对当日选中的股票等权平均——因为没选中的是 0,分母仍是总股票数,所以这里隐含「等权多头/空头组合」假设。-fwd_returns:做空收益 = 负的资产收益。最后 notebook 把策略累计收益和 SP500 基准画在一起对比:
sp500 = web.DataReader('SP500', 'fred', '2014', '2018').pct_change() strategy.join(sp500).add(1).cumprod().sub(1).plot()
add(1).cumprod().sub(1) 是从日收益还原累计收益的标准写法:每日收益 +1 累乘再 -1。两条曲线一比较,就能直观看出策略相对市场的超额。
notebook 最后还看了标准差、相关性等,但向量化回测到这里就该停。它的假设清单写满了「理想世界」:
| 假设 | 真实情况 | 偏差方向 |
|---|---|---|
| 信号一出立即以开盘价成交 | 有滑点、有限价单未成交 | 高估收益 |
| 无手续费 | 每笔都有佣金 | 高估收益 |
| 等权且资金充足 | 资金有限、有最小手数 | 高估可执行性 |
| 任意标的都可做空 | 融券难、有利息 | 高估空头可行性 |
| 无市场冲击 | 大单会推高/压低价格 | 高估容量 |
⚠️ 何时必须切换:一旦雏形值得继续,就立刻转向事件驱动回测(下一节的 backtrader),让它把滑点、手续费、订单未成交、做空限制一个个加回去。向量化回测的「漂亮曲线」永远不能作为上线依据,只能作为「是否值得写更多代码」的判断。
策略收益 = 持仓权重 × 资产收益,向量化用 pandas 一次性算完整段时间,极快但极理想化。shift(-1) 把「次日收益」对齐到「今天」,方向搞反就是前视偏差——必须画图核对。rank 把连续预测转成「多 N + 空 N」的离散信号,规避预测尺度漂移。signal.mul(forward_return).mean(axis=1) 隐含等权组合假设,做空侧乘负号。add(1).cumprod().sub(1) 还原累计收益,与 SP500 同图对比看超额。下一节,我们看 backtrader 事件驱动回测——用 Cerebro 架构把订单、撮合、手续费逐根 K 线模拟,看一个策略在更接近实盘的环境里还活不活得下来。