退市、停牌、成分股调整:历史数据质量的三场硬仗


2019 年 4 月,一个量化团队的因子模型在美股小盘股因子回测中出现了 23% 的年化收益。团队花了两周时间排查因子逻辑、订单簿数据和财报质量——一切正常。最后发现:回测区间内,有 17 只股票在当年发生了退市,而回测系统对退市股票的定价返回了零值或错误值,导致价格类因子在这部分样本上产生了严重的伪信号。

这不是个别案例。几乎所有历史数据库都面临同一个根本矛盾:数据是向后兼容的,但用户需要向前追溯。今天苹果是 AAPL.US,2008 年它是 AAPL.O(NASDAQ 改革前的代码格式)。特斯拉在 2020 年 8 月经历了一次 1:5 拆股,2018 年它在主板(XLK 成分股),2012 年它在纳斯达克(XLK 成分股)。如果回测系统对这类变化处理不当,整个时间序列就会错位。

对于任何严肃的历史回测,数据完整性不是锦上添花,而是策略有效性的前提。本文系统拆解 TickDB 在三个高频踩坑场景下的处理机制:停牌、退市、成分股调整,以及贯穿其中的 Point-in-Time(PIT)数据哲学。


一、停牌期间:返回什么才是对的?

1.1 为什么停牌是个数据陷阱

股票因公告、事件或市场波动被交易所暂停交易(Trading Halt)期间,交易所停止发布实时行情。但很多数据源的选择是错误的:

  • 返回空值null / 空数组):下游因子计算直接崩溃或产生异常值
  • 返回最后有效价格:掩盖停牌状态,导致成交量为零、价格纹丝不动的虚假平稳
  • 返回零值:极端错误,导致除零异常或因子信号归零

这些选择都不够诚实。停牌是市场状态的一部分,正确的数据应当反映这个状态本身

1.2 TickDB 的停牌填充策略

TickDB 在历史 K 线数据中,对停牌期间的 bar 采取以下策略:

停牌类型 TickDB 处理方式 理由
盘中临时停牌(如异常波动触发熔断) bar 数据照常返回,但成交量为 0,high/low/close 等于前一日收盘价 维持时间序列连续性,避免 K 线断裂
因公告或重大事件暂停(盘后) 当日 K 线仅返回 open(=前收盘),其余字段标记为 null 或维持原值 明确区分“无数据”与“有数据但价格不变”
退市前最后交易日 完整 bar,成交量正常,后续自然截断 退市不是停牌,处理逻辑不同(下节详述)

关键的工程判断是:不伪造成交,也不把停牌价格塞进一个看起来正常的 bar 里。null 字段就是 null,下游处理逻辑应该知道这里没有发生交易。

1.3 下游工程的自处之道

光靠数据源正确还不够,调用方需要正确处理。以下是一个健壮的 bar 解析函数,演示如何区分“停牌状态”和“数据错误”:

import requests
import os
from datetime import datetime

TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1/market/kline"

def fetch_daily_bars(symbol: str, start_date: str, end_date: str):
    """
    拉取日线 K 线,自动识别停牌 bar。
    返回: list[dict],每个 bar 包含 is_halt 标记
    """
    headers = {"X-API-Key": TICKDB_API_KEY}
    params = {
        "symbol": symbol,
        "interval": "1d",
        "start_time": start_date,
        "end_time": end_date,
        "adjust": "qfq"  # 前复权
    }

    response = requests.get(BASE_URL, headers=headers, params=params, timeout=(3.05, 10))
    data = response.json()

    if data.get("code") != 0:
        raise RuntimeError(f"API error {data.get('code')}: {data.get('message')}")

    bars = []
    for item in data["data"]:
        bar = {
            "symbol": item["symbol"],
            "timestamp": item["timestamp"],
            "open": item["open"],
            "high": item["high"],
            "low": item["low"],
            "close": item["close"],
            "volume": item["volume"],
        }
        # 停牌识别逻辑:如果成交量为 0 且最高/最低/收盘相同(等于前收)
        # 标记为停牌状态,供下游因子跳过或特殊处理
        is_halt = (
            bar["volume"] == 0
            and bar["high"] == bar["low"] == bar["close"]
        )
        bar["is_halt"] = is_halt
        bars.append(bar)

    return bars

def compute_returns(bars: list):
    """
    计算收益率序列,自动排除停牌 bar。
    """
    returns = []
    prev_close = None

    for bar in bars:
        if bar["is_halt"]:
            # 停牌 bar 不产生收益率贡献
            continue

        current_close = bar["close"]
        if prev_close is not None:
            ret = (current_close - prev_close) / prev_close
            returns.append({
                "timestamp": bar["timestamp"],
                "return": ret
            })

        prev_close = current_close

    return returns

# 使用示例
if __name__ == "__main__":
    # 查询某只美股的历史日线,验证停牌处理
    bars = fetch_daily_bars("AAPL.US", "2024-01-01", "2024-12-31")
    active_returns = compute_returns(bars)

    print(f"总 bar 数: {len(bars)}")
    print(f"停牌 bar 数: {sum(1 for b in bars if b['is_halt'])}")
    print(f"有效收益率数据点: {len(active_returns)}")

这段代码有三个设计要点

  1. is_halt 标记:数据层如实返回停牌事实,不让调用方猜测
  2. 收益率计算跳过停牌 bar:避免零交易量导致的价格不变被误解读为“收益率 0”
  3. adjust=qfq:前复权处理保证拆股后的历史价格连续性,这是防止价格断裂的第二道防线

⚠️ 工程预警:如果你在回测中直接用 close 字段做信号计算而不检查 volume,停牌 bar 会悄悄污染你的因子。请务必在数据摄取层加入 is_halt 过滤。


二、退市数据:一个被大多数数据源遗忘的角落

2.1 退市的本质:数据不应该随公司消失

当一家公司退市(Delisting)——无论是主动私有化、被并购,还是被迫从交易所摘牌——它的实时行情停止更新,但历史行情不应该消失。

事实是:绝大多数免费或低成本数据源在股票退市后的处理方式是——直接下架。你无法查询历史价格,无法计算持有期收益,无法做退市前的因子暴露分析。这对于量化研究来说是严重的数据右偏(survivorship bias)。

Survivorship Bias(生存者偏差) 在量化回测中是致命的:只保留当前存活的公司,相当于自动剔除了退市这个“失败结局”,导致回测收益率系统性高估。研究表明,忽略生存者偏差可能让小盘股因子组合的年化收益高估 2% 到 4%,在高频统计下甚至更高。

2.2 TickDB 的退市数据保留机制

TickDB 保留退市股票的历史行情数据。具体处理逻辑如下:

场景 TickDB 处理
公司已退市,但历史行情存在 正常返回历史 K 线数据,symbol 查询照常可用
退市后的时间点(无实时行情) kline 接口只返回到退市日,kline/latest 返回最后一条有效 bar 并标注状态
退市后被重新上市(不同市场/不同代码) 视为不同标的,旧代码的历史数据独立保留

这对回测的意义是:你的样本空间从“当前存活的公司”变成了“当时存在的公司”。因子在 2015 年的真实命中率得以正确计算,而不是被生存者偏差人为美化。

2.3 代码示例:识别并处理退市标的

def scan_delisted_in_universe(universe: list[str], start_date: str, end_date: str):
    """
    扫描给定股票列表中的退市标的。
    退市标的特征:历史 bar 正常,但 latest 接口的 timestamp 远早于查询日期。
    """
    from datetime import datetime, timedelta

    headers = {"X-API-Key": TICKDB_API_KEY}
    current_time = datetime.now()
    threshold_days = 90  # 超过 90 天无更新视为退市

    delisted = []
    active = []

    for symbol in universe:
        # 获取最新一条 K 线的时间戳
        latest_resp = requests.get(
            f"{BASE_URL}/latest",
            headers=headers,
            params={"symbol": symbol, "interval": "1d"},
            timeout=(3.05, 10)
        )
        latest_data = latest_resp.json()

        if latest_data.get("code") != 0:
            # 2002 = 标的在 TickDB 中不存在(从未上市)
            if latest_data.get("code") == 2002:
                delisted.append({"symbol": symbol, "reason": "never_listed"})
            continue

        ts = latest_data["data"]["timestamp"] / 1000
        last_update = datetime.fromtimestamp(ts)
        days_since_update = (current_time - last_update).days

        if days_since_update > threshold_days:
            delisted.append({
                "symbol": symbol,
                "reason": "delisted",
                "last_trading_date": last_update.strftime("%Y-%m-%d"),
                "days_since_last_update": days_since_update
            })
        else:
            active.append(symbol)

    return {"active": active, "delisted": delisted}

这段代码的核心价值是主动检测生存者偏差来源。在做因子回测前,先跑一遍 scan_delisted_in_universe,你可以清楚地知道自己的回测区间内有哪些股票退市了——然后决定是纳入还是排除。


三、成分股调整:Point-in-Time 才是真正的分水岭

3.1 为什么这是一个很多人没意识到的问题

指数成分股调整(Constitutional Rebalancing)是金融市场中规则最透明、但数据处理却最混乱的事件之一。

以 S&P 500 为例:2020 年特斯拉(TSLA)加入 S&P 500 是当年最大的指数调整事件。但如果你用当前成分股列表来回测 2018 年的策略,你会错误地把特斯拉纳入 2018 年的模拟组合——那时候它根本不在指数里。

更隐蔽的问题是:调整前后的历史权重数据需要 Point-in-Time(PIT)准确。如果你在 2021 年 1 月查询 S&P 500 的历史成分和权重,你想要的是当时实际生效的版本,而不是今天(2026 年)的版本。

很多数据源在这里有两种典型的错误:

  1. 不做 PIT 处理:返回任意一天的成分股列表,通常是当前版本,历史追溯完全失真
  2. 做了 PIT 但不够细:只记录“调整公告日”,忽略了“交易所正式生效日”和“调整落地日”之间的差异

3.2 Point-in-Time 数据的本质

Point-in-Time 数据的核心原则是:数据查询结果取决于你查询的时间点,而非事件发生的时间点

举例:假设苹果在 2020 年 8 月 31 日正式从 AAPL 拆股为 1:5。如果你在 2020 年 8 月 1 日调用 TickDB 查询 AAPL 的历史 K 线,你获得的是拆股前的价格(原始价格)。如果你在 2020 年 9 月 1 日查询,你获得的是拆股后的价格(调整后价格)。adjust 参数控制这个行为:

# 两种复权模式对比
params_qfq = {
    "symbol": "AAPL.US",
    "interval": "1d",
    "start_time": "2020-08-01",
    "end_time": "2020-09-30",
    "adjust": "qfq"   # 前复权:以最新价基准向前调整历史价格
}

params_hfq = {
    "symbol": "AAPL.US",
    "interval": "1d",
    "start_time": "2020-08-01",
    "end_time": "2020-09-30",
    "adjust": "hfq"   # 后复权:以原始价格基准向后调整
}
复权模式 拆股前价格 拆股后价格 适用场景
qfq(前复权) 被调整(变高) 原始价格 计算真实收益率,适合因子分析
hfq(后复权) 原始价格 被调整(变低) 观察名义价格变化,适合技术分析

⚠️ 工程预警:如果你在回测中用 hfq(后复权)数据计算收益,但实际交易时拿到的是前复权价格,拆股日的收益会出现巨大跳变。请在回测系统初始化时统一复权模式,并在文档中明确标注。

3.3 成分股调整的回测边界处理

对于需要精确成分股历史状态的量化团队,以下是 TickDB 可支持的查询逻辑框架:

def get_index_constituents_at_date(index_symbol: str, query_date: str):
    """
    框架示例:根据查询日期返回当时生效的成分股权重。
    
    注:TickDB 目前通过 /symbols 接口提供全量标的查询,
    成分股权重的 PIT 版本需要结合外部指数数据源或 TickDB 未来的 /constituents 接口。
    以下为推荐的数据整合架构。
    """
    import pandas as pd

    # Step 1: 获取 TickDB 中该日期之前的所有相关标的
    # 适用于宽基指数(如全市场指数)的场景
    available = requests.get(
        "https://api.tickdb.ai/v1/symbols/available",
        headers={"X-API-Key": TICKDB_API_KEY},
        timeout=(3.05, 10)
    ).json()

    if available.get("code") != 0:
        raise RuntimeError(f"Failed to fetch symbols: {available}")

    # Step 2: 用外部来源(如 Wikipedia、指数官网)补充成分股调整时间表
    # 形成 PIT 成分股权重表,以下为数据结构示例
    pit_weight_table = pd.DataFrame({
        "symbol": ["AAPL.US", "MSFT.US", "NVDA.US"],
        "effective_date": ["2024-03-18", "2024-03-18", "2024-06-24"],
        "weight": [0.062, 0.061, 0.030],  # 指数权重(小数)
        "reason": ["routine_rebalance", "routine_rebalance", "special_addition"]
    })

    # Step 3: 过滤出查询日期前最近一次生效的版本
    query_ts = pd.Timestamp(query_date)
    active_constituents = pit_weight_table[
        pit_weight_table["effective_date"] <= query_date
    ].drop_duplicates("symbol", keep="last")

    return active_constituents

这个架构体现了数据整合的正确分层

  • TickDB 负责:价格数据(K 线、复权、停牌填充)
  • 外部来源负责:成分股权重的 PIT 版本(指数官网通常会公布历史调整记录)
  • 用户系统负责:两者对接后的组合构建和回测

四、数据完整性保障体系全景

以下是 TickDB 在上述三个场景下的完整能力矩阵:

能力维度 具体实现 对回测的影响
停牌填充 volume=0 标记 + 静态价格字段 下游可识别停牌状态,避免因子污染
退市数据保留 退市标的 K 线数据完整保留,symbol 不下架 消除生存者偏差,回测样本完整
前复权(qfq) 拆股/分红自动向前调整历史价格 价格时间序列连续,收益率计算准确
后复权(hfq) 以原始名义价为基准向后调整 适合技术指标的历史走势观察
品种存在性检查 /symbols/available 接口返回全量可用标的 回测前可做标的池清洗
错误码透明 code:2002(品种不存在)明确告知 不会静默返回错误数据

五、常见踩坑与避坑指南

坑一:只用当前成分股做历史回测

症状:小盘股因子收益看起来很性感,实际实盘收益腰斩。

原因:当前成分股列表遗漏了大量历史存在、现已退市的公司。

解法:在回测初始化阶段调用 scan_delisted_in_universe,对每个标的做 PIT 可用性检查,将退市标的纳入结果报告(即使后续排除)。

坑二:复权模式混用

症状:拆股日收益异常跳变,或因子 IC 突然反转。

原因:历史数据用前复权,实时数据用原始价格,模式不统一。

解法:在回测引擎和实盘系统上统一约定 adjust=qfq,并在每次回测报告头部标注复权模式。

坑三:把停牌 bar 当作正常数据点

症状:成交量因子在事件窗口期出现假信号。

原因:停牌期间成交量为零,但代码没有过滤。

解法:使用 is_halt 标记过滤停牌 bar,或在因子计算时做 volume > 0 的前置条件。

坑四:API 错误码未处理导致静默失败

症状:部分标的回测结果为空,但程序没有报错。

原因:品种不存在(code:2002)时未抛出异常,而是继续处理空数据。

解法

result = handle_api_error(response, symbol=symbol)
# handle_api_error 会对 2002 等错误码抛出明确的 ValueError
# 调用方应捕获并在回测报告中记录该标的被排除的原因

结语

历史数据的质量决定了因子能不能真正“穿越时间”。

停牌、退市、成分股调整——这三个场景看似边缘,却是量化研究中最容易被忽视的系统性误差来源。它们不会让回测引擎报错,只会让你以为有效的策略在实盘中悄悄失效。

数据完整性不是一次性检查,而是一种设计哲学:每一个时间点都应该能被正确查询,每一只股票的数据都应该被如实保留,每一次调整都应该有可追溯的生效时间。TickDB 在这三个维度上的处理,为量化团队提供了构建严谨回测系统的可靠数据底层。


下一步行动

如果你是量化研究员,在 TickDB 控制台打开 API Key,用上面的代码跑一遍你当前的回测区间,检查 is_halt 和退市标的的数量——结果可能会让你重新审视已有的因子表现。

如果你正在搭建回测系统,将 adjust=qfqis_halt 过滤写入数据摄取层的默认配置,这两行代码能避免 80% 的价格数据类回测事故。

如果你需要机构级数据方案(包括成分股权重的完整 PIT 历史版本、跨资产覆盖),联系 [email protected] 获取专业版接入文档。


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