Chaque chiffre de la note, et d'où il vient
Assez de méthode pour vérifier les calculs à la main, ou pour reconstruire une étude équivalente à partir de la même source sous licence.
Données et provenance
La source est Algoseek US Equity Trades and Quotes en résolution nanoseconde, tirée du flux SIP consolidé (CTS, CQS, UTDF, UQDF) collecté à Equinix NY2 et NY4. Chaque jour-titre est un CSV compressé au schéma Date, Timestamp, EventType, Ticker, Price, Quantity, Exchange, Conditions, où Timestamp vaut HH:MM:SS.nnnnnnnnn et Conditions est un masque de conditions de vente sur 32 bits en hexadécimal.
Les séances sont énumérées depuis le manifeste quotidien .index/YYYYMMDD.csv.gz plutôt que par listage du bucket, refusé sur ce produit. Un petit GET renvoie bucket, prefix, file, compressed_bytes, uncompressed_bytes pour toute la journée, la colonne 3 étant la taille compressée et la colonne 4 la taille décompressée. Lire cet ordre à l'envers est ce qui a produit une première estimation de 300 Go/jour pour un produit qui pèse 27 Go/jour.
| Bloc | Séances | Plage | Taille compressée |
|---|---|---|---|
| 2019 | 60 | 20190201 à 20190429 | 12.4 GB |
| 2022 | 60 | 20220201 à 20220427 | 44.9 GB |
| 2024 | 60 | 20240201 à 20240426 | 29.3 GB |
Périmètre exact téléchargé : 900 jours-titre sur AAPL, SPY, JPM, XOM et F, 86,6 Go compressés. Les fichiers sont récupérés en GET gzip simple et non via S3 Select, qui renvoie du CSV décompressé et a presque doublé les octets sur un fichier test.
Deux pièges du flux, tous deux vérifiés empiriquement
Se tromper sur l'un ou l'autre corrompt silencieusement toute feature de volume et de flux d'ordres, et aucun des deux n'est évident dans la spécification du fournisseur.
Le tape est l'union de TRADE et TRADE NB, et les deux sont disjoints. La spécification décrit TRADE NB comme un trade au NBBO ou à l'intérieur, et TRADE comme tout le reste, ce qui laisse ouverte la question de savoir si le second réémet le premier : elle a donc été tranchée par la mesure.
Le recouvrement exact par tuple horodatage, prix, quantité et venue vaut 0. Un test de correspondance approchée, demandant si chaque impression TRADE NB a une impression TRADE de mêmes prix, quantité et venue à moins de 1 ms, renvoie un taux de 0,0000, contre 0,0000 pour un témoin dont les horodatages TRADE sont décalés de 5 secondes.
Structurellement les deux sont également disjoints : 95% à 98% des impressions TRADE portent le drapeau odd lot au bit 31 contre 0,00% des impressions TRADE NB, et le produit trades seul d'Algoseek contient exactement cette union, 83 244 lignes contre 83 243 pour l'union sur JPM au 2019-03-15. N'utiliser qu'une moitié coupe le tape en deux, les traiter comme des doublons aussi.
Les corrections officielles d'ouverture et de clôture doivent être écartées. Le bit 24 (tOfficialClose) et le bit 26 (tOfficialOpen) signalent une place de marché qui réénonce le prix de fixing, pas une nouvelle exécution. Pour F au 2024-03-15 l'impression de clôture du NYSE de 36 626 227 titres apparaît cinq fois, une comme exécution du fixing puis à nouveau à 16:01, 16:10, 18:30 et 19:00, si bien que sommer l'union naïvement donne à F 238 millions de titres ce jour-là, alors qu'écarter les bits 24 et 26 donne 88 millions, ce que F a réellement traité.
Une troisième correction est plus mineure mais mérite d'être dite : le brief initial demandait d'exclure FINRA, CRF et les autres venues hors bourse, or CRF n'existe pas comme libellé dans ce flux. Les 19 identifiants de place du SIP ont été énumérés sur les trois années, et Algoseek consolide tout rapport hors bourse sous FINRA (code SIP D) : exclure FINRA est donc la forme opérationnelle de cette consigne.
Voici le filtre complet, appliqué à l'identique aux deux jeux de données pour que les barres OHLC sous les bras A et B soient identiques au bit près.
tape = pl.concat([trade, trade_nb]) # disjoint halves, verified above
tape = tape.filter(
(pl.col("cond") & (1 << 24) == 0) # tOfficialClose restatement
& (pl.col("cond") & (1 << 26) == 0) # tOfficialOpen restatement
& (~pl.col("event").is_in(["TRADE CANCELLED", "TRADE NB CANCELLED"]))
& (~pl.col("Exchange").is_in(["FINRA", "UNKNOWN", "INVALID"]))
& pl.col("ts").is_between(RTH_OPEN, RTH_CLOSE)
& (pl.col("Price") > 0) & (pl.col("Quantity") > 0)
)Reconstruction du NBBO
La meilleure limite nationale à l'achat et à la vente vient des lignes QUOTE BID NB et QUOTE ASK NB, remplies vers l'avant côté par côté, les lignes partageant un horodatage étant d'abord réduites à leur dernier état. Cette réduction compte parce qu'un côté peut imprimer avant l'autre : remplir la séquence brute fabrique des carnets verrouillés ou croisés qui n'ont jamais existé, à un rythme proche de 5% des mises à jour.
nb = (
quotes.sort("ts")
.group_by("ts", maintain_order=True).last() # collapse before filling, not after
.with_columns([pl.col("bid").forward_fill(), pl.col("ask").forward_fill()])
)Les statistiques pondérées par le temps sont calculées exactement en injectant une ligne d'état synthétique à chaque frontière de minute, de sorte que chaque intervalle de pondération tombe entièrement dans une barre.
Jeux de features
Les 53 features du bras B couvrent la géométrie de barre (amplitude, corps, mèches, close location value et gap), les estimateurs de volatilité Parkinson, Garman-Klass et Rogers-Satchell, les écarts et croisements de moyennes mobiles à 5, 15, 60 et 390 barres, le momentum et le taux de variation jusqu'à 1 950 barres, la volatilité et l'asymétrie glissantes, le RSI à 14 et 60, le MACD avec signal et histogramme, l'ATR, le %B et la largeur de Bollinger, les %K et %D stochastiques, le Williams %R, le CCI, la position Donchian, le TRIX, l'indice d'Ulcer, la fréquence de nouveaux extrêmes, un ratio de volatilité courte sur longue et des rendements retardés.
Le volume est délibérément absent parce que ce n'est pas une quantité OHLC, ce qui exclut par construction l'on-balance volume, le money flow et les indicateurs fondés sur le VWAP du bras B.
Le bras A ajoute 151 features de tape en trois familles. Les colonnes côté transactions portent le nombre de trades, le volume, le volume en dollars, la taille moyenne, médiane et maximale, l'écart du VWAP à la clôture, les volumes acheteur et vendeur sous classification Lee-Ready contre le NBBO en vigueur, le volume et le flux en dollars signés, le déséquilibre de comptage, les fractions exécutées à chaque côté de la fourchette ou au-delà et strictement à l'intérieur, le spread effectif simple et pondéré par le volume, la variance réalisée et la variation bipower depuis les prix traités, l'illiquidité d'Amihud, le spread implicite de Roll, le lambda de Kyle estimé dans la minute, la moyenne et la dispersion des durées inter-trades, la part d'odd lots et de blocs, le nombre de venues, l'indice de Herfindahl des venues, la part de la venue principale et un markout à une seconde.
Les colonnes côté cotations portent le spread coté pondéré par le temps en bps avec son minimum, son maximum et sa dispersion, la profondeur pondérée par le temps de chaque côté, le déséquilibre de taille, le microprice et son écart au point médian, l'ouverture, le plus haut, le plus bas et la clôture du point médian, la variance réalisée du point médian, la fraction de temps verrouillé ou croisé, le nombre de mises à jour du NBBO par côté, le ratio cotations sur transactions et le déséquilibre de flux d'ordres de Cont cumulé sur les états successifs du NBBO. Douze de ces signaux entrent aussi aux retards 1, 2, 3 et 5 et en moyennes glissantes sur 5, 15 et 60 barres, ce qui donne au bras A la même profondeur d'historique que les indicateurs glissants du bras B.
Mettre dans le même panel une action à 11 $ et un ETF à 520 $ dont les débits de messages diffèrent de deux ordres de grandeur interdit d'entrer les colonnes de niveau telles quelles. Les niveaux de prix deviennent des écarts en points de base à la clôture de barre, et les colonnes de comptage et de taille deviennent des z-scores glissants sur cinq jours, les flux étant normalisés par le turnover glissant, chaque fenêtre se terminant en t n'utilisant que l'information disponible en t.
# levels -> bps from close; counts/sizes -> trailing z over 5 sessions (1,950 bars)
df = df.with_columns([
((pl.col("q_mid_close") / pl.col("close") - 1) * 1e4).alias("a_q_mid_close_bps"),
((pl.col("t_n") - pl.col("t_n").rolling_mean(1950)) /
pl.col("t_n").rolling_std(1950)).alias("a_t_n_z"),
])Modèles
Trois implémentations de gradient boosting, LightGBM, XGBoost et CatBoost, toutes en profondeur 4, 15 feuilles, minimum 500 échantillons par feuille, taux d'apprentissage 0,02, 300 arbres, sous-échantillonnage de colonnes 0,5 et pénalité L2 de 10, avec SEED = 20260731 partout.
Les rendements actions à une minute portent un rapport signal-bruit de l'ordre de 1e-3, si bien qu'une capacité tabulaire ordinaire mémorise la fenêtre d'entraînement : un test rapide avec des arbres à 63 feuilles a renvoyé un R-carré hors échantillon de -0,60 là où la configuration régularisée renvoie -0,046 avec des prévisions non dégénérées. Ces valeurs ont été fixées avant la campagne complète à partir des propriétés connues de la cible, jamais des performances d'un bras, puis appliquées à l'identique à chaque bras, horizon, bloc et fold, donc il n'y a aucune recherche cachée à déflater.
Mécanique du walk-forward
Chaque bloc-titre est découpé en cinq segments contigus donnant quatre folds expansifs, l'entraînement précédant toujours le test dans le temps. Les cibles chevauchantes sont la façon standard de fuiter dans une étude d'horizon, puisqu'à h = 390 une ligne d'entraînement près de la frontière a une cible qui plonge dans la fenêtre de test : chaque fold écarte donc les h dernières barres de sa plage d'entraînement, et un fold est sauté quand la purge laisse moins de 2 000 lignes, ce qui supprime la plupart des folds à un mois.
L'indicateur le plus long du bras B demande 1 950 barres d'historique là où celui du bras A en demande 60 : sans échauffement commun, les deux panels différeraient par le nombre de lignes portant des valeurs manquantes, ce qui est une différence dans les données et non dans l'information. Les 1 950 premières barres de chaque bloc-titre sont donc écartées sur tous les bras.
for fold in expanding_folds(block, n_segments=5):
train = block[: fold.train_end - HORIZON] # purge h bars of overlap
if len(train) < 2000:
continue # skipped, and logged as skipped
test = block[fold.train_end : fold.test_end]
model = fit(ARM_FEATURES[arm], train, params=FROZEN, seed=SEED)
preds[fold] = model.predict(test[ARM_FEATURES[arm]])Cette boucle a produit 459 ajustements sur 3 blocs x 5 horizons x jusqu'à 4 folds x 3 bras x 3 modèles.
Taille d'échantillon honnête et inférence
Le R-carré hors échantillon est mesuré contre une prévision nulle et non contre la moyenne de la période de test, cette dernière étant une constante avec look-ahead. À côté figurent le rank IC de Spearman, la justesse directionnelle et les décompositions par ticker, l'indicateur principal restant l'écart apparié A moins B dans chaque cellule bloc, horizon, fold et modèle, ce qui retire les effets de période et de fold qui dominent les niveaux.
Des cibles chevauchantes de h barres fournissent environ n/h observations indépendantes, et cinq tickers corrélés ne fournissent pas cinq séries indépendantes. La corrélation moyenne deux à deux mesurée sur les rendements horaires vaut 0,32, avec AAPL contre SPY à 0,85, ce qui par l'argument de variance standard donne 2,19 séries effectives.
rho = mean_pairwise_corr(returns_1h) # 0.32 measured, not assumed n_series_eff = n_sym / (1 + (n_sym - 1) * rho) # 5 -> 2.19 n_eff = (n_rows / horizon) * (n_series_eff / n_sym)
Les intervalles de confiance viennent d'un bootstrap qui rééchantillonne des tranches de temps contiguës portant les cinq tickers ensemble. Un intervalle construit en rééchantillonnant des lignes est ressorti 1,7 fois trop étroit, à une largeur à 95% de 0,184 là où le bootstrap conjoint par tranches donne 0,310.
La nulle empirique
Les seuils fixes ont été entièrement remplacés par une nulle empirique, qui n'exige aucune hypothèse sur la taille d'échantillon effective. Le même fold est relancé contre une cible tournée circulairement à l'intérieur de chaque bloc-titre, ce qui détruit la correspondance entre features et cible tout en préservant chaque distribution marginale, l'autocorrélation de la cible et la corrélation entre tickers, après quoi un résultat réel est jugé par son centile contre cette dispersion.
k = rng.integers(1, len(y)) y_null = np.roll(y, k) # circular: no rows are dropped
Cela a représenté 480 réajustements, 20 rotations pour chaque bloc, horizon et bras. La rotation est le seul réétiquetage préservant la structure quand les observations ne sont pas librement échangeables, et c'est ce qui fait de la distribution obtenue la bonne nulle pour ce plan.
Le contrôle économique
La détectabilité statistique n'est pas la tradabilité : à une minute le signe de la prévision est tradé et rapporté en brut, puis net du spread effectif que le tape a réellement imprimé cette minute-là plus une provision de frais, le coût étant mesuré sur les données et non supposé.
pos = np.sign(pred) turnover = np.abs(np.diff(pos, prepend=0)) / 2 gross_bps = pos * fwd_ret_bps cost_bps = turnover * (eff_spread_bps + FEE_BPS) # FEE_BPS = 0.5 net_bps = gross_bps - cost_bps breakeven = gross_bps.mean() / turnover.mean() # what the signal can afford to pay
Les minutes sans impression au marché lité, environ 0,01% des barres, héritent de la clôture précédente et sont marquées d'un compte de trades nul : leur spread effectif est vide et est comblé par la médiane de l'échantillon, uniquement dans cette analyse.
Tests de fuite et ce qu'ils ont attrapé
Trois tests devaient passer avant qu'une comparaison soit rapportée, et le pipeline sort avec un code non nul en cas d'échec.
| Test | Ce qu'il fait | Résultat |
|---|---|---|
| Causalité | Reconstruire un jour-titre depuis un tape tronqué à 12:00 et comparer chaque barre antérieure à la construction sur la journée entière | Réussi : 149 barres, 63 colonnes numériques, 0 écart |
| Fuite plantée | Ajouter la cible elle-même comme feature au bras B | Réussi : le R-carré passe de -0,097 à +0,609, l'IC à 0,993 |
| Nulle de rotation | Tourner circulairement les cibles dans chaque bloc-titre, 480 réajustements | Réussi : l'IC réel à 1 minute est à environ 12 écarts-types de la moyenne de la nulle |
Les trois tests sont passés sur données réelles avant qu'aucune comparaison de la note ne soit produite.
Deux bugs dans les tests eux-mêmes sont consignés ici parce que la différence entre un test qui passe et un test qui ne pouvait pas échouer est toute la valeur d'un test. Le premier était écrit if python3 leak_tests.py 2>&1 | tee -a "$LOG"; then, ce qui teste le code de sortie de tee et non celui du script, et a été corrigé par set -o pipefail et ${PIPESTATUS[0]}.
Le second était la nulle par décalage, qui utilisait shift(-k) et annulait donc les k dernières lignes de chaque groupe : comme le test évalue le dernier fold, son ensemble de test était vide et la fonction ne renvoyait silencieusement rien. La corriger a demandé une rotation circulaire et fill_nan(None), parce que polars traite NaN et null comme distincts et que drop_nulls passe à côté des NaN ramenés par la rotation.
Cette nulle une fois fonctionnelle a exposé une erreur d'inférence, pas de pipeline. Son premier passage a marqué un R-carré de +0,042 sur une cible qui ne peut contenir aucun signal, parce que la taille d'échantillon effective était calculée en lignes/h, traitant cinq tickers comme cinq répliques indépendantes, ce qui surestimait chaque n_eff d'environ 2,4x et produisait des intervalles bootstrap 1,7x trop étroits, les deux corrigés comme décrit plus haut.
Environnement
python 3.10.12 polars 1.40.1 numpy 2.2.6 scipy 1.15.3 pandas 2.3.3 lightgbm 4.6.0 xgboost 3.2.0 catboost 1.2.10 scikit-learn 1.7.2 shap 0.49.1 pyarrow 24.0.0 boto3 1.43.9 matplotlib 3.10.9
Chaque modèle est initialisé par une graine, et les tirages de bootstrap et de rotation utilisent numpy.random.default_rng(SEED) avec SEED = 20260731, si bien qu'une relance sur les mêmes données brutes reproduit exactement les tables. Le coût total de la campagne a été de 0 $, les lectures S3 étant facturées à Algoseek, et le temps total d'environ 90 minutes.
Les graphiques de la note sont dessinés à partir des tables de résultats publiées, les mêmes que celles dont proviennent les chiffres du texte.
Demander le pipeline complet
Cette page suffit à vérifier à la main chaque chiffre de la note, ou à reconstruire une étude équivalente depuis la même source sous licence. Les scripts de production (le dimensionneur, le téléchargeur, la construction des features du tick à la barre, le constructeur de panel, le moteur de walk-forward, la nulle, l'analyse et les figures) ne sont pas joints : écrivez à daniel@daru.finance et je vous les envoie.

