复权与幸存者偏差:回测前必须做的两项数据修正

“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"&timestamp={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 参数获取后复权数据。对于回测场景:

  1. 始终使用 adj=backward:这会自动处理拆股和分红,确保收益率计算准确。

  2. 数据范围限制需知晓:TickDB 的 trades 接口不支持美股和 A 股,但 K 线数据(1d 周期及以上)可用。对于需要分钟级复权数据的场景,需要结合其他数据源。

  3. 幸存者偏差需要自建:TickDB 提供当前可用的交易品种列表,但历史成分股快照需要额外数据源。可以通过 Wikipedia 历史版本、指数官网或 CRSP 授权数据获取。

  4. 分层回测验证:先用当前成分股跑一次基准回测,再用历史快照做一次校正回测。两者的差值就是幸存者偏差的量化估计值。


结语

复权和幸存者偏差不是“高级技巧”,而是基础门槛

任何一个在 2000 年用当时纳指 100 成分股做回测的人,都会发现自己的策略“年化 30%”。但到了 2001 年,当思科从纳指 100 移除、朗讯退市、安然崩溃的时候,那些“历史数据”里的公司已经不存在了——而你的资金是真实存在的。

回测是量化策略的实验室,而实验室的第一规则是:确保你测试的不是幸存者群体的集合,而是历史上真实存在过的市场。

数据对了,努力才有意义。

风险提示:本文所有回测示例仅用于说明方法论,不构成任何投资建议。历史收益不代表未来表现。实际交易需考虑流动性、滑点、佣金等市场摩擦因素。