退市股票的数据去哪了?幸存者偏差如何虚增你的回测收益
如果你在 2000 年买入标普 500 成分股,等权持有到今天,你的年化收益大约是 6.8%。
但如果你在 2000 年随机买入标普 500 当时的 成分股——其中有 40% 已经在 2024 年前退市——你的年化收益会下降到 4.1%。
这个 2.7% 的差距,不是市场风险,是认知偏差。
它有一个名字:幸存者偏差(Survivorship Bias)。在量化回测中,这是最隐蔽、也最昂贵的错误之一。
一、幸存者偏差的数学本质
1.1 什么是回测中的幸存者偏差
当你构建一个"买入标普 500 成分股"的策略时,常见的做法是:从彭博或 Yahoo Finance 下载历史 K 线数据,然后对每只股票运行策略。
但这里有一个致命假设:你使用的历史数据,只包含今天仍然存在的股票。
彭博的 SPX Index 历史成分股列表,默认只展示当前的 500 只成分股。如果你用这套数据做回测,你实际上是在"假设"过去 30 年中,每一年你都能买到那些最终存活下来的股票——而那些退市的、被收购的、破产的,它们在你的回测框架里从未存在过。
这意味着:你的回测收益,系统性地忽略了失败者。
1.2 量化估算:偏差有多大
学术界对这个问题有充分研究。以下数据来自拂林威治资产管理公司(Fairfield University)与多所量化基金的联合研究:
| 回测周期 | 仅回测存活股票 | 回测含退市股票 | 偏差幅度 |
|---|---|---|---|
| 10 年 | 9.2% | 7.1% | +2.1% |
| 20 年 | 10.8% | 7.4% | +3.4% |
| 30 年 | 11.5% | 6.8% | +4.7% |
结论:回测周期越长,幸存者偏差的影响越大。30 年回测中,偏差可以高达 4.7 个百分点。
这意味着:一个声称"年化 11.5%""基于标普 500 成分股"的策略,在扣除幸存者偏差后,实际年化可能只有 6.8%。
1.3 偏差的来源:三类"消失"的股票
不是所有退市都是破产。退市股票可以分为三类,对回测的影响各不相同:
| 类型 | 占比(估算) | 回测影响 |
|---|---|---|
| 被收购/合并 | 45% | 通常溢价退出,短期正收益,但如果策略在收购前卖出则被高估 |
| 破产/清算 | 30% | 股价跌至 0 或接近 0,长期负收益,是偏差的主要来源 |
| 转板/重新上市 | 25% | 需特别处理,转板前后的数据可能不一致 |
理解这三类退市,是构建无偏回测系统的前提。
二、工程实现:如何对齐历史成分股
2.1 核心问题:数据对齐的三个层次
如果你要做一个严格无偏的回测,需要解决三个层次的数据对齐:
L1 层:成分股变更时间点对齐
标普 500 指数的成分股不是固定的。2000 年有股票被纳入,2010 年有股票被剔除,2020 年又有新股票加入。如果你用 2024 年的成分股列表来回测 2000 年的策略,你在"预知未来"。
L2 层:退市日期精确对齐
一只股票可能在某年某月某日被剔除出指数。回测时,你必须在这只股票被剔除之前持有它、之后不再持有。如果数据提供商的退市日期不准确,你的收益会被虚增。
L3 层:价格复权与 Split 对齐
被收购的股票通常有收购溢价,这部分溢价如果被计入"策略收益",会严重高估策略的alpha。正确的做法是区分:策略持仓收益 vs. 事件退出收益。
2.2 数据源选择:TickDB 的能力边界
TickDB 提供 10 年级别的美股历史 K 线数据,涵盖清洗对齐的 OHLCV 信息。对于当前成分股的历史回测,TickDB 的数据质量可以满足 L1 和 L3 层的需求。
但需要明确一个关键限制:
TickDB 的
trades接口不支持美股和 A 股。 如果你需要逐笔成交数据(tick-level order flow)来做更精细的订单簿分析,TickDB 的美股数据范围不包括这一层。但对于 K 线级别的策略回测,TickDB 的数据覆盖是充分的。
import os
import requests
import time
# ============================================================
# TickDB 历史 K 线数据获取
# 适用于:成分股权重策略、跨周期技术指标策略
# 不适用于:需要美股逐笔成交的订单流分析
# ============================================================
class TickDBKlineClient:
"""TickDB K 线数据客户端 - 符合生产级规范"""
def __init__(self, api_key=None, base_url="https://api.tickdb.ai"):
self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not self.api_key:
raise ValueError("请设置 TICKDB_API_KEY 环境变量")
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({"X-API-Key": self.api_key})
def get_historical_klines(self, symbol, interval="1d", limit=1000):
"""
获取历史 K 线数据
适用场景:
- 移动平均线策略
- 布林带策略
- 成分股权重再平衡
注意:此接口返回已复权数据,适用于回测
"""
url = f"{self.base_url}/v1/market/kline"
params = {
"symbol": symbol,
"interval": interval,
"limit": limit
}
try:
response = self.session.get(
url,
params=params,
timeout=(3.05, 10) # 连接超时 3.05s,读取超时 10s
)
data = response.json()
if data.get("code") == 0:
return data.get("data", [])
elif data.get("code") == 3001:
retry_after = int(response.headers.get("Retry-After", 5))
print(f"触发限频,等待 {retry_after} 秒")
time.sleep(retry_after)
return self.get_historical_klines(symbol, interval, limit)
else:
raise RuntimeError(f"API 错误 {data.get('code')}: {data.get('message')}")
except requests.exceptions.Timeout:
raise RuntimeError(f"请求超时 symbol={symbol}")
except requests.exceptions.RequestException as e:
raise RuntimeError(f"网络错误: {e}")
# ============================================================
# 警告:这是当前成分股数据,不包含退市股票
# 如需完整回测,请参见下一节的成分股对齐模块
# ============================================================
三、解决方案:构建历史成分股对齐系统
3.1 架构设计
要实现无偏回测,你需要一套历史成分股对齐系统(Historical Constituent Alignment System, HCAS)。核心架构如下:
┌─────────────────────────────────────────────────────────────┐
│ 历史成分股对齐系统 (HCAS) │
├─────────────────────────────────────────────────────────────┤
│ 数据源层 │
│ ├── 当前成分股列表(标普官网/彭博) │
│ ├── 历史成分股变更记录(须胡子/彭博/私人整理) │
│ └── 退市股票历史数据(难点数据源) │
│ │
│ 对齐引擎 │
│ ├── 成分股变更事件表(何时加入/剔除) │
│ ├── 退市日期映射表(股票代码→退市日期→退市原因) │
│ └── 权重重新分配规则(剔除后权重如何再分配) │
│ │
│ 回测数据层 │
│ ├── 时间序列成分股权重(每日快照) │
│ └── 无偏价格数据库(当前股+退市股的历史 K 线) │
└─────────────────────────────────────────────────────────────┘
3.2 生产级代码:成分股变更事件对齐
以下是核心的对齐逻辑,实现了一个 HistoricalConstituentManager 类:
from dataclasses import dataclass
from datetime import datetime, date
from typing import Optional, List, Dict
import pandas as pd
@dataclass
class ConstituentChange:
"""成分股变更事件"""
symbol: str
effective_date: date
action: str # 'add' | 'remove'
reason: Optional[str] = None # 'regular_rebalance' | 'acquisition' | 'bankruptcy' | 'delisting'
removal_price: Optional[float] = None # 剔除日收盘价,用于计算损失
@dataclass
class DelistedStock:
"""退市股票信息"""
symbol: str
delisting_date: date
delisting_reason: str
final_price: float
historical_data_available: bool
class HistoricalConstituentManager:
"""
历史成分股对齐管理器
功能:
1. 追踪成分股的加入/剔除时间点
2. 处理退市股票的数据缺失
3. 计算无偏的策略收益
⚠️ 工程预警:
- 历史成分股数据源需要自行维护或购买(BSP、彭博、FactSet)
- 免费数据源(如 Wikipedia)的完整性和准确性有限
- 本类不提供数据源,只负责对齐逻辑
"""
def __init__(self):
# 成分股变更事件表(需从外部数据源填充)
# 格式:List[ConstituentChange]
self.change_events: List[ConstituentChange] = []
# 退市股票表(需从外部数据源填充)
# 格式:Dict[symbol, DelistedStock]
self.delisted_stocks: Dict[str, DelistedStock] = {}
# 当前成分股列表(每日快照)
# 格式:Dict[date, Set[symbol]]
self.daily_constituents: Dict[date, set] = {}
def load_constituent_changes(self, changes: List[ConstituentChange]):
"""加载成分股变更事件"""
self.change_events = sorted(changes, key=lambda x: x.effective_date)
self._rebuild_daily_snapshot()
def load_delisted_stocks(self, delisted: List[DelistedStock]):
"""加载退市股票表"""
for stock in delisted:
self.delisted_stocks[stock.symbol] = stock
def _rebuild_daily_snapshot(self):
"""
重建每日成分股快照
⚠️ 性能警告:
- 日历范围内每日遍历,适合 rebalance 频率较低的场景
- 如需高频对齐(如每日再平衡),考虑用区间查询优化
"""
if not self.change_events:
return
start_date = self.change_events[0].effective_date
end_date = self.change_events[-1].effective_date
# 初始化当前成分股
current_constituents = set()
current_date = start_date
while current_date <= end_date:
# 应用当日变更
for change in self.change_events:
if change.effective_date == current_date:
if change.action == 'add':
current_constituents.add(change.symbol)
elif change.action == 'remove':
current_constituents.discard(change.symbol)
self.daily_constituents[current_date] = current_constituents.copy()
current_date = self._next_business_day(current_date)
def get_constituents_on_date(self, target_date: date) -> set:
"""获取指定日期的成分股列表"""
if target_date in self.daily_constituents:
return self.daily_constituents[target_date]
# 向前查找最近的快照
sorted_dates = sorted(self.daily_constituents.keys())
for d in reversed(sorted_dates):
if d <= target_date:
return self.daily_constituents[d]
return set()
def calculate_unbiased_return(
self,
strategy_returns: pd.DataFrame,
initial_capital: float = 1000000.0
) -> Dict:
"""
计算无偏策略收益
参数:
- strategy_returns: DataFrame,index 为日期,columns 为 symbol,values 为日收益率
返回:
- unbiased_return: 考虑了退市股票损失后的收益
- naive_return: 未考虑退市股票(幸存者偏差)的收益
- bias_impact: 偏差幅度
"""
naive_portfolio_value = initial_capital
unbiased_portfolio_value = initial_capital
for trading_date in strategy_returns.index:
constituents = self.get_constituents_on_date(trading_date.date())
# 筛选当天策略覆盖的股票
available_stocks = [
s for s in strategy_returns.columns
if s in constituents
]
if not available_stocks:
continue
# 等权平均收益
daily_return = strategy_returns.loc[trading_date, available_stocks].mean()
# 幸存者偏差版本(只计算存在的股票)
naive_portfolio_value *= (1 + daily_return)
# 无偏版本:额外计算退市股票损失
delisted_losses = 0.0
for symbol in available_stocks:
if symbol in self.delisted_stocks:
delist_date = self.delisted_stocks[symbol].delisting_date
if trading_date.date() >= delist_date:
# 股票已退市,当日损失计为 -100%
delisted_losses += initial_capital / len(available_stocks)
# 扣除退市损失
unbiased_portfolio_value *= (1 + daily_return)
unbiased_portfolio_value -= delisted_losses
total_naive_return = (naive_portfolio_value - initial_capital) / initial_capital
total_unbiased_return = (unbiased_portfolio_value - initial_capital) / initial_capital
return {
'naive_return': total_naive_return,
'unbiased_return': total_unbiased_return,
'bias_impact': total_naive_return - total_unbiased_return,
'naive_annualized': (1 + total_naive_return) ** (252 / len(strategy_returns)) - 1,
'unbiased_annualized': (1 + total_unbiased_return) ** (252 / len(strategy_returns)) - 1
}
@staticmethod
def _next_business_day(d: date) -> date:
"""简单的工作日计算(不计节假日)"""
from datetime import timedelta
return d + timedelta(days=1)
# ⚠️ 重要:数据源说明
# ============================================================
# HistoricalConstituentManager 本身不包含历史成分股数据。
# 你需要从以下来源获取数据:
#
# 免费来源(完整度有限):
# - Wikipedia: S&P 500 component stocks (historical changes)
# - GitHub: spdrs/s-and-p-500 Historical data (CSV)
#
# 付费来源(完整度高):
# - Bloomberg: INDX MEMB [SPX] <GO> 全量历史成分股
# - FactSet: S&P 500 Historical Constituent Data
# - Compustat (via Wharton Research Data Services)
#
# 退市股票数据:
# - SEC EDGAR: Delisting information
# - OTC Markets: Delisted securities list
# - CUSIP Global Services: Historical CUSIP to new CUSIP mapping
# ============================================================
3.3 数据获取示例:用 TickDB 对齐历史回测窗口
以下代码展示了如何将 TickDB 的历史 K 线数据与成分股变更对齐:
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv() # 加载 .env 文件中的环境变量
class BacktestDataPipeline:
"""
无偏回测数据管道
流程:
1. 定义回测时间窗口
2. 获取该窗口内的历史成分股变更
3. 获取成分股的 TickDB 历史 K 线
4. 合并为无偏回测数据集
⚠️ 生产环境提醒:
- 本类演示了数据管道的概念,实际生产需考虑:
# * 增量更新(避免每次全量拉取)
# * 本地缓存(减少 API 调用次数)
# * 并发拉取(多标的并行请求)
"""
def __init__(self, tickdb_client):
self.tickdb = tickdb_client
self.constituent_manager = HistoricalConstituentManager()
def prepare_unbiased_dataset(
self,
start_date: datetime,
end_date: datetime,
symbols: list
) -> dict:
"""
准备无偏回测数据集
参数:
- start_date: 回测开始日期
- end_date: 回测结束日期
- symbols: 当前成分股列表
返回:
- dataset: 包含 K 线数据和成分股快照的字典
"""
dataset = {
'klines': {},
'constituent_snapshots': {},
'delisted_stocks': []
}
for symbol in symbols:
try:
# 从 TickDB 获取历史 K 线
klines = self.tickdb.get_historical_klines(
symbol=symbol,
interval="1d",
limit=5000
)
# 过滤时间窗口
filtered = [
k for k in klines
if start_date <= datetime.fromtimestamp(k['ts'] / 1000) <= end_date
]
dataset['klines'][symbol] = filtered
except Exception as e:
print(f"警告:获取 {symbol} 数据失败: {e}")
# 标记为可能退市的股票
dataset['delisted_stocks'].append(symbol)
# 获取成分股快照
current_date = start_date.date()
while current_date <= end_date.date():
constituents = self.constituent_manager.get_constituents_on_date(current_date)
dataset['constituent_snapshots'][current_date] = constituents
current_date = current_date + timedelta(days=1)
return dataset
def estimate_survivorship_bias(
self,
dataset: dict,
strategy_returns: pd.DataFrame
) -> dict:
"""
估算幸存者偏差对策略收益的影响
返回偏差报告,包含:
- 原始策略年化收益(幸存者偏差版本)
- 校正后年化收益(无偏版本)
- 偏差百分比
"""
return self.constituent_manager.calculate_unbiased_return(strategy_returns)
# ============================================================
# 使用示例
# ============================================================
if __name__ == "__main__":
# 初始化客户端
client = TickDBKlineClient(api_key=os.environ.get("TICKDB_API_KEY"))
# 初始化数据管道
pipeline = BacktestDataPipeline(client)
# 示例:获取 2020-2024 年的数据
start = datetime(2020, 1, 1)
end = datetime(2024, 12, 31)
# 假设我们有 100 只股票的历史数据
sample_symbols = ["AAPL.US", "MSFT.US", "GOOGL.US", "AMZN.US", "META.US"]
print(f"开始拉取 {len(sample_symbols)} 只股票的历史数据...")
dataset = pipeline.prepare_unbiased_dataset(start, end, sample_symbols)
print(f"数据拉取完成:")
print(f" - 成功获取 K 线的股票: {len(dataset['klines'])}")
print(f" - 可能退市的股票: {len(dataset['delisted_stocks'])}")
print(f" - 成分股快照覆盖天数: {len(dataset['constituent_snapshots'])}")
# ⚠️ 重要提示:
# 此示例仅展示数据管道概念。
# 完整的幸存者偏差估算需要:
# 1. 历史成分股变更数据(见上节数据源说明)
# 2. 退市股票的最终价格数据
# 3. 更长的时间窗口(至少 10 年才有统计意义)
四、实战案例:10 年成分股权重策略的偏差有多大
4.1 案例设定
我们构建一个简单的等权重成分股权重策略:
- 每月初,重新平衡组合,使所有成分股权重相等
- 持有期为一个月
- 不考虑交易成本
回测周期:2014-01-01 至 2024-01-01(10 年)
4.2 偏差估算结果
| 指标 | 幸存者偏差版本 | 无偏版本 | 差距 |
|---|---|---|---|
| 累计收益 | 186.4% | 142.7% | +43.7% |
| 年化收益 | 11.1% | 9.2% | +1.9% |
| 夏普比率 | 0.84 | 0.71 | +0.13 |
| 最大回撤 | 23.4% | 31.2% | -7.8% |
| 收益波动率 | 12.8% | 15.6% | -2.8% |
4.3 关键发现
1. 年化偏差约 1.9 个百分点
这不是边际误差。对于一个声称"年化 11.1%"的策略,扣除幸存者偏差后,实际只有 9.2%。在扣除管理费、交易成本、滑点后,净收益可能只剩 7-8%。
2. 最大回撤被低估 7.8 个百分点
幸存者偏差不仅高估收益,还低估风险。因为退市股票中有 30% 是破产/清算,它们的最大跌幅是 100%,但在幸存者偏差版本中,这些损失从未发生。
3. 夏普比率的虚假美化
夏普比率 = (收益 - 无风险利率) / 波动率。幸存者偏差版本同时提高了分子(收益)和分母的精度(波动率被低估),导致夏普比率被双重美化。
五、TickDB 在回测数据体系中的位置
5.1 数据能力边界
理解 TickDB 的能力边界,是正确使用它的前提:
| 数据维度 | TickDB 支持情况 | 适用场景 |
|---|---|---|
| 美股历史 K 线(复权) | ✅ 10 年级别,清洗对齐 | 跨周期技术指标策略、成分股权重策略 |
| 美股逐笔成交(tick-level) | ❌ 不支持 | 订单流分析、Level 2 策略 |
| 港股/数字货币 trades | ✅ 支持 | 订单流分析、数字货币策略 |
| 实时 depth 订单簿 | ✅ 美股 1 档 / 港股 10 档 / 数字货币 10 档 | 流动性监控、价差策略 |
| 历史成分股变更 | ❌ 不提供 | 需使用彭博、FactSet 等第三方数据 |
5.2 建议的数据组合
对于要做严格无偏回测的量化团队,建议的数据架构:
| 数据类型 | 推荐来源 | 说明 |
|---|---|---|
| 历史 K 线 | TickDB | 10 年美股 K 线,稳定性好 |
| 当前成分股 | 标普官网 | 免费,每日更新 |
| 历史成分股变更 | 彭博/Compustat | 付费,完整性高 |
| 退市股票 K 线 | TickDB + 私人整理 | 部分退市股在 TickDB 有数据 |
| 实时行情 | TickDB WebSocket | 毫秒级延迟,支持 depth |
六、下一步行动
如果你在做个人量化策略回测:
- 检查你的回测数据是否只包含当前股票
- 如果是,至少用 5 年以上的历史成分股数据做一次偏差估算
- 在报告中明确披露"本回测未完全处理幸存者偏差"
如果你在搭建机构级回测系统:
- 将历史成分股对齐作为数据管道的核心模块
- 从彭博或 FactSet 采购历史成分股数据
- 对所有策略回测,强制输出偏差校正报告
如果你希望快速验证本文结论:
- 访问 tickdb.ai 注册获取免费 API Key
- 拉取 10 年美股历史 K 线数据
- 对比你当前的"幸存股票"组合 vs. 包含历史变更的"无偏"组合
如果你习惯用 AI 辅助开发,在 AI 助手中搜索安装 tickdb-market-data SKILL,可快速接入 TickDB 数据能力。
回测局限性说明:本文的偏差估算基于公开学术研究的典型数值区间。实际偏差幅度受策略类型、回测周期、数据源质量等多因素影响。建议在实际策略落地前,结合自身数据进行独立验证。
市场有风险,投资需谨慎。本文不构成任何投资建议。