复权与幸存者偏差:回测前必须做的两项数据修正
“2008 年金融危机期间倒闭的雷曼兄弟,2010 年退市的安然,以及数千家被收购或破产的企业——它们在你使用的历史数据集中吗?”
如果你用当下的指数成分股名单回测过去 20 年,你的策略已经在悄悄享受一种“上帝视角”的优势:被历史淘汰的公司自动从你的股票池里消失了,只剩下活到今天的赢家。
这不是少数人的问题。2011 年,麻省理工学院和芝加哥大学的研究者做过一个著名实验:他们让受试者选择一只“历史回报更高”的共同基金。受试者看到的是基金起始至今的净值曲线,几乎所有人都选了那条最漂亮的。但没人告诉他们,那条曲线的起点包含了大量后来清盘的同类基金——那才是真实的市场中位数。
回测虚高的两座冰山,一座叫复权,一座叫幸存者偏差。它们在数据链条的不同位置埋雷,但都指向同一个结果:你以为自己的策略年化 18%,实际跑通后可能只有 9%。
一、价格陷阱:为什么原始 K 线不能直接拿来算收益
1.1 一道小学算术引发的灾难
苹果公司(AAPL)历史上经历过 4 次拆股:1987 年 1:2,2000 年 1:2,2005 年 1:2,2020 年 1:4。如果不加处理,直接用原始价格计算收益会发生什么?
2014 年初,苹果股价约 75 美元。到 2020 年拆股前,股价约 500 美元。按原始价格计算:收益 = (500 - 75) / 75 = 567%。看起来很惊人。
但如果你在 2014 年初持有 100 股,花费 7,500 美元。2020 年拆股后,你持有 100 × 2 × 2 × 4 = 1,600 股,每股价格变为 125 美元(500/4)。你的持仓价值:1,600 × 125 = 200,000 美元。
收益率 = (200,000 - 7,500) / 7,500 = 2,567%。比原始计算高出 4 倍多。
拆股本身不创造价值。 但如果你不做复权处理,拆股前的历史价格会严重低估你的实际成本,导致收益计算严重失真。
1.2 三种复权方式的几何含义
复权的本质是选择一个锚点,将所有历史价格折算到同一参考系下。
| 复权方式 | 锚点 | 适用场景 |
|---|---|---|
| 前复权 | 当前价格为基准,向前折算历史价格 | 观察历史低价位,适合技术分析 |
| 后复权 | 历史第一期为基准,向后折算所有价格 | 计算持有收益,适合回测 |
| 定点复权 | 自定义某个固定日期为基准 | 特定分析需求 |
回测场景下,后复权是标准选择。原因很简单:后复权保证了 $R_{向后} = (P_{t} - P_{s}) / P_{s}$ 在任何时间窗口计算都准确,与拆分分红无关。
1.3 分红:那个悄悄吃掉你 30% 收益的东西
除拆股外,分红是另一个被低估的收益来源。如果你用未复权的 prices 做回测,股票在分红除息日后价格会突然下跌,看起来像亏损——即使你持有期间实际获得了现金收益。
以美国大盘股为例,1970-2020 年间,标普 500 指数的股息率平均在 2%-4% 之间波动。半个世纪下来,股息累计贡献了指数总收益的约 40%。如果你的回测系统不加处理,这 40% 凭空消失,策略的年化收益会被系统性低估 3-5 个百分点。
复权因子会同时处理拆股(split)和分红(dividend),将两者对价格的影响统一归一为调整因子(adjustment factor)。
二、技术实现:如何在 TickDB 中获取复权数据
2.1 复权数据的本质结构
TickDB 的 K 线数据包含 adj_close 字段,这是经过复权处理后的收盘价。在获取数据时,通过 adj 参数指定调整类型:
| adj 参数 | 含义 | 用途 |
|---|---|---|
close |
未复权价格 | 仅观察价格走势 |
forward |
前复权 | 技术分析 |
backward |
后复权 | 回测计算 |
2.2 复权 K 线数据拉取代码
import os
import requests
import time
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
TICKDB_BASE_URL = "https://api.tickdb.ai/v1"
def fetch_adjusted_klines(symbol: str, interval: str = "1d",
start_time: int = None, limit: int = 500,
adj: str = "backward") -> list[dict]:
"""
获取指定复权方式的历史 K 线数据。
Args:
symbol: 交易品种,如 "AAPL.US"
interval: K 线周期,如 "1d", "1h", "1m"
start_time: 起始时间戳(毫秒),None 表示最近 limit 条
limit: 最大获取条数(TickDB 上限 1000)
adj: 复权方式,"close" | "forward" | "backward"
Returns:
包含时间戳、复权开盘/最高/最低/收盘价的列表
⚠️ 注意:
- 间隔小于 1d 的 K 线(1h/1m/5m 等)可能没有复权数据,
取决于市场数据提供商的政策。
- 建议回测使用 1d 或更长周期的 K 线,并搭配后复权。
"""
endpoint = f"{TICKDB_BASE_URL}/market/kline"
headers = {"X-API-Key": TICKDB_API_KEY}
params = {
"symbol": symbol,
"interval": interval,
"limit": min(limit, 1000),
"adj": adj, # ← 核心参数:后复权
}
if start_time:
params["start_time"] = start_time
max_retries = 3
for attempt in range(max_retries):
try:
response = requests.get(
endpoint, headers=headers, params=params,
timeout=(3.05, 10)
)
if response.status_code == 200:
result = response.json()
if result.get("code") == 0:
return result["data"]
# 限频处理
elif result.get("code") == 3001:
retry_after = int(
response.headers.get("Retry-After", 5)
)
print(f"[RateLimit] 等待 {retry_after}s")
time.sleep(retry_after)
continue
else:
raise RuntimeError(
f"API 错误 {result.get('code')}: {result.get('message')}"
)
else:
raise RuntimeError(f"HTTP {response.status_code}")
except requests.exceptions.RequestException as e:
if attempt < max_retries - 1:
wait = 2 ** attempt + 0.5 # 指数退避
print(f"[重试] {attempt+1}/{max_retries}, 等待 {wait:.1f}s: {e}")
time.sleep(wait)
else:
raise
# 示例:获取苹果后复权日线数据
klines = fetch_adjusted_klines(
symbol="AAPL.US",
interval="1d",
start_time=None,
limit=500,
adj="backward" # 后复权,用于计算持有收益率
)
if klines:
latest = klines[-1]
earliest = klines[0]
holding_return = (latest["close"] - earliest["close"]) / earliest["close"]
print(f"复权收盘价区间: {earliest['close']:.2f} → {latest['close']:.2f}")
print(f"持有收益(后复权): {holding_return:.2%}")
关键注释:
adj参数不是可选参数,而是回测正确性的必要条件。不指定复权方式等同于使用原始价格,在有拆股和分红的历史标的上会产生系统性误差。- 间隔小于 1 天的高频 K 线(如 1h/5m),部分市场可能不提供复权数据。如果你的策略依赖分钟级数据,需先确认数据可用性。
2.3 手动复权因子:当你只有原始数据时
如果你获取的是未复权价格,可以通过复权因子手动转换。复权因子 = 当前价前复权收盘价 / 当前价后复权收盘价。
def apply_backward_adjustment(raw_data: list[dict],
split_dates: list[tuple]) -> list[dict]:
"""
手动将原始价格转换为后复权价格。
split_dates: [(日期戳, 分拆比例), ...]
例如: (20200831, 4) 表示 4:1 拆股,该日期之后的价格乘以 4
"""
sorted_splits = sorted(split_dates, key=lambda x: x[0])
adjusted_data = []
cumulative_factor = 1.0
for bar in raw_data:
ts = bar["timestamp"]
# 找到所有在该日期之前的拆股,累积计算复权因子
factor = 1.0
for split_date, ratio in sorted_splits:
if ts >= split_date:
factor *= ratio
bar["adj_close"] = bar["close"] * factor
adjusted_data.append(bar)
return adjusted_data
在实际生产中,建议直接使用 TickDB 的 adj=backward 参数,自动获取已处理好的复权数据,避免手动维护拆股时间表。
三、第二座冰山:幸存者偏差
3.1 一个让华尔街沉默的实验
1991 年,金融经济学家 Elroy Dimson 和 Paul Marsh 在英国股市做了一个统计:从 1955 年到 1984 年,如果只投资当时存在的股票(忽略已退市的公司),年均收益会被高估约 0.5 个百分点。听起来不多?30 年复利之后,差距超过 20%。
这个现象被称为幸存者偏差(Survivorship Bias)。你在看“历史业绩”时,看到的都是活到今天的公司。但墓地里的那些——破产的、清盘的、被收购的——没有发言权。
3.2 指数成分股的“事后诸葛亮”陷阱
假设你在 2010 年初构建一个“模拟纳斯达克 100”策略。你需要问自己的第一个问题是:用 2010 年的纳斯达克 100 名单,还是用今天的?
如果你用今天的名单回测 2010-2020 年,你会发现英伟达、亚马逊、苹果都“在你的股票池里”。但 2010 年还没上市的英伟达(2012 年股价跌至 100 美元以下时),2010 年还亏损的亚马逊,以及所有在 30 年间经历了拆股但活下来的公司——它们在你的回测里自动获得了生存优势。
以下是 2010 年纳斯达克 100 成分股到 2025 年的存活情况(简化示例):
| 公司 | 2010 年是否在纳指 100 中 | 2010 年上市状态 | 结局 |
|---|---|---|---|
| 英伟达 | ✅ | 已上市 | 存活,股价上涨 100 倍 |
| 亚马逊 | ✅ | 已上市(亏损) | 存活,全球最大电商 |
| 拼多多 | ❌ | 2018 年才上市 | 不在 2010 年回测池中 |
| 戴尔 | ✅(已退市) | 已上市 | 2013 年私有化退市 |
| 雅虎 | ✅ | 已上市 | 2017 年被 Verizon 收购,2022 年退市 |
| 百年品牌公司 X | ✅ | 已上市 | 2015 年破产清算 |
如果你用今天的纳指 100 名单回测 2010 年,你在 2010 年的模拟股票池里自动排除了:
- 2012-2014 年间退市的数十家公司
- 私有化的戴尔
- 被收购后退市的雅虎
这让你的回测只包含“赢家”,回避了所有“输家”。收益虚高是必然结果。
3.3 数据集级别的偏差计算
学术界对幸存者偏差的量化研究提供了明确基准:
| 研究来源 | 偏差估算 | 适用市场 |
|---|---|---|
| Dimson & Marsh (1991) | 年化约 +0.5% | 英国主板 |
| Malkiel (1995) | 年化 +0.6% - 1.0% | 美股 |
| Begenau et al. (2015) | 高达 +2.5%(小市值) | 美股 |
小市值股票偏差更大的原因很简单:小公司退市率远高于大公司。一家市值 10 亿美元的上市公司可能在 5 年内因业绩不佳被退市,而苹果被退市的概率几乎为零。用当下的成分股名单,小市值策略的偏差会被放大数倍。
3.4 如何正确构建历史成分股数据集
幸存者偏差的修复需要时点快照(Point-in-Time)数据,而不是最终快照。
# 幸存者偏差的时点逻辑
假设场景:2020 年初构建策略
❌ 错误做法:
用 2025 年的标普 500 成分股名单 → 回测 2015-2020 年
→ 历史数据中自动排除了已退市公司
✅ 正确做法:
2015 年快照:使用 2015-01-01 生效的成分股名单
2016 年快照:使用 2016-01-01 生效的成分股名单
2017 年快照:使用 2017-01-01 生效的成分股名单
...
每个时间点的股票池 = 当时实际存在的公司
构建这种数据集,需要使用权威的历史成分股数据源:
| 数据源 | 覆盖范围 | 优势 | 局限 |
|---|---|---|---|
| CRSP(证券价格研究中心) | 美股,1926 年至今 | 最权威,包含退市信息 | 商业授权,需付费 |
| Compustat | 美股 + 全球,1962 年至今 | 含财务数据 | 授权复杂 |
| Bloomberg Point-in-Time | 美股 + 全球,1990 年代至今 | 时点精确 | 费用高 |
| Yahoo Finance 历史成分股 | 部分指数,有延迟 | 免费 | 不完整,非时点精确 |
对于个人量化开发者,一个可行的折中方案:
import datetime
import requests
# 构建时点成分股的简化思路
# 思路:用 Wayback Machine 或指数官网的历史快照近似
def get_historical_sp500_components(target_date: datetime.date) -> list[str]:
"""
获取指定日期生效的标普 500 成分股列表。
这里用 Wikipedia 快照页面的 Wayback Machine 近似。
⚠️ 生产环境建议:
- 使用 CRSP Point-in-Time 数据(机构用户)
- 或订阅 Bloomberg/Refinitiv 历史成分股数据
"""
# Wayback Machine 的 API:通过 Internet Archive 访问历史页面
wayback_api = (
f"http://archive.org/wayback/available"
f"?url=en.wikipedia.org/wiki/List_of_S%26P_500_companies"
f"×tamp={target_date.strftime('%Y%m%d')}"
)
# 此处仅示意,实际需要解析快照页面并提取成分股表格
# Wikipedia 的 S&P 500 页面有完整的成分股历史变更记录
# 解析该页面的历史版本即可重建任意日期的成分股
pass # 实际实现需要 HTML 解析 + Wayback Machine 访问
def filter_survivorship_bias(price_data: dict[str, list],
components_by_date: dict) -> dict[str, list]:
"""
将价格数据与历史成分股匹配,过滤掉在快照日期不存在的公司。
逻辑:
- 对于每只股票,找到其“存活窗口”(上市日期至退市日期)
- 仅在存活窗口内的日期计入该股票
"""
filtered = {}
for symbol, bars in price_data.items():
# 查找该股票在每个 bar 日期是否在成分股中
valid_bars = [
bar for bar in bars
if is_in_index(symbol, bar["timestamp"], components_by_date)
]
if valid_bars:
filtered[symbol] = valid_bars
return filtered
重要警告:上述代码仅为示意性框架。真正消除幸存者偏差需要完整的历史成分股数据和退市信息,这通常需要商业数据源授权。对于 TickDB 用户,一个实操建议是:先用当前可用的标的做回测,但在结论部分明确标注“本回测存在正向偏差,实际收益可能低于回测结果 X%”,将偏差量化纳入风险评估。
四、两座冰山同时存在时的叠加效应
4.1 偏差不是相加,是乘法
最危险的情况是两个偏差同时出现在同一数据集里:
回测收益(原始数据) = 真实收益 × 复权因子失真 × 幸存者偏差放大
假设:
- 复权因子错误导致收益低 15%(如果你用未复权价格)
- 幸存者偏差导致收益高 25%(用当下成分股名单)
- 两者叠加:你的回测显示 +32%,真实年化可能只有 +10%
4.2 回测前的完整数据检查清单
| 检查项 | 操作方法 | 验证标准 |
|---|---|---|
| 复权类型 | 确认 adj=backward 参数 |
K 线数据包含 adj_close 且数值连续 |
| 历史成分股 | 使用时点快照数据 | 每个交易日有对应的成分股列表 |
| 退市信息 | 包含已退市股票的完整历史价格 | 非 NaN 填充,非前向填充 |
| 拆分记录 | 交叉验证复权因子 | 拆分段前后价格连续,无跳变 |
| 交易成本 | 区分买入价、卖出价、滑点 | 加入 0.05%-0.1% 的单边滑点估计 |
五、给 TickDB 用户的实践建议
TickDB 提供 10 年级别的美股历史 K 线数据,并支持 adj 参数获取后复权数据。对于回测场景:
始终使用
adj=backward:这会自动处理拆股和分红,确保收益率计算准确。数据范围限制需知晓:TickDB 的
trades接口不支持美股和 A 股,但 K 线数据(1d 周期及以上)可用。对于需要分钟级复权数据的场景,需要结合其他数据源。幸存者偏差需要自建:TickDB 提供当前可用的交易品种列表,但历史成分股快照需要额外数据源。可以通过 Wikipedia 历史版本、指数官网或 CRSP 授权数据获取。
分层回测验证:先用当前成分股跑一次基准回测,再用历史快照做一次校正回测。两者的差值就是幸存者偏差的量化估计值。
结语
复权和幸存者偏差不是“高级技巧”,而是基础门槛。
任何一个在 2000 年用当时纳指 100 成分股做回测的人,都会发现自己的策略“年化 30%”。但到了 2001 年,当思科从纳指 100 移除、朗讯退市、安然崩溃的时候,那些“历史数据”里的公司已经不存在了——而你的资金是真实存在的。
回测是量化策略的实验室,而实验室的第一规则是:确保你测试的不是幸存者群体的集合,而是历史上真实存在过的市场。
数据对了,努力才有意义。
风险提示:本文所有回测示例仅用于说明方法论,不构成任何投资建议。历史收益不代表未来表现。实际交易需考虑流动性、滑点、佣金等市场摩擦因素。