回测是把「策略想法」变成「可信结论」的唯一手段,但绝大多数回测的结果是不可信的。原因不是代码写错了,而是回测天然倾向于高估收益:它能看到未来、假设无限流动性、忽略交易成本、忽略幸存者偏差。一个不加约束的回测,几乎必然给出年化 30% 以上的漂亮曲线。
工程上真正难的地方在于让回测的每一个假设都显式化并可验证。你无法消除回测与实盘的差距,但可以度量它、缩小它。本文的核心主张是:回测框架的价值不在于跑得快,而在于逼你面对每一个假设。
本文按「范式 → 偏差 → 成本 → 验证 → 性能 → 归因」的顺序展开。行情数据本身的清洗问题在 行情数据接入与清洗 中讨论,本文假设输入数据已经是干净的。
目录
- 回测的三种范式
- 前视偏差的七种形态
- 幸存者偏差与样本选择
- 撮合与成交假设
- 交易成本建模
- 滑点与冲击成本
- 样本外与 Walk-forward
- 回测性能优化
- 回测报告与归因
1. 回测的三种范式
| 范式 | 实现 | 速度 | 真实度 | 适用 |
|---|---|---|---|---|
| 向量化 | numpy/pandas 矩阵运算 | 极快 | 低 | 因子初筛 |
| 事件驱动 | 逐 tick/bar 回调 | 慢 | 高 | 策略验证 |
| 事件驱动 + 撮合 | 模拟完整订单簿 | 很慢 | 最高 | 高频/执行算法 |
向量化回测的核心是「把持仓序列和收益序列做点积」:
import numpy as np
def vector_backtest(prices, signals, cost_bps=5):
returns = np.diff(prices) / prices[:-1] # 日收益率序列
pos = signals[:-1] # 对齐:信号延后一天生效
turnover = np.abs(np.diff(pos)) # 换手率
gross = (pos * returns).sum()
cost = (turnover * cost_bps / 10000).sum()
return gross - cost
向量化最大的陷阱是对齐:signals[t] 必须只用 t 时刻及之前的信息,但代码里很容易写成 signals[t] 用了 t+1 的价格。这类 bug 不会报错,只会让收益虚高。
事件驱动回测把时间轴逐格推进,每个事件触发一次回调:
class EventBacktest:
def __init__(self, feed, strategy, broker):
self.feed = feed
self.strategy = strategy
self.broker = broker
def run(self):
for event in self.feed:
if event.type == 'BAR':
self.strategy.on_bar(event) # 策略产生信号
elif event.type == 'ORDER':
self.broker.submit(event) # 撮合
elif event.type == 'FILL':
self.strategy.on_fill(event) # 回报反馈
事件驱动的优势是时序天然正确——策略只能看到已经发生的事件。这种架构本质上就是消息驱动模式在交易场景的应用。
2. 前视偏差的七种形态
前视偏差(look-ahead bias)是回测的头号杀手。它至少有七种常见形态:
| 形态 | 例子 | 规避 |
|---|---|---|
| 用收盘价交易 | 用当日收盘价决定并成交 | 信号延后一个 bar |
| 财报时间错位 | 用报告期而非公告日 | 用公告日期索引 |
| 全样本标准化 | 用全期均值做 z-score | 滚动窗口标准化 |
| 指数成分 | 用今天的成分股回测过去 | 用历史成分股快照 |
| 复权数据 | 前复权价含未来信息 | 用后复权 |
| 参数调优 | 用全样本挑最优参数 | 样本外验证 |
| 数据修订 | 用修订后的宏观数据 | 用初值(vintage) |
最隐蔽的是全样本标准化。看这段代码:
z_bad = (factor - factor.mean()) / factor.std() # 错误:全样本统计量含未来信息
z_ok = (factor - factor.rolling(252).mean()) / factor.rolling(252).std()
第一行让每个时点都知道「全期均值」,等于给了策略一个水晶球。实测中这一处改动就能把回测年化从 25% 降到 8%。
3. 幸存者偏差与样本选择
如果你用「今天还活着的股票」回测过去十年,你会自动剔除所有退市、被并购、暴雷的标的。A 股 2000 年以来退市公司累计数百家,剔除它们相当于系统性地剔除了最差的样本。
| 偏差 | 来源 | 影响 |
|---|---|---|
| 幸存者偏差 | 只用现存标的 | 高估收益 2~5% |
| 前视成分 | 用当前指数成分 | 高估收益 3~8% |
| 数据窥探 | 反复试参数 | 高估 Sharpe 1 以上 |
| 样本选择 | 只挑好年份 | 完全失真 |
治理方法是使用历史快照:每个时点都用「当时真实存在的标的池和指数成分」。
-- 用历史成分快照构建每个调仓日的股票池
SELECT c.symbol
FROM index_membership c
WHERE c.index_code = '000300.SH'
AND c.effective_date <= :rebalance_date
AND (c.end_date IS NULL OR c.end_date > :rebalance_date);
维护这样一张成分变更表是数据工程的活,但它是可信回测的前提。查询性能上,这类时序表的分区与索引设计与 数据库查询优化 里的时序场景一致。
4. 撮合与成交假设
回测的成交假设决定了「信号能否变成收益」。从乐观到保守:
| 假设 | 成交价 | 真实度 | 适用 |
|---|---|---|---|
| 理想成交 | 信号价 | 极低 | 不推荐 |
| 收盘价成交 | 当日收盘 | 低 | 粗筛 |
| 次日开盘 | 次日开盘 | 中 | 中频 |
| 盘口成交 | 对手价 | 高 | 中高频 |
| 撮合队列 | 模拟排队 | 最高 | 高频 |
中频策略最常用「次日开盘 + 滑点」:
def next_open_fill(order, bars, i):
nxt = bars[i + 1]
if order.side == 'BUY':
price = nxt.open * (1 + SLIPPAGE) # 买入向上滑
else:
price = nxt.open * (1 - SLIPPAGE)
qty = min(order.qty, int(nxt.volume * 0.05)) # 成交量 5% 上限
return Fill(price=price, qty=qty, ts=nxt.ts)
成交量约束常被忽略。如果策略信号要求买入 1000 万股,但当日该股只成交 500 万股,那么最多只能成交 25 万股(按 5% 参与率)。不设这个约束,回测会假设你可以无限量成交。
5. 交易成本建模
交易成本分三类,必须分开建模:
| 成本 | 计算 | A 股典型值 |
|---|---|---|
| 佣金 | 成交额 × 费率 | 万分之一到万分之三 |
| 印花税 | 卖出成交额 × 税率 | 千分之一(卖出单边) |
| 过户费 | 成交额 × 费率 | 十万分之一 |
def total_cost(price, qty, side):
amount = price * qty
commission = max(amount * 0.00025, 5.0) # 万 2.5,最低 5 元
stamp = amount * 0.001 if side == 'SELL' else 0.0
transfer = amount * 0.00001
return commission + stamp + transfer
对高频策略,成本是决定性的:如果单笔期望收益是 3 bp,而往返成本是 5 bp,策略必亏。成本建模的精度直接决定策略能否上线。
期货的成本结构不同:没有印花税,但有交易所手续费 + 期货公司加收,平今仓手续费常常是开仓的数倍,日内策略必须按「平今」计费。
6. 滑点与冲击成本
滑点是你下单导致的价格不利变动。它随下单量占市场成交量的比例非线性增长:
冲击成本 ≈ k × σ × sqrt(Q / V)
σ:标的日波动率
Q:你的下单量
V:市场日均成交量
k:经验系数(A 股约 0.3~0.8)
这意味着大资金天然吃亏。用一个具体例子:某股日均成交 2 亿、日波动 2%,你要买入 2000 万(占 10%):
import math
sigma, Q, V, k = 0.02, 2e7, 2e8, 0.5
impact = k * sigma * math.sqrt(Q / V)
print(f"冲击成本约 {impact*10000:.1f} bp") # 约 22.4 bp
22 bp 的冲击成本意味着年化需要多赚 20% 以上才能覆盖(假设 100 次换手)。这就是策略容量的物理来源。
滑点模型的选择上,简单固定滑点(如 10 bp)适合初筛,平方根模型适合容量评估,盘口深度模型适合执行算法回测。
7. 样本外与 Walk-forward
用同一段数据既调参又验证,等于没有验证。正确做法是划分样本:
| 方法 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 简单划分 | 前 70% 训练,后 30% 测试 | 简单 | 只用了一次数据 |
| Walk-forward | 滚动训练 + 滚动测试 | 更稳健 | 计算量大 |
| Purged CV | 时间序列交叉验证 | 防泄漏 | 实现复杂 |
| 组合对称 CV | 多次随机划分 | 统计性好 | 需要足够数据 |
Walk-forward 的标准流程:
窗口 1: [训练 2015-2017] → [测试 2018]
窗口 2: [训练 2016-2018] → [测试 2019]
窗口 3: [训练 2017-2019] → [测试 2020]
...
把所有测试段拼起来,才是最终表现
关键细节:训练段与测试段之间要留 embargo 期(如 5 个交易日),避免因持仓重叠导致的泄漏。
def walk_forward(data, train_years=3, test_months=6, embargo_days=5):
folds = []
start = data.index[0]
while True:
train_end = start + pd.DateOffset(years=train_years)
test_start = train_end + pd.Timedelta(days=embargo_days)
test_end = test_start + pd.DateOffset(months=test_months)
if test_end > data.index[-1]:
break
folds.append((start, train_end, test_start, test_end))
start = start + pd.DateOffset(months=test_months)
return folds
8. 回测性能优化
一次因子研究可能涉及上千次回测,性能直接决定研究效率。常见优化手段:
| 手段 | 提速 | 代价 |
|---|---|---|
| 向量化替代循环 | 10~100x | 表达能力受限 |
| 数据预加载到内存 | 5~20x | 内存占用 |
| 多进程并行 | 核数倍 | 进程启动开销 |
| Cython/Numba | 10~50x | 编译复杂度 |
| 增量计算 | 2~10x | 实现复杂 |
把数据预加载成 numpy 数组是最简单有效的优化:
class FastData:
def __init__(self, path):
t = pq.read_table(path)
self.ts = t['ts'].to_numpy() # 时间戳数组
self.close = t['close'].to_numpy() # 价格数组
self.volume = t['volume'].to_numpy() # 成交量数组
self.n = len(self.ts)
def slice(self, start, end):
i = np.searchsorted(self.ts, start)
j = np.searchsorted(self.ts, end)
return slice(i, j)
np.searchsorted 做二分查找,比 pandas 的布尔索引快一个数量级。回测数据的列式存储与数据库分区分片的思路一致,都是让「只读需要的部分」成为可能。
9. 回测报告与归因
一份合格的回测报告至少包含:
收益指标:年化收益、累计收益、月度胜率
风险指标:最大回撤、年化波动、Sharpe、Sortino、Calmar
交易指标:换手率、持仓周期、成本占比、滑点占比
归因指标:行业暴露、风格暴露、选股 vs 择时贡献
稳定性:分年度表现、滚动 12 个月 Sharpe
收益归因是区分「真 alpha」和「风格暴露」的关键。把收益拆成:
def attribution(returns, factor_exposures, factor_returns):
explained = (factor_exposures * factor_returns).sum(axis=1)
residual = returns - explained
return {
'factor_return': explained.mean() * 252, # 风格贡献
'alpha': residual.mean() * 252, # 真 alpha
'residual_vol': residual.std() * (252 ** 0.5),
}
如果 alpha 项接近 0 而因子项很高,说明你的策略只是在承担风格风险,一旦风格反转就会巨亏。这是 2021 年初「抱团股」崩盘时大量量化产品回撤的根因。
权衡取舍
| 维度 | 向量化 | 事件驱动 | 撮合级 |
|---|---|---|---|
| 速度 | 极快 | 中 | 慢 |
| 真实度 | 低 | 中高 | 最高 |
| 开发成本 | 低 | 中 | 高 |
| 适合阶段 | 因子初筛 | 策略验证 | 执行研究 |
实践中的组合是分层回测:用向量化做初筛(从 1000 个因子里挑 50 个),用事件驱动做验证(从 50 个里挑 5 个),用撮合级做执行算法调优。全流程用同一种范式,要么慢得无法迭代,要么粗糙得没有意义。
成本建模上也有取舍:固定滑点简单但会系统性高估小票策略(小票滑点大得多),动态模型准确但引入参数不确定性。宁可高估成本,因为高估成本导致的「误杀」只是错过一个策略,低估成本导致的「误放」会亏掉真金白银。
常见坑清单
- 信号与成交同 bar:用当日收盘价决定并成交,必须延后一个 bar。
- 全样本标准化:z-score 用了全期均值,改用滚动窗口。
- 用当前指数成分回测历史:必须用历史成分快照。
- 忽略成交量约束:假设无限成交,需按市场成交量比例设上限。
- 只算佣金不算印花税:A 股卖出有千分之一印花税,高频策略影响巨大。
- 参数在全样本上挑最优:必须留样本外,最好用 Walk-forward。
- 忽略期货平今手续费:日内策略的成本可能被低估数倍。
- 不设 embargo 期:训练与测试持仓重叠,验证结果虚高。
- 回测不记录每日持仓:无法做归因,也无法排查异常。
- 只看年化不看回撤:Sharpe 与最大回撤才是风险调整后的真实质量。
小结
回测框架的核心不是「跑得快」,而是「逼你显式化每一个假设」。成交价怎么定、成本怎么算、股票池怎么选、参数怎么挑——每一个假设都是一处可能的高估。把假设写进代码、写进报告,回测才有讨论的基础。
判断一个回测是否可信,有个简单的自检清单:如果去掉交易成本后收益翻倍,说明成本建模不足;如果样本外表现比样本内差一半以上,说明过拟合;如果收益主要来自少数几只票,说明样本不足。
下一步建议阅读 因子研究与有效性检验 ,看如何系统地评估一个因子而不是靠回测曲线;如果你更关心成本与执行,可以直接跳到 订单管理与执行算法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。