回测的 7 个致命偏差:你中招了几个
「我用 5 年数据、20 个因子、15 种机器学习模型,跑出了一个年化 87% 的策略。」
然后,实盘亏光了。
这不是段子。这是无数量化新手真实走过的路。回测时觉得自己找到了圣杯,实盘时发现圣杯是纸糊的。大多数情况下,问题不在策略本身,而在回测过程——那些看起来合理、实则隐蔽的偏差,像白蚁一样从内部瓦解你的策略。
本文系统拆解回测中最常见的 7 种致命偏差:幸存者偏差、前视偏差、过拟合、流动性偏差、交易成本低估、交易执行模拟失真、数据窥探偏差。每一种都会附上直观的案例说明,以及你能在代码层面做什么来规避。
一、幸存者偏差:死人不会说话
什么是幸存者偏差
幸存者偏差(Survivorship Bias)的本质是你只看见了活下来的标的,而忽略了已经退市、破产、被并购的那些。
想象你在 2010 年选取「10 年股价涨幅 Top 10」的回测区间。如果你在 2024 年做回测,用的是 2010 年还存活的公司池——但 2010 年有 5000 家公司,到 2024 年还活着的可能只有 3000 家。那消失的 2000 家去哪了?你在回测里根本看不见它们。
结果:你选到了一批「天生优秀」的公司作为样本,收益率当然虚高。
量化场景中的具体表现
| 回测场景 | 真实发生的事 | 你以为的事 |
|---|---|---|
| 用 2015 年的成分股池回测「低估策略」 | 2015 年低估值股票有一批后来退市了 | 低估值策略在历史上表现优异 |
| 选取历史长牛股作为因子样本 | 那些暴涨的股票在因子评分中天然得分高 | 因子有效性被高估 |
| 用指数成分股回测主动管理策略 | 指数定期调入好股票、调出坏股票 | 策略跑赢指数是真实的alpha |
一个直观的数学例子
假设 2010 年有 1000 只股票,平均年化收益率 8%,但每年有 5% 的股票退市(收益率为 -100%)。如果你只统计活下来的股票:
初始股票池:1000 只
第 1 年:50 只退市,950 只存活 → 平均收益 8% 的幸存者
第 5 年:约 229 只退市,771 只存活
到第 5 年,你的「幸存者样本」的平均年化收益已经被退市股拉高了。因为那 229 只你永远看不见的股票,平均把你的真实收益拉下来了多少?
真实市场年均收益 ≈ 8%(含退市股)
幸存者样本年均收益 ≈ 11%(不含退市股)
差距 3 个百分点,全靠死人「贡献」。
代码层面的应对
import pandas as pd
import numpy as np
def get_delisted_symbols(universe_start: pd.DataFrame, end_date: str) -> set:
"""
从 TickDB 获取历史上所有曾经存在但后来退市的标的。
关键:不只是拿当前存活的标的,而是在起始日期筛选全量历史标的。
"""
# ⚠️ 这是概念演示。实际实现需要 TickDB 提供历史成分股/全量标的接口。
# 你也可以从第三方数据源(如 Polygon、SEC EDGAR)获取退市标的列表。
# 正确做法:获取 universe_start 日期的全量可用标的,不只是「今天」存活的
all_historical_symbols = fetch_all_symbols_at_date(universe_start)
# 获取当前存活标的
current_symbols = fetch_current_symbols()
# 差集即为退市标的
delisted = all_historical_symbols - current_symbols
return delisted
def backtest_with_survivorship_correction(
prices_start: pd.DataFrame,
returns: pd.Series,
delisted_returns: pd.Series
) -> dict:
"""
将退市标的收益率纳入回测,修正幸存者偏差。
退市标的的收益率通常用「最后交易日收盘价 → 0」或「最后交易日后固定天数 → 0」估算。
"""
# 合并:含退市标的的完整收益率序列
full_returns = pd.concat([returns, delisted_returns])
# 计算修正后的累计收益
cumulative_return = (1 + full_returns).prod() - 1
# 同时计算幸存者偏差版本(仅活下来的)
survivorship_return = (1 + returns).prod() - 1
bias = survivorship_return - cumulative_return
print(f"修正后年化收益: {cumulative_return:.2%}")
print(f"幸存者偏差版本: {survivorship_return:.2%}")
print(f"偏差幅度: {bias:.2%} ← 这就是被你忽略的死人的贡献")
return {"corrected": cumulative_return, "biased": survivorship_return, "bias": bias}
核心原则:在任何回测开始前,你必须明确使用的是「起始日期的全量标的」,而不是「当前存活标的」。TickDB 的 symbols 接口在某些市场支持历史快照查询,建议优先确认这一能力。
二、前视偏差:你在用未来数据作弊
什么是前视偏差
前视偏差(Look-Ahead Bias)是指回测代码中不经意使用了本该在「今天」之后才公布的数据。这是最隐蔽、最常见、最容易让量化新人觉得自己「天才」的错误。
最经典的案例
# ❌ 错误代码:财务数据泄露
def calculate_pe_ratio(stock: StockData, report_date: str) -> float:
"""计算 PE 比率"""
# 「财报公布日」往往晚于「财年结束日」数周甚至数月
# 如果你直接用财年结束日的财务数据做 PE 筛选,
# 等于在财报还没公布时就知道财报数字——前视偏差
earnings = stock.get_financials(report_date) # ← 问题在这里
price = stock.get_price(report_date)
return price / earnings
例如,假设 A 公司财年截止日是 12 月 31 日,财报实际公布日是次年 3 月 15 日。如果你在回测中用 1 月 1 日的财务数据——那时候财报还没出,你「偷看」了未来。
前视偏差的三种常见形态
形态一:财务报表公布日期偏移
| 事件 | 实际发生时间 | 回测中的「错误时间」 |
|---|---|---|
| 年度财报发布 | 财年后 60-90 天 | 财年结束日(提前 2-3 个月) |
| 季报发布 | 季度后 20-45 天 | 季度结束日 |
| 分红除权 | 公告日 + T+2 | 公告日当天 |
形态二:技术指标「偷看」收盘价
# ❌ 错误代码:收盘价泄露
def compute_signal_bars(data: pd.DataFrame) -> pd.Series:
"""
在 bar 数据生成阶段就用了当根 bar 的收盘价。
如果你的策略在盘中运行,实际只能看到当前价格,不能看到收盘价。
"""
close = data['close'] # ← 当根 bar 的收盘价提前泄露
sma20 = close.rolling(window=20).mean()
signal = (close > sma20).astype(int)
return signal
形态三:买卖价差用收盘价而非实际成交价
# ❌ 错误代码:执行价格前视
def simulate_trade(price: float, side: str) -> float:
"""
直接用中间价((买一 + 卖一) / 2)作为成交价。
实际成交价是「你下单那一刻」的卖一或买一,不是收盘价。
"""
execution_price = price # ← 应该取下单时的实际档位价格
return execution_price
代码层面的应对
def backtest_with_proper_timestamps(
price_data: pd.DataFrame,
financial_data: pd.DataFrame,
announcement_offset_days: int = 45 # 财报公布通常延迟 45 天
) -> pd.Series:
"""
通过时间偏移修正前视偏差。
所有财务数据统一后移一个公布延迟窗口。
"""
# 正确做法:将财务数据「推迟」到实际公布日
financial_data_dated = financial_data.copy()
financial_data_dated['effective_date'] = (
financial_data_dated['fiscal_period_end'] +
pd.Timedelta(days=announcement_offset_days)
)
# 用 effective_date 合并,确保回测只在「数据可用」后才使用
merged = price_data.merge(
financial_data_dated,
left_on='date',
right_on='effective_date',
how='left'
)
return merged['signal']
def compute_bar_signal_non_repaint(
open_price: float,
high: float,
low: float,
current_price: float,
window: int = 20
) -> int:
"""
盘中信号计算:只能用 open/high/low/current_price,
不能用当根 bar 的 close(因为 bar 还没收完)。
策略类型:区间突破。用 open 确定基准,用 high/low/current 判定突破。
"""
upper_bound = high # 可用
lower_bound = low # 可用
# 只能在 bar 内盘中高点突破时临时发出信号,
# 但该信号在 bar 关闭前不能「确认」,否则就是前视
if current_price > upper_bound:
return 1
return 0
核心原则:在数据层面,问自己一个问题——「如果我在 2015 年 3 月 15 日做这个决策,我能看到哪些数据?」 如果回测中使用的数据在那时还不可见,就是前视偏差。
三、过拟合:你的模型记住了答案,而不是学到了规律
过拟合的本质
过拟合(Overfitting)是模型在训练集上学到了噪声,而不是信号。在量化回测中,这通常表现为策略在历史数据上表现惊艳,但在新数据(未来)上迅速失效。
两种过拟合的量化场景
场景一:参数过度优化
最经典的例子是「均线参数」优化:
# ❌ 过度优化:遍历 500 组参数,选最优
def optimize_ma_cross(data: pd.DataFrame) -> dict:
best_sharpe = -np.inf
best_params = {}
for short_window in range(5, 101): # 5~100,共 96 个参数
for long_window in range(20, 201): # 20~200,共 181 个参数
for threshold in np.linspace(0.001, 0.01, 100): # 100 个阈值
# 总共 96 × 181 × 100 = 1,737,600 组参数
sharpe = run_backtest(data, short_window, long_window, threshold)
if sharpe > best_sharpe:
best_sharpe = sharpe
best_params = {"short": short_window, "long": long_window, "threshold": threshold}
return best_params # ⚠️ 你选中的「最优」可能是噪声最大值
你跑了 170 万组参数,选出最优的 Sharpe = 3.8。问题是:如果策略的有效 Sharpe 真实分布是 0.5~1.5,那 3.8 大概率是噪声的峰值。
场景二:多重检验问题
每增加一个指标(因子、过滤条件、仓位规则),你就多了一次「在随机数据上选出有效结果」的机会。
| 增加的指标数 | 在纯随机数据上「至少有一次显著」的预期概率 |
|---|---|
| 1 | 5%(p < 0.05 阈值) |
| 5 | 22.6% |
| 20 | 64.1% |
| 50 | 92.3% |
当你在 50 个候选因子中筛选时,你有 92% 的概率在纯随机数据上「发现」显著因子。这不是策略,这是多重检验的统计幻觉。
过拟合的量化度量
一个实用的过拟合指标:Walk-Forward 折数比(Walk-Forward Ratio)。
def walk_forward_analysis(
data: pd.DataFrame,
train_ratio: float = 0.6,
n_folds: int = 5
) -> dict:
"""
Walk-Forward 分析:在多个时间窗口上评估策略稳定性。
核心思想:
- 用「过去」训练策略参数
- 用「未来」验证策略效果
- 如果每个 fold 的验证结果差异过大 → 过拟合风险高
"""
n = len(data)
fold_size = n * (1 - train_ratio) / n_folds
in_sample_sharpes = []
out_of_sample_sharpes = []
for fold in range(n_folds):
train_end = int(n * train_ratio * (1 + fold * 0.2))
test_start = train_end
test_end = min(int(test_start + fold_size), n)
train_data = data.iloc[:train_end]
test_data = data.iloc[test_start:test_end]
# 训练阶段
params = optimize_strategy(train_data)
train_result = run_backtest(train_data, **params)
# 验证阶段
test_result = run_backtest(test_data, **params)
in_sample_sharpes.append(train_result["sharpe"])
out_of_sample_sharpes.append(test_result["sharpe"])
# 过拟合指标:样本内 Sharpe 均值 / 样本外 Sharpe 均值
# 正常范围:0.7 ~ 0.9(样本外会差一些,但不至于崩盘)
# 危险信号:> 1.2 → 严重过拟合
ratio = np.mean(in_sample_sharpes) / np.mean(out_of_sample_sharpes)
print(f"样本内 Sharpe 均值: {np.mean(in_sample_sharpes):.2f}")
print(f"样本外 Sharpe 均值: {np.mean(out_of_sample_sharpes):.2f}")
print(f"折数比 (WFR): {ratio:.2f} ← {'⚠️ 严重过拟合' if ratio > 1.2 else '✅ 正常'}")
return {
"in_sample": in_sample_sharpes,
"out_of_sample": out_of_sample_sharpes,
"walk_forward_ratio": ratio
}
一个经验规则:如果你的样本内 Sharpe 是样本外的 2 倍以上,策略大概率存在过拟合。
代码层面的应对原则
# ✅ 核心原则 1:参数数量与样本量的比例
MIN_SAMPLES_PER_PARAMETER = 30 # 每增加一个参数,至少需要 30 交易日数据
def validate_parameter_count(data: pd.DataFrame, n_parameters: int) -> None:
"""参数数量的合理性校验"""
n_samples = len(data)
required_samples = n_parameters * MIN_SAMPLES_PER_PARAMETER
if n_samples < required_samples:
raise ValueError(
f"样本量不足:{n_samples} 个样本支撑 {n_parameters} 个参数。"
f"最低需要 {required_samples} 个样本(每参数 {MIN_SAMPLES_PER_PARAMETER} 个)。"
)
# ✅ 核心原则 2:减少优化自由度
# 用物理意义明确的固定参数,而非遍历搜索
FIXED_SHORT_MA = 20 # 固定 20 日均线(行业惯例)
FIXED_LONG_MA = 60 # 固定 60 日均线(行业惯例)
# 而不是遍历 5~200 的所有可能性
四、流动性偏差:你的策略根本进不去
流动性偏差的本质
流动性偏差(Liquidity Bias)发生在策略交易量超过市场实际可承接量的情况下。你的回测假设「你能以回测价格买到想要的全部仓位」,但实盘中大额订单会把价格推到你想要的价格之前就已经开始滑移。
量化场景中的具体表现
| 策略类型 | 流动性偏差的表现 | 真实场景 |
|---|---|---|
| 日内趋势策略 | 假设每天可买入 50 万股,但实际日均成交量只有 10 万股 | 实际操作量只能占成交量的 1%~5% |
| 小市值轮动 | 持仓市值 5000 万,但某只小票日成交额仅 200 万 | 进出一次就把股价打涨停/跌停 |
| 事件驱动 | 财报后追涨停,实际封板量不够你排队 | 完全进不去 |
订单簿视角:为什么回测价格不可信
在 TickDB 的 depth 频道中,你可以看到订单簿的真实深度:
def estimate_market_impact(
order_size: int,
depth_data: dict, # TickDB depth 频道数据
avg_slippage_per_share: float = 0.02
) -> float:
"""
基于订单簿深度估算市场冲击成本。
depth_data 结构示例(TickDB depth 频道):
{
"symbol": "AAPL.US",
"timestamp": 1710500000000,
"bids": [[150.00, 2500], [149.99, 5000], ...], # [价格, 挂单量]
"asks": [[150.01, 3000], [150.02, 4500], ...]
}
"""
# 计算逐档累计量,找到「吃掉前 N 档」的价格
bids = depth_data['bids'] # 按价格降序
asks = depth_data['asks'] # 按价格升序
cumulative_bid_volume = 0
weighted_cost = 0.0
for price, volume in asks: # 买入方向:看卖单(asks)
available = min(volume, order_size - cumulative_bid_volume)
cumulative_bid_volume += available
weighted_cost += available * (price - asks[0][0]) # 滑出第一档的价差
if cumulative_bid_volume >= order_size:
break
# 实际冲击成本
market_impact = weighted_cost / order_size
# ⚠️ 回测中你用的固定 0.02% 滑点,可能严重低估
print(f"预估市场冲击成本: {market_impact:.4f} ({market_impact*100:.2f}%)")
print(f"回测中常用固定滑点: {avg_slippage_per_share:.4f} ({avg_slippage_per_share*100:.2f}%)")
if market_impact > avg_slippage_per_share * 3:
print("⚠️ 警告:实际冲击成本远超回测假设,请降低仓位或分批建仓")
return market_impact
代码层面的应对
def backtest_with_liquidity_constraint(
prices: pd.DataFrame,
volume: pd.Series,
max_position_pct: float = 0.01, # 单笔不超过前一日成交量的 1%
order_split_factor: int = 10 # 分 10 批建仓
) -> pd.DataFrame:
"""
在回测中加入流动性约束。
核心逻辑:你的可交易量上限 = 前一日成交量 × max_position_pct
"""
signals = compute_raw_signals(prices)
constrained_positions = []
for date, signal in signals.items():
prev_volume = volume.loc[date - pd.Timedelta(days=1)]
max_shares = int(prev_volume * max_position_pct)
if signal * max_shares < min_trade_size:
# 信号存在但流动性不足,放弃这笔交易
constrained_positions.append(0)
else:
# 分批建仓,取加权平均价(粗略估算)
constrained_positions.append(
min(signal, max_shares) / order_split_factor
)
return pd.Series(constrained_positions, index=signals.index)
def calculate_realistic_slippage(
price: float,
volume: int,
depth_snapshot: dict,
side: str = "buy"
) -> float:
"""
基于实际订单簿深度计算真实滑点。
比固定百分比滑点更接近实盘表现。
"""
levels = depth_snapshot['asks'] if side == "buy" else depth_snapshot['bids']
remaining = volume
total_cost = 0.0
first_price = levels[0][0]
for price_level, vol_level in levels:
fill = min(remaining, vol_level)
total_cost += fill * price_level
remaining -= fill
if remaining <= 0:
break
avg_fill_price = total_cost / volume
slippage = abs(avg_fill_price - first_price) / first_price
return slippage
五、交易成本低估:吃掉你利润的不是市场,是你自己
交易成本的真实构成
大多数回测者只算了「佣金 + 滑点」,但实际交易成本包含四层:
| 成本层级 | 说明 | 典型数值(美股) | 回测中的错误处理 |
|---|---|---|---|
| 佣金/手续费 | 券商收取 | $0~5/笔 | ✅ 通常计入 |
| 滑点 | 期望成交价 vs 实际成交价 | 0.01%~0.1% | ⚠️ 通常用固定值估算 |
| 市场冲击 | 大单交易推动价格 | 0.1%~1%+ | ❌ 通常完全忽略 |
| 机会成本 | 因流动性不足放弃的交易 | 无法量化 | ❌ 通常忽略 |
交易成本的累积效应
这就是为什么交易成本经常被低估——它是复合的。
假设年化换手率 500%(即每年全部仓位的 5 倍),策略收益 8%:
佣金成本:0.05% × 500 = 0.25%
固定滑点(0.05%):0.05% × 500 = 0.25%
市场冲击(保守 0.1%):0.1% × 500 = 0.50%
总成本:1.00%
你的「8%」回测收益,实际到手 ≈ 7%
但如果你的策略夏普只有 0.5,
那 1% 的成本就能让你的夏普变成负数。
代码层面的应对
def run_backtest_with_realistic_costs(
prices: pd.DataFrame,
signals: pd.Series,
cost_config: dict = None
) -> dict:
"""
四层成本叠加回测,而非单一固定费率。
cost_config 建议配置:
{
"commission_per_share": 0.005, # $0.005/股(如 TD Ameritrade)
"commission_per_trade": 0.0, # 或者固定每笔 $1
"fixed_slippage_bps": 5, # 固定滑点 5 个基点
"market_impact_bps": 10, # 市场冲击 10 个基点(大单)
"bid_ask_spread_bps": 2, # 买卖价差 2 个基点
"opportunity_cost_pct": 0.0 # 建议单独记录,不要假设为 0
}
"""
if cost_config is None:
cost_config = {
"commission_per_share": 0.005,
"fixed_slippage_bps": 5,
"market_impact_bps": 10,
"bid_ask_spread_bps": 2,
}
portfolio_value = 1_000_000 # 初始资金 $100 万
trades = []
for i in range(1, len(prices)):
date = prices.index[i]
signal = signals.iloc[i]
prev_signal = signals.iloc[i-1]
if signal != prev_signal:
# 换手:平旧仓 + 开新仓 = 两次交易成本
price = prices.iloc[i]['close']
shares = int(portfolio_value / price)
# 各层成本计算
commission = cost_config["commission_per_share"] * shares * 2
spread_cost = price * shares * (cost_config["bid_ask_spread_bps"] / 10000)
slippage = price * shares * (cost_config["fixed_slippage_bps"] / 10000)
impact = price * shares * (cost_config["market_impact_bps"] / 10000)
total_cost = commission + spread_cost + slippage + impact
portfolio_value -= total_cost
trades.append({
"date": date,
"signal": signal,
"shares": shares,
"cost": total_cost,
"cost_bps": total_cost / (price * shares) * 10000
})
# 关键指标:成本占比
total_return = (portfolio_value / 1_000_000 - 1) * 100
gross_return = ((portfolio_value + sum(t['cost'] for t in trades)) / 1_000_000 - 1) * 100
print(f"毛收益(未扣成本): {gross_return:.2f}%")
print(f"净收益(已扣成本): {total_return:.2f}%")
print(f"成本吞噬比例: {(gross_return - total_return) / gross_return * 100:.1f}%")
return {"portfolio_value": portfolio_value, "trades": trades}
六、交易执行模拟失真:回测假设订单 100% 成交,实盘不是这样
执行模拟失真的常见场景
回测中最容易被忽略的假设是**「你的订单能 100% 成交、价格是回测中设定的那一个」**。实盘中,这个假设几乎从不成立。
场景一:涨跌停无法成交
# ❌ 错误代码:假设任何时候都能成交
def simulate_trade_order(price: float, shares: int, side: str) -> dict:
return {
"filled": shares, # 100% 成交
"avg_price": price,
"execution_price": price,
"status": "filled"
}
实盘中:财报后股价跳空高开 20%,你的买单在涨停板排队,可能成交 0 股,也可能在打开涨停后以远高于回测价格的价格成交。
场景二:订单薄模式的执行假设
# ❌ 错误代码:市价单假设无限流动性
def execute_market_order(symbol: str, shares: int, side: str) -> dict:
# 错误假设:市价单立即以当前价格全部成交
current_price = get_current_price(symbol)
return {
"filled": shares,
"avg_price": current_price, # ← 这就是问题所在
"slippage_assumed": 0.0
}
实际:500 万股的大单,分散在 10 分钟内成交,每一批的价格都不同。你的回测用了一个不存在的「平均价」。
代码层面的应对
import random
def simulate_order_fill_realistic(
order: dict,
depth_data: dict,
side: str,
time_limit_seconds: int = 60
) -> dict:
"""
基于订单簿深度的真实成交模拟。
模拟逻辑:
1. 检查即时成交能力(是否超过买一/卖一总量)
2. 若超过,分批成交,取加权平均价
3. 若涨跌停,标记为「未成交」
4. 加入随机延迟(模拟滑转)
"""
levels = depth_data['asks'] if side == "buy" else depth_data['bids']
remaining = order['shares']
fills = []
for price, volume in levels:
if remaining <= 0:
break
# 检查涨跌停
if order.get('daily_limit_hit', False):
fills.append({"price": price, "volume": 0, "reason": "limit_up"})
continue
fill_qty = min(remaining, volume)
fills.append({"price": price, "volume": fill_qty})
remaining -= fill_qty
if remaining > 0:
# 未能完全成交,部分成交
fills.append({"price": levels[-1][0], "volume": remaining, "reason": "partial_fill"})
total_filled = sum(f['volume'] for f in fills)
fill_ratio = total_filled / order['shares']
# 加权平均成交价
if total_filled > 0:
avg_price = sum(f['price'] * f['volume'] for f in fills) / total_filled
else:
avg_price = order.get('limit_price', levels[0][0])
result = {
"filled": total_filled,
"requested": order['shares'],
"fill_ratio": fill_ratio,
"avg_price": avg_price,
"slippage_bps": (avg_price - order.get('limit_price', avg_price))
/ order.get('limit_price', avg_price) * 10000,
"fills": fills,
"status": "filled" if fill_ratio == 1.0 else "partial"
}
return result
def backtest_with_execution_realism(
signals: pd.Series,
prices: pd.DataFrame,
depth_data: dict # TickDB depth 历史数据
) -> pd.DataFrame:
"""
在回测中模拟真实订单成交,而非假设 100% 成交。
对于日内高频策略尤其重要:
depth 数据能告诉你每一时刻的挂单量,
据此计算你的订单能否在指定价格成交。
"""
trades = []
for date, signal in signals.items():
prev_signal = signals.iloc[signals.index.get_loc(date) - 1] if signals.index.get_loc(date) > 0 else 0
if signal != prev_signal and signal != 0:
order = {
"shares": calculate_position_size(signal, prices.loc[date]),
"side": "buy" if signal > prev_signal else "sell",
"limit_price": prices.loc[date, 'close'],
"daily_limit_hit": check_limit_up_down(date, prices)
}
# 用 TickDB depth 数据模拟成交
depth = depth_data.get(date, None)
if depth is not None:
fill_result = simulate_order_fill_realistic(order, depth, order['side'])
trades.append(fill_result)
else:
# 无 depth 数据时,用保守估计
trades.append({
"filled": order['shares'] * 0.95, # 保守假设 95% 成交率
"fill_ratio": 0.95,
"status": "partial",
"slippage_bps": 10 # 假设 10 个基点滑点
})
return pd.DataFrame(trades)
七、数据窥探偏差:在考试中偷看了标准答案
数据窥探偏差的本质
数据窥探偏差(Data Snooping Bias)是你在「看到数据」之后才决定策略参数,而非先用策略参数、再验证数据。
这和过拟合有重叠,但更偏向「数据选择」层面:你选择看哪些数据、用哪段时间、剔除哪些异常值,都可能受到主观偏见的影响。
数据窥探的三种隐蔽形式
形式一:「我看到这个形态之后才设计策略」
「我看到 2020 年 3 月 V 型反弹后用动量策略赚钱了,所以设计了一个 V 型反弹动量策略」
你在看到数据之后才提出假设,本质上是在用数据拟合策略,而不是用策略验证数据。
形式二:挑选回测区间
# ❌ 常见错误:选「刚好」能证明策略有效的区间
def select_benchmark_period(returns: pd.Series, strategy_returns: pd.Series) -> str:
"""
恶意回测者的手法:选对自己有利的区间。
如果策略表现好:选长区间(包含这个好时段)
如果策略表现差:截取短区间(绕过这个差时段)
"""
# 检查所有可能的起止点组合,找出策略表现最好的那个
# ⚠️ 学术上称之为「 cherry-picking」,是一种数据窥探
pass
一个 10 年数据,如果允许你自由选择起止点,理论上你可以找出无数个「策略有效」和「策略无效」的子区间。总有一个恰好支持你的结论。
形式三:异常值处理的主观性
| 异常值处理方式 | 效果 | 主观性风险 |
|---|---|---|
| 删除超过 3σ 的数据点 | 去除黑天鹅事件 | 低估极端风险 |
| winsorize 到 3σ | 折中处理 | 中等风险 |
| 保留异常值 | 诚实但显得策略表现差 | 被认为「不专业」 |
| 按「业务原因」删除特定日期 | 最高风险 | 几乎等于作弊 |
代码层面的应对
def robust_backtest_design(
data: pd.DataFrame,
strategy_func: callable,
out_of_sample_ratio: float = 0.3
) -> dict:
"""
反数据窥探的回测设计框架。
核心原则:
1. 先划分样本内外,再做参数优化
2. 参数优化只能在样本内进行
3. 样本外只做一次评估(不调参)
4. 多时间段交叉验证
"""
n = len(data)
cutoff = int(n * (1 - out_of_sample_ratio))
in_sample_data = data.iloc[:cutoff]
out_of_sample_data = data.iloc[cutoff:]
# Step 1: 在样本内优化参数
params = strategy_func(in_sample_data)
# Step 2: 在样本外评估(不调参!)
in_sample_result = run_backtest(in_sample_data, params)
out_of_sample_result = run_backtest(out_of_sample_data, params)
# Step 3: 交叉验证(rolling window)
cross_validation_results = []
for split_ratio in [0.5, 0.6, 0.7]:
cv_result = walk_forward_analysis(data, train_ratio=split_ratio, n_folds=3)
cross_validation_results.append(cv_result)
avg_cv_sharpe = np.mean([
r["out_of_sample"]
for r in cross_validation_results
for r in r["out_of_sample"]
])
print(f"样本内 Sharpe: {in_sample_result['sharpe']:.2f}")
print(f"样本外 Sharpe: {out_of_sample_result['sharpe']:.2f}")
print(f"交叉验证平均样本外 Sharpe: {avg_cv_sharpe:.2f}")
# 如果交叉验证 Sharpe < 0.5,策略基本不可信
if avg_cv_sharpe < 0.5:
print("⚠️ 警告:交叉验证显示策略样本外表现极弱,数据窥探风险高")
return {
"in_sample": in_sample_result,
"out_of_sample": out_of_sample_result,
"cross_validation": cross_validation_results,
"params": params,
"trust_level": "高" if avg_cv_sharpe > 1.0 else "中" if avg_cv_sharpe > 0.5 else "低"
}
八、偏差汇总:一张图看清回测陷阱
| 偏差名称 | 问题本质 | 核心特征 | 应对方法 |
|---|---|---|---|
| 幸存者偏差 | 只看活下来的标的 | 收益持续高估 | 使用历史全量标的池 |
| 前视偏差 | 用了未来才有的数据 | 策略「提前」反应 | 数据后移处理(公布延迟) |
| 过拟合 | 参数记住了噪声 | 样本内极佳,样本外极差 | Walk-Forward / 参数数量限制 |
| 流动性偏差 | 策略交易量超出市场能力 | 大资金策略回测失真 | 订单簿深度模拟 / 仓位限制 |
| 交易成本低估 | 忽略了真实成本构成 | 高换手策略利润消失 | 四层成本叠加模型 |
| 执行模拟失真 | 假设 100% 成交 | 涨跌停、流动性不足场景 | 真实成交模拟 / 部分成交建模 |
| 数据窥探偏差 | 用数据决定策略 | 「看到答案后设计题目」 | 样本内外严格分离 / 交叉验证 |
九、结语
回到开篇那个故事——「年化 87% 的策略」最终亏光了。你现在知道原因了:幸存者偏差把死掉的股票藏起来,前视偏差让策略偷看了未来,过拟合让模型记住了噪声,流动性偏差让大单根本进不去,交易成本无声地蚕食每一笔利润。
回测不是验证你的策略有多好,而是度量你的策略离真实市场有多远。
每一种偏差,都是那段距离的一个刻度。聪明的人不是「找出不回测的策略」,而是系统性地识别并修正这些偏差。当你把一个策略的 7 种偏差逐一修正后仍然有效——那才是真正值得放进实盘的方法。
下一步行动
如果你是量化新人,从幸存者偏差和前视偏差开始检查你的回测——这两个是最常见也是最容易修复的。
如果你已经有回测框架,在 TickDB 控制台申请免费 API Key,使用其历史 K 线数据(美股 10 年级别)验证你的策略在不同市场周期下的表现,并用 TickDB 的 depth 频道数据测试流动性假设是否成立。
如果你需要更长的历史数据做稳健的回测,联系 [email protected] 获取机构级历史数据方案。
回测局限性说明:本文中的回测示例和代码用于说明偏差原理,未包含完整的历史数据验证。回测结果存在以下固有局限性:未完全模拟实际交易中的流动性约束和极端行情冲击;样本量有限时统计显著性不足;市场结构随时间变化,历史表现不代表未来。本文不构成任何投资建议。市场有风险,投资需谨慎。