h5i-db: dati point-in-time per backtest
I dati point-in-time eliminano il look-ahead bias nei backtest a livello di storage. Versioni, filing lag e storia obsoleta con h5i-db.
I dati point-in-time registrano il contenuto di un dataset a una data passata. In un backtest distinguono un risultato difendibile da uno che ha utilizzato senza dichiararlo dati futuri. h5i-db è un giovane database open-source per serie storiche, scritto in Rust e dotato di un’API Python. Memorizza ogni scrittura come una versione numerata e consente a qualsiasi lettura di fissarsi su una versione precedente. Di seguito viene illustrato il look-ahead bias che questo meccanismo impedisce, misurato su dati reali provenienti dai filing, seguito da uno scenario eseguibile su un laptop.
Che cos’è il point-in-time data in un backtest?
Ogni informazione di mercato ha due timestamp. L’event time indica quando si è verificato l’evento. L’arrival time indica quando l’informazione è diventata conoscibile a soggetti esterni alla società che l’ha comunicata. Un report trimestrale sulle partecipazioni descrive le posizioni detenute l’ultimo giorno del trimestre, ma diventa pubblico diverse settimane dopo. Un modello che effettua il join utilizzando solo l’event time riceve quindi informazioni che nessuno aveva ancora a disposizione.
Il ritardo è misurabile. I gestori istituzionali presentano il Form 13F dopo la chiusura di ogni trimestre. Il pannello seguente misura i giorni trascorsi tra il trimestre descritto da un filing e il giorno in cui il filing è stato presentato.
L'esatto SQL dietro ogni numero
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_endPer il trimestre terminato il 2026-06-30, i filing di 10688 sono arrivati in media 34.1 giorni dopo il periodo a cui si riferivano. Il pannello ripete questa misurazione su 16 trimestri. Un gestore può inoltre modificare un report molto tempo dopo averlo presentato. Di conseguenza, il record che descrive una data passata continua a cambiare anche dopo che quella data è trascorsa.
Il look-ahead bias è un problema di archiviazione
La nostra guida al look-ahead bias nel backtesting tratta il leakage come una disciplina: applicare un lag a ogni feature e rispettare le date di pubblicazione. La disciplina regge finché qualcuno non la dimentica, e l’errore passa inosservato. Un backtest con leakage stampa uno Sharpe ratio migliore senza generare alcun errore.
Lo storage point-in-time sposta la garanzia a un livello più basso. Quando il frame passato a una strategia proviene da una lettura ancorata a una versione, una riga scritta dopo quella versione non può comparire al suo interno, qualunque cosa faccia poi il codice della strategia. Il controllo non è più una revisione del codice, ma una proprietà della lettura.
I dividendi mostrano il divario temporale dall’altro lato. Un dividendo cash viene dichiarato prima e diventa ex-dividend in un momento successivo, mentre una tabella caricata oggi riporta entrambe le date per ogni pagamento, compresi quelli che non erano ancora stati annunciati alla data simulata.
L'esatto SQL dietro ogni numero
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 monthNel mese che inizia 2026-07-01, le dichiarazioni sono state effettuate in media 88.2 giorni prima della data ex-dividend, e il panel copre 24 mesi della stessa misurazione. Se si legge una moderna tabella dei dividendi a una data simulata compresa in quell’intervallo, il pagamento è già presente, settimane prima che l’annuncio fosse disponibile.
La cronologia archiviata viene riscritta
L’arrivo tardivo è un possibile punto di failure. La rettifica è l’altro. Le operazioni sul capitale riscrivono prezzi già registrati: dopo un frazionamento four-for-one, ogni prezzo precedente in una serie rettificata viene diviso per four e la serie scaricata oggi non corrisponde più al tape osservato dal trader. La nostra nota sulla cronologia dei prezzi rettificata per frazionamento illustra i calcoli. Qui conta la frequenza.
L'esatto SQL dietro ogni numero
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_dateNel trimestre iniziato il 2026-04-01 sono diventati effettivi 131 forward split e 303 reverse split. Ognuno di questi eventi rettifica una cronologia dei prezzi che una pipeline di ricerca potrebbe avere già messo in cache. Lo storage con versioning non impedisce la rettifica. Registra il nuovo stato come una nuova versione e mantiene leggibile quello precedente. È questo che trasforma un risultato obsoleto in un risultato riproducibile.
I timestamp delle notizie nascondono lo stesso rischio, in forma ridotta.
L'esatto SQL dietro ogni numero
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_hourI titoli vengono pubblicati ininterrottamente, durante tutte le 24 ore della giornata di New York. Nell’intervallo delle 09:00 sono stati pubblicati 790 articoli negli ultimi 90 giorni, contro 404 nell’intervallo delle 20:00. Attribuire a quella chiusura delle 16:00 un titolo serale consegna a una strategia che opera sulla chiusura diverse ore di hindsight.
Uno scenario point-in-time eseguibile
Tutto ciò che segue riguarda il package Python. Il progetto include anche uno strumento da riga di comando in Rust, che richiede un’installazione separata e non serve per questa procedura. I dati di esempio vengono generati localmente, senza download.
- Installa la release fissata:
pip install 'h5i-db==0.1.6', pubblicata il 4 agosto 2026. Richiede Python 3.9 o una versione successiva e includepyarrow>=14. I wheel precompilati coprono Linux su x86-64 e arm64, oltre a macOS con Apple silicon e Windows su x86-64. - Descrivi i dati una sola volta, dopo
import pyarrow as paeimport pyarrow.parquet as pq:schema = pa.schema([('ts', pa.timestamp('us', tz='UTC')), ('symbol', pa.string()), ('price', pa.float64())]). - Scrivi due righe inventate in un file locale con
pq.write_table(pa.table({'ts': [d1, d2], 'symbol': ['ACME', 'ACME'], 'price': [10.0, 10.5]}, schema=schema), 'day1.parquet'), doved1ed2sono oggetti datetime consapevoli del fuso orario. - Crea il database e la tabella, indicando la colonna temporale:
db = h5i_db.Database('pit.db', create=True), quindidb.create_table('prices', schema, time_column='ts'). - Inserisci il file con una chiave:
db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1'). La chiamata restituisce il commit eseguito. - Esegui di nuovo la stessa riga. La documentazione del progetto specifica che una ripetizione con la stessa chiave individua il commit già prodotto e lo restituisce con
"segments_added": 0, invece di scrivere nuovamente le righe. Stampadb.versions('prices')prima e dopo il retry e verifica che l’elenco delle versioni resti invariato. - Inserisci un secondo giorno con
idempotency_key='load-day2', quindi esegui una query su entrambi:db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas(). - Leggi la tabella nello stato precedente all’inserimento del secondo giorno:
db.read('prices', version=1). Per lo stesso scopo, lo stesso metodo accetta gli argomentias_of=esnapshot=.
Il passaggio 6 merita particolare attenzione. Un append duplicato non genera errori. Da quel momento lascia la tabella in uno stato errato, e ogni esecuzione successiva eredita silenziosamente il problema. Il passaggio 8 è il risultato più importante: un valore calcolato a marzo può essere ricalcolato ad agosto a partire dalla stessa versione fissata, cioè la proprietà sostenuta a livello di framework dal nostro testo sul backtest riproducibile.
Cosa sostiene il progetto e cosa abbiamo verificato
Il README apre con un benchmark:
oltre 4,5 volte più veloce di DuckDB e Polars nei rollup OHLCV+VWAP su 20 milioni di righe
Questo dato è una misurazione del progetto, riportata nel README di h5i-db come recuperato ad agosto 2026. Non l’abbiamo eseguita e nulla di quanto precede dipende da essa.
Qui la maturità conta più della velocità. Al momento della stesura, il repository aveva 29 stelle ed era alla versione 0.1.6, con licenza Apache-2.0. Questa combinazione indica un gruppo ristretto di maintainer e un’API ancora soggetta a modifiche tra una point release e l’altra. Non esiste una lunga documentazione pubblica del funzionamento del motore sotto carico. Fissare la versione esatta e conservare i file parquet usati per alimentare il database offre una possibilità di ripristino se una release modifica il comportamento. Conservare una propria copia dei dati grezzi segue la stessa prassi che protegge dal rischio che un vendor riscriva la storia dei dati alle tue spalle: è il tema ricorrente di survivorship bias nei dati azionari.
FAQ
Che cosa sono i dati point-in-time?
I dati point-in-time sono un dataset archiviato con i timestamp che indicano quando ogni informazione è diventata conoscibile. Una query può così ricostruire ciò che era visibile in una determinata data passata. Una semplice tabella con il “latest value” non consente di farlo, perché sovrascrive il passato con i numeri corretti di oggi.
Lo storage versionato elimina il look-ahead bias?
No. Il versioning risolve una classe di leakage: quella in cui un’esecuzione legge valori scritti dopo il momento della decisione simulata. La costruzione delle feature può tuttavia generare leakage in altri modi, per esempio quando un campione viene normalizzato usando statistiche calcolate sull’intera serie storica.
Che cosa fa una chiave di idempotenza durante l’ingest?
Identifica una scrittura, così un retry viene riconosciuto come la stessa operazione. Un loader può quindi essere rieseguito dopo un crash senza aggiungere due volte le stesse righe. È questo il tipo di errore che lascia una tabella silenziosamente errata.
h5i-db è pronto per la produzione?
Al momento della stesura, h5i-db è alla versione 0.1.6, ha 29 stelle su GitHub ed è distribuito con licenza Apache-2.0. Un software iniziale di queste dimensioni comporta cambiamenti frequenti delle API e uno storico pubblico ancora limitato. Fissare la versione e conservare una propria copia dei file sorgente permette di rendere reversibile la valutazione.
Ogni pannello sopra include l’SQL che lo ha prodotto. Aprine uno per vedere come è stato calcolato il numero. Le stesse domande possono essere poste in inglese semplice sul terminale Strasmore.