Même commit, mêmes fichiers parquet, même machine, deux exécutions à une heure d’intervalle. Sharpe 1.34 et Sharpe 1.19. Rendement total de 41.2% et 37.8%. Nombre de transactions : 1,418 et 1,421. Et les deux courbes de capital concordaient jusqu’à la dernière décimale affichée pendant 11,205 barres consécutives avant de diverger.
Les trois premiers chiffres sont agaçants. Le dernier est intéressant, car il montre que le problème ne vient pas d’erreurs d’arrondi en virgule flottante qui se seraient propagées sur toute l’exécution. Un événement discret s’est produit à une barre précise, puis ses effets se sont amplifiés. Cet article explique comment trouver cette barre et ce que cet écart de 0.15 implique pour tous les balayages de paramètres que vous avez effectués.
La stratégie : momentum transversal sur les 30 contrats perpétuels USDⓈ-M les plus échangés, rééquilibrage toutes les 4 heures, position acheteuse sur les cinq meilleurs rendements sur 12 heures, vendeuse sur les cinq moins bons, 18 mois d’historique, frais et funding comptabilisés par jambe.
Trouver la barre où les exécutions divergent
Si vous consignez la valeur du capital barre par barre en pleine précision, cela prend environ quatre minutes. Exportez les deux exécutions en CSV de (bar_index, equity, open_positions_hash), chargez-les, puis trouvez le premier indice où elles diffèrent. Nous avions déjà écrit ce script pour un autre bug. C’est la seule raison pour laquelle je n’y ai pas passé toute une matinée.
Barre 11,206, 2025-03-14T08:00 UTC. À la barre précédente, le capital était identique à 13 chiffres significatifs. À la barre 11,206, les empreintes des positions diffèrent : l’exécution A est acheteuse sur SOL, l’exécution B sur AVAX. Même sens, même notionnel, symbole différent. Tout ce qui suit en découle.
J’ai donc affiché les données utilisées pour le classement à cette barre dans les deux exécutions. Elles étaient identiques. À l’octet près : les mêmes 30 symboles, les mêmes 30 scores. Deux scores étaient 0.0. Exactement zéro, tous les deux, car les deux actifs avaient enregistré une barre de 4 heures sans aucune transaction pendant la fenêtre de calcul. Le rapport entre les cours de clôture successifs était donc égal à un, et son logarithme à zéro. Ce n’était pas un arrondi à zéro. C’était zéro.
Deux symboles à égalité au rang cinq. Nous retenions les cinq premiers. Lequel des deux serait sélectionné dépendait de l’ordre dans lequel le tri les laissait, et le tri ne faisait pas ce que je croyais.
Une égalité, un tri instable et un détail que j’ignorais depuis deux ans
Le classement passait par une agrégation groupée, et le tableau qui l’alimentait provenait d’une compréhension de dictionnaire parcourant un ensemble de symboles, reconstruit à chaque barre à partir d’une récupération asynchrone. L’ordre de parcours de l’ensemble change avec la graine de hachage, et Python randomise la graine de hachage des chaînes à chaque processus, sauf si vous fixez PYTHONHASHSEED. L’ordre des lignes avant le tri variait donc d’une exécution à l’autre et, avec un tri instable sur une clé ex æquo, l’égalité était départagée différemment.
Ce genre d’égalité n’a rien d’exceptionnel. C’est inhérent à la structure du problème. Chaque fois qu’une caractéristique sature ou est plafonnée, vous créez des valeurs exactement égales : les barres sans volume donnent des rendements exactement nuls, un z-score plafonné se fixe à ±3.0, une transformation en rang avec peu de valeurs distinctes produit des dizaines d’égalités, un filtre booléen attribue un score de 1.0 à tous les éléments retenus. Sur 18 mois de barres de 4 heures, cette exécution a compté 47 barres avec une égalité à la limite de sélection. Trois ont modifié le panier sélectionné. Pour les autres, les deux actifs à égalité étaient déjà tous les deux retenus ou tous les deux exclus.
Pendant environ une journée, j’étais convaincu que le chargeur de données n’était pas déterministe, parce que c’est l’explication la plus excitante. Ce n’était pas ça. Ce n’est jamais ça. C’est un ensemble, une égalité et une hypothèse sur la stabilité du tri que personne n’avait consignée.
Pourquoi trois transactions valent 0.15 de Sharpe
C’est là que les gens contestent le résultat. La réponse, c’est qu’un backtest qui dimensionne les positions en fonction d’une fraction du capital est un système dépendant du chemin suivi. Avec une taille de position égale à 8% du capital courant par jambe, un écart de capital à la barre n entraîne un écart de notionnel pour chaque barre à partir de n.
La première divergence a eu peu d’effet à elle seule. La jambe AVAX de l’exécution B a perdu 2.1% en neuf heures ; la jambe SOL de l’exécution A a gagné 0.4%. Écart de capital après cette transaction : 0.21%. Négligeable. Mais à partir de là, les deux exécutions ne suivent plus la même stratégie. Elles détiennent des positions de tailles légèrement différentes, donc accumulent légèrement des montants de funding différents, et deux autres égalités à la limite ont été départagées différemment, car les scores utilisés provenaient alors de positions détenues légèrement différentes. L’une d’elles est survenue le 2025-03-27, la veille d’une tendance de six jours qui a généré environ un tiers du PnL total de l’exécution. L’exécution A a profité de tout le mouvement ; l’exécution B est entrée avec un rééquilibrage de retard.
Écart de rendement : 3.4 points. L’écart de Sharpe est supérieur à ce que suggère l’écart de rendement, car les transactions réordonnées de l’exécution B se sont chevauchées avec une période plus volatile : le dénominateur a augmenté tandis que le numérateur diminuait. Une petite cause, deux amplificateurs.
Si vos positions sont à notionnel fixe et que vos entrées ne dépendent pas des positions détenues, vous êtes bien mieux protégés. Les stratégies intéressantes ne sont généralement ni l’un ni l’autre.
Les cinq sources concrètes du problème
| Source | Symptôme | Correctif |
|---|---|---|
PYTHONHASHSEED non fixée, ordre de parcours d’un ensemble ou dictionnaire utilisé avant un tri | Les départages changent d’une exécution à l’autre ; première divergence à une barre précise | Fixer la graine ; trier selon une deuxième clé explicite (le symbole) pour que les égalités soient toujours départagées de la même façon |
Tri instable sur une clé à égalité (quicksort par défaut dans NumPy/pandas) | Même problème, qui persiste après avoir fixé la graine | kind="stable", ou définir une clé totale |
| Générateur aléatoire sans graine dans un bootstrap, un mélange des données d’entraînement et de test, ou une perturbation synthétique des prix d’exécution | Dérive sur toute l’exécution, sans point de divergence net | Une graine explicite par composant, consignée dans le manifeste d’exécution |
| Réduction parallèle des nombres flottants (ordre de sommation dépendant du nombre de threads) | Écarts sur les derniers bits, généralement sans conséquence jusqu’à ce qu’ils franchissent un seuil de comparaison | Fixer le nombre de threads pour les exécutions de recherche ; ne jamais comparer des flottants avec == à la limite d’une décision |
| Versions de bibliothèques non fixées | Reproductible aujourd’hui, pas en novembre | Empreinte du fichier de verrouillage dans le manifeste, avec l’empreinte de l’instantané des données |
La quatrième ligne est moins souvent problématique qu’on ne le craint, et la première est celle qui nous piège constamment.
La reproductibilité bit à bit est un outil, pas une vertu
Vous avez besoin du déterminisme pour que, lorsque vous modifiez une ligne, l’écart sur la courbe de capital puisse être attribué à cette ligne. C’est tout. Chaque exécution d’agent sur Stratmill écrit désormais un manifeste contenant l’empreinte de l’instantané des données, l’empreinte du fichier de verrouillage et toutes les graines. Si une relance ne reproduit pas la courbe précédente bit à bit, c’est une compilation qui a échoué, pas une curiosité.
Mais une fois la reproductibilité acquise, mettez-la volontairement à l’épreuve. Lancez l’expérience 64 fois avec 64 graines et examinez la dispersion :
La bande de variabilité. Même stratégie, mêmes données, 64 permutations avec graines des départages et de l’ordre d’exécution des ordres. Sharpe p5 : 1.12, médiane : 1.27, p95 : 1.41. Largeur de la bande : 0.29.
Revenez maintenant au balayage de paramètres. La meilleure configuration a obtenu 1.46. Celle classée 40e sur 96 a obtenu 1.31. L’écart entre elles est de 0.15, soit la moitié de la bande. Le balayage n’a pas classé ces deux configurations. Il a tiré une valeur dans la distribution de chacune, puis trié ces deux valeurs.
Cette autre façon de voir les choses a changé notre méthode de sélection. Un résultat de balayage ne constitue un classement que si les écarts entre configurations dépassent la variabilité d’une configuration donnée. Or, pour une stratégie dépendante du chemin suivi avec 1,400 transactions, cette variabilité est généralement assez importante pour que le tiers supérieur du classement se retrouve à égalité. Dans ce cas, appuyez-vous sur des critères que la bande ne peut pas masquer : moins de rotation, moins de paramètres, une hypothèse de coûts que vous pourriez défendre face à un sceptique, un meilleur comportement dans la fenêtre de validation glissante que vous appréciez le moins. Ce sont de vrais critères de départage. Un avantage de 0.15 en Sharpe n’en est pas un.
Encore une chose à faire avant de vous fier à vos résultats. Lancez votre backtest deux fois dès maintenant, comparez le capital barre par barre et voyez si vos résultats sont identiques bit à bit ou s’ils appartiennent au groupe à 0.15 d’écart. L’expérience prend quinze minutes et vous indique quelle part de l’historique de votre recherche mesurait la stratégie et quelle part mesurait une graine de hachage.
← Tous les articles


