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

h5i-db 時間點資料與回測前視偏誤

h5i-db 以版本化寫入提供 point-in-time 資料,從儲存層阻擋回測前視偏誤,並用申報落後期揭示歷史資料何時仍不完整。

Point-in-time data 是記錄資料集在過去某一日期所持內容的資料。在回測中,它能區分可辯護的結果與不知不覺讀取未來數據的結果。h5i-db 是一套年輕的開放原始碼時間序列資料庫,以 Rust 撰寫並提供 Python API。它會將每次寫入儲存為編號版本,讓任何讀取操作都能固定在較早的版本。以下先說明這項版本固定功能如何阻擋資料洩漏,並以真實申報資料進行衡量;接著提供一個可在筆記型電腦上執行的情境。

回測中的 point-in-time 資料是什麼?

每項市場資訊都有兩個時間戳記。事件時間是事情發生的時間。到達時間則是申報該資訊的機構以外的人,最早能取得這項資訊的時間。一份季度持股報告說明的是季度最後一天持有的部位,但通常在數週後才公開。因此,若模型只依事件時間進行連結,就會使用當時沒有人能取得的資訊。

這段時間差可以量化。機構管理人會在每季結束後申報 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 個季度。管理人也可能在申報後很久才修正報告。因此,即使某個日期已經過去,描述該日期的紀錄仍可能持續變動。

前視偏誤是儲存問題

我們介紹回測中的前視偏誤時,將資料洩漏視為一項紀律:所有特徵都要加上落後期,並遵守資料發布日期。只要有人疏忽,這項紀律就會失效,而且通常不易察覺。發生資料洩漏的回測可能呈現更高的 Sharpe ratio,卻完全不會報錯。

時間點資料儲存會把這項保證下移一層。當交給策略的資料框來自固定於某個版本的讀取操作時,在該版本之後寫入的資料列就不可能出現在其中,不論策略程式接下來如何執行。檢查的重點因此不再是程式碼審查,而是讀取操作本身的特性。

股息則從另一個角度呈現這項時間差。現金股息會先公告,之後才到除息日;而今天載入的資料表,會為每筆分配同時記錄這兩個日期,包括在模擬日期當時尚未公告的分配。

查詢各月份股利宣告日至除息日的天數
每個數據背後的精確 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個月。在模擬日期落於這段期間內時讀取現代股息資料表,會發現該筆分配早已存在資料中,距離公告發布還有數週。

儲存的歷史資料會被改寫

資料晚到是其中一種失效模式。重編則是另一種。公司行動會改寫已經成交的價格:在 4-for-1 分割後,調整後序列中的每個早期價格都會除以四,因此今天下載的序列,已不再符合交易員當時看到的盤面。我們的分割調整後價格歷史說明了相關計算。這裡真正重要的是發生頻率。

查詢各季生效的股票分割(正向與反向)
每個數據背後的精確 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 篇。若將晚間新聞標題標記在當日 16:00 的收盤價上,對一個在收盤時交易的策略而言,就等於提供數小時的事後資訊。

可執行的時間點情境

這裡的所有內容都使用 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 倍

這項數據是專案自行測得,引用自 h5i-db README,內容擷取於 2026年8月。我們沒有實際執行測試,以上內容也不依賴這項數據。

在這裡,成熟度比速度更重要。撰寫本文時,該儲存庫有 29 顆 stars,版本為 0.1.6,採 Apache-2.0 授權。這代表維護者人數不多,API 仍可能在小版本更新間變動。也沒有長期且公開的紀錄,能證明這套引擎曾在負載下持續運作。鎖定確切版本,並保留匯入資料庫的 parquet 檔案,可在版本變更行為時保留退路。自行保留原始資料副本,也是防止供應商在你不知情的情況下改寫歷史資料的相同做法;這正是 股票資料中的存活者偏誤 一文貫穿的主題。

常見問題

什麼是時間點資料?

時間點資料會連同每項事實變得可知時的時間戳記一併儲存,因此查詢可以重建過去任一天可見的資訊。一般的「最新值」資料表無法做到這點,因為它會以今天修正後的數字覆寫過去資料。

版本化儲存能消除前視偏誤嗎?

不能。版本控制只能修正其中一類資料外洩,也就是程式讀取了模擬決策時間之後才寫入的數值。特徵建構仍可能以其他方式造成資料外洩,例如以涵蓋完整歷史期間計算出的統計量,對樣本進行尺度調整。

匯入資料時,冪等性鍵有什麼作用?

它會替一次寫入加上標籤,讓系統在重試時辨識出這是同一筆寫入。如此一來,載入程序在當機後可以重新執行,而不會重複附加相同資料列;若未妥善處理這種故障,資料表可能在不知不覺中變得錯誤。

h5i-db 已經可以用於正式環境嗎?

撰寫本文時,h5i-db 的版本為 0.1.6,在 GitHub 上有 29 顆星,採用 Apache-2.0 授權。這類規模的早期軟體可能仍有 API 頻繁變動及公開使用紀錄不足等問題。固定版本,並自行保存一份原始程式檔案,才能讓評估過程具備可逆性。


上方每個面板都附有產生該面板的 SQL。開啟其中一個面板,即可查看數字的計算方式。在 Strasmore terminal 上,也可以用一般英文提出相同問題。