La taxe du framework : quand votre bibliothèque de backtest est plus lente qu'une boucle pandas naïve
Fait partie de la série « Backtests sans illusions ».
Il y a une hypothèse confortable au fond de la plupart des projets de trading algorithmique : qu'une bibliothèque de backtest mature et bien étoilée est rapide. Elle a des années de contributions, une vraie boucle d'événements, un modèle de courtier, un schéma de commissions. Elle bat forcément la boucle pandas bricolée que vous auriez écrite vous-même. Alors vous vous en emparez, câblez votre stratégie et lancez un balayage de paramètres — quelques milliers de configurations, un travail d'une nuit. Vous revenez le matin et il tourne toujours.
Nous avons évalué huit moteurs de backtest sur un même balayage de paramètres identique et découvert quelque chose qui devrait changer votre façon de choisir un outil de recherche. Deux des frameworks événementiels open source les plus populaires — backtrader et bt — ont exécuté le balayage plus lentement qu'une boucle pandas naïve que nous avions écrite comme référence jetable. Pas un peu plus lentement. backtrader a pris environ 2,5× le temps de la référence pandas ; bt, environ 4,7×. Pendant ce temps, un moteur vectorisé/compilé sur le même travail tournait environ 13 000× plus vite que bt.
C'est la taxe du framework. Les backtesters populaires sont construits pour une exécution honnête — une stratégie, un jeu de données, des remplissages soignés, un courtier qui se comporte comme un courtier. C'est exactement ce que vous voulez pour une validation finale ou un contrôle de parité avec le live. C'est exactement le mauvais outil pour ce à quoi la recherche algo passe réellement son temps : exécuter la même stratégie dix mille fois avec des réglages légèrement différents. Cet article mesure la taxe, explique le mécanisme et donne une règle de décision pour savoir quand un « vrai » framework de backtest est le mauvais choix.
Chaque chiffre ici provient d'un même banc reproductible (benchmarks/bench_oss_engines.py, commit 250dbb5) sur une charge de travail identique et à parité verrouillée. Là où nous n'avons pas exécuté un moteur nous-mêmes, nous le disons et le plaçons dans une section d'honnêteté distincte plutôt que d'inventer un chiffre.
Une exécution contre dix mille

Le fait déterminant de la recherche de paramètres est que le moteur s'exécute des milliers de fois mais que l'analyse n'a lieu qu'une fois. Quel que soit le coût fixe que vous payez pour mettre en place un backtest — construire la boucle d'événements, instancier un courtier, allouer un objet par barre —, vous le payez à chaque combinaison. Un coût invisible dans une seule exécution (« qui se soucie de 6 secondes ? ») devient toute la facture sur un balayage (« 6 secondes × 10 000 = 16,6 heures »).
Les moteurs de backtest relèvent de trois paradigmes, et le paradigme est le destin de la performance de balayage :
- Événementiel — le moteur parcourt barre par barre, émet des événements, appelle votre callback
next()/onBars(), achemine les ordres via un objet courtier. C'est l'architecture de backtrader, backtesting.py, PyAlgoTrade, zipline et nautilus_trader. Elle reflète le fonctionnement réel du trading en direct, ce qui est précisément pourquoi on lui fait confiance pour le réalisme — et précisément pourquoi elle est lente : le surcoût Python par barre est payé 150 000 fois par combinaison. - Poids/rééquilibrage —
btse situe ici. Vous lui remettez une matrice de poids cibles et il rééquilibre aux dates que vous spécifiez. Pas de callback par barre, mais toujours un graphe d'objets par événement (un arbre d'algos, un grand livre de transactions) évalué en Python. - Vectorisé / compilé — toute la stratégie est exprimée comme des opérations sur tableaux (vectorbt), un noyau compilé en JIT (numba) ou du code natif (Rust, un noyau GPU MLX). Il n'y a plus aucun Python par barre. La boucle, s'il y en a une, tourne à la vitesse de la machine.
Le reste de cet article est la conséquence empirique de cette taxonomie. Nous avons construit une charge de travail, fait exécuter à chaque moteur — de façon prouvable — le même travail, et chronométré.
La charge de travail : une stratégie, quatre-vingts réglages, identique pour tous
Un benchmark n'est honnête que si chaque moteur fait le même travail. Le nôtre est délibérément simple — une charge de travail où la seule chose qui diffère entre les moteurs est le moteur lui-même.
- Les données. Une seule série de clôtures synthétique en mouvement brownien géométrique :
150,000barres,seed=42, volatilité par barresigma=0.0008,x0=30000. Déterministe, donc n'importe qui peut la reproduire bit pour bit. Clôture uniquement par construction — l'OHLC est fixé à la clôture sur chaque jambe, parce que la stratégie est un croisement de clôtures. - La stratégie. Un croisement de Hull Moving Average : une HMA de longueur
Lcontre une variante HMA-du-tiers plus rapide (HMAcontreHMA3). Toujours dans le marché, on inverse long/short à chaque croisement. C'est un indicateur réel, non trivial — deux moyennes mobiles pondérées imbriquées plus un lisseur à fenêtre racine carrée — pas une SMA jouet, donc le travail par barre est représentatif. - Le balayage.
80longueurs de HMA couvrant6..200. Ce sont les « dix mille exécutions » rendues assez petites pour être mesurées directement : 80 combinaisons indépendantes, chacune un backtest complet sur 150k barres. - Les coûts. Des frais aller-retour de
0.09%, répartis par côté pour les moteurs qui modélisent les commissions par côté. Remplissage sur la même barre àclose[i]— le signal sur la barreiest exécuté à la clôture de cette barre, la convention qu'utilise notre moteur de production.
Le chronomètre par combinaison enveloppe exactement deux choses : le pré-calcul HMA en numpy et l'exécution du moteur. La mise en place véritablement ponctuelle (chargement des données, construction des objets barre) reste hors du chronomètre. Il y a une exécution d'échauffement, puis le meilleur de N répétitions, et — parce que les moteurs événementiels sont assez lents pour que 80 combinaisons complètes prennent de nombreuses minutes à plus d'une heure — nous chronométrons un échantillon uniforme de la grille et extrapolons linéairement. Les combinaisons sont indépendantes, donc l'extrapolation linéaire est exacte en espérance ; la même convention est appliquée à la référence pandas, donc aucun moteur n'en est avantagé.
Parité : prouver que tous font le même travail
Voici le piège dans lequel tombe une comparaison naïve de moteurs : un moteur « rapide » pourrait simplement faire moins. Si backtrader enregistre 2 700 trades et que votre moteur vectorisé en enregistre 40, le moteur vectorisé n'est pas plus rapide — il est faux, et la comparaison n'a aucun sens.
Nous verrouillons donc la comparaison avec un contrôle de parité du nombre de trades. À L=104, la référence numpy produit exactement 2 707 trades clôturés. Chaque moteur doit reproduire cela à une tolérance de ±1 près, sinon l'exécution s'interrompt avec une assertion work-parity FAILED. La tolérance n'existe que parce que les moteurs divergent sur les conventions de comptabilité — si la position ouverte finale est clôturée de force et comptée, si l'entrée initiale est un « trade » — non sur les trades eux-mêmes :
| Moteur | Trades rapportés à L=104 | Convention |
|---|---|---|
| référence numpy | 2707 | allers-retours clôturés |
| backtesting.py | 2708 | +1 : position finale clôturée de force à la fin |
| backtrader | 2707 | position ouverte finale non comptée |
| bt | 2708 | +1 : entrée initiale comptée comme une transaction |
| PyAlgoTrade | 2708 | +1 : entrée initiale comptée comme un remplissage |
Chaque moteur atterrit à 2707 ± 1. Quelles que soient les différences de vitesse qui se révèlent, elles ne sont pas un artefact d'un moteur sautant discrètement du travail. C'est la discipline qui nous permet de mettre un framework événementiel et un noyau GPU dans le même tableau et de le penser vraiment.
Les résultats
Voici tout le tableau, trié du plus rapide au plus lent. combos/s est le débit ; la dernière colonne est le temps que prend le balayage complet de 80 combinaisons. La ligne de référence est M0 — le moteur pandas naïf, une boucle for sur les barres avec une comptabilité scalaire, la chose que vous écririez en un après-midi et jetteriez. Tout ce qui est plus lent que cette référence est en gras.
| Moteur | combos/s | Paradigme | Balayage complet de 80 combos |
|---|---|---|---|
| noyau GPU MLX | 779 | vectorisé (Apple GPU) | 0.10 s |
| Rust natif | ~350 | compilé | 0.23 s |
| mp + numba | 246 | JIT compilé + multiprocessus | 0.33 s |
| vectorbt | 56.9 | vectorisé (numpy/numba) | 1.4 s |
| numba (mono-cœur) | 39.7 | JIT compilé | 2.0 s |
| backtesting.py | 1.42 | événementiel | 56 s |
| PyAlgoTrade | 0.51 | événementiel | 2.6 min |
| M0 — pandas naïf + boucle | 0.28 | référence scalaire | 4.8 min |
| backtrader | 0.11 | événementiel | 12.7 min |
| bt | 0.06 | poids / rééquilibrage | 22.5 min |
Lisez le tableau de haut en bas et le paradigme se trie de lui-même : les cinq premiers sont tous vectorisés ou compilés, les cinq derniers tous événementiels ou basés sur un graphe d'objets — avec la boucle pandas naïve assise au-dessus de deux frameworks matures et populaires. L'écart du haut vers le bas est de quatre ordres de grandeur. Sur exactement la même charge de travail de 2 707 trades, le noyau MLX termine le balayage en un dixième de seconde ; bt a besoin de vingt-deux minutes et demie. C'est un facteur d'environ 13 000×.
Le scandale au milieu du tableau

Les chiffres accrocheurs sont aux extrêmes, mais le résultat instructif est au milieu : backtrader (0,11 combos/s) et bt (0,06 combos/s) sont tous deux plus lents que la référence pandas naïve (0,28 combos/s).
Cela mérite qu'on y pense. M0 n'est pas un moteur astucieux. C'est une boucle for Python qui indexe dans un DataFrame, suit une position et des liquidités en scalaires simples, et ajoute des trades à une liste — le « témoin » délibérément non optimisé que nous avons inclus pour avoir quelque chose de manifestement mauvais à battre. L'accès par ligne de pandas est notoirement lent, et nous avons joué là-dessus. Et pourtant deux des bibliothèques de backtest les plus recommandées de l'écosystème lui perdent : backtrader de 2,5×, bt de 4,7×.
La nuance qui maintient l'honnêteté : tous les moteurs événementiels ne sont pas plus lents que pandas. backtesting.py (1,42 combos/s) bat la référence de 5×, parce que c'est une boucle d'événements élancée et adossée à numpy qui maintient la création d'objets par barre au minimum. PyAlgoTrade (0,51) devance aussi la référence. Donc « événementiel » n'est pas automatiquement une condamnation à mort — mais plus la machinerie par barre est lourde, pire c'est, et backtrader et bt portent la machinerie la plus lourde ici. Le paradigme fixe le plafond ; l'implémentation décide où sous ce plafond vous atterrissez.
Le point n'est pas que ce sont de mauvaises bibliothèques. Le modèle de courtier de backtrader et la conception en arbre d'algos de bt existent pour vous acheter justesse et expressivité — gestion réaliste des ordres, rééquilibrage de portefeuille, analyseurs. Ces fonctionnalités ont un coût d'exécution, et ce coût est invisible quand vous exécutez une fois. Sur un balayage, c'est toute l'histoire.
Pourquoi les moteurs événementiels paient la taxe

Le mécanisme n'a rien de mystérieux. Un backtest événementiel, par barre, fait quelque chose comme ceci :
- Avance l'horloge, découpe la barre suivante du flux de données et la matérialise en objet (un
Bar, uneLine, un dict). - Déclenche un callback dans le code utilisateur (
next(),onBars()), qui est un appel de fonction Python avec sa propre trame. - À l'intérieur du callback, interroge l'état du courtier/de la position, de nouveau via des appels de méthode et des recherches d'attributs.
- Si un ordre est créé, l'achemine via le courtier : le valide, vérifie la marge/les liquidités, planifie un remplissage, mute un objet portefeuille, ajoute à un grand livre de transactions.
- Met à jour les analyseurs, les observateurs et toute comptabilité que le framework maintient.
Multipliez maintenant par 150 000 barres, puis par 80 combinaisons : douze millions d'itérations de barre par balayage, chacune une poignée d'allocations d'objets au niveau Python et de répartitions dynamiques. Le surcoût par opération de Python — des dizaines à des centaines de nanosecondes pour une recherche d'attribut ou une petite allocation — est trivial une fois et ruineux douze millions de fois. Le cas bt est une variante de la même maladie : bien qu'il ne rééquilibre qu'aux dates de trade plutôt qu'à chaque barre, chaque rééquilibrage évalue un arbre d'objets algo et touche un grand livre de portefeuille adossé à pandas, et il y en a 2 707 par combinaison, fois 80.
La boucle pandas naïve bat backtrader et bt pour une raison brute : elle fait moins par barre. Elle saute le courtier, les objets d'événement, la pile d'analyseurs, la machine à états d'acheminement des ordres. Elle paie la vilaine taxe par ligne de pandas, mais cette unique vilaine taxe reste moins chère que la taxe soignée, complète et à un objet par événement du framework. Quand vous réduisez un backtest à « position × rendement suivant − frais », l'essentiel de ce qu'un framework fait par barre est du surcoût que vous n'utilisez pas pendant une recherche.
Et voici le piège : le surcoût est la raison pour laquelle vous avez choisi le framework. Vous vouliez le courtier réaliste. Vous vouliez les analyseurs. Pendant la validation finale, vous voulez tout cela. Pendant une recherche de 10 000 combinaisons dont vous ne lisez qu'un seul objectif scalaire à l'autre bout, vous payez une limousine pour faire des tours de piste.
L'autre bout du tableau : vectorisé et compilé
Le haut du tableau, c'est ce qui arrive quand vous supprimez entièrement le Python par barre.
- vectorbt (56,9 combos/s) exprime toute la stratégie comme des opérations sur tableaux numpy/numba. Il n'y a pas de boucle de barre en Python — le signal, la position, le PnL sont tous au niveau tableau. Il exécute le balayage en 1,4 seconde contre les 22,5 minutes de
bt: environ 950× plus vite sur un travail identique. (Nous couvrons la conception de vectorbt plus en profondeur dans l'aperçu de vectorbt et la comparaison plus large pandas-vs-polars.) - numba (39,7) est la boucle pandas, inchangée dans sa forme, compilée en JIT vers du code natif. Même algorithme que M0, mais
@njittransforme 0,28 combos/s en 39,7 — une accélération d'environ 140× à partir d'un décorateur, parce que le surcoût de l'interpréteur qui dominait la boucle scalaire s'évapore tout simplement. - mp + numba (246) exécute le noyau compilé sur plusieurs cœurs de CPU. Les combinaisons sont massivement parallèles — chacune est indépendante — donc le multiprocessing passe à l'échelle de façon quasi linéaire par-dessus le JIT.
- Rust natif (~350) enlève le dernier de la glu Python : tout le balayage est natif.
- MLX GPU (779) projette le balayage sur un noyau GPU en silicium Apple. 80 combinaisons deviennent 80 voies parallèles d'arithmétique ; le balayage se termine avant que vous ne relâchiez la touche entrée.
Deux choses méritent d'être nommées précisément. Premièrement, numba prouve que le paradigme compte plus que le langage. M0 et numba exécutent le même algorithme — la différence est purement que l'un est du Python interprété par barre et l'autre est compilé. C'est toute la taxe du framework dans un A/B contrôlé : ~140× pour supprimer l'interpréteur de la boucle interne. Deuxièmement, le saut de numba (39,7) à mp+numba (246) à MLX (779) ne concerne plus du tout le moteur — il concerne l'orchestration et le matériel. Une fois la taxe par barre disparue, la vitesse devient une question de combien de combinaisons vous exécutez en parallèle et sur quel silicium. Nous parcourons toute cette progression dans l'échelle de vitesse des moteurs de backtest, et la raison pour laquelle le dernier kilomètre est dominé par le coût de processus/sérialisation dans l'article sur la taxe IPC.
Ce que nous n'avons pas mesuré (et pourquoi nous vous le disons)
La crédibilité d'un benchmark vit dans ce qu'il refuse de truquer. Nous avons exécuté huit moteurs de bout en bout sous parité. Plusieurs frameworks bien connus, nous n'avons pas mis de chiffre dessus, et nous préférons les lister honnêtement plutôt qu'extrapoler un chiffre que nous n'avons pas mesuré :
- zipline / zipline-reloaded — événementiel, lignée Quantopian. Mise en place lourde (un calendrier de trading complet et un bundle de données), ce qui rend délicat un chronométrage par combinaison à comparaison équitable. Architecturalement, il se range avec backtrader dans le camp événementiel ; nous l'attendrions près de ce bout du tableau, mais nous ne l'avons pas prouvé.
- nautilus_trader — événementiel avec un cœur Rust/Cython, explicitement conçu pour la parité avec le live. Son cœur est compilé, donc c'est le moteur événementiel le plus susceptible de ne pas payer la pleine taxe Python — une mesure véritablement intéressante que nous n'avons pas encore réalisée.
- QuantConnect Lean — basé sur C#, un runtime entièrement différent ; pas directement comparable dans un banc Python.
- Jesse — événementiel, orienté crypto ; nous avons passé en revue sa conception dans une note séparée mais ne l'avons pas évalué ici.
- QSTrader — événementiel, orienté portefeuille ; mêmes réserves de paradigme.
- fastquant — nous avons essayé ; l'installation/l'API était cassée dans notre environnement, donc pas de chiffre. Nous n'allons pas en deviner un.
Deux réserves honnêtes sur les chiffres que nous rapportons bien. Les chiffres de vectorbt, numba, mp+numba, Rust natif et MLX proviennent de notre propre échelle de moteurs sur la charge de travail identique, non du banc OSS qui a produit les quatre lignes événementielles — c'est la même charge de travail mais un banc de mesure différent, et le chiffre Rust natif est un ~350 approximatif, pas une mesure précise. Et les combos/s absolus sont spécifiques au matériel ; ce qui voyage, c'est l'ordre et les ratios, qui sont assez grands (13 000× du haut vers le bas, 2,5–4,7× pour l'inversion pandas-contre-framework) pour qu'aucune différence de matériel raisonnable ne les renverse.
En défense des moteurs événementiels

Il serait facile de lire ceci comme « les backtesters événementiels sont mauvais ». C'est la mauvaise leçon, et une leçon injuste.
Les moteurs événementiels sont construits pour un travail différent, et ils y sont bons. Le courtier par barre, le cycle de vie de l'ordre, la logique de remplissage, les analyseurs — cette machinerie existe pour faire ressembler un backtest au trading en direct d'aussi près que possible. Quand votre but est une répétition générale unique et digne de confiance d'une stratégie que vous êtes sur le point de déployer, vous voulez que le moteur transpire sur chaque remplissage, modélise les exécutions partielles, respecte la marge et refuse de vous laisser trader à des prix que vous n'auriez pas pu obtenir. Cette fidélité est le produit. Son coût d'exécution est le prix du réalisme, et pour une seule exécution le prix est négligeable.
L'échec n'est pas le moteur ; c'est de l'utiliser pour la mauvaise phase. La recherche a deux phases distinctes aux exigences opposées :
- La recherche veut du débit. Vous explorez un paysage, dont la majeure partie est de la camelote, et vous devez évaluer des milliers de points pour trouver les quelques-uns qui méritent un deuxième regard. La fidélité par point importe à peine — vous classez, vous ne déployez pas. Ici, la taxe du framework est du pur gaspillage.
- La validation veut de la fidélité. Vous avez une poignée de candidats et vous devez savoir, aussi précisément que possible, s'ils survivent à une exécution réaliste, aux frais, au slippage et aux pièges d'anticipation qui gonflent les rendements sur papier. Ici, le moteur événementiel gagne son coût.
L'erreur que punit la taxe du framework, c'est d'exécuter votre recherche sur votre moteur de validation — payer des tarifs de limousine pour explorer un paysage dont vous allez jeter 99 %.
La règle de décision
L'enseignement pratique se comprime en une seule décision :
Cherchez sur un moteur vectorisé/compilé. Validez les survivants sur un moteur événementiel.
Concrètement :
- Construisez ou empruntez un noyau rapide pour le balayage. vectorbt si vous le voulez sur étagère ; une boucle compilée en numba si votre stratégie ne se vectorise pas proprement (le décorateur
@njità lui seul a acheté ~140× ici). Faites passer tout l'espace de paramètres à travers. - N'exécutez jamais un grand balayage sur backtrader,
bt, zipline ou n'importe quel framework événementiel lourd. Si un balayage sur l'un de ceux-là est votre goulot d'étranglement, la solution n'est pas une machine plus grosse — c'est le mauvais moteur. Même la boucle pandas naïve en battrait deux. - Promouvez une liste restreinte vers le moteur événementiel pour un contrôle de fidélité. Prenez la poignée de survivants et réexécutez-les sur le moteur réaliste, où le modèle de courtier et la logique de remplissage peuvent exposer des problèmes que le noyau rapide avait abstraits.
- Imposez la parité entre les deux. Le moteur rapide et le moteur de fidélité doivent s'accorder sur le nombre de trades et le PnL sur une configuration fixe (notre contrôle à ±1 trade à L=104), sinon la recherche et la validation mesurent des stratégies différentes et tout le pipeline est un mensonge.
C'est la même architecture à deux vitesses qui apparaît chaque fois que recherche et validation ont des profils de coût opposés, et c'est pourquoi notre propre pile garde un chemin vectorisé/compilé rapide pour la recherche de paramètres et réserve la machinerie lourde à l'évaluation de la fonction objectif finale et aux contrôles de plateau.
À retenir
- Populaire ≠ rapide pour les balayages. Sur une même charge de travail identique et à parité verrouillée (150k barres, 80 combinaisons de croisements HMA, 2 707 trades), backtrader (0,11 combos/s) et
bt(0,06) ont tous deux tourné plus lentement qu'une boucle pandas naïve (0,28). Un framework mature et bien étoilé n'est pas automatiquement le choix rapide. - La taxe du framework est par barre, et un balayage la multiplie. Douze millions d'itérations de barre par balayage, chacune portant un objet d'événement, un callback et un aller-retour de courtier. Un coût invisible dans une seule exécution est toute la facture sur dix mille.
- Le paradigme fixe le plafond. Les moteurs vectorisés/compilés (vectorbt 56,9, numba 39,7, mp+numba 246, Rust natif ~350, MLX 779) battent les moteurs événementiels de deux à quatre ordres de grandeur — jusqu'à ~13 000× du haut vers le bas. Le même algorithme, simplement compilé en JIT, est allé 140× plus vite.
- Tous les moteurs événementiels ne sont pas égaux. backtesting.py (1,42) et PyAlgoTrade (0,51) battent encore la référence naïve ; la taxe augmente avec la lourdeur de la machinerie par barre. L'implémentation décide où sous le plafond vous atterrissez.
- Deux moteurs, deux phases. Cherchez sur un noyau vectorisé/compilé ; validez les survivants sur le moteur événementiel réaliste. Imposez la parité du nombre de trades/du PnL entre eux pour que les deux mesurent la même stratégie.
- Soyez honnête sur ce que vous avez mesuré. Nous avons évalué huit moteurs sous parité et listé ceux que nous n'avons pas mesurés (zipline, nautilus_trader, Lean, Jesse, QSTrader et l'installation de fastquant qui refusait de tourner) plutôt que d'inventer des chiffres pour eux.
Le résumé inconfortable : si un balayage de paramètres est votre goulot d'étranglement, le problème n'est probablement ni votre machine ni votre stratégie. C'est que vous exécutez une recherche sur un moteur qui a été construit pour une seule exécution honnête — et vous seriez plus rapide avec la boucle pandas que vous aviez trop honte de garder.
Le banc complet, les assertions de parité et les résultats JSON bruts par moteur vivent dans benchmarks/bench_oss_engines.py et benchmarks/results_oss/ au commit 250dbb5. Pour le bout compilé/GPU de l'échelle, voir l'échelle de vitesse des moteurs de backtest et l'analyse de la taxe IPC.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.