05-回测陷阱与防范

预计学习时间:1.5 小时

难度:⭐⭐⭐⭐

核心问题:回测中最危险的不是代码错误,而是认知偏差!


为什么这节最重要?

┌────────────────────────────────────────────────────────────────┐
│                                                                 │
│   一个诚实的回测应该让你对策略的局限性有清晰的认识,             │
│   而不是让你对自己的策略产生盲目的自信。                         │
│                                                                 │
│   ┌─────────────────────────────────────────────────────┐     │
│   │                                                     │     │
│   │   "完美的回测曲线 = 可疑!"                          │     │
│   │                                                     │     │
│   │   ┌──────────┐      ┌──────────┐                   │     │
│   │   │ 回测显示 │   vs │ 实盘交易 │                   │     │
│   │   │ 年化30%  │      │ 年化-5%  │                   │     │
│   │   │ Sharpe 3 │      │ Sharpe 0 │                   │     │
│   │   └──────────┘      └──────────┘                   │     │
│   │                                                     │     │
│   │   这种差距通常不是因为"运气不好",                   │     │
│   │   而是因为回测中存在未被发现的问题。                 │     │
│   │                                                     │     │
│   └─────────────────────────────────────────────────────┘     │
│                                                                 │
└────────────────────────────────────────────────────────────────┘

本节目标:学会识别回测中的七大陷阱,让你的回测更诚实、更可靠。


陷阱1:过拟合 (Overfitting)

定义

过拟合:策略过度适应历史数据,在样本内表现完美,但在样本外(未来)表现糟糕。

过拟合的两种形式

1. 参数过度优化

# 危险的做法:穷举所有参数组合
param_grid = {
    'short_window': [5, 10, 15, 20, 25, 30],
    'long_window': [30, 60, 90, 120, 180, 240],
    'threshold': [0.1, 0.2, 0.3, 0.5, 0.8, 1.0],
    'stop_loss': [0.02, 0.05, 0.08, 0.10, 0.15],
}
 
# 共 6 × 6 × 6 × 5 = 1080 种组合
# 总能找到"最优"参数,但这只是数据挖掘的偶然

问题:在这么多参数中,总有一组表现最好,但这纯粹是运气。

2. 曲线拟合

# 危险:根据历史走势"定制"规则
if 日期 == '2020-03-01':
    使用策略A
elif 日期 == '2020-07-01':
    使用策略B
elif 市场波动率 > 0.03:
    使用策略C
# ...
 
# 这种策略在历史上完美拟合,但未来无效

检测方法

1. 样本外检验

def train_test_split_backtest(data, train_ratio=0.7):
    """
    训练集/测试集分离
 
    参数
    ----
    data : DataFrame
      全部数据
    train_ratio : float
      训练集比例
 
    返回
    ----
    train_data, test_data
    """
    n = len(data)
    split_point = int(n * train_ratio)
 
    train_data = data.iloc[:split_point]
    test_data = data.iloc[split_point:]
 
    return train_data, test_data
 
# 使用
train_data, test_data = train_test_split_backtest(price_data)
 
# 在训练集上优化参数
best_params = optimize_parameters(train_data)
 
# 在测试集上验证
test_performance = backtest(test_data, best_params)
 
# 如果测试表现显著下降,说明过拟合

2. 参数稳定性分析

def parameter_stability_test(data, param_name, param_values):
    """
    参数稳定性测试
 
    检查策略表现对参数变化的敏感度
    """
    results = []
 
    for value in param_values:
        params = {param_name: value}
        performance = backtest(data, params)
        results.append({
            'param_value': value,
            'sharpe': performance['sharpe'],
            'return': performance['annual_return']
        })
 
    results_df = pd.DataFrame(results)
 
    # 绘制参数-表现曲线
    fig, axes = plt.subplots(1, 2, figsize=(12, 4))
 
    axes[0].plot(results_df['param_value'], results_df['sharpe'], marker='o')
    axes[0].set_xlabel(f'{param_name}')
    axes[0].set_ylabel('Sharpe')
    axes[0].set_title('Sharpe vs Parameter')
    axes[0].grid(True, alpha=0.3)
 
    axes[1].plot(results_df['param_value'], results_df['return'], marker='o')
    axes[1].set_xlabel(f'{param_name}')
    axes[1].set_ylabel('Annual Return')
    axes[1].set_title('Return vs Parameter')
    axes[1].grid(True, alpha=0.3)
 
    plt.tight_layout()
    plt.show()
 
    return results_df
 
# 稳定的参数:曲线平缓,性能变化小
# 不稳定的参数:曲线尖锐,最优值附近性能急剧下降

防范措施

措施说明
减少自由参数参数越少,过拟合风险越小
样本外验证预留一部分数据做测试
滚动窗口回测模拟实盘的递进式学习
参数稳定性测试选择稳健的参数而非最优参数
正则化对模型复杂度施加惩罚

陷阱2:前视偏差 (Look-Ahead Bias)

定义

前视偏差:在回测中使用了”未来”才能获得的信息。

常见来源

1. 复权价格问题

# ❌ 错误:直接使用后复权价格
price = get_adjusted_price('2020-01-01')  # 包含未来的复权信息
 
# 问题:在2020年1月1日,我们不知道未来会有分红送股
# 后复权价格是用当前的复权因子调整所有历史价格
 
# ✅ 正确:使用前复权或计算日对日收益
price = get_forward_adjusted_price('2020-01-01')
# 或者
returns = calculate_returns_from_unadjusted(raw_prices)

2. 财报发布日

# ❌ 错误:在财报发布日之前就使用财报数据
# 某公司2023Q3财报在10月30日发布
data.loc['2023-10-15', 'revenue'] = get_earnings('2023Q3')  # 错误!
 
# ✅ 正确:考虑财报发布的延迟
earnings_date = '2023-10-30'
data.loc[earnings_date:, 'revenue'] = get_earnings('2023Q3')

3. 指标计算中的未来信息

# ❌ 错误:标准化时使用了未来统计量
# 对整个序列标准化
returns_normalized = (returns - returns.mean()) / returns.std()
 
# 问题:在t时刻,returns.mean()和returns.std()包含了未来的数据
 
# ✅ 正确:滚动统计量
returns_normalized = (returns - returns.expanding().mean()) / returns.expanding().std()

4. 停牌数据

# ❌ 错误:忽略了停牌
# 某股票从3月1日停牌到3月15日
signal['2023-03-05'] = 1  # 认为可以买入
# 实际上这段时间无法交易
 
# ✅ 正确:考虑停牌
signal.loc[suspend_period] = 0

检测代码

def detect_look_ahead_bias(data_columns, rules_description):
    """
    前视偏差检测检查清单
 
    参数
    ----
    data_columns : dict
      {列名: 数据来源说明}
    rules_description : str
      策略规则描述
 
    返回
    ----
    warnings : list
      潜在的前视偏差警告
    """
    warnings = []
 
    # 检查复权价格
    if 'adj_price' in data_columns:
        warnings.append("⚠️ 使用复权价格:确认使用前复权或正确处理")
 
    # 检查财务数据
    if 'financials' in data_columns:
        warnings.append("⚠️ 财务数据:确认使用了正确的发布日期")
 
    # 检查标准化方式
    if 'normalize' in rules_description.lower():
        if 'expanding' not in rules_description.lower():
            warnings.append("⚠️ 标准化:可能使用了全局统计量")
 
    return warnings
 
# 使用示例
columns = {
    'close': '后复权收盘价',
    'revenue': '营业收入',
    'pe_ratio': '市盈率'
}
rules = "计算20日均线,买入信号"
 
warnings = detect_look_ahead_bias(columns, rules)
for w in warnings:
    print(w)

防范代码示例

class SafeBacktest:
    """防范前视偏差的回测框架"""
 
    def __init__(self, data, earnings_release_dates=None, suspend_dates=None):
        """
        参数
        ----
        data : DataFrame
          价格数据(使用前复权或原始价格)
        earnings_release_dates : dict
          {股票: {季度: 发布日期}}
        suspend_dates : dict
          {股票: [停牌日期列表]}
        """
        self.data = data
        self.earnings_dates = earnings_release_dates or {}
        self.suspend_dates = suspend_dates or {}
 
    def is_tradable(self, stock, date):
        """检查某日是否可交易"""
        # 检查停牌
        if stock in self.suspend_dates:
            if date in self.suspend_dates[stock]:
                return False
 
        # 检查涨跌停(简单判断)
        if self._is_limit_move(stock, date):
            return False
 
        return True
 
    def get_fundamental(self, stock, date, quarter):
        """获取基本面数据(考虑发布延迟)"""
        if stock not in self.earnings_dates:
            return None
 
        release_date = self.earnings_dates[stock].get(quarter)
        if release_date is None or date < release_date:
            return None  # 财报还未发布
 
        return self.data.loc[date, f'{quarter}_{quarter}']
 
    def _is_limit_move(self, stock, date):
        """判断是否涨跌停"""
        # 简化实现
        prev_close = self.data.loc[date - pd.Timedelta(days=1), stock]
        today_price = self.data.loc[date, stock]
 
        limit_up = prev_close * 1.1  # 10%涨跌停
        limit_down = prev_close * 0.9
 
        return today_price >= limit_up or today_price <= limit_down

陷阱3:幸存者偏差 (Survivorship Bias)

定义

幸存者偏差:只保留当前存续的股票,忽略了历史上退市的股票。

影响

┌────────────────────────────────────────────────────────────────┐
│                                                                 │
│   错误的股票池:只包含现在还在交易的股票                         │
│   ├─ 退市的垃圾股被排除 → 高估收益                             │
│   ├─ 被并购的好股被排除 → 低估收益                             │
│   └─ IPO前的股票被排除 → 错过早期收益                          │
│                                                                 │
│   结果:回测收益被高估2-5%!                                   │
│                                                                 │
└────────────────────────────────────────────────────────────────┘

示例

# ❌ 错误:使用当前股票池回测历史
current_stocks = get_current_stock_list()  # 2024年的股票池
backtest_data = get_prices(current_stocks, start='2010-01-01')
 
# 问题:这忽略了2010-2024年间退市的200+只股票
# 这些退市股大多是表现差的股票
# 排除它们会高估策略收益
 
# ✅ 正确:使用历史股票池
def get_historical_stock_pool(date):
    """获取某日的真实股票池"""
    # 考虑已上市、未退市的股票
    listed = get_listed_stocks(date)  # date之前上市
    not_delisted = get_not_delisted_stocks(date)  # date之后未退市
    return list(set(listed) & set(not_delisted))
 
# 在回测中动态确定股票池
for date in backtest_period:
    stock_pool = get_historical_stock_pool(date)
    # ...

防范措施

措施说明
使用全历史股票池包含所有历史上存在过的股票
动态股票池每个时间点使用当时真实存在的股票
退市数据保留退市股票直到退市日
考虑IPO新股上市后才能买入

陷阱4:数据窥探 (Data Snooping)

定义

数据窥探:通过对同一数据的多次测试,偶然发现”显著”的策略。

多重比较问题

# 测试100个随机策略
for i in range(100):
    signal = np.random.randn(1000)  # 随机信号
    returns = backtest(signal)
 
    # 即使是纯随机,也会有5个策略p值<0.05(5%)
    if returns['sharpe'] > 2:
        print(f"策略{i}: Sharpe={returns['sharpe']:.2f}")
 
# 问题:这不是Alpha,是运气!

检测方法

def false_discovery_rate(strategies, alpha=0.05):
    """
    计算错误发现率
 
    在N个策略中,如果k个策略显著,
    其中约α×k个是假阳性
    """
    n_significant = sum([s['p_value'] < alpha for s in strategies])
    expected_false_positive = alpha * n_significant
 
    return expected_false_positive / n_significant if n_significant > 0 else 0

防范措施

措施说明
预留测试集策略开发完成后,在全新数据上测试一次
调整显著性水平Bonferroni校正:α’ = α / N(N为测试次数)
样本外验证每次测试后用新数据验证
记录所有测试记录失败的测试,不只是成功的
# Bonferroni校正示例
def bonferroni_correction(p_values, alpha=0.05):
    """
    Bonferroni校正:调整显著性水平
 
    如果测试N次,则需要p < α/N才认为显著
    """
    n_tests = len(p_values)
    adjusted_alpha = alpha / n_tests
 
    significant = [p < adjusted_alpha for p in p_values]
 
    return significant, adjusted_alpha
 
# 示例
p_values = [0.03, 0.01, 0.04, 0.001, 0.02]  # 测试5次
sig, adj_alpha = bonferroni_correction(p_values)
 
print(f"原始显著性水平: 5%")
print(f"校正后显著性水平: {adj_alpha:.4%}")
print(f"显著策略数: {sum(sig)}")

陷阱5:忽略流动性

问题

┌────────────────────────────────────────────────────────────────┐
│                                                                 │
│   回测假设:可以无限量买卖任何股票                               │
│   实际情况:                                                    │
│   ├─ 小盘股日成交只有几百万元                                   │
│   ├─ 大资金进入会推高买入价格                                   │
│   ├─ 涨跌停无法成交                                            │
│   └─ 停牌无法交易                                              │
│                                                                 │
└────────────────────────────────────────────────────────────────┘

流动性指标

def liquidity_filter(prices, volumes, min_daily_turnover=10000000):
    """
    流动性过滤
 
    参数
    ----
    prices : DataFrame
      价格
    volumes : DataFrame
      成交量
    min_daily_turnover : float
      最小日成交额
 
    返回
    ----
    tradable_stocks : DataFrame
      可交易股票标记
    """
    daily_turnover = prices * volumes
    tradable = daily_turnover >= min_daily_turnover
 
    return tradable
 
# 使用
tradable = liquidity_filter(prices, volumes, min_daily_turnover=50000000)
 
# 在回测中只交易可流动的股票
signal = signal * tradable

涨跌停处理

def handle_limit_moves(prices, signal, limit=0.1):
    """
    处理涨跌停
 
    参数
    ----
    prices : DataFrame
      价格
    signal : DataFrame
      交易信号
    limit : float
      涨跌停幅度(默认10%)
 
    返回
    ----
    adjusted_signal : DataFrame
      调整后的信号
    """
    # 计算涨跌幅
    pct_change = prices.pct_change()
 
    # 检测涨跌停
    limit_up = pct_change >= limit
    limit_down = pct_change <= -limit
 
    # 涨停时无法买入,跌停时无法卖出
    adjusted_signal = signal.copy()
    adjusted_signal[limit_up] = signal[limit_up].clip(upper=0)  # 不能买
    adjusted_signal[limit_down] = signal[limit_down].clip(lower=0)  # 不能卖
 
    return adjusted_signal

陷阱6:交易成本低估

详见第03节。这里补充几个容易被忽略的点:

1. 市场冲击与资金规模

def estimate_capacity(returns, volumes, max_impact=0.01):
    """
    估计策略容量
 
    在给定冲击成本限制下,策略能管理的最大资金量
    """
    # 假设换手率
    turnover = 2.0  # 200%年化
 
    # 每日可交易金额(不超过日成交额的一定比例)
    daily_tradable = volumes.mean() * max_impact
 
    # 年化可交易金额
    annual_tradable = daily_tradable * 252
 
    # 策略容量
    capacity = annual_tradable / turnover
 
    return capacity

2. 融资融券成本

# 做空成本:通常年化5-10%
short_borrow_rate = 0.08
 
# 融资成本:通常与券商约定
margin_interest_rate = 0.06

陷阱7:股息和分红处理

除权除息日的价格跳空

# ❌ 错误:忽略分红
# 某股票分红10元,股价从100元跳空到90元
# 策略认为亏损10%,实际上持有者收到分红,总价值不变
 
# ✅ 正确:使用总收益指数
total_return_index = calculate_total_return_index(price, dividends)
 
# 或者
# 1. 价格使用前复权
# 2. 分红单独计入收益
portfolio_return = price_return + dividend_yield

前复权 vs 后复权

类型定义回测使用
前复权当前价格不变,调整历史价格✅ 推荐
后复权历史价格不变,调整当前价格❌ 不适用

回测质量检查清单

def validate_backtest(strategy_config, backtest_results):
    """
    回测质量诊断
 
    参数
    ----
    strategy_config : dict
      策略配置
    backtest_results : dict
      回测结果
 
    返回
    ----
    report : dict
      质量报告
    """
    report = {
        'warnings': [],
        'passed_checks': [],
        'score': 100
    }
 
    # 1. Sharpe比率检查
    sharpe = backtest_results.get('sharpe', 0)
    if sharpe > 3:
        report['warnings'].append(f"⚠️ Sharpe={sharpe:.2f}过高,可能过拟合")
        report['score'] -= 30
    elif sharpe > 2:
        report['warnings'].append(f"⚠️ Sharpe={sharpe:.2f}较高,需仔细验证")
        report['score'] -= 10
    else:
        report['passed_checks'].append("✓ Sharpe比率在合理范围")
 
    # 2. 换手率检查
    turnover = backtest_results.get('annual_turnover', 0)
    if turnover > 5:
        report['warnings'].append(f"⚠️ 换手率={turnover:.0%}过高,成本可能吞噬收益")
        report['score'] -= 15
    else:
        report['passed_checks'].append("✓ 换手率适中")
 
    # 3. 最大回撤检查
    max_dd = backtest_results.get('max_drawdown', 0)
    if max_dd < -0.1:
        report['warnings'].append(f"⚠️ 最大回撤={max_dd:.2%}需确认风险可控")
        report['score'] -= 10
 
    # 4. 样本外验证检查
    if not backtest_results.get('out_of_sample_tested', False):
        report['warnings'].append("⚠️ 未进行样本外验证")
        report['score'] -= 20
    else:
        report['passed_checks'].append("✓ 已进行样本外验证")
 
    # 5. 参数数量检查
    n_params = len(strategy_config.get('params', {}))
    if n_params > 5:
        report['warnings'].append(f"⚠️ 参数数量={n_params}过多,过拟合风险")
        report['score'] -= min(n_params - 5, 20)
 
    # 6. 交易成本检查
    if not backtest_results.get('cost_included', False):
        report['warnings'].append("⚠️ 未考虑交易成本")
        report['score'] -= 25
    else:
        report['passed_checks'].append("✓ 已考虑交易成本")
 
    # 7. 股票池检查
    if not backtest_results.get('survivorship_bias_corrected', False):
        report['warnings'].append("⚠️ 可能存在幸存者偏差")
        report['score'] -= 15
    else:
        report['passed_checks'].append("✓ 已处理幸存者偏差")
 
    # 8. 前视偏差检查
    if 'future_data' in strategy_config.get('data_source', ''):
        report['warnings'].append("⚠️ 可能存在前视偏差")
        report['score'] -= 40
 
    # 9. 流动性检查
    if not backtest_results.get('liquidity_considered', False):
        report['warnings'].append("⚠️ 未考虑流动性限制")
        report['score'] -= 10
 
    # 10. 胜率检查
    win_rate = backtest_results.get('win_rate', 0)
    if win_rate > 0.7:
        report['warnings'].append(f"⚠️ 胜率={win_rate:.2%}过高,需验证")
        report['score'] -= 10
 
    return report
 
# 使用示例
strategy_config = {
    'params': {'short': 10, 'long': 30, 'threshold': 0.5},
    'data_source': 'future_data_adjusted'
}
 
results = {
    'sharpe': 2.5,
    'annual_turnover': 3.5,
    'max_drawdown': -0.15,
    'win_rate': 0.65,
    'cost_included': True,
    'out_of_sample_tested': True,
    'survivorship_bias_corrected': False,
    'liquidity_considered': False
}
 
report = validate_backtest(strategy_config, results)
 
print("=" * 60)
print("回测质量诊断报告")
print("=" * 60)
print(f"\n质量评分: {report['score']}/100")
 
if report['warnings']:
    print("\n⚠️ 警告:")
    for w in report['warnings']:
        print(f"  {w}")
 
if report['passed_checks']:
    print("\n✓ 通过的检查:")
    for c in report['passed_checks']:
        print(f"  {c}")
 
# 质量评级
if report['score'] >= 80:
    rating = "优秀"
elif report['score'] >= 60:
    rating = "良好"
elif report['score'] >= 40:
    rating = "一般,需改进"
else:
    rating = "较差,不可信"
 
print(f"\n总体评级: {rating}")

核心知识点总结

┌────────────────────────────────────────────────────────────────┐
│                 05-回测陷阱与防范 核心要点                       │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  七大陷阱                                                      │
│  ┌────────────────────────────────────────────────────────┐   │
│  │ 1. 过拟合     → 样本外验证、参数稳定性测试              │   │
│  │ 2. 前视偏差   → 检查数据时点、使用滚动统计              │   │
│  │ 3. 幸存者偏差 → 使用全历史股票池、动态股票池            │   │
│  │ 4. 数据窥探   → 预留测试集、Bonferroni校正              │   │
│  │ 5. 忽略流动性 → 流动性过滤、涨跌停处理                  │   │
│  │ 6. 成本低估   → 准确建模所有成本                        │   │
│  │ 7. 分红处理   → 使用总收益指数                          │   │
│  └────────────────────────────────────────────────────────┘   │
│                                                                │
│  防范原则                                                      │
│  ├─ 怀疑一切:完美的回测通常是错的                             │
│  ├─ 样本外验证:必须在不曾见过数据上测试                       │
│  ├─ 记录所有测试:包括失败的                                   │
│  └─ 质量检查清单:系统化检查每个环节                           │
│                                                                │
└────────────────────────────────────────────────────────────────┘

思考题

  1. 一个Sharpe=4的回测结果,你首先怀疑什么?
  2. 如何确认策略没有前视偏差?
  3. 为什么预留测试集比交叉验证更适合回测?

下一步

现在你已经了解了回测中的所有陷阱。最后一节,让我们通过一个完整的实战案例来综合应用所学知识。

前往:06-实战案例