h5i-db : données point-in-time pour les backtests
Les données point-in-time évitent le biais d’anticipation dans les backtests. Découvrez le versionnage des écritures et l’effet du décalage des publications.
Les données point-in-time correspondent à l’état d’un jeu de données à une date passée donnée. Dans un backtest, elles permettent de distinguer un résultat défendable d’un résultat qui a utilisé sans le dire des chiffres publiés seulement le lendemain. h5i-db est une jeune base de données open source de séries temporelles, écrite en Rust et dotée d’une API Python. Elle enregistre chaque écriture sous la forme d’une version numérotée et permet d’associer chaque lecture à une version antérieure. Vous trouverez ci-dessous le biais d’anticipation que ce mécanisme empêche, mesuré sur des données réelles issues de publications réglementaires, puis un scénario exécutable sur un ordinateur portable.
Qu’est-ce que la donnée point-in-time dans un backtest ?
Chaque information de marché comporte deux horodatages. Le temps de l’événement correspond au moment où le fait s’est produit. Le temps d’arrivée correspond au moment où l’information est devenue accessible à toute personne extérieure à l’entreprise qui l’a publiée. Un reporting trimestriel des positions décrit les titres détenus le dernier jour d’un trimestre, mais il est rendu public plusieurs semaines plus tard. Un modèle qui effectue une jointure uniquement sur le temps de l’événement reçoit donc une information que personne ne pouvait connaître à ce moment-là.
Cet écart est mesurable. Les gérants institutionnels déposent le formulaire 13F après la clôture de chaque trimestre. Le graphique ci-dessous mesure le nombre de jours entre le trimestre décrit par un dépôt et la date de ce dépôt.
Le SQL exact derrière chaque chiffre
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_endPour le trimestre clos en 2026-06-30, les dépôts de 10688 ont été publiés en moyenne 34.1 jours après la période couverte. Le graphique reproduit cette mesure sur 16 trimestres. Un gérant peut également modifier un reporting longtemps après son dépôt. L’enregistrement décrivant une date passée continue donc d’évoluer une fois cette date révolue.
Le look-ahead bias est un problème de stockage
Notre guide consacré au look-ahead bias dans le backtesting traite la fuite d’information comme une règle de méthode : retarder chaque variable et respecter les dates de publication. Cette discipline tient jusqu’à ce qu’un oubli survienne. L’échec passe alors inaperçu. Un backtest contaminé affiche un meilleur ratio de Sharpe, sans générer la moindre erreur.
Le stockage point-in-time déplace cette garantie d’un niveau. Lorsque le jeu de données transmis à une stratégie provient d’une lecture rattachée à une version, une ligne écrite après cette version ne peut pas y apparaître, quelles que soient les instructions exécutées ensuite par la stratégie. Le contrôle ne relève plus d’une revue de code. Il devient une propriété de la lecture.
Les dividendes illustrent le décalage temporel sous un autre angle. Un dividende en numéraire est d’abord annoncé, puis détaché ultérieurement. Or une table chargée aujourd’hui contient les deux dates pour chaque versement, y compris ceux qui n’avaient pas encore été annoncés à la date simulée.
Le SQL exact derrière chaque chiffre
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 monthAu cours du mois commençant en 2026-07-01, les annonces sont intervenues en moyenne 88.2 jours avant la date ex-dividende, et le panel couvre 24 mois selon la même mesure. Si vous lisez une table de dividendes moderne à une date simulée située dans cet intervalle, le versement y figure déjà, plusieurs semaines avant que l’annonce n’ait existé.
L’historique enregistré est réécrit
L’arrivée tardive est un mode de défaillance. La restatement en est un autre. Les opérations sur titres réécrivent des cours déjà publiés : après un split de quatre pour un, chaque cours antérieur d’une série ajustée est divisé par quatre. La série téléchargée aujourd’hui ne correspond alors plus au tape qu’observait un trader. Notre note sur l’historique des cours ajusté des splits détaille le calcul. Le point important ici est la fréquence.
Le SQL exact derrière chaque chiffre
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_dateAu cours du trimestre commençant en 2026-04-01, 131 splits à la hausse et 303 splits à la baisse ont pris effet. Chacun réécrit un historique des cours qu’un pipeline de recherche peut déjà avoir mis en cache. Le stockage versionné n’empêche pas cette réécriture. Il enregistre le nouvel état comme une nouvelle version et conserve l’ancien état en lecture, ce qui transforme un résultat obsolète en résultat reproductible.
Les horodatages des actualités présentent le même piège, à plus petite échelle.
Le SQL exact derrière chaque chiffre
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_hourLes titres sont publiés en continu, pendant les 24 heures de la séance new-yorkaise. L’heure de 09:00 a compté 790 articles au cours des 90 derniers jours, contre 404 pour l’heure de 20:00. Attribuer un titre publié le soir à la clôture de 16:00 du même jour donne à une stratégie qui trade la clôture plusieurs heures d’avance.
Un scénario figé que vous pouvez exécuter
Tout ce qui suit concerne le package Python. Le projet fournit également un outil en ligne de commande en Rust, qui s’installe séparément et dont ce parcours n’a pas besoin. Les données d’exemple sont générées localement, sans téléchargement.
- Installez la version figée :
pip install 'h5i-db==0.1.6', publiée le 4 août 2026. Elle nécessite Python 3.9 ou une version ultérieure et fournitpyarrow>=14. Des wheels précompilés sont disponibles pour Linux en x86-64 et arm64, ainsi que pour macOS sur Apple silicon et Windows en x86-64. - Décrivez les données une seule fois, après
import pyarrow as paetimport pyarrow.parquet as pq:schema = pa.schema([('ts', pa.timestamp('us', tz='UTC')), ('symbol', pa.string()), ('price', pa.float64())]). - Écrivez deux lignes fictives dans un fichier local avec
pq.write_table(pa.table({'ts': [d1, d2], 'symbol': ['ACME', 'ACME'], 'price': [10.0, 10.5]}, schema=schema), 'day1.parquet'), oùd1etd2sont des objets datetime tenant compte du fuseau horaire. - Créez la base de données et la table en nommant la colonne temporelle :
db = h5i_db.Database('pit.db', create=True)puisdb.create_table('prices', schema, time_column='ts'). - Ingérez le fichier sous une clé :
db.append('prices', pq.read_table('day1.parquet'), idempotency_key='load-day1'). L’appel renvoie le commit qu’il a créé. - Exécutez exactement la même ligne une nouvelle fois. Le projet précise qu’une répétition utilisant la même clé retrouve le commit déjà créé et le renvoie avec
"segments_added": 0, sans réécrire les lignes. Affichezdb.versions('prices')de part et d’autre de la nouvelle tentative et vérifiez que la liste des versions ne change pas. - Ingérez une deuxième journée avec
idempotency_key='load-day2', puis interrogez les deux journées :db.sql('SELECT symbol, count(*) AS n, avg(price) AS px FROM prices GROUP BY symbol').to_pandas(). - Lisez la table dans l’état où elle se trouvait avant l’arrivée de la deuxième journée :
db.read('prices', version=1). La même méthode accepte les argumentsas_of=etsnapshot=pour effectuer la même opération.
L’étape 6 mérite une attention particulière. Un append dupliqué ne génère aucune erreur. Il fausse la table à partir de ce moment, et toutes les exécutions suivantes héritent silencieusement de cette erreur. L’étape 8 en est l’intérêt principal : un chiffre calculé en mars peut être recalculé en août à partir de la même version figée. C’est la propriété défendue au niveau du framework dans notre présentation d’un backtest reproductible.
Ce que le projet affirme et ce que nous avons vérifié
Le README commence par un benchmark :
plus de 4,5 fois plus rapide que DuckDB et Polars pour des agrégations OHLCV+VWAP sur 20 millions de lignes
Ce chiffre provient des propres mesures du projet. Il est cité dans le README de h5i-db, consulté en août 2026. Nous ne l’avons pas reproduit. Rien de ce qui précède n’en dépend.
La maturité compte ici davantage que la vitesse. Au moment de la rédaction, le dépôt comptait 29 stars et était en version 0.1.6, sous licence Apache-2.0. Cette combinaison indique une équipe de maintenance réduite et une API encore susceptible d’évoluer d’une version corrective à l’autre. Il n’existe pas encore d’historique public établi montrant le fonctionnement du moteur sous charge. Épingler la version exacte et conserver les fichiers parquet utilisés pour alimenter la base de données permet de revenir en arrière si une version modifie le comportement du logiciel. Conserver votre propre copie des données brutes relève de la même prudence que celle qui vous protège contre un fournisseur réécrivant l’historique à votre insu : c’est le thème développé dans le biais de survivance dans les données boursières.
FAQ
Que sont les données point-in-time ?
Les données point-in-time sont stockées avec l’horodatage correspondant au moment où chaque information est devenue connue. Une requête peut ainsi reconstituer ce qui était visible à n’importe quelle date passée. Une simple table de « dernière valeur » ne le permet pas, car elle remplace les données historiques par les chiffres corrigés du jour.
Le stockage versionné élimine-t-il le look-ahead bias ?
Non. Le versionnage corrige une forme de fuite d’information : celle où une exécution lit des valeurs enregistrées après la date de décision simulée. La construction des features peut encore introduire des fuites d’une autre manière, par exemple lorsqu’un échantillon est standardisé à partir de statistiques calculées sur l’ensemble de son historique.
À quoi sert une clé d’idempotence lors de l’ingestion ?
Elle associe une écriture à un identifiant afin qu’une nouvelle tentative soit reconnue comme la même écriture. Un loader peut alors être relancé après un crash sans ajouter deux fois les mêmes lignes. C’est ce type d’incident qui peut rendre une table incorrecte sans signalement apparent.
h5i-db est-il prêt pour la production ?
Au moment de la rédaction, h5i-db est en version 0.1.6, compte 29 stars sur GitHub et est distribué sous licence Apache-2.0. Un logiciel récent de cette taille peut encore connaître des changements fréquents de son API et dispose de peu d’historique public. Épingler la version et conserver sa propre copie des fichiers sources permet de rendre l’évaluation réversible.
Chaque panneau ci-dessus est fourni avec le SQL qui l’a produit. Ouvrez-en un pour voir comment le chiffre a été calculé. Les mêmes questions peuvent être posées en français courant sur le terminal Strasmore.