量化回测指南
由bqu1vdra创建,最终由bqu1vdra 被浏览 4 用户
回测的目的是模拟真实交易环境,验证策略在历史数据上的表现是否具有统计意义,而不是通过优化历史数据找到"完美曲线"。一个好的回测应当:正确处理时间顺序(避免未来函数)、覆盖完整的市场环境(包含退市股票)、设置合理的成本假设、并通过样本外数据最终验证。
本文将从四个维度帮助你构建可靠的回测体系:引擎选型 → 陷阱规避 → 成本建模 → 指标解读
一、回测引擎(Vectorization & Event-driven)
1.1 向量化回测
工作原理:将整个历史数据加载到内存中,通过矩阵/数组运算一次性计算所有时间点的信号和收益。
import dai
import pandas as pd
# 一次性拉取全量数据,矩阵运算
df = dai.query("""
SELECT date, instrument, close
FROM cn_stock_bar1d
WHERE date BETWEEN '2024-01-01' AND '2024-09-30'
ORDER BY date, instrument
""").df()
# 计算信号(向量化,无循环)
df = df.sort_values(["instrument", "date"])
df["ma20"] = df.groupby("instrument")["close"].transform(lambda x: x.rolling(20).mean())
df["signal"] = (df["close"] > df["ma20"]).astype(int) # 1=持有,0=空仓
# 计算收益
df["ret"] = df.groupby("instrument")["close"].pct_change()
df["strategy_ret"] = df["signal"].shift(1) * df["ret"]
# 汇总净值
nav = (1 + df.groupby("date")["strategy_ret"].mean()).cumprod()
nav.plot(title="策略净值")
| 优势 | 说明 |
|---|---|
| 速度极快 | 适合大规模因子扫描、参数优化 |
| 代码简洁 | 几行代码即可完成策略逻辑 |
| 研究友好 | 适合因子有效性初筛 |
| 劣势 | 风险 |
|---|---|
| 未来函数风险高 | 数组操作容易"越界"引用未来数据 |
| 仓位管理困难 | 难以模拟资金不足、仓位限制等路径依赖 |
| 成交假设理想 | 通常假设按收盘价成交,忽略滑点 |
1.2 事件驱动回测
工作原理:模拟真实交易流程,按时间顺序逐个处理每个Bar(K线),维护仓位、资金等状态变量。
# 每个 Bar 都会触发 handle_data,模拟真实下单流程
def initialize(context):
context.stocks = ["000001.SZA", "600000.SHA"]
def handle_data(context, data):
for stock in context.stocks:
if data.current(context.symbol(stock), "close") > \
data.history(context.symbol(stock), "close", 20, "1d").mean():
context.order_target_percent(context.symbol(stock), 0.5)
else:
context.order_target_percent(context.symbol(stock), 0)
performance = bigtrader.run(
market=bigtrader.Market.CN_STOCK,
frequency=bigtrader.Frequency.DAILY,
start_date="2024-01-01",
end_date="2024-09-30",
capital_base=1000000,
initialize=initialize,
handle_data=handle_data,
order_price_field_buy="open",
order_price_field_sell="close",
)
performance.render()
| 优势 | 说明 |
|---|---|
| 防止未来函数 | 时间顺序强制,物理上无法获取未来数据 |
| 仓位管理精确 | 可模拟资金限制、仓位上限、T+1规则 |
| 成交更真实 | 支持限价单、滑点模型、涨跌停限制 |
| 劣势 | 说明 |
|---|---|
| 速度较慢 | 串行处理,大规模参数优化耗时 |
| 代码量大 | 需要维护状态变量和事件回调函数 |
引擎选择决策树
您的策略需求?
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
因子初筛/研究 复杂订单逻辑 实盘前验证
│ │ │
▼ ▼ ▼
向量化引擎 事件驱动引擎 事件驱动引擎
(快速迭代) (精细模拟) (逻辑一致)
推荐使用向量化的场景
- 简单均线/因子策略,主要依赖数学计算
- 需要快速验证大量因子(100+因子筛选)
- 参数优化/网格搜索(需要跑上千次回测)
- 学术研究/论文验证
推荐使用事件驱动的场景
-
复杂订单类型(止损单、追踪止损、条件单)
-
事件驱动策略(财报发布、公告事件)
-
多资产/多策略组合(资金分配逻辑复杂)
-
A股特殊规则(T+1、涨跌停、停牌处理)
-
实盘前最终验证(最重要!)
一句话总结:向量化回测适合因子挖掘(快),事件驱动回测适合策略验证(真)
==Bigquant平台一般使用事件驱动型回测,回测引擎的运行逻辑为每一根K线运行一次,在当前K线进行下单操作时会在下一根K线开始撮合成交==
二、回测三大陷阱
2.1 陷阱一:未来函数 (Look-Ahead Bias)
定义:在回测中使用了当时不可得的数据,导致策略"预知未来"
2.1.1 常见未来函数场景
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
| 收盘价交易 | 用当日收盘价决定当日开盘买入 | 用昨日收盘价决定今日开盘买入 |
| 财务数据 | 财报期末当天使用财报数据 | 财报实际公告日后才使用 |
| 复权处理 | 使用前复权价格(包含未来分红信息) | 使用后复权或不复权 |
| 指标计算 | 用全天最高最低计算当日信号 | 只能用截至当前的最高最低 |
| 数据对齐 | 所有股票数据按统一日期对齐 | 考虑各股票实际交易日历 |
2.1.2 对比示例
# 错误示例:未来函数
def wrong_signal(df):
df['ma5'] = df['close'].rolling(5).mean()
df['ma20'] = df['close'].rolling(20).mean()
# 问题:当天知道当天的收盘价后才产生信号,但当天已经交易了
df['signal'] = np.where(df['ma5'] > df['ma20'], 1, 0)
df['returns'] = df['signal'] * df['close'].pct_change()
return df
# 正确示例:信号滞后一期
def correct_signal(df):
df['ma5'] = df['close'].rolling(5).mean()
df['ma20'] = df['close'].rolling(20).mean()
# 解决:信号滞后,今天信号明天交易
df['signal'] = np.where(df['ma5'] > df['ma20'], 1, 0).shift(1)
df['returns'] = df['signal'] * df['close'].pct_change()
return df
财务数据未来函数案例
# 错误:假设财报当天可用
# 实际:年报通常在次年 4 月 30 日前披露,有滞后
wrong_way = """
2023-12-31 财报数据生成
2024-01-01 策略就开始使用这个数据 ← 未来函数!
"""
right_way = """
2023-12-31 财报数据生成
2024-03-15 公司实际公告财报
2024-03-16 策略才开始使用这个数据 ← 正确
"""
前复权导致的未来函数
# 错误:使用前复权数据
# 问题:前复权会根据未来的分红调整历史价格,导致"预知"分红信息
# 正确:使用后复权或不复权
# 后复权:以当前价格为基准,向前调整
# 不复权:使用原始价格,自己处理分红再投资
-
Bigtrader如何处理除权除息:
现在平台均使用复权因子来处理,即发现当天和前一天的复权因子发生变化,会得到复权因子的变化比率,来调整持仓数量和持仓价格,发生除权除息后,交易引擎会生成一条成交记录,成交记录中有成交数量(转送股数量)或成交金额(分红金额)等值
2.2 陷阱二:幸存者偏差 (Survivorship Bias)
定义:只使用当前存活的股票数据回测,忽略了已退市股票
| 回测范围 | 包含退市股 | 年化收益 | 最大回撤 | 夏普比率 |
|---|---|---|---|---|
| 仅当前成分股 | ❌ | 18% | -25% | 1.5 |
| 全量历史数据 | ✅ | 12% | -35% | 0.9 |
| 差异 | - | +6% | +10% | +0.6 |
A 股退市数据现状(截至 2024 年):历史退市股票数量:约 200+ 只
退市原因:财务造假、连续亏损、面值退市等
影响:小市值策略受影响最大(退市股多为小市值)
解决方案
| 方法 | 说明 |
|---|---|
| 使用全量数据池 | 包含已退市股票的历史数据 |
| 固定股票池 | 在回测开始时确定股票池,不动态调整 |
| 标注退市日期 | 退市后不再交易,退市前数据保留 |
| 使用专业数据源 | 如 BigQuant包含退市股的数据 |
BigQuant 平台退市股票处理机制
平台默认处理逻辑:当回测途中持仓股票发生退市时,BigQuant 回测引擎的处理方式如下:
- 对于股票退市或期货合约到期的处理
- 股票会在退市日期,按最新价格自动平仓,会生成一条平仓成交记录,成交金额返还至账户资金,成交时间为00:00:00,也会生成一条 expire 开头的日志
- 期货会在到期日期,按最新价格自动平仓,会生成一条平仓成交记录,释放的保证金和平仓盈亏返还至账户资金,成交时间为00:00:00,也会生成一条 expire 开头的日志
| 阶段 | 回测引擎行为 |
|---|---|
| 退市整理期 | 股票仍在交易,策略可正常下单(但流动性极差) |
| 摘牌日 | 引擎强制以最后交易日收盘价清仓持仓 |
| 摘牌后 | 该标的从可交易股票池中移除,不再触发信号 |
需要注意的问题
股票池过滤(最重要!)
def initialize(context):
# ❌ 错误:使用静态股票池,不过滤退市/ST股
context.stocks = ["000001.SZA", "000002.SZA", "XXXX.SZA"] # 可能含退市股
def before_trading_start(context, data):
# ✅ 正确:每日动态过滤,排除停牌、ST、退市风险股
from bigtrader.finance.controls import LimitUpDownOrder
# 通过 DAI 查询当日有效股票(自动排除退市股)
context.stock_list = context.filter_instruments(
context.universe,
suspended=False, # 排除停牌
st=False, # 排除 ST
)
退市股的亏损是真实的
退市整理期通常连续下跌,最终可能跌至接近 0
回测中这部分亏损会如实计入净值 → 不要忽略!
推荐的防退市策略写法
def before_trading_start(context, data):
# 1. 动态获取当日可交易股票(平台自动排除已退市)
today = context.trading_day
# 2. 排除 ST、*ST(退市高风险)
# 3. 排除上市不足 N 天的次新股
# 4. 排除涨跌停(无法成交)
df = context.get_factor_values(
instruments=context.universe,
fields=["is_st", "list_days", "is_suspended"],
dt=today
)
valid = df[(df["is_st"] == 0) &(df["list_days"] > 60) &(df["is_suspended"] == 0)].index.tolist()
context.stock_list = valid
2.3 陷阱三:数据泄露 (Data Leakage)
训练数据中包含了测试数据的信息,导致模型"作弊",这是最难发现的一类陷阱
与未来函数的区别:
| 维度 | 未来函数 | 数据泄露 |
|---|---|---|
| 发生阶段 | 回测逻辑设计 | 数据预处理/模型训练 |
| 典型场景 | 信号计算、交易执行 | 标准化、特征工程、标签构造 |
2.3.1 常见数据泄露场景
| 类型 | 案例 | 避免方法 |
|---|---|---|
| 标准化泄露 | 用全样本均值标准化 | 只用训练集均值标准化 |
| 特征工程泄露 | 用未来数据构造特征 | 严格时间序列分割 |
| 标签泄露 | 标签包含未来信息 | 标签滞后构造 |
| 交叉验证泄露 | 时间序列交叉验证打乱顺序 | 使用滚动窗口验证 |
2.3.2 对比示例
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
# ❌ 错误:全样本标准化
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # 用了全部数据!
X_train, X_test = train_test_split(X_scaled, test_size=0.2)
# ✅ 正确:训练集拟合,测试集转换
X_train, X_test = train_test_split(X, test_size=0.2, shuffle=False) # 时间序列不打乱
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train) # 只用训练集拟合
X_test_scaled = scaler.transform(X_test) # 用训练集参数转换测试集
机器学习中的标签泄露
# ❌ 错误:标签使用未来数据
# 预测明天涨跌,但标签用了明天的收盘价
df['label'] = (df['close'].shift(-1) > df['close']).astype(int)
# ✅ 正确:标签滞后,确保交易时可用
# 今天收盘后知道信号,明天开盘交易,后天知道收益
df['label'] = (df['close'].shift(-2) > df['close'].shift(-1)).astype(int)
2.4 检查清单
| 检查项 | 问题 | 通过标准 |
|---|---|---|
| 未来函数 | 信号是否滞后一期? | shift(1) 或事件驱动引擎 |
| 未来函数 | 财务数据是否使用公告日? | 公告日后 T+1 才可使用 |
| 未来函数 | 是否使用前复权数据? | 使用后复权或不复权 |
| 幸存者偏差 | 股票池是否包含退市股? | 使用全量历史数据 |
| 幸存者偏差 | 股票池是否动态调整? | 回测开始时固定 |
| 数据泄露 | 标准化是否只用训练集? | fit 训练集,transform 测试集 |
| 数据泄露 | 交叉验证是否保持时间顺序? | 使用滚动窗口,不打乱 |
| 数据泄露 | 标签构造是否滞后? | 确保交易时标签不可知 |
三、成本建模
回测收益 = 策略 Alpha - 交易成本
3.1 三类成本详解
在 A 股量化回测中,成本主要由三部分组成。很多新手只设置了手续费,忽略了滑点和冲击成本,导致实盘大幅亏损。
| 成本类型 | 构成 | 典型费率/模型 | 影响场景 |
|---|---|---|---|
| 手续费 | 佣金 + 印花税 + 过户费 | 佣金万 3,印花税千 1(卖出收) | 所有交易 |
| 滑点 | 买卖价差 + 流动性不足 | 固定比例(如 0.1%)或动态模型 | 高频/小盘股 |
| 冲击成本* | 大资金交易对价格的冲击 | 线性模型或平方根模型 | 大资金/低流动性 |
公式示例
总成本估算公式
总成本 = 交易金额 × (佣金率 + 印花税率) + 交易金额 × 滑点率 + 冲击成本
冲击成本简化模型(平方根模型)
冲击成本 = 交易金额 × 0.1% × sqrt(交易金额 / 日均成交额)
3.2 A 股特有成本与限制
A 股市场的微观结构会导致隐性成本,这些在回测中极易被忽略:
| 限制 | 隐性成本 | 回测处理建议 |
|---|---|---|
| T+1 交易 | 当日买入无法卖出,隔夜风险 | 事件驱动引擎强制限制,向量化需手动检查 |
| 涨跌停 | 涨停买不进,跌停卖不出 | 回测需设置"无法成交"逻辑 |
| 停牌 | 资金占用,无法交易 | 数据需标注停牌状态,跳过交易 |
| 最小交易单位 | 100 股整数倍 | 资金利用不足导致的现金拖累 |
四、回测指标解析
4.1 核心绩效指标解读
4.1.1 收益类指标:策略"赚了多少"
| 指标 | 计算公式 | 业务含义 | 参考阈值 |
|---|---|---|---|
| 累计收益率 | (期末净值 - 期初净值) / 期初净值 |
策略全周期绝对收益 | - |
| 年化收益率 | (1+累计收益)^(252/交易天数) - 1 |
标准化时间维度,便于横向对比 | >15% 较优 |
| 超额收益(α) | 策略年化 - 基准年化 |
衡量策略相对市场的能力 | 持续为正为佳 |
4.1.2 风险类指标:策略"可能亏多少"
| 指标 | 计算公式 | 业务含义 | 参考阈值 |
|---|---|---|---|
| 最大回撤 | max[(峰值-谷值)/峰值] |
历史最坏情况下的亏损幅度 | <20% 较优,<10% 优秀 |
| 年化波动率 | 日收益标准差 × √252 |
收益的不确定性程度 | <25% 较稳健 |
| 95% VaR(历史法) | 日收益5%分位数 × √252 |
极端情况下单日最大可能亏损 | - |
4.1.3 风险调整后收益:策略"性价比如何"
| 指标 | 计算公式 | 业务含义 | 参考阈值 | 适用场景 |
|---|---|---|---|---|
| Sharpe 比率 | (年化收益 - 无风险利率) / 年化波动率 |
单位总风险带来的超额收益 | >1 较优,>2 优秀 | 通用型,最常用 |
| Sortino 比率 | (年化收益 - 无风险利率) / 下行波动率 |
单位"坏风险"带来的收益 | >2 较优 | 收益分布偏斜时更准确 |
| 信息比率(IR) | 超额收益均值 / 超额收益波动率 |
相对基准的稳定性 | >0.5 较优 | 指数增强/市场中性策略 |
4.1.4 交易质量指标:策略"逻辑是否健康"
| 指标 | 计算方式 | 业务含义 | 健康参考 | 分析建议 |
|---|---|---|---|---|
| 胜率 | 盈利交易次数 / 总交易次数 |
策略预测准确度 | 趋势策略 40-50%,均值回归 60%+ | 需结合盈亏比看,高胜率≠高收益 |
| 盈亏比 | 平均盈利金额 / 平均亏损金额 |
单笔盈利覆盖亏损的能力 | >2.0 较健康 | 低胜率策略必须高盈亏比才能盈利 |
| 平均持仓周期 | 总持仓天数 / 交易次数 |
策略风格(短线/中线/长线) | 与策略逻辑一致 | 过短可能受手续费侵蚀,过长可能暴露风险 |
| 换手率 | 买卖总金额 / 平均持仓市值 |
交易频率与成本敏感度 | 年化<500% 较可控 | 高换手需验证成本模型是否充分 |
| 交易次数 | 完整买卖回合数 |
统计显著性基础 | >100 次结论更可靠 | <30 次的回测结果谨慎参考 |
4.2 指标误读案例与避坑指南
❌ 案例1:高夏普陷阱
回测结果:Sharpe=2.3,年化收益 35%,最大回撤 8%
表面看:完美策略!
深度拆解:
- 交易次数:23 次(统计显著性不足)
- 收益分布:3 笔交易贡献 80% 收益,其余微亏
- 时间集中:所有大赚发生在 2020 年 3 月疫情反弹
✅ 正确做法:
1. 延长回测周期至包含完整牛熊
2. 要求最小交易次数>100
3. 滚动窗口夏普(如 6 个月)应保持稳定
❌ 案例2:幸存者偏差导致的指标虚高
问题:回测股票池使用"当前沪深300成分股"
后果:自动剔除已退市/被调出的弱势股,收益被高估 3-8%/年
✅ 正确做法:
- 使用"时点成分股"数据(BigQuant 支持历史成分股查询)
- 或在因子计算中加入"上市时间>2 年"等过滤条件
❌ 案例3:过度优化引发的过拟合
现象:参数网格搜索后,夏普从 1.2 提升至 2.8
风险:参数对样本内数据过度适配,样本外失效
✅ 防御措施:
1. 保留 20-30% 数据作为样本外测试集
2. 使用滚动回测(walk-forward)验证参数稳定性
3. 参数敏感性分析:±10% 变动下,夏普波动应<20%
五、完整回测流程建议
从数据准备到结果评估的端到端检查流程:
Step 1: 数据准备
使用历史成分股(含退市)
财务数据按 ann_date 对齐
检查数据缺失/异常值处理方式
Step 2: 信号生成
所有信号使用 shift(1) 或显式延迟
标准化/特征工程仅用历史数据
对比信号日期与数据日期
Step 3: 成本设置
根据资金量选择合理佣金率
设置印花税(卖出 0.1%)
设置合理滑点(至少 0.1%)
Step 4: 回测执行
向量化:快速验证因子有效性
事件驱动:精细验证策略逻辑
Step 5: 结果评估
同时查看 Sharpe、Sortino、Calmar、MDD
分牛/熊/震荡市分段评估
进行参数敏感性分析
Step 6: 样本外验证
在预留测试集(或滚动样本外)验证
样本外指标与样本内差距不超过 30%
通过后再考虑实盘
最后,切记,历史回测结果不代表未来收益,策略上线前务必进行模拟盘验证!
\