精华帖子

量化回测指南

由bqu1vdra创建,最终由bqu1vdra 被浏览 10 用户

回测的目的是模拟真实交易环境,验证策略在历史数据上的表现是否具有统计意义,而不是通过优化历史数据找到"完美曲线"。一个好的回测应当:正确处理时间顺序(避免未来函数)、覆盖完整的市场环境(包含退市股票)、设置合理的成本假设、并通过样本外数据最终验证。

本文将从四个维度帮助你构建可靠的回测体系:引擎选型 → 陷阱规避 → 成本建模 → 指标解读

一、回测引擎(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%

通过后再考虑实盘

最后,切记,历史回测结果不代表未来收益,策略上线前务必进行模拟盘验证!


\

{link}