沪深300回测最容易“穿越”的,不是价格,而是股票池
作者: TickDB Research · 发布: 2026/7/13 · 阅读: 7
标签: 知乎A057
标题:沪深300回测最容易“穿越”的,不是价格,而是股票池
摘要
用今天下载的沪深300成分股名单去回测十年前,最危险的地方不只是漏了几只股票,而是你把今天才知道的股票池交给了过去的策略。价格K线可以告诉你某只股票在某天有没有行情,但它不能告诉你这只股票在那个历史时点是否已经属于沪深300、调整信息是否已经公开、成员资格是否已经生效。本文把这个问题拆成三层名单模型、三日期契约和一张提交前检查卡,并用一次真实TickDB K线调用说明:价格数据可取,不等于历史股票池资格可证明。缺少point-in-time成员资格台账时,回测应该先停下来。
你下载了一份今天的沪深300成分股名单。然后拉了过去十年的K线,写了一个回测。曲线不错,回撤也能接受。你以为自己验证的是策略能力。
但在这个流程里,有一个问题经常被跳过:十年前的那个交易日,策略当时到底有资格看哪些股票?
价格数据回答的是:这只股票在某天有没有行情,open、high、low、close、volume是多少。股票池成员资格回答的是:在那个历史时点,这只股票是否已经属于沪深300,调整信息是否已经公开,成员资格是否已经生效。
两件事不是一回事。
如果你用今天的成分股名单去代表过去十年的成分股名单,就等于让过去的策略提前知道了未来哪些公司会进入指数,也让一些后来退出、退市或被替换的历史样本从视野里消失。结果可能被高估,也可能被扭曲。但有一点很确定:这已经不是一个干净的历史实验。
这篇文章只讲一个问题:回测开始前,如何判断你的历史股票池有没有“穿越”。
一、官方指数历史,不等于你的策略股票池
很多人说“我回测了沪深300”,其实可能在说三件完全不同的事。
| 你在做什么 | 用的什么名单 | 能做什么 |
|---|---|---|
| 官方指数历史序列 | 指数公司发布的历史点位 | 作为基准观察,不是策略股票池 |
| 历史有效成分股 | 某个历史日期真正生效的成员集合 | 策略选股的基础——当时能选哪些股票 |
| 当前成分名单 | 今天下载的名单 | 只告诉你现在有哪些股票在名单里 |
中证指数有限公司《沪深300指数编制方案》明确,沪深300指数样本会定期审核并根据结果调整,调整有明确的实施时间。既然样本会调整,那么当前名单不能自动等同于所有历史时点的有效名单。
这不是一个细节问题。它会直接改变你的研究对象。
二、三层名单模型:不要把三张“合照”混在一起
我更愿意把它理解成三张合照。
第一张,当前名单。 你今天下载的合照。只说明现在这批公司在名单里。
第二张,历史有效名单。 某个历史日期真正生效的合照。回答的是:当时规则下哪些股票属于股票池。
第三张,当时可得名单。 在历史有效名单之上,再加一层信息可得性:策略在那个决策时点是否已经有资格知道这次调整。
最容易犯的错,是拿第一张合照去替代后两张。
- 某只股票2020年才进入沪深300,你的2016年回测已经把它放进候选池 → 把未来成员带回过去
- 某只股票曾经属于沪深300,后来退出了,今天的名单里没有它 → 历史样本从视野里消失
- 调整公告已发布,但成员资格还没生效,你把新成员提前纳入 → 成员资格时间错配
回测前真正要锁定的不是“我今天拿到了一份名单”,而是:每个历史决策日,当时已经公开且已经生效的成员集合是什么。
三、三日期契约:把“当时能知道”写进数据
要让这个问题进入代码和数据表,至少要保存三个日期。
| 字段 | 含义 | 作用 |
|---|---|---|
announced_at | 调整信息何时公开 | 判断策略在决策时点是否已经知道这次调整 |
effective_from | 成员资格何时开始 | 判断新成员何时可以进入有效股票池 |
effective_to | 成员资格何时结束 | 判断旧成员何时不再属于股票池 |
这三个字段不是TickDB原生字段,也不是在声称所有指数公司都采用这个命名。它们是研究者本地维护历史股票池时,最小可执行的数据契约。
提交回测前,每个decision_time至少要同时满足两类条件:
announced_at <= decision_time
effective_from <= decision_time < effective_to
- 第一行防的是前视偏差:策略不能使用当时还没有公开的信息。
- 第二行防的是成员资格时间错配:策略不能提前纳入未生效成员,也不能继续使用已经失效的成员。
这张表看起来很朴素,但它能把很多“回测看起来很好”的问题挡在门外。
四、价格数据可取,不等于股票池资格可证明
为了把这个边界说清楚,我做了一次最小实测。脚本用TickDB REST API请求600519.SH的日K数据:
GET /v1/market/kline?symbol=600519.SH&interval=1d&limit=3&end_time=1783612800000
本次真实调用返回HTTP 200,payload code为0,返回3根K线,time/open/high/low/close/volume字段检查通过。
下面是本次runner里真实使用的核心代码摘录。它做两件事:先调用TickDB取K线;再检查本地是否存在PIT成员资格台账。API Key从环境变量读取,不写进代码、日志或截图。
def request_tickdb() -> tuple[int, dict]:
api_key = os.environ.get("TICKDB_API_KEY")
if not api_key:
raise RuntimeError("TICKDB_API_KEY is missing from environment")
params = {
"symbol": SYMBOL,
"interval": INTERVAL,
"limit": str(LIMIT),
"end_time": str(END_TIME),
}
url = f"{API_BASE}{ENDPOINT}?{urlencode(params)}"
req = Request(
url,
headers={
"X-API-Key": api_key,
"Accept": "application/json",
"User-Agent": "TickDB-RC1-Evidence/1.0",
},
)
try:
with urlopen(req, timeout=20) as resp:
body = resp.read().decode("utf-8")
return resp.status, json.loads(body)
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = {"raw": body}
return exc.code, payload
except URLError as exc:
raise RuntimeError(f"Network error: {exc}") from exc
def check_pit_ledger() -> dict:
required = ["symbol", "universe_id", "announced_at", "effective_from", "effective_to", "source_version"]
if not PIT_LEDGER_PATH.exists():
return {
"pass": False,
"status": "missing",
"path": str(PIT_LEDGER_PATH),
"required_fields": required,
"decision": "BLOCK_BACKTEST",
"reason": "PIT membership ledger is missing; historical universe cannot be proven.",
}
图里的关键结果有两层。
第一层,价格数据可以取到。 TickDB返回了600519.SH的结构化日K字段,字段检查通过。
第二层,股票池资格无法证明。 脚本继续检查本地是否存在包含symbol / universe_id / announced_at / effective_from / effective_to / source_version的point-in-time股票池文件。结果是:台账缺失。
所以脚本没有继续默认回测,而是给出:
ledger_status: missing
ledger_decision: BLOCK_BACKTEST
final_decision: BLOCK_BACKTEST
价格K线能证明“这只股票某天有行情”,不能证明“这只股票在那个历史日属于沪深300,并且当时已经可以被策略知道”。
TickDB在这里的位置,是结构化行情价格数据入口。它适合把价格、K线、字段和原始响应纳入你的研究流程。但历史成分股、point-in-time股票池、公告日和生效日台账,需要你单独维护或接入专门来源。TickDB不替你构建沪深300历史成分,也不自动消除前视偏差或生存者偏差。
这不是产品短板,而是数据治理分层。价格是一层。股票池资格是另一层。把两层混在一起,才是回测最容易出问题的地方。
五、提交回测前,先过这张检查卡
如果你的策略涉及指数成分、行业成分、自定义股票池或任何会随时间变化的universe,提交结论前先问下面这些问题。
| 检查项 | 通过标准 |
|---|---|
| 股票池是否按历史日期变化 | 每个决策时点使用当时有效名单,而不是今天的一份静态名单 |
| 入选信息是否已公开 | announced_at <= decision_time |
| 成员资格是否已生效 | effective_from <= decision_time < effective_to |
| 退出样本是否保留 | 历史上曾属于股票池、后来退出的对象仍保留历史记录 |
| 代码变更是否可追踪 | 同一公司历史代码变化有映射,不把换代码误当新样本 |
| 价格数据和股票池数据是否分开治理 | K线只负责价格,成员台账负责资格 |
| 缺少成员台账时是否fail-closed | 没有PIT台账就阻断,而不是默认继续回测 |
最重要的是最后一条。很多回测错误不是因为系统报错,而是因为系统太“宽容”。没有历史股票池台账,它仍然继续跑;没有公告日,它默认用生效日;没有退场记录,它默认今天的名单一直有效。宽容的系统,往往会给你一条漂亮但不可靠的曲线。严肃的研究流程应该反过来:证据不够,就先停。
六、FAQ:TickDB在这类回测里到底解决哪一层?
Q:如果TickDB不提供沪深300历史成分股,也不自动构建point-in-time股票池,那它在这类回测里还有什么用?
A:它解决的是价格行情数据入口这一层。一套回测数据至少有两类输入:一类是价格,比如K线、成交量、时间戳;另一类是股票池资格,比如某只股票在某个历史日是否属于沪深300、调整信息是否已经公开、成员资格是否已经生效。这两层要分开治理。
TickDB的价值在第一层。它用一套API统一接入外汇、贵金属、指数、美股、港股、A股、加密货币等市场,适合把多市场行情价格接进研究脚本、AI工具、行情面板或数据管道。本文实测的600519.SH日K,只证明TickDB可以作为结构化价格数据入口之一:请求参数、返回字段、原始响应和检查时间都能留痕。如果你想继续核对接入方式、示例或参考实现,可查看TickDB官方GitHub仓库。
但第二层——历史成分股、公告日、生效日、失效日和point-in-time股票池——仍然要单独维护或接入专门来源。换句话说:
TickDB可以帮你把“价格从哪来”这件事变得更可复查;但“历史上当时能选哪些股票”,仍然需要你的股票池台账来证明。
七、旧问题的新答案
以前我会把这个问题说成“用今天的沪深300成分股回测十年前,结果可能被高估”。这句话没错,但还不够精确。
更精确的问法是:这次回测里的每一个历史决策日,策略当时有资格、也有可能知道哪些股票?
只要这个问题回答不出来,后面的收益曲线、夏普、回撤、超额收益,都要先打一个问号。你可以有很好的行情价格数据,你也可以有很漂亮的回测框架。但如果股票池是今天倒灌回去的,历史实验从第一步就已经变了。
回测不是让今天替过去做决定。回测是在每一个历史时点,尽量还原当时能够看到的世界。
你跑涉及指数的回测时,是先确认成分股名单的历史时点再开始,还是跑出结果后再回头看名单有没有问题?你在回测里遇到过“当前名单倒灌历史”的情况吗?欢迎在评论区聊聊你的排查经历。
本文只讨论历史股票池、成员资格和回测前数据检查,不构成任何投资建议。涉及TickDB的部分仅基于本次REST K线实测与已核产品资料;历史成分股、point-in-time股票池和指数调整信息仍需独立治理。
通过 TickDB API 获取实时行情数据
一个 API 接入外汇、加密货币、美股、港股、A股、贵金属和全球指数的实时行情。支持 WebSocket 低延迟推送,免费开始使用。
免费领取 API Key查看 API 文档