回测的 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] 获取机构级历史数据方案。


回测局限性说明:本文中的回测示例和代码用于说明偏差原理,未包含完整的历史数据验证。回测结果存在以下固有局限性:未完全模拟实际交易中的流动性约束和极端行情冲击;样本量有限时统计显著性不足;市场结构随时间变化,历史表现不代表未来。本文不构成任何投资建议。市场有风险,投资需谨慎。