退市股票的数据去哪了?幸存者偏差如何虚增你的回测收益

如果你在 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

六、下一步行动

如果你在做个人量化策略回测

  1. 检查你的回测数据是否只包含当前股票
  2. 如果是,至少用 5 年以上的历史成分股数据做一次偏差估算
  3. 在报告中明确披露"本回测未完全处理幸存者偏差"

如果你在搭建机构级回测系统

  1. 将历史成分股对齐作为数据管道的核心模块
  2. 从彭博或 FactSet 采购历史成分股数据
  3. 对所有策略回测,强制输出偏差校正报告

如果你希望快速验证本文结论

  1. 访问 tickdb.ai 注册获取免费 API Key
  2. 拉取 10 年美股历史 K 线数据
  3. 对比你当前的"幸存股票"组合 vs. 包含历史变更的"无偏"组合

如果你习惯用 AI 辅助开发,在 AI 助手中搜索安装 tickdb-market-data SKILL,可快速接入 TickDB 数据能力。


回测局限性说明:本文的偏差估算基于公开学术研究的典型数值区间。实际偏差幅度受策略类型、回测周期、数据源质量等多因素影响。建议在实际策略落地前,结合自身数据进行独立验证。

市场有风险,投资需谨慎。本文不构成任何投资建议。