回测框架设计与前视偏差

回测框架设计与前视偏差的系统性拆解:向量化与事件驱动两种范式的取舍、七种前视偏差的识别与规避、幸存者偏差与样本选择、撮合与成交假设、交易成本与滑点建模、Walk-forward 样本外验证、回测性能优化与收益归因,回答为什么大多数回测结果不可信以及如何度量回测与实盘的差距。

回测是把「策略想法」变成「可信结论」的唯一手段,但绝大多数回测的结果是不可信的。原因不是代码写错了,而是回测天然倾向于高估收益:它能看到未来、假设无限流动性、忽略交易成本、忽略幸存者偏差。一个不加约束的回测,几乎必然给出年化 30% 以上的漂亮曲线。

工程上真正难的地方在于让回测的每一个假设都显式化并可验证。你无法消除回测与实盘的差距,但可以度量它、缩小它。本文的核心主张是:回测框架的价值不在于跑得快,而在于逼你面对每一个假设。

本文按「范式 → 偏差 → 成本 → 验证 → 性能 → 归因」的顺序展开。行情数据本身的清洗问题在 行情数据接入与清洗 中讨论,本文假设输入数据已经是干净的。

目录

  1. 回测的三种范式
  2. 前视偏差的七种形态
  3. 幸存者偏差与样本选择
  4. 撮合与成交假设
  5. 交易成本建模
  6. 滑点与冲击成本
  7. 样本外与 Walk-forward
  8. 回测性能优化
  9. 回测报告与归因

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/Numba10~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 个),用撮合级做执行算法调优。全流程用同一种范式,要么慢得无法迭代,要么粗糙得没有意义。

成本建模上也有取舍:固定滑点简单但会系统性高估小票策略(小票滑点大得多),动态模型准确但引入参数不确定性。宁可高估成本,因为高估成本导致的「误杀」只是错过一个策略,低估成本导致的「误放」会亏掉真金白银。

常见坑清单

  1. 信号与成交同 bar:用当日收盘价决定并成交,必须延后一个 bar。
  2. 全样本标准化:z-score 用了全期均值,改用滚动窗口。
  3. 用当前指数成分回测历史:必须用历史成分快照。
  4. 忽略成交量约束:假设无限成交,需按市场成交量比例设上限。
  5. 只算佣金不算印花税:A 股卖出有千分之一印花税,高频策略影响巨大。
  6. 参数在全样本上挑最优:必须留样本外,最好用 Walk-forward。
  7. 忽略期货平今手续费:日内策略的成本可能被低估数倍。
  8. 不设 embargo 期:训练与测试持仓重叠,验证结果虚高。
  9. 回测不记录每日持仓:无法做归因,也无法排查异常。
  10. 只看年化不看回撤:Sharpe 与最大回撤才是风险调整后的真实质量。

小结

回测框架的核心不是「跑得快」,而是「逼你显式化每一个假设」。成交价怎么定、成本怎么算、股票池怎么选、参数怎么挑——每一个假设都是一处可能的高估。把假设写进代码、写进报告,回测才有讨论的基础。

判断一个回测是否可信,有个简单的自检清单:如果去掉交易成本后收益翻倍,说明成本建模不足;如果样本外表现比样本内差一半以上,说明过拟合;如果收益主要来自少数几只票,说明样本不足。

下一步建议阅读 因子研究与有效性检验 ,看如何系统地评估一个因子而不是靠回测曲线;如果你更关心成本与执行,可以直接跳到 订单管理与执行算法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「量化交易」更多文章

  1. 风险模型与因子归因
  2. 回测偏差与过拟合防范
  3. 市场微结构与流动性