退市、停牌、成分股调整:历史数据质量的三场硬仗
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)}")
这段代码有三个设计要点:
is_halt标记:数据层如实返回停牌事实,不让调用方猜测- 收益率计算跳过停牌 bar:避免零交易量导致的价格不变被误解读为“收益率 0”
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 年)的版本。
很多数据源在这里有两种典型的错误:
- 不做 PIT 处理:返回任意一天的成分股列表,通常是当前版本,历史追溯完全失真
- 做了 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=qfq 和 is_halt 过滤写入数据摄取层的默认配置,这两行代码能避免 80% 的价格数据类回测事故。
如果你需要机构级数据方案(包括成分股权重的完整 PIT 历史版本、跨资产覆盖),联系 [email protected] 获取专业版接入文档。
本文不构成任何投资建议。市场有风险,投资需谨慎。