沪深300回测最容易“穿越”的,不是价格,而是股票池

TickDB Research ·

标题:沪深300回测最容易“穿越”的,不是价格,而是股票池

摘要
用今天下载的沪深300成分股名单去回测十年前,最危险的地方不只是漏了几只股票,而是你把今天才知道的股票池交给了过去的策略。价格K线可以告诉你某只股票在某天有没有行情,但它不能告诉你这只股票在那个历史时点是否已经属于沪深300、调整信息是否已经公开、成员资格是否已经生效。本文把这个问题拆成三层名单模型、三日期契约和一张提交前检查卡,并用一次真实TickDB K线调用说明:价格数据可取,不等于历史股票池资格可证明。缺少point-in-time成员资格台账时,回测应该先停下来。

image.png 你下载了一份今天的沪深300成分股名单。然后拉了过去十年的K线,写了一个回测。曲线不错,回撤也能接受。你以为自己验证的是策略能力。

但在这个流程里,有一个问题经常被跳过:十年前的那个交易日,策略当时到底有资格看哪些股票?

价格数据回答的是:这只股票在某天有没有行情,open、high、low、close、volume是多少。股票池成员资格回答的是:在那个历史时点,这只股票是否已经属于沪深300,调整信息是否已经公开,成员资格是否已经生效。

两件事不是一回事。

如果你用今天的成分股名单去代表过去十年的成分股名单,就等于让过去的策略提前知道了未来哪些公司会进入指数,也让一些后来退出、退市或被替换的历史样本从视野里消失。结果可能被高估,也可能被扭曲。但有一点很确定:这已经不是一个干净的历史实验。

这篇文章只讲一个问题:回测开始前,如何判断你的历史股票池有没有“穿越”。

一、官方指数历史,不等于你的策略股票池

很多人说“我回测了沪深300”,其实可能在说三件完全不同的事。

你在做什么用的什么名单能做什么
官方指数历史序列指数公司发布的历史点位作为基准观察,不是策略股票池
历史有效成分股某个历史日期真正生效的成员集合策略选股的基础——当时能选哪些股票
当前成分名单今天下载的名单只告诉你现在有哪些股票在名单里

中证指数有限公司《沪深300指数编制方案》明确,沪深300指数样本会定期审核并根据结果调整,调整有明确的实施时间。既然样本会调整,那么当前名单不能自动等同于所有历史时点的有效名单。

这不是一个细节问题。它会直接改变你的研究对象。

二、三层名单模型:不要把三张“合照”混在一起

我更愿意把它理解成三张合照。

第一张,当前名单。 你今天下载的合照。只说明现在这批公司在名单里。

第二张,历史有效名单。 某个历史日期真正生效的合照。回答的是:当时规则下哪些股票属于股票池。

第三张,当时可得名单。 在历史有效名单之上,再加一层信息可得性:策略在那个决策时点是否已经有资格知道这次调整。

image.png

最容易犯的错,是拿第一张合照去替代后两张。

  • 某只股票2020年才进入沪深300,你的2016年回测已经把它放进候选池 → 把未来成员带回过去
  • 某只股票曾经属于沪深300,后来退出了,今天的名单里没有它 → 历史样本从视野里消失
  • 调整公告已发布,但成员资格还没生效,你把新成员提前纳入 → 成员资格时间错配

回测前真正要锁定的不是“我今天拿到了一份名单”,而是:每个历史决策日,当时已经公开且已经生效的成员集合是什么。

三、三日期契约:把“当时能知道”写进数据

要让这个问题进入代码和数据表,至少要保存三个日期。

字段含义作用
announced_at调整信息何时公开判断策略在决策时点是否已经知道这次调整
effective_from成员资格何时开始判断新成员何时可以进入有效股票池
effective_to成员资格何时结束判断旧成员何时不再属于股票池

这三个字段不是TickDB原生字段,也不是在声称所有指数公司都采用这个命名。它们是研究者本地维护历史股票池时,最小可执行的数据契约。

image.png

提交回测前,每个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.",
        }

image.png 图里的关键结果有两层。

第一层,价格数据可以取到。 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股票池和指数调整信息仍需独立治理。

相关文章

多市场选股别只算指标:用一套行情 API 串起扫描池、市场指标和资金流

把A股策略扩展到港股和美股时,很多人第一反应是加指标。真正拖慢你的,往往是更早的数据准备:三份股票名单、不同的symbol规则,以及分散在不同入口里的行情、指标和资金流。 TickDB的优势就在这里:用同一套REST API接入A股、港股和美股。产品目录建立扫描池,ticker和K线确认行情,批量读取市场指标,继续查看候选股票的资金流结构。 它不替你选股票,但能把选股前最容易断开的几层数据接起来。

TickDB Research ·

A股美股港股行情看板Demo三天、上线三个月:拆解7个返工点

同一张行情表,寒武纪的成交额自己算是5,414万元,接口给的是54.20亿元,差了100倍,原因只是一个单位。行情看板三天就能接通,上线后的返工,几乎都来自界面上说不清“自己是什么”的数字。 本文依据上交所、港交所的一手文件,加上10月12日盘中对7类资产的实测,给你四样东西: 一张“价格身份证”:每个数字上线前要答的5个问题 7个返工点逐条拆:钱在哪里算错、为什么会错 一份可以直接发给数据…

TickDB Research ·

拆解美股夜盘数据5个口径:12月6日起美股进入23小时交易

同一只苹果股票,同一个周六中午,三个“最新价”,1万股持仓估值差出 9,500 美元。12月6日起,美股将进入近23小时交易:夜盘数据的5个口径不说清,日K线、收益率、风控和对账都会悄悄算错。 本文依据纳斯达克、SEC、DTCC、FINRA 等一手文件,加上30只美股(含9只中概股)的实测数据,给你四样东西: 一张“北京时间 × 美股夜盘”全景图(含11月1日夏令时切换前后) 5个口径逐条拆…

TickDB Research ·

港股数据源选型:踩一个回测就假(富途/IBKR/TickDB)

回测里某个港股策略表现稳定,实盘跑了一个月,收益差了将近一半。 不是完全对不上,是那种“差异不大但一直在”的偏差。团队排查了三周——策略逻辑、滑点模型、手续费、执行延迟,全部查过。最后发现问题在数据层:数据源返回的港股代码是“700”,而团队数据库里存的是“00700”。两个格式查出来的数据,部分重叠,部分不重叠。回测用的样本,比实际可交易的样本少了一批。 这不是某个操作失误,是港股数据接入…

TickDB Research ·

A股休市10天,港股跌了,美联储还要开会,APEC即将落地深圳——8号开盘,你盘清楚了几件事?

后天开盘。10月8日,A股恢复交易。你手里的A股仓位,价格停在9月30日;但如果你同时持有港股、美股、QDII,这10天它们一直在动——港股10月2日就开盘了,美股全程正常交易。 这篇文章要说的不是“A股休市了”。有仓位的人都知道A股休市。它要说的是: 同一段时间里,你的三个市场仓位在动,你的信息却只跟得上一个。 核心概念是 信息时态差 ——港股和美股的信息是连续的,每天有K线;A股的信息是…

TickDB Research ·