Strasmore Research
学习 Matt Connor作者: Matt Connor · data as of August 16, 2026 · refreshed weekly

h5i-db时点数据如何避免回测前视偏差

了解h5i-db如何为每次写入保存版本,在存储层固定历史读取,避免回测读取未来数据,并用申报滞后数据验证历史何时真正可用。

时点数据记录的是某个数据集在过去某一日期实际包含的内容。在回测中,它能区分经得起检验的结果与悄然读取了未来数据的结果。h5i-db 是一款较新的开源时间序列数据库,使用 Rust 编写,并提供 Python API。它会将每次写入保存为带编号的版本,并允许任何读取操作固定到较早的版本。下文将先介绍这种固定机制在真实申报数据上阻断的前视偏差,再给出一个可在笔记本电脑上运行的场景。

回测中的时点数据是什么?

每条市场事实都带有两个时间戳。事件时间是事情发生的时间。到达时间是报告该事实的公司以外的任何人能够获知它的时间。季度持仓报告描述的是季度最后一天持有的仓位,但要在数周后才向公众披露。因此,如果模型只按事件时间进行关联,就会使用当时无人能够获得的信息。

这段时间差可以量化。机构管理人须在每个季度结束后提交Form 13F。下方面板统计了报告所描述的季度与提交日期之间相隔的天数。

查询13F持仓申报公开所需时间
每个数字背后的完整 SQL
WITH per_filing AS
(
    SELECT
        accession_number,
        any(toDate(parseDateTimeBestEffortOrNull(toString(period)))) AS period_end,
        any(toDate(filing_date))                                     AS filed_on
    FROM global_markets.stocks_13f_filings
    WHERE filing_date >= '2023-01-01'
    GROUP BY accession_number
)
SELECT
    toString(period_end)                                 AS period_end_date,
    countDistinct(accession_number)                      AS filings_count,
    round(avg(dateDiff('day', period_end, filed_on)), 1) AS avg_days_to_public
FROM per_filing
WHERE period_end IS NOT NULL
  AND filed_on >= period_end
  AND filed_on <= period_end + 400
GROUP BY period_end
HAVING filings_count >= 100
ORDER BY period_end
Run this yourself

截至2026-06-30的季度,10688提交的报告平均在所覆盖期间结束34.1天后才到达。该面板对16个季度重复进行这一测算。管理人还可能在提交报告很久之后进行更正,因此,即使某个日期已经过去,描述该日期的记录仍可能继续变化。

回看偏差是存储问题

我们的回测中的回看偏差指南将数据泄漏视为纪律问题:为每个特征设置滞后,并遵守发布日期。只要有人忘记,这种纪律就会失效,而且通常不会留下明显痕迹。发生数据泄漏的回测会显示更高的夏普比率,却完全不报错。

时间点存储将这项保证下沉到更底层。当交给策略的数据框来自固定在某个版本的读取操作时,在该版本之后写入的行就不可能出现在其中,无论策略代码随后如何运行。检查重点不再是代码审查,而是读取操作本身的属性。

股息从另一个角度展现了这一时间差。现金股息先由公司宣布,之后才进入除息阶段;而今天加载的表格会为每笔分配同时记录这两个日期,其中也包括模拟日期当天尚未宣布的分配。

查询按月统计股息宣告日至除息日的天数
每个数字背后的完整 SQL
SELECT
    toString(toStartOfMonth(ex_dividend_date))                         AS month,
    round(avg(dateDiff('day', declaration_date, ex_dividend_date)), 1) AS avg_days_announced_ahead,
    countDistinct(ticker)                                              AS payers_count
FROM global_markets.stocks_dividends
WHERE ex_dividend_date >= toStartOfMonth(today() - 730)
  AND ex_dividend_date <  toStartOfMonth(today())
  AND declaration_date >= '1990-01-01'
  AND declaration_date <= ex_dividend_date
GROUP BY month
ORDER BY month
Run this yourself

在以2026-07-01开头的月份中,股息宣布日平均早于除息日88.2天;该面板涵盖了同一项测量的24个月。在模拟日期处于这段间隔内时读取现代股息表,该笔分配已经存在于表中,尽管距离公告发布还有数周。

历史数据会被重写

数据迟到是一种故障模式。数据修订是另一种。公司行动会重写已经打印的价格:四比一拆股后,调整序列中的每个历史价格都会除以四,因此今天下载的序列将不再匹配交易者当时看到的行情。我们的拆股调整后的价格历史说明详细演示了计算过程。这里的关键在于发生频率。

查询每季度生效的正向与反向股票拆分
每个数字背后的完整 SQL
SELECT
    toString(toStartOfQuarter(execution_date))    AS quarter_start_date,
    countDistinctIf(id, split_to > split_from)    AS forward_splits,
    countDistinctIf(id, split_to < split_from)    AS reverse_splits
FROM global_markets.stocks_splits
WHERE execution_date >= toStartOfQuarter(today() - 1460)
  AND execution_date <  toStartOfQuarter(today())
  AND split_from > 0
  AND split_to   > 0
GROUP BY quarter_start_date
ORDER BY quarter_start_date
Run this yourself

在始于 2026-04-01 的季度内,131 次正向拆股和 303 次反向拆股生效。每一次公司行动都会修订研究流程可能已经缓存的价格历史。版本化存储无法阻止修订,但会将新状态记录为新版本,同时保留旧版本供读取。这正是将过时结果转化为可复现结果的机制。

新闻时间戳也存在同类陷阱,只是表现得更为细微。

查询按纽约时间小时统计市场新闻发布时间
每个数字背后的完整 SQL
SELECT
    formatDateTime(toTimeZone(published_utc, 'America/New_York'), '%H:00') AS et_hour,
    countDistinct(id)                                                     AS articles
FROM global_markets.stocks_news
WHERE published_utc >= today() - 90
GROUP BY et_hour
ORDER BY et_hour
Run this yourself

新闻标题全天候发布,覆盖纽约交易日的 24 个小时。在过去90天内,09:00时段发布了 790 篇文章,20:00时段发布了 404 篇。若将晚间标题标记为当天纽约时间下午4点的收盘时点,就会让一项在收盘时交易的策略获得数小时的事后信息。

可运行的时间点情景

这里全部使用 Python 软件包。该项目还提供 Rust 命令行工具,但那是单独安装的,本演示不需要。示例数据在本地生成,无需下载。

  1. 安装固定版本:pip install 'h5i-db==0.1.6',该版本于 2026 年 8 月 4 日发布。它要求 Python 3.9 或更高版本,并包含 pyarrow>=14。预构建 wheel 覆盖 x86-64 和 arm64 Linux、Apple silicon macOS,以及 x86-64 Windows。
  2. import pyarrow as paimport pyarrow.parquet as pq 之后定义一次数据:schema = pa.schema([('ts', pa.timestamp('us', tz='UTC')), ('symbol', pa.string()), ('price', pa.float64())])
  3. 使用 pq.write_table(pa.table({'ts': [d1, d2], 'symbol': ['ACME', 'ACME'], 'price': [10.0, 10.5]}, schema=schema), 'day1.parquet') 将两行虚构数据写入本地文件,其中 d1d2 是带时区信息的 datetime。
  4. 创建数据库和数据表,并指定时间列名称:db = h5i_db.Database('pit.db', create=True),然后是 db.create_table('prices', schema, time_column='ts')
  5. 使用一个键导入文件:db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1')。该调用会返回它创建的提交。
  6. 再次运行完全相同的代码行。项目文档说明,使用同一个键重复执行时,系统会找到此前创建的提交,并通过 "segments_added": 0 返回该提交,而不是再次写入这些行。在重试前后分别打印 db.versions('prices'),观察版本列表保持不变。
  7. 使用 idempotency_key='load-day2' 导入第二天的数据,然后查询两天的数据:db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas()
  8. 读取第二天数据写入前的表状态:db.read('prices', version=1)。相同的方法还接受 as_of=snapshot= 参数,用于执行同样的操作。

第 6 步最值得仔细理解。重复追加不会报错。从那一刻起,数据表会处于错误状态,之后每次运行都会悄然继承这一问题。第 8 步体现了其价值:3 月计算出的数值,可以在 8 月根据同一个固定版本重新计算。这正是我们在 可复现回测 中从框架层面主张的特性。

项目的主张,以及我们的核查结果

README首先给出了一项基准测试:

在对2,000万行数据执行OHLCV+VWAP滚动汇总时,速度比DuckDB和Polars快4.5倍以上

这是项目自行测得的数据,引用自我们于2026年8月获取的h5i-db README。我们没有运行这项测试,上文内容也不依赖该结果。

在这里,成熟度比速度更重要。本文撰写时,该代码库获得29颗星,版本为0.1.6,采用Apache-2.0许可证。这意味着维护者数量较少,API在小版本更新之间仍可能发生变化。该引擎在高负载下运行的长期公开记录并不充分。锁定确切版本,并保留用于生成数据库的parquet文件,可以在后续版本改变行为时留有回退路径。保留自己的原始数据副本,也是防止供应商在您不知情的情况下改写历史数据的同一做法。这正是股票数据中的幸存者偏差所涉及的主题。

常见问题

什么是时点数据?

时点数据是一种按每项事实变得可知的时间戳保存的数据集。查询因此可以重建过去任意日期上可见的信息。

普通的“最新值”表无法做到这一点,因为它会用今天修正后的数字覆盖过去的数据。

版本化存储能消除前视偏差吗?

不能。版本化存储只能修复一种数据泄漏,即运行过程读取了模拟决策时间之后写入的数值。

特征构建仍可能通过其他方式造成泄漏。例如,使用基于完整历史数据计算的统计量对样本进行缩放。

数据导入时,幂等键有什么作用?

幂等键会为一次写入操作打上标记,使系统能够将重试识别为同一次写入。

这样,加载程序在崩溃后可以重新运行,而不会重复追加相同的行。否则,表格可能在不易察觉的情况下变得错误。

h5i-db可以用于生产环境吗?

截至本文撰写时,h5i-db的版本为0.1.6,在GitHub上有29颗星,采用Apache-2.0许可证。

这类规模的早期软件通常会经历API变动,公开运行记录也较少。固定版本,并保留一份自己的源文件副本,才能让评估过程可逆。


上面的每个面板都附带生成它的SQL。您可以打开其中一个,查看数字的计算方式。

在Strasmore终端中,您也可以用自然语言提出同样的问题。