h5i-db 時間點資料與回測前視偏誤
h5i-db 以版本化寫入提供 point-in-time 資料,從儲存層阻擋回測前視偏誤,並用申報落後期揭示歷史資料何時仍不完整。
Point-in-time data 是記錄資料集在過去某一日期所持內容的資料。在回測中,它能區分可辯護的結果與不知不覺讀取未來數據的結果。h5i-db 是一套年輕的開放原始碼時間序列資料庫,以 Rust 撰寫並提供 Python API。它會將每次寫入儲存為編號版本,讓任何讀取操作都能固定在較早的版本。以下先說明這項版本固定功能如何阻擋資料洩漏,並以真實申報資料進行衡量;接著提供一個可在筆記型電腦上執行的情境。
回測中的 point-in-time 資料是什麼?
每項市場資訊都有兩個時間戳記。事件時間是事情發生的時間。到達時間則是申報該資訊的機構以外的人,最早能取得這項資訊的時間。一份季度持股報告說明的是季度最後一天持有的部位,但通常在數週後才公開。因此,若模型只依事件時間進行連結,就會使用當時沒有人能取得的資訊。
這段時間差可以量化。機構管理人會在每季結束後申報 Form 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截至 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在以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在始於 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新聞標題全天候發布,涵蓋紐約交易日的 24 個小時。過去 90 天中,09:00 這個小時發布了 790 篇文章,20:00 這個小時則發布了 404 篇。若將晚間新聞標題標記在當日 16:00 的收盤價上,對一個在收盤時交易的策略而言,就等於提供數小時的事後資訊。
可執行的時間點情境
這裡的所有內容都使用 Python 套件。該專案也提供 Rust 命令列工具,但那是另一個安裝項目,本操作不需要使用。範例資料會在本機產生,不需要下載。
- 安裝固定版本:
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。 - 在
import pyarrow as pa與import pyarrow.parquet as pq之後一次描述資料:schema = pa.schema([('ts', pa.timestamp('us', tz='UTC')), ('symbol', pa.string()), ('price', pa.float64())])。 - 使用
pq.write_table(pa.table({'ts': [d1, d2], 'symbol': ['ACME', 'ACME'], 'price': [10.0, 10.5]}, schema=schema), 'day1.parquet')將兩筆自行建立的資料列寫入本機檔案,其中d1與d2是具時區資訊的 datetime。 - 建立資料庫與資料表,並命名時間欄位:
db = h5i_db.Database('pit.db', create=True),接著執行db.create_table('prices', schema, time_column='ts')。 - 使用鍵值
db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1')擷取該檔案。該呼叫會回傳它建立的提交版本。 - 再次執行完全相同的程式列。專案文件說明,重複使用相同鍵值時,系統會找到先前已建立的提交版本,並以
"segments_added": 0回傳,而不會再次寫入資料列。在重試前後各印出db.versions('prices'),觀察版本清單維持不變。 - 使用
idempotency_key='load-day2'擷取第二天的資料,接著查詢兩天的資料:db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas()。 - 讀取第二天資料寫入前的資料表狀態:
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 上,也可以用一般英文提出相同問題。