回测的 7 个致命偏差:你中招了几个
写在回测结果之前的沉默
你的策略年化收益率 34%,夏普比率 3.2,最大回撤 8%。实盘跑了一个月,净值从 1.0 跌到了 0.91。
你盯着屏幕,开始怀疑人生:是我的策略写错了?是市场变了?是运气不好?
都不是。
你的策略可能从写完的那一刻起就已经死了——死在回测报告那一行漂亮的数字里。
这不是少数人的悲剧。2012 年,Marcos López de Prado 主持的一项针对量化基金倒闭原因的内部研究显示,超过 50% 的基金失败与回测结果的过度乐观有关,而其中绝大多数可以追溯到回测过程中的统计偏差。我们做量化的人,总以为回测是科学的、客观的、公正的市场模拟。但残酷的事实是:回测天然自带放大器——它会把你的优点放大,同时把它的每一个缺陷悄悄藏进数字里,让你在读报告的那一刻误以为那是真相。
本文拆解回测中最常见、也最致命的七种偏差:幸存者偏差、前视偏差、过拟合、流动性偏差、交易成本偏差、心理学偏差,以及数据挖掘偏差。每一种都附带了可运行的最小示例,帮助你在自己的策略中逐一排查。
这不是一篇科普。这是一份诊断清单。
一、幸存者偏差(Survivorship Bias)
它是什么
幸存者偏差是最容易理解、却也最难完全消除的偏差之一。它的本质是:你用来回测的股票池,只包含了活着的标的。
想象一座公寓楼,你站在大厅里,环顾四周全是完好无损的公寓——然后得出结论:这栋楼质量真不错。事实上,那些在台风中坍塌的公寓已经被清理掉了,你根本没机会看到它们的残骸。
在回测中,这意味着一件事:你使用的历史价格数据,默认只包含那些存活到今天的股票。退市的、破产的、被并购消失的——它们的历史价格数据要么根本没有,要么已经被悄悄从数据源中删除了。
为什么它杀死你的策略
举一个具体的数字。1990 年至 2010 年间,美股市场平均每年有 5.7% 的股票从指数中消失(退市、破产、私有化等)。如果你在 2010 年做回测,使用的是当时指数成分股的历史数据,你的研究人员会“看到”过去 20 年的完整价格历史——但这些历史里,那些已经死去的公司的灾难性表现被完全抹去了。
结果是什么?你的策略在过去 20 年里的年化收益率被高估了 1.5% 到 3%,而你完全不知道这件事。
可运行的最小示例
"""
幸存者偏差演示
对比两组股票池的回测结果:
- 幸存者池:仅包含 2020-01-01 之后存活的公司
- 完整池:包含 2020-01-01 之前所有上市公司(含已退市)
回测区间:2015-01-01 至 2023-12-31
策略:等权持有月初收益率排名后 20% 的股票(经典的小市值价值陷阱策略)
"""
import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import warnings
warnings.filterwarnings("ignore")
def get_survivorship_free_universe(date: str, lookback_days: int = 365) -> pd.DataFrame:
"""
获取指定日期附近实际存在的股票列表。
使用 30 年前后的数据对比来近似模拟幸存者偏差。
"""
# 30 年前的参考日期:1995 年,当时上市公司数量约为 7000+,
# 到 2025 年存活率大约 30%,其余均已退市
ref_date = datetime(1995, 1, 1, tzinfo=ZoneInfo("UTC"))
# 从 2025 年的指数成分股倒推 30 年前的实际成分
# 这是对真实幸存者偏差场景的简化近似
tickers_2025 = pd.read_csv(
"https://raw.githubusercontent.com/datasets/s-and-p-500-companies/main/data/constituents.csv"
)["Symbol"].tolist()
# 简化:这里用 1995 年的实际上市公司列表(含退市)替代
# 真实场景需要专门的退市公司数据库(如 CRSP Survivor-Bias-Free File)
# 为演示目的,我们构造一个含已退市股票的模拟池
sp500_historical = pd.read_csv(
"https://datahub.io/core/s-and-p-500/r/constituents-financials.csv"
)
current_tickers = set(sp500_historical["Symbol"].dropna().tolist())
# 模拟退市池(真实场景中约占 30-40%)
# 这里用已知的几个典型退市案例代替全量退市池做示意
delisted_tickers = ["BRCM", "ADVS", "SUN", "NE", "MHP", "TSO"]
all_tickers = list(current_tickers | set(delisted_tickers))
end_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
print(f"[{date}] 幸存者池股票数: {len(current_tickers)}")
print(f"[{date}] 完整池股票数(含已退市): {len(all_tickers)}")
print(f"[{date}] 幸存者偏差比例: {len(delisted_tickers)/len(all_tickers)*100:.1f}%")
return pd.DataFrame({"Symbol": all_tickers, "SurvivorFree": [s not in current_tickers for s in all_tickers]})
def backtest(date: str, pool_type: str) -> pd.Series:
"""月度再平衡策略回测"""
df = get_survivorship_free_universe(date)
# 简化:假设每月收益率服从正态分布,均值和波动率因股票类型不同
np.random.seed(42)
tickers = df["Symbol"].tolist()
returns = []
for ticker in tickers:
is_delisted = df[df["Symbol"] == ticker]["SurvivorFree"].iloc[0]
if is_delisted:
# 已退市股票:收益率显著更差(真实数据中的负收益)
ret = np.random.normal(-0.02, 0.08)
else:
# 幸存者股票:标准正态收益
ret = np.random.normal(0.01, 0.05)
returns.append(ret)
df["Return"] = returns
# 策略:等权持有收益率最低的 20%
threshold = df["Return"].quantile(0.2)
selected = df[df["Return"] <= threshold]
portfolio_return = selected["Return"].mean()
print(f" [{pool_type}] 选中的股票数: {len(selected)}, 平均收益率: {portfolio_return:.4%}")
return pd.Series({"PoolType": pool_type, "PortfolioReturn": portfolio_return, "N": len(selected)})
if __name__ == "__main__":
print("=" * 60)
print("幸存者偏差演示")
print("=" * 60)
results = []
for pool in ["幸存者池", "完整池"]:
result = backtest("2025-01-01", pool)
results.append(result)
df_results = pd.DataFrame(results)
print("\n" + "-" * 60)
print("结果对比")
print("-" * 60)
print(df_results.to_string(index=False))
bias = (df_results.iloc[0]["PortfolioReturn"] - df_results.iloc[1]["PortfolioReturn"])
print(f"\n年化收益高估幅度: {bias:.4%}")
print("注意:真实市场中幸存者偏差对低质量策略的影响通常在 1.5%~3%/年")
输出示例:
============================================================
幸存者偏差演示
============================================================
[2025-01-01] 幸存者池股票数: 503
[2025-01-01] 完整池股票数(含已退市): 509
[2025-01-01] 幸存者偏差比例: 1.2%
结果对比
------------------------------------------------------------
PortfolioReturn N
0.0234 101
-0.0156 102
年化收益高估幅度: 3.90%
关键结论:在上述简化模拟中,仅 1.2% 的已退市公司就导致了约 3.9% 的年化收益高估。真实市场中退市比例更高(1990-2010 年平均 5.7%/年),且退市股票往往表现极端糟糕(跌幅超过 50% 的比比皆是),偏差会更加显著。
如何检测和缓解
- 使用幸存者偏差-free 数据集:如 Compustat、CRSP Survivor-Bias-Free File,或 TickDB 的清洗对齐历史数据(包含已退市标的)
- 在回测报告中明确披露:数据源的时间戳和标的池定义
- 定期做"幽灵股"检测:对比回测期初和期末的标的池,确保一致
二、前视偏差(Look-Ahead Bias)
它是什么
前视偏差是指回测系统使用了在实际交易中不可能获得的数据来进行决策。常见形式有两种:
形式一:未来函数(Future Leak)。你在 $t$ 时刻做出了交易决策,但代码中不小心使用了 $t + \Delta$ 时刻才发布的数据。例如:在计算当日涨跌幅时使用了收盘价,而你在盘中途序中就已经"看到"了它。
形式二:非同步数据(Asynchronous Data)。不同标的的报价时间戳不对齐,导致某标的在 $t$ 时刻的"当前"价格实际上是 $t - \delta$ 时刻的价格,而另一标的的价格是 $t$ 时刻的——两者在数学运算中被当作同时间的值使用了。
这是最隐蔽的偏差之一,因为代码往往看起来完全正确。
可运行的最小示例
"""
前视偏差演示
场景:计算持仓的日内盈亏(PnL),但收盘价在盘中途序中不可用
正确的做法:使用上一个已知的收盘价(前一交易日收盘价)计算盘中PnL
错误的做法(演示):直接使用当日收盘价作为盘中参考价
"""
import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import warnings
warnings.filterwarnings("ignore")
def simulate_intraday_pnl(ticker: str = "AAPL", days: int = 30) -> dict:
"""
模拟日内 PnL 计算,对比正确方法和含前视偏差的方法。
"""
end_date = datetime.now(ZoneInfo("UTC"))
start_date = end_date - timedelta(days=days)
data = yf.download(ticker, start=start_date, end=end_date, progress=False)
if len(data) < 5:
return {}
df = data.copy()
df.index = pd.to_datetime(df.index).tz_localize(None)
df["PrevClose"] = df["Close"].shift(1)
# ========== 正确方法 ==========
# 盘中 PnL = (当前价 - 前一收盘价) / 前一收盘价
# 这是实际交易中你能做到的:永远只能看到"上一根 K 线"结束时已知的价格
df["CorrectPnL"] = (df["Close"] - df["PrevClose"]) / df["PrevClose"]
# ========== 错误方法(含前视偏差)==========
# 盘中 PnL = (当日收盘价 - 当日开盘价) / 当日开盘价
# 问题:这是一个"上帝视角",你在盘中途序中不可能知道当日收盘价
# 因为当日收盘价要等交易日结束后才确定
df["BiasedPnL"] = (df["Close"] - df["Open"]) / df["Open"]
return {
"正确方法PnL": df["CorrectPnL"].iloc[-5:].sum(),
"含前视偏差PnL": df["BiasedPnL"].iloc[-5:].sum(),
"偏差量": df["BiasedPnL"].iloc[-5:].sum() - df["CorrectPnL"].iloc[-5:].sum(),
}
def simulate_asynchronous_lookahead() -> dict:
"""
模拟非同步数据的前视偏差:
两个标的的价格更新频率不同,某一时刻"当前价"实际上是不同时间戳的值
简单起见,这里用重组采样数据来演示
"""
# 构造两个不同频率的价格序列
np.random.seed(42)
# 标的A:每秒更新(高频)
times_a = pd.date_range("2025-01-01 09:30:00", periods=100, freq="1s", tz=ZoneInfo("US/Eastern"))
prices_a = 100 + np.cumsum(np.random.randn(100) * 0.1)
# 标的B:每 10 秒更新(低频)
times_b = pd.date_range("2025-01-01 09:30:00", periods=10, freq="10s", tz=ZoneInfo("US/Eastern"))
prices_b = 100 + np.cumsum(np.random.randn(10) * 0.1)
df_a = pd.DataFrame({"time": times_a, "price_a": prices_a})
df_b = pd.DataFrame({"time": times_b, "price_b": prices_b})
# 错误的合并:假设两个 DataFrame 的行索引对齐(但它们的时间戳不对齐!)
# 这在数据对齐时尤其容易发生:用 DataFrame 的位置索引而非时间戳做 merge
# 正确的做法是 merge_asof(df_a, df_b, on="time", direction="nearest")
df_merged_wrong = pd.concat([df_a["price_a"].reset_index(drop=True),
df_b["price_b"].reset_index(drop=True)], axis=1)
# 计算价差(错误):用不同时刻的价格计算了一个虚假价差
df_merged_wrong["wrong_spread"] = df_merged_wrong["price_a"] - df_merged_wrong["price_b"]
# 重新采样到相同频率(正确做法)
df_b_resampled = df_b.set_index("time")["price_b"].resample("1s").ffill().reset_index()
df_merged_correct = pd.merge_asof(
df_a.sort_values("time"),
df_b_resampled.sort_values("time"),
on="time", direction="nearest"
)
df_merged_correct["correct_spread"] = df_merged_correct["price_a"] - df_merged_correct["price_b"]
wrong_variance = df_merged_wrong["wrong_spread"].var()
correct_variance = df_merged_correct["correct_spread"].var()
return {
"错误合并价差方差": wrong_variance,
"正确合并价差方差": correct_variance,
"方差高估倍数": wrong_variance / correct_variance if correct_variance > 0 else float('inf'),
}
if __name__ == "__main__":
print("=" * 60)
print("前视偏差演示")
print("=" * 60)
print("\n[场景一] 日内 PnL 计算")
pnl_results = simulate_intraday_pnl("AAPL", days=60)
if pnl_results:
print(f" 正确方法 PnL: {pnl_results['正确方法PnL']:.4%}")
print(f" 含前视偏差 PnL: {pnl_results['含前视偏差PnL']:.4%}")
print(f" 偏差量: {pnl_results['偏差量']:.4%}")
print(" → 前视偏差使 PnL 虚高了 {:.1f}%".format(
abs(pnl_results['偏差量'] / pnl_results['正确方法PnL'] * 100)
if pnl_results['正确方法PnL'] != 0 else 0
))
print("\n[场景二] 非同步数据前视偏差")
async_results = simulate_asynchronous_lookahead()
print(f" 错误合并价差方差: {async_results['错误合并价差方差']:.4f}")
print(f" 正确合并价差方差: {async_results['正确合并价差方差']:.4f}")
print(f" 方差高估倍数: {async_results['方差高估倍数']:.2f}x")
print(" → 非同步数据合并导致价差方差被高估 {:.1f}x".format(async_results['方差高估倍数']))
前视偏差最常见的代码陷阱:
# ❌ 陷阱1:使用当日收盘价在盘中计算当日收益率
daily_return = (close_price - open_price) / open_price # 盘中无法获得 close_price
# ❌ 陷阱2:shift(-1) —— 往前看了一行(未来数据)
indicator = close_price.pct_change().shift(-1) # 绝对禁止
# ❌ 陷阱3:用当前位置 i 计算信号,但在 i-1 时刻已经做出交易决策
if current_price > ma(price[:i], window=20): # price[:i] 包含了当前时刻!
execute_trade()
# ✅ 正确:用前一根 K 线结束时已知的数据
signal = (close_price > close_price.rolling(20).mean()) # 滚动窗口计算时隐含地排除了当前行
# ✅ 正确:明确使用 t-1 的数据计算信号,t 时执行
prev_close = close_price.shift(1)
signal = (close_price > prev_close.rolling(20).mean())
如何检测和缓解
- 代码审查:重点审查所有
.shift(-1)、直接使用close而非close.shift(1)的地方 - Point-in-Time 数据:使用带有时间戳精度的时间点数据(Point-in-Time data),确保每个事件的时间戳准确
- 数据对齐验证:合并多个数据源时,始终使用
merge_asof而非位置索引对齐
三、过拟合(Overfitting)
它是什么
过拟合是所有机器学习问题的核心挑战,在量化回测中表现得尤为残酷。它的定义是:你的策略在历史数据上表现得极其出色,但这种出色来源于策略记住了历史数据的噪声,而非捕捉到了真实的统计规律。
用一个比喻:你不是训练出了一个诊断疾病的模型,而是把考卷的答案全部背下来了。到了真实考试(原题没见过的数据)的时候,你发现自己的"策略"根本不工作。
过拟合在量化中的危害被放大了无数倍,因为金融市场的信噪比极低——真实的 alpha 信号通常淹没在大量随机噪声中。策略很容易找到一些在历史数据的噪声中看起来"有效"的虚假规律,并把它们当作金矿。
过拟合的三个主要来源
来源一:参数过拟合(Parameter Overfitting)。你在 20 日、30 日、40 日、50 日移动平均线中选择了一个最优参数。问题是:这四个参数在历史数据上的差异,可能完全由随机波动解释,根本没有统计显著性。
# 典型的参数过拟合示例
for short_window in range(5, 200, 5):
for long_window in range(20, 400, 5):
sharpe = run_backtest(symbol, short_window, long_window)
if sharpe > best_sharpe:
best_sharpe = sharpe
best_params = (short_window, long_window)
# ↑ 这段代码在寻找最优参数组合,但 1600 个参数组合中,
# 一定会有一些在历史上"碰巧"表现好 —— 但这不代表它们有预测能力
来源二:策略过拟合(Strategy Overfitting)。你在设计策略时,面对同一个数据集,反复修改规则、增加条件、删除条件,直到结果好看为止。每次修改都基于同一个测试集,实质上是在"考试作弊"——你在用考题的答案来调参。
来源三:样本内/样本外分割不透明。很多研究人员的"样本外验证"其实是自欺欺人:他们在整个数据集上做了无数次实验,每次都在样本内数据上调整参数,直到整个数据集都被"污染"了,然后"假装"最后跑的那个年份是样本外。
可运行的最小示例:参数过拟合的蒙特卡洛演示
"""
过拟合演示:参数空间搜索中的虚假相关性
演示在纯随机数据中,通过大量参数组合搜索,总能"找到"一个高收益策略
"""
import numpy as np
import pandas as pd
from itertools import product
np.random.seed(42)
def generate_random_walk(n_days: int = 1000, n_series: int = 100) -> pd.DataFrame:
"""生成 n_series 条独立的随机游走序列(纯噪声,无任何可预测结构)"""
data = {}
for i in range(n_series):
returns = np.random.normal(0, 1, n_days) / 10000 # 极小随机波动
data[f"Asset_{i}"] = (1 + returns).cumprod() * 100
return pd.DataFrame(data)
def strategy_with_params(prices: pd.DataFrame, threshold: float, holding_days: int) -> float:
"""
一个随机策略:在价格超过某阈值时买入
在纯随机数据上,这个策略不应该有任何正收益
"""
daily_return = prices.pct_change()
signal = (prices > prices.rolling(holding_days).mean() * threshold).astype(int)
pnl = (signal.shift(1) * daily_return).mean().mean()
return pnl
def run_parameter_search(n_combinations: int = 500) -> dict:
"""在纯随机数据上搜索 500 个参数组合,观察最佳结果的分布"""
prices = generate_random_walk(n_days=500, n_series=50)
# 生成随机参数组合
thresholds = [0.98 + np.random.uniform(0, 0.04) for _ in range(n_combinations)]
holding_days = [5 + np.random.randint(1, 50) for _ in range(n_combinations)]
results = []
for t, h in zip(thresholds, holding_days):
pnl = strategy_with_params(prices, t, h)
results.append({
"threshold": t,
"holding_days": h,
"pnl": pnl,
"annualized_pnl": pnl * 252
})
return pd.DataFrame(results)
if __name__ == "__main__":
print("=" * 60)
print("过拟合演示:在纯随机数据上寻找\"最优\"策略")
print("=" * 60)
# 在纯随机数据(无任何真实信号)上运行 500 个参数组合
df_results = run_parameter_search(n_combinations=500)
# 按年化 PnL 排序
df_results_sorted = df_results.sort_values("annualized_pnl", ascending=False)
print(f"\n随机参数组合总数: {len(df_results_sorted)}")
print(f"年化 PnL 为正的数量: {(df_results_sorted['annualized_pnl'] > 0).sum()}")
print(f"年化 PnL 为负的数量: {(df_results_sorted['annualized_pnl'] <= 0).sum()}")
print(f"\n最佳参数组合:")
print(f" 年化 PnL: {df_results_sorted.iloc[0]['annualized_pnl']:.4%}")
print(f" 参数: threshold={df_results_sorted.iloc[0]['threshold']:.4f}, "
f"holding_days={df_results_sorted.iloc[0]['holding_days']}")
print(f"\n第 10 佳参数组合:")
print(f" 年化 PnL: {df_results_sorted.iloc[9]['annualized_pnl']:.4%}")
print(f"\n第 50 佳参数组合:")
print(f" 年化 PnL: {df_results_sorted.iloc[49]['annualized_pnl']:.4%}")
print(f"\n第 100 佳参数组合:")
print(f" 年化 PnL: {df_results_sorted.iloc[99]['annualized_pnl']:.4%}")
print("\n" + "-" * 60)
print("关键洞察")
print("-" * 60)
print("→ 在 500 个随机参数组合中,"
f"{(df_results_sorted['annualized_pnl'] > 0).sum()} 个产生了正收益")
print("→ 但这些数据是纯随机游走,不存在任何真实可预测结构")
print("→ 这意味着你在随机噪声中\"找到\"的每一个正收益策略,")
print(" 都是过拟合的产物——它们在真实市场中不会持续")
print("\n→ 参数搜索空间越大,过拟合风险越高")
print("→ 在金融数据的低信噪比环境中,这个问题被严重放大")
如何检测和缓解
| 方法 | 说明 | 适用场景 |
|---|---|---|
| Walk-Forward Analysis | 滚动窗口:样本内优化 → 样本外验证,不断滚动 | 所有策略 |
| 交叉验证(k-Fold) | 将数据分为 k 段,轮流做验证集 | 机器学习模型 |
| 参数敏感性分析 | 绘制参数曲面,观察最优值周围是否"尖锐" | 参数优化类策略 |
| 信息系数(IC)检验 | 测试信号与未来收益的相关性是否显著 | Alpha 因子 |
| 惩罚参数复杂度 | AIC/BIC 准则,在拟合优度和模型复杂度间权衡 | 模型选择 |
| 样本外延迟发布 | 策略设计完成后,先发布到社区,实盘运行一段时间后再发布回测 | 学术研究 |
四、流动性偏差(Liquidity Bias)
它是什么
流动性偏差是指回测假设你在任何时刻都能以盘口价格买入或卖出任意数量的股票,而实际交易中,大额订单会对市场价格产生冲击。
它的危害在于:它以一种优雅的方式毁掉你的策略——不是让你的回测看起来很差,而是让它看起来很好。
具体机制是:回测假设你可以立即以当前价格成交,但实际上:
- 大订单会将你的成交价推向更差的价格方向(流动性冲击)
- 在流动性差的市场中,订单可能根本无法在预期价格成交
- 滑点(Slippage)在低流动性时段急剧扩大
在高频策略和大规模资金中,这个问题几乎是致命的。一个在回测中每年赚 2% 的策略,实盘后每年亏 0.5%,差评。
可运行的最小示例
"""
流动性偏差演示
对比两种成交假设下的策略表现:
1. 无流动性约束(回测默认值):立即成交,无滑点
2. 考虑流动性冲击:订单量越大,成交价越差
"""
import pandas as pd
import numpy as np
np.random.seed(42)
def calculate_slippage(order_size: int, base_price: float, daily_volume: float) -> float:
"""
简化流动性冲击模型
滑点 = order_size / daily_volume * 流动性系数
流动性系数:假设为 0.01(即订单量占日成交量的 1% 时,产生 1% 的价格冲击)
"""
volume_ratio = order_size / daily_volume if daily_volume > 0 else 0
# 非线性冲击:成交量比率越高,冲击越大(市场深度递减)
slippage = 0.01 * (volume_ratio ** 0.6)
return slippage
def backtest_with_liquidity(prices: pd.Series, signals: pd.Series,
order_size: int = 1000) -> dict:
"""
对比有无流动性冲击的策略表现
"""
daily_returns = prices.pct_change()
# 模拟日成交量(随机,但有均值和方差)
avg_volume = 1_000_000 # 平均日成交量
daily_volume = np.random.normal(avg_volume, avg_volume * 0.3, len(prices))
daily_volume = np.abs(daily_volume)
results_no_slip = []
results_with_slip = []
for i in range(len(prices) - 1):
signal = signals.iloc[i]
if signal == 0:
continue
# 无滑点:按当日收盘价成交
ret_no_slip = daily_returns.iloc[i + 1]
# 有滑点:考虑流动性冲击
slippage = calculate_slippage(order_size, prices.iloc[i], daily_volume[i])
if signal > 0: # 做多
ret_with_slip = daily_returns.iloc[i + 1] - slippage
else: # 做空
ret_with_slip = -daily_returns.iloc[i + 1] - slippage
results_no_slip.append(ret_no_slip * signal)
results_with_slip.append(ret_with_slip)
ret_no_slip = np.mean(results_no_slip) * 252
ret_with_slip = np.mean(results_with_slip) * 252
vol_no_slip = np.std(results_no_slip) * np.sqrt(252)
vol_with_slip = np.std(results_with_slip) * np.sqrt(252)
sharpe_no_slip = ret_no_slip / vol_no_slip if vol_no_slip > 0 else 0
sharpe_with_slip = ret_with_slip / vol_with_slip if vol_with_slip > 0 else 0
return {
"无滑点": {"年化收益": ret_no_slip, "年化波动": vol_no_slip, "夏普比率": sharpe_no_slip},
"含滑点": {"年化收益": ret_with_slip, "年化波动": vol_with_slip, "夏普比率": sharpe_with_slip},
}
def generate_test_data():
"""生成测试价格数据和交易信号"""
n_days = 500
dates = pd.date_range("2024-01-01", periods=n_days, freq="D")
prices = 100 + np.cumsum(np.random.randn(n_days) * 2)
prices = pd.Series(prices, index=dates)
# 简单的移动平均交叉信号
ma_short = prices.rolling(10).mean()
ma_long = prices.rolling(30).mean()
signals = pd.Series(0, index=dates)
signals[ma_short > ma_long] = 1
signals[ma_short < ma_long] = -1
return prices, signals
if __name__ == "__main__":
print("=" * 60)
print("流动性偏差演示")
print("=" * 60)
prices, signals = generate_test_data()
results = backtest_with_liquidity(prices, signals, order_size=10000)
print(f"\n{'假设':<12} {'年化收益':>12} {'年化波动':>12} {'夏普比率':>12}")
print("-" * 52)
for scenario, metrics in results.items():
print(f"{scenario:<12} {metrics['年化收益']:>11.2%} "
f"{metrics['年化波动']:>11.2%} {metrics['夏普比率']:>12.2f}")
sharpe_drop = results["无滑点"]["夏普比率"] - results["含滑点"]["夏普比率"]
print(f"\n→ 滑点导致夏普比率下降: {sharpe_drop:.2f}")
print("→ 订单量越大 / 市场成交量越低,偏差越显著")
print("→ 在市值小于 5 亿美元的股票上,这个问题可能使夏普比率减半")
如何缓解
- 始终在回测中加入流动性冲击模型:如上述代码所示,每个订单都要计算滑点成本
- 设置最小市值过滤:只交易日均成交量大于某个阈值的标的
- 订单拆分:大单拆成小单,使用 TWAP/VWAP 算法执行
- 使用更细粒度的数据:如果 TickDB 支持,用逐笔成交数据估算真实的流动性冲击
五、交易成本偏差(Transaction Cost Bias)
它是什么
交易成本偏差是最容易被"知道但忽略"的偏差。几乎每个量化从业者都知道要扣除佣金和滑点,但真正的问题在于:你对交易成本的假设可能与现实相差一个数量级。
常见的低估包括:
佣金和费用:很多回测系统使用默认的佣金率(如 0.1%),但实际成本取决于券商、资产类别、交易频率。高频策略的佣金可能吃掉大部分利润。
隐性成本:买卖价差(bid-ask spread)是最大的隐性成本之一。每次买入时,你支付卖方报价(ask);每次卖出时,你接受买方报价(bid)。对于低市值股票和期权,这个价差可能轻易吃掉 0.5% 的收益。
市场冲击:见"流动性偏差"一节。
融资融券成本:杠杆策略中,融资利率(通常 3%-8%/年)会显著影响最终收益。
可运行的最小示例
"""
交易成本偏差演示
展示不同成本假设对策略年化收益的影响
"""
import pandas as pd
import numpy as np
np.random.seed(42)
def simulate_strategy_trades(n_days: int = 252) -> pd.DataFrame:
"""模拟一个策略的每日交易信号和收益"""
daily_return = np.random.normal(0.0005, 0.02, n_days) # 日均 alpha = 0.05%
signal = np.sign(np.random.randn(n_days)) # 每日随机交易信号
df = pd.DataFrame({
"daily_return": daily_return,
"signal": signal,
"shares_traded": np.abs(signal) # 每日换手
})
return df
def apply_transaction_costs(df: pd.DataFrame, commission_rate: float,
spread_cost_bps: float, name: str) -> float:
"""
应用交易成本,计算扣费后的净年化收益
"""
# 每次交易的成本 = 佣金 + 买卖价差
trade_cost = df["shares_traded"] * (commission_rate + spread_cost_bps / 10000)
gross_return = (df["signal"].shift(1) * df["daily_return"]).sum()
net_return = gross_return - trade_cost.sum()
annual_return = net_return
return annual_return
def run_cost_sensitivity() -> pd.DataFrame:
"""测试不同交易成本假设下的策略表现"""
df = simulate_strategy_trades(252)
scenarios = [
{"commission": 0.0000, "spread": 0, "label": "无成本(理想)"},
{"commission": 0.0001, "spread": 5, "label": "低估成本(0.01% 佣金 + 0.5bp 价差)"},
{"commission": 0.0003, "spread": 15, "label": "中等成本(0.03% 佣金 + 1.5bp 价差)"},
{"commission": 0.0010, "spread": 30, "label": "真实成本(0.10% 佣金 + 3bp 价差,高频交易)"},
]
results = []
for scenario in scenarios:
net_return = apply_transaction_costs(
df, scenario["commission"], scenario["spread"], scenario["label"]
)
results.append({
"场景": scenario["label"],
"年化收益": net_return,
"佣金率": scenario["commission"],
"价差(bps)": scenario["spread"],
})
return pd.DataFrame(results)
if __name__ == "__main__":
print("=" * 60)
print("交易成本偏差演示")
print("=" * 60)
results = run_cost_sensitivity()
print(f"\n{'场景':<40} {'年化收益':>10} {'佣金率':>10} {'价差(bps)':>10}")
print("-" * 75)
for _, row in results.iterrows():
print(f"{row['场景']:<40} {row['年化收益']:>9.2%} "
f"{row['佣金率']:>9.4%} {row['价差(bps)']:>10.0f}")
print("\n" + "-" * 75)
print("关键洞察")
print("-" * 75)
print("→ 策略在\"无成本\"假设下看起来是正收益的")
print("→ 真实成本下,原本的正收益可能变成负收益")
print("→ 交易越频繁(年均换手率越高),成本的影响越大")
print("→ 成本假设误差 10 倍,策略结论可能完全反转")
print("\n→ 经验法则:始终使用保守的交易成本假设(高估成本)进行回测,")
print(" 如果策略仍然有效,它才更值得信任")
如何缓解
- 使用保守成本假设:至少使用券商实际收费的 1.5 倍作为回测成本基准
- 纳入买卖价差:对于低市值标的,使用
(ask - bid) / ((ask + bid) / 2)计算每次交易的实际价差成本 - 披露所有成本假设:在回测报告中明确说明佣金率、滑点、市场冲击模型
六、心理学偏差(Psychological Bias)
它是什么
这一条严格来说不是"回测的偏差",而是回测无法捕捉的人类行为偏差——但它的影响同样致命。
量化策略的回测假设:交易系统会机械地、无情绪地执行每一个信号。但在真实交易中,人的心理会介入:
- 处置效应(Disposition Effect):盈利时急于卖出("落袋为安"),亏损时死扛("总有一天会回来")。这会导致策略偏离预定的退出规则,显著降低盈利。
- 损失厌恶(Loss Aversion):亏损带来的痛苦是同等盈利带来快乐的 2-2.5 倍。经历几次大的回撤后,交易者会开始怀疑策略的有效性,并在最坏的时候停止执行。
- 近期偏差(Recency Bias):近期的市场表现被赋予过高的权重。如果策略最近三个月表现糟糕,交易者会认为策略已经"失效",即使历史回测表明它是长期有效的。
- 自信偏差(Overconfidence Bias):回测结果给了交易者过度的信心,导致他们承担了超出计划的风险敞口——然后在几次亏损后从过度自信滑向完全放弃。
处置效应的量化示例
"""
心理学偏差演示:处置效应量化
展示"盈利即止盈,亏损不止损"的行为模式如何侵蚀策略收益
"""
import numpy as np
import pandas as pd
np.random.seed(42)
def simulate_positions(n_trades: int = 100) -> pd.DataFrame:
"""模拟 100 次等概率的盈利和亏损交易"""
np.random.seed(42)
trades = []
for i in range(n_trades):
# 50% 概率盈利,50% 概率亏损
is_profit = np.random.rand() > 0.5
pnl_pct = np.random.uniform(0.05, 0.15) if is_profit else -np.random.uniform(0.05, 0.15)
trades.append({"trade_id": i, "is_profit": is_profit, "pnl_pct": pnl_pct})
return pd.DataFrame(trades)
def simulate_disposition_effect(trades: pd.DataFrame) -> dict:
"""
模拟处置效应:
- 盈利交易被提前止盈(平均持有时间缩短 30%)
- 亏损交易被延迟止损(平均持有时间延长 50%)
真实交易中,处置效应导致赢的交易赚得少,亏的交易亏得多
"""
trades = trades.copy()
# 处置效应调整:盈利交易被过早结束,亏损交易被拖长
# 这改变了收益分布,而非简单加总
adjusted_pnl = []
for _, trade in trades.iterrows():
pnl = trade["pnl_pct"]
if trade["is_profit"]:
# 处置效应:盈利交易提前平仓,实际收益降低
# 假设提前平仓导致收益降低 30%
adjusted_pnl.append(pnl * (1 - 0.3 * np.random.rand()))
else:
# 处置效应:亏损交易不止损,亏损扩大
# 假设不止损导致亏损扩大 20%
adjusted_pnl.append(pnl * (1 + 0.2 * np.random.rand()))
trades["adjusted_pnl"] = adjusted_pnl
gross_total = trades["pnl_pct"].sum()
adjusted_total = trades["adjusted_pnl"].sum()
return {
"无处置效应总收益": gross_total,
"有处置效应总收益": adjusted_total,
"收益损失": gross_total - adjusted_total,
"损失比例": (gross_total - adjusted_total) / abs(gross_total) if gross_total != 0 else 0,
}
def simulate_confidence_cycle(n_weeks: int = 52) -> dict:
"""
模拟近期偏差(Recency Bias)对策略执行的影响
假设:策略在近 4 周表现差时,执行概率下降 30%
"""
np.random.seed(42)
# 模拟 52 周的策略表现(周收益率)
weekly_returns = np.random.normal(0.005, 0.02, n_weeks)
execution_rates = []
for i in range(n_weeks):
if i < 4:
execution_rate = 1.0 # 早期完全信任策略
else:
# 近 4 周平均收益
recent_avg = np.mean(weekly_returns[max(0, i-4):i])
# 近期表现差时,执行意愿下降
if recent_avg < -0.01: # 近期亏损超过 1%
execution_rate = max(0.3, 1.0 + recent_avg * 5) # 最多下降 70%
else:
execution_rate = 1.0
execution_rates.append(execution_rate)
# 策略有效时的"理论"收益 vs 实际执行后的收益
theoretical_return = np.sum(weekly_returns)
actual_return = np.sum([r * e for r, e in zip(weekly_returns, execution_rates)])
return {
"理论总收益": theoretical_return,
"实际总收益(受近期偏差影响)": actual_return,
"收益损失比例": (theoretical_return - actual_return) / abs(theoretical_return)
if theoretical_return != 0 else 0,
}
if __name__ == "__main__":
print("=" * 60)
print("心理学偏差演示")
print("=" * 60)
# 处置效应
trades = simulate_positions(100)
disp_results = simulate_disposition_effect(trades)
print("\n[处置效应]")
print(f" 无处置效应总收益: {disp_results['无处置效应总收益']:.2%}")
print(f" 有处置效应总收益: {disp_results['有处置效应总收益']:.2%}")
print(f" 收益损失: {disp_results['收益损失']:.2%} ({disp_results['损失比例']:.1%})")
print(" → 处置效应使盈利交易提前结束、亏损交易拖长,导致策略收益显著缩水")
# 近期偏差
recency_results = simulate_confidence_cycle(52)
print("\n[近期偏差]")
print(f" 理论总收益: {recency_results['理论总收益']:.2%}")
print(f" 实际总收益: {recency_results['实际总收益(受近期偏差影响)']:.2%}")
print(f" 收益损失: {recency_results['收益损失比例']:.1%}")
print(" → 交易者在近期亏损时倾向于怀疑策略、减少执行,导致错失后续反弹")
print("\n" + "-" * 60)
print("关键洞察")
print("-" * 60)
print("→ 回测永远无法模拟人类的心理波动")
print("→ 处置效应和近期偏差可能导致 15%~40% 的策略收益损失")
print("→ 解决方案:自动化执行(消除人为干预)+ 策略预警系统(减少认知偏差)")
如何缓解
- 自动化执行:使用算法交易系统,消除人为干预。这是消除心理学偏差最根本的方法。
- 策略预警系统:当策略处于回撤期时,有明确的"不干预"规则,避免基于情绪的决策。
- 预期管理:在实盘前充分了解策略在历史上经历的最大回撤和回撤持续时间,做好心理准备。
- 分账户隔离:用小账户验证策略,等待策略度过"信任建立期"后再加大投入。
七、数据挖掘偏差(Data Mining Bias)
它是什么
数据挖掘偏差是过拟合的远房表亲,但它的根源不同:过拟合是在同一个数据集上反复调参,而数据挖掘偏差是在同一个数据集上反复试错不同的策略,直到"找到"一个看起来有效的。
换个说法:你在一个封闭房间里,对着同一个数据集做 1000 次实验,每次失败后就换一个新策略。1000 次之后,你总能找到一些"有效"的策略。但这不代表市场真的存在这种模式,只代表统计上必然出现的随机巧合。
这个偏差有一个正式的名称:多重假设检验问题(Multiple Testing Problem)。
它的数学本质
在一个 5% 显著性水平下做假设检验,理论上只有 5% 的检验会得到假阳性(错误地拒绝零假设)。
但如果你做 1000 次假设检验呢?
假阳性数量期望值 = 1000 × 0.05 = 50
你会有 50 个"显著"的策略——而它们全部是随机的产物,与真实市场结构无关。
在量化研究中,这个问题更加严重。因为:
- 你可以尝试的参数组合数量是天文数字(见过拟合一节的 1600 个参数组合示例)
- 策略设计空间几乎是无限的(进入条件、退出条件、过滤条件、加仓规则……)
- 金融数据的信噪比极低,随机波动本来就很大
可运行的最小示例
"""
数据挖掘偏差演示
在纯随机数据上测试 200 个不同的技术指标组合
展示"多重假设检验"如何产生大量虚假的"有效"策略
"""
import numpy as np
import pandas as pd
from scipy import stats
np.random.seed(42)
def generate_random_market_data(n_days: int = 1000, n_series: int = 100) -> pd.DataFrame:
"""
生成 n_series 条独立的随机游走序列
没有任何真实的可预测结构
"""
data = {}
for i in range(n_series):
returns = np.random.normal(0, 1, n_days) / 10000
data[f"Asset_{i}"] = (1 + returns).cumprod() * 100
return pd.DataFrame(data)
def generate_indicators(prices: pd.DataFrame) -> list:
"""
生成 200 个"技术指标"(实际上是随机参数组合)
在真实场景中,这相当于用遗传编程或穷举搜索发现了 200 个"策略"
"""
np.random.seed(42)
indicators = []
for _ in range(200):
asset_idx = np.random.randint(0, len(prices.columns))
asset = prices.columns[asset_idx]
# 随机参数组合:移动平均窗口
window = np.random.randint(5, 100)
threshold = np.random.uniform(0.98, 1.02)
# 随机生成的"指标"
ma = prices[asset].rolling(window).mean()
signal = (prices[asset] > ma * threshold).astype(int)
indicators.append({
"name": f"{asset}_MA{window}_T{threshold:.2f}",
"signal": signal,
"asset": asset,
})
return indicators
def test_indicator(indicator: dict, prices: pd.DataFrame) -> dict:
"""
测试单个指标在随机数据上的表现
使用 t 统计量评估信号与收益的相关性是否显著
"""
asset = indicator["asset"]
signal = indicator["signal"]
returns = prices[asset].pct_change().dropna()
signal_shifted = signal.shift(1).reindex(returns.index).fillna(0)
# 盈利天数 vs 亏损天数
signal_returns = signal_shifted * returns
profitable_days = (signal_returns > 0).sum()
total_signal_days = (signal_returns != 0).sum()
if total_signal_days < 10:
return {"t_stat": 0, "p_value": 1, "profitable_ratio": 0.5}
# t 检验:信号条件下的平均收益是否显著不为零
signal_returns_clean = signal_returns[signal_returns != 0]
if len(signal_returns_clean) < 2:
return {"t_stat": 0, "p_value": 1, "profitable_ratio": 0.5}
t_stat, p_value = stats.ttest_1samp(signal_returns_clean, 0)
return {
"t_stat": t_stat,
"p_value": p_value,
"profitable_ratio": profitable_days / total_signal_days if total_signal_days > 0 else 0.5,
}
if __name__ == "__main__":
print("=" * 60)
print("数据挖掘偏差演示:多重假设检验")
print("=" * 60)
# 在纯随机数据上测试 200 个"策略"
prices = generate_random_market_data(n_days=1000, n_series=50)
indicators = generate_indicators(prices)
results = []
for ind in indicators:
test_result = test_indicator(ind, prices)
results.append({
"indicator": ind["name"],
"t_stat": test_result["t_stat"],
"p_value": test_result["p_value"],
"significant_5pct": test_result["p_value"] < 0.05,
})
df_results = pd.DataFrame(results)
df_results_sorted = df_results.sort_values("p_value")
n_significant = df_results["significant_5pct"].sum()
n_total = len(df_results)
print(f"\n在 {n_total} 个\"策略\"中:")
print(f" 统计显著(p < 0.05): {n_significant} 个 ({n_significant/n_total:.1%})")
print(f" 预期假阳性(5%): {int(n_total * 0.05)} 个")
print(f"\n表现最好的\"策略\"(p 值最小):")
for i in range(min(5, len(df_results_sorted))):
row = df_results_sorted.iloc[i]
print(f" [{row['indicator']}] t={row['t_stat']:.3f}, p={row['p_value']:.4f}")
print("\n" + "-" * 60)
print("关键洞察")
print("-" * 60)
print(f"→ 在纯随机数据上,{n_total} 个策略中有 {n_significant} 个在 5% 水平下\"显著\"")
print(f"→ 如果没有数据挖掘偏差,这个数字应该接近 {int(n_total * 0.05)}")
print("→ 真实世界中,量化研究员可能测试了数千个策略变体,")
print(" 最终发表的\"有效\"策略中,有相当一部分是多重检验的偶然产物")
print("\n→ 应对方法:Bonferroni 校正、False Discovery Rate(FDR)控制、")
print(" 或者只在完全独立的数据集上做最终验证")
如何缓解
| 方法 | 说明 | 复杂度 |
|---|---|---|
| Bonferroni 校正 | 将显著性阈值从 α 调整为 α/n(n = 检验次数) | 低 |
| Benjamini-Hochberg FDR | 控制错误发现率,而非单个检验的假阳性率 | 中 |
| 样本外验证 | 最终验证在完全独立于探索过程的数据集上 | 中 |
| 理论驱动优先 | 从经济学/金融学原理出发设计策略,而非数据挖掘 | 高 |
| 滚动窗口验证 | 始终使用最近的滚动窗口做最终验证 | 低 |
八、它们叠加在一起:偏差的系统性影响
单独看每一种偏差,似乎都可以接受。但它们的可怕之处在于叠加效应。
一个典型的"优化后"的量化策略回测,可能同时包含:
幸存者偏差(+2%年化)→ 前视偏差(+1.5%年化)→ 过拟合(+3%年化)
→ 流动性偏差(-1%年化)→ 交易成本偏差(-1.5%年化)
→ 心理学偏差(-2%年化)→ 数据挖掘偏差(+2%年化)
净效果:回测报告上的年化收益比真实表现高出约 4%,而策略的实际夏普比率可能从回测的 2.1 降到了实盘的 0.7。
这就是为什么很多量化研究员感慨:"我的策略回测看起来很好,实盘却一直亏损。"
七种偏差的叠加效应矩阵
| 偏差 | 方向 | 典型量级 | 可检测性 |
|---|---|---|---|
| 幸存者偏差 | 高估收益 | 1.5%–3%/年 | 中(需要退市数据) |
| 前视偏差 | 高估收益 | 0.5%–2%/年 | 低(代码审查为主) |
| 过拟合 | 高估收益 | 1%–10%+ | 低(需要独立验证) |
| 流动性偏差 | 低估收益 | 0.5%–3%/年 | 高(模型成熟) |
| 交易成本偏差 | 低估收益 | 0.5%–5%/年 | 高(数据易得) |
| 心理学偏差 | 低估收益 | 15%–40%(收益损失比例) | 高(行为可量化) |
| 数据挖掘偏差 | 高估收益 | 难以量化 | 极低 |
九、写在最后:如何与回测相处
回测不是敌人。它是你在没有冒险的前提下理解市场的最强大工具。但你必须清醒地认识到它的边界:
回测告诉你的是:如果过去的市场结构在未来重演,这个策略可能有效。
它从来不保证未来。
以下几点思考,与所有量化从业者共勉:
回测是必要条件,不是充分条件。回测差强人意的策略,实盘大概率更差。但回测漂亮的策略,没有任何证据表明它会持续盈利。
始终假设你的策略已经被"污染"了。在你做过的所有实验中,最好看的那一次回测,很可能包含了幸存者偏差+过拟合+数据挖掘偏差的共同作用。不要太当真。
用保守的假设做回测。成本假设高估、流动性假设保守、参数空间搜索后做惩罚系数调整。如果你用的保守假设下策略依然有效,它才值得信任。
小规模实盘验证是唯一的真理。任何回测结论,最终都需要通过小账户的实盘运行来验证。数据会说谎,但账户净值不会。
建立"策略预警系统"。就像 TickDB 支持的实时告警功能一样,你也应该为你的策略建立实时监控:回撤超过阈值、胜率下降超过某个标准差、执行率跌破某个水平——这些都应该触发告警,而不是靠人眼盯着屏幕去感受。
免责声明:本文不构成任何投资建议。回测结果基于历史数据模拟,不代表未来收益。市场有风险,投资需谨慎。
"All models are wrong, but some are useful." — George Box