Le piège de précision GPU : comment un backtest fp32 sur Apple Metal renvoie silencieusement des résultats aberrants
Fait partie de la série "Backtests Sans Illusions".
Nous avons porté notre backtest de balayage de paramètres sur le GPU, et il est devenu 2,796× plus rapide. Les chiffres renvoyés étaient plausibles. Ils étaient aussi, dans la toute première version fonctionnelle, complètement erronés — décalés d'un facteur deux cents — et rien n'a planté, rien n'a averti, rien n'avait l'air suspect.
Voici le piège qui rend le backtesting GPU sur Apple Silicon dangereux d'une manière que le code CPU ne l'est pas : Metal n'a pas de float64. Chaque nombre que votre backtest touche sur un GPU Apple est un flottant 32 bits, que vous l'ayez demandé ou non. Et la manière la plus tentante de vectoriser une moyenne mobile — l'astuce en O(n) de la somme cumulée que tout quant soucieux de performance a le réflexe d'utiliser — est précisément la formulation que le fp32 ne peut pas supporter à l'échelle des prix. Ça ne plante pas. Ça tourne à pleine vitesse et vous livre une courbe d'equity d'apparence plausible, construite sur une moyenne mobile fausse d'un facteur 211.
Le correctif est la partie intéressante, parce que ce n'est pas "utiliser plus de bits" (impossible) ni "faire plus attention" (le code naïf est déjà prudent). Le correctif consiste à calculer la même quantité mathématique par une somme différente — une convolution directe par fenêtre glissante — qui maintient chaque valeur intermédiaire assez petite pour que le fp32 la représente exactement. Même WMA, même résultat à sept chiffres significatifs près, et 55.9× plus rapide que numba mono-thread sur le CPU. Cet article est l'autopsie : pourquoi Metal vous impose le fp32, à quel endroit précis la formulation évidente déborde, pourquoi la bonne formulation n'en souffre pas, et comment nous avons prouvé — en comptant les trades, pas en regardant des courbes à l'œil — que la version rapide et la version fp64 honnête concordent.
Tous les chiffres ici sont mesurés sur un Apple M2 Max, 150,000 barres × 80 combinaisons de paramètres, meilleur de 3, à partir de scripts/bench_param_sweep.py du dépôt (la méthode GPU M5, commit 97eadaf), de scripts/engine_multitf_gpu.py (04d71e8), et des notes de conception dans scripts/GPU_NOTES.md.
Metal n'a pas de float64

Sur un CPU, un backtest Python/numpy tourne par défaut en double précision. float64 vous donne une mantisse de 52 bits : les entiers sont représentés exactement jusqu'à 2⁵³ ≈ 9×10¹⁵, et la précision relative est d'environ 1.1×10⁻¹⁶. Vous n'y pensez presque jamais, parce qu'à l'échelle des prix — une clôture BTC autour de 30,000, une somme cumulée atteignant 10¹⁴ — le fp64 a de la marge à revendre.
Le GPU d'Apple n'offre pas cette marge, parce qu'il n'offre pas de float64 du tout. Le Metal Shading Language n'a pas de type double ; MLX, PyTorch-MPS, et tous les autres frameworks qui ciblent Apple Silicon héritent de cette limitation. Il n'y a pas de flag pour l'activer, ni de repli lent-mais-correct. Si votre calcul touche le GPU, il se fait en fp32 :
- Mantisse : 23 bits (24 avec le bit implicite de tête). Les entiers sont exacts seulement jusqu'à 2²⁴ = 16,777,216 ≈ 1.6×10⁷.
- Précision relative : ~1.2×10⁻⁷. Environ sept chiffres décimaux significatifs, pas un de plus.
Ce plafond de 1.6×10⁷ résume tout le problème. Ça paraît généreux — seize millions — jusqu'à ce qu'on remarque qu'un backtest construit couramment des quantités intermédiaires bien plus grandes que ça, et qu'au moment où un intermédiaire franchit 1.6×10⁷, le fp32 ne peut même plus représenter des entiers consécutifs, encore moins la structure fractionnaire dont vous avez besoin. La précision ne se dégrade pas en douceur ; elle chute d'une falaise, et cette falaise se trouve à un nombre que vos données dépassent sans même y penser.
Le piège, c'est que le fp32 est presque toujours suffisant. La majeure partie d'un backtest — prix, rendements, PnL, Sharpe — vit confortablement dans une plage où sept chiffres suffisent largement. Le portage naïf fonctionne donc, passe un test de fumée, produit une sortie d'apparence raisonnable. L'échec est localisé à exactement une opération, le seul endroit où un intermédiaire explose au-delà de 10⁷ — et c'est précisément l'opération que tout le monde vectorise en premier.
La formulation tentante : la WMA en une seule passe de somme cumulée
Notre stratégie s'appuie sur les moyennes mobiles de Hull. Une HMA est la composition de trois moyennes mobiles pondérées ; la variante HMA3 en compose quatre. Une moyenne mobile pondérée avec un noyau linéaire sur une fenêtre de longueur p s'écrit
En balayant des milliers de combinaisons de paramètres sur 150k barres, les convolutions de WMA sont le coût. L'instinct — le bon instinct, sur un CPU — est donc de rendre chaque WMA en O(n) au lieu de O(n·p) en utilisant des sommes cumulées. On précalcule une fois deux sommes cumulées,
et alors la somme pondérée linéairement de n'importe quelle fenêtre se réduit à une poignée de différences et de décalages d'index sur S1 et S2. Aucune boucle par fenêtre, aucune réduction par fenêtre — deux passes de cumsum et toute la matrice des WMA tombe directement de l'arithmétique matricielle. Ça se vectorise à merveille, ça se transpose parfaitement sur la primitive de scan parallèle d'un GPU, et en fp64 c'est exactement juste.
C'est aussi la pire chose que l'on puisse faire en fp32, et la raison se cache dans S2.
S2 = mx.cumsum(j * price) # j is the global bar index: 0, 1, 2, ... , n-1
Le terme j · price est le problème. Avec j qui monte jusqu'à 150,000 et price autour de 30,000, le dernier terme à lui seul vaut 4.5×10⁹, et S2 est la somme courante de 150,000 termes de cet ordre. Ça ne reste pas à l'échelle du prix. Ça grimpe dans un territoire où le fp32 a déjà cessé de savoir compter.
Où ça déborde : l'arithmétique du piège

Mettons les ordres de grandeur côte à côte, parce que c'est là que se niche tout l'échec.
S2 = cumsum(j · price) atteint environ price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴. Appelons ça ~10¹⁴. Rappelons maintenant le plafond d'entiers exacts du fp32 : ~1.6×10⁷. La somme courante dépasse le dernier entier que le fp32 peut représenter exactement de sept ordres de grandeur.
Concrètement, qu'est-ce que ça signifie ? Près de 10¹⁴, l'écart entre deux nombres fp32 représentables — une unité dans la dernière position, l'ULP — vaut environ 2²³ ≈ 8×10⁶. Donc une fois que S2 atteint la plage des 10¹⁴, il n'est connu qu'à ±8 millions près. Chaque valeur qu'il stocke a été arrondie au multiple de ~8×10⁶ le plus proche.
Regardez maintenant ce que la récupération de la WMA fait de ça. Pour extraire la somme pondérée d'une seule fenêtre, on soustrait deux valeurs voisines de S2 (plus des corrections de S1). Ces deux valeurs de S2 valent chacune ~10¹⁴, et portent chacune ±8×10⁶ de bruit d'arrondi. Leur véritable différence — la quantité de fenêtre que l'on veut réellement — correspond, après normalisation, à une WMA de l'ordre du prix lui-même, ~3×10⁴. L'arithmétique est donc la suivante :
C'est de l'annulation catastrophique dans sa forme la plus pure : l'erreur d'arrondi de chaque opérande (±8×10⁶) est plus grande que la réponse que l'on essaie de récupérer. Le signal est plus petit que le plancher de bruit des nombres dont il est extrait. Ce n'est pas qu'on perd quelques chiffres — on les perd tous, et ce qui revient est dominé par l'arrondi accumulé du cumsum.
La conséquence mesurée, tirée de GPU_NOTES.md : pour une WMA calculée ainsi sur 150k barres à un prix ~30,000, l'erreur relative maximale par rapport au fp64 atteint ~211. Pas 211 pour cent — 211×. La moyenne mobile calculée peut être à deux ordres de grandeur de la vraie. Et voici ce qui en fait un piège plutôt qu'un bug : elle tourne jusqu'au bout et renvoie des nombres finis, plausibles. Pas de débordement vers l'infini, pas de NaN, pas d'exception. Une moyenne mobile fausse d'un facteur 211 ressemble quand même à une moyenne mobile — elle est lisse, elle est finie, elle tombe à peu près dans le bon ordre de grandeur sur les barres où l'annulation se trouve être légère — si bien qu'elle traverse sans encombre tout contrôle de cohérence qui n'est pas une comparaison directe avec une référence de confiance. On obtient un backtest complet, une courbe d'equity complète, un jeu complet de paramètres "optimaux", le tout construit sur un indicateur qui relève de la fiction.
Le correctif n'est pas plus de précision — c'est une somme différente

Le réflexe, une fois qu'on voit l'erreur, est de se tourner vers plus de précision — accumuler en fp64, ou utiliser une sommation compensée (Kahan). Sur Metal, la première option est tout simplement indisponible. Mais on n'a besoin ni de l'une ni de l'autre, parce que le problème n'a jamais été le nombre de bits. Le problème était la formulation. L'astuce de la somme cumulée fabrique des intermédiaires à l'échelle 10¹⁴ puis les soustrait pour redescendre ; les magnitudes qu'elle crée sont un artefact de l'algorithme, pas de la réponse. Choisissez une formulation qui ne les crée jamais, et le fp32 se porte bien.
Cette formulation, c'est la définition elle-même : une convolution directe par fenêtre glissante. Au lieu de deux sommes cumulées globales, on fait glisser le noyau linéaire de longueur p le long de la série et on somme sur place. Chaque sortie est une somme d'au plus p ≈ 200 termes, et chaque terme vaut weight × price où les poids sont normalisés pour sommer à 1 — donc chaque terme est de l'ordre de price / p, chaque somme partielle reste autour de l'échelle du prix (~3×10⁴), et aucun intermédiaire ne s'approche jamais à moins de six ordres de grandeur du plafond du fp32. Il n'y a rien à annuler parce que rien n'a jamais gonflé.
Dans MLX, c'est une seule primitive — mx.conv1d — exactement ce que les GPU sont conçus pour faire vite :
def _mx_wma_valid(x, period):
w = mx.arange(1, period + 1, dtype=mx.float32) / (period * (period + 1) / 2.0)
return mx.conv1d(x.reshape(1, -1, 1), w.reshape(1, period, 1), padding=0).reshape(-1)
Même WMA, mathématiquement identique à la version en somme cumulée et aux vec_wma/nb_wma en fp64 du CPU. Mais maintenant, l'erreur relative maximale mesurée par rapport au fp64 est de 8.2×10⁻⁷ — tout juste au niveau du plancher de bruit du fp32, ~1.2×10⁻⁷, soit sept chiffres significatifs de concordance. La formulation qui paraît plus lente sur le papier (O(n·p) au lieu de O(n)) est la seule qui soit correcte, et — parce que c'est une convolution dense que le GPU parallélise à la fois sur les barres et sur les fenêtres — elle est aussi extrêmement rapide. On est passés d'une erreur relative de 211 à 8×10⁻⁷ en changeant notre façon de sommer, pas le nombre de bits sur lesquels on somme.
Deux remarques pratiques découlent de cette façon de faire. D'abord, MLX ne propage pas les NaN à travers un conv1d comme le fait numpy, donc la région de chauffe (les premières p−1 barres, où une moyenne par fenêtre n'est pas définie) ne peut pas être marquée avec NaN sur le GPU. On n'en a pas besoin : le début valide de chaque série est connu analytiquement, les préfixes invalides sont remplis de zéros qui ne sont jamais lus, et le remplissage NaN est restauré ensuite sur le CPU — bit pour bit le même masque de validité que les versions vectorisée et numba. Ensuite, l'ensemble du balayage partage une seule série cand_close et réutilise abondamment les fenêtres entre combos, si bien qu'un seul conv1d groupé avec de nombreux canaux de sortie calcule chaque WMA unique dont le balayage a besoin en un seul appel GPU, matérialisé par un unique mx.eval().
Prouver qu'on n'est pas tombé dans le piège : la parité par le nombre de trades
Voici la question gênante que la section précédente devrait soulever : si une WMA fausse d'un facteur 211 ressemble quand même à une WMA, comment sait-on que la version à 8×10⁻⁷ est réellement juste, et pas simplement fausse de façon plus subtile ? On ne peut pas en juger à l'œil. Il faut un invariant qu'une partie discrète et en aval du pipeline expose — et un backtest en offre un parfait : les trades.
Les autres méthodes de l'échelle (M0–M4) tournent entièrement en fp64, donc on leur impose un assert d'équivalence strict — nombres de trades identiques, PnL concordant à atol=1e-6 près. La méthode GPU en fp32 (M5) ne peut pas réussir ce test par construction, et assouplir discrètement l'assert pour tout le monde afin de l'accommoder serait exactement le genre de malhonnêteté que cette série existe pour combattre. M5 obtient donc son propre rapport de parité quantitatif, report_equiv_fp32, qui compare ses trades extraits à la référence fp64.
Le mécanisme de tout désaccord résiduel mérite d'être nommé précisément, parce que ce n'est pas la catastrophe de l'annulation — c'est le petit arrondi fp32 ordinaire, celui auquel on s'attendrait. La stratégie se déclenche sur le croisement de deux moyennes de Hull, h contre h3. Une erreur relative de ~1×10⁻⁶ sur un indicateur à un prix ~30,000 représente une oscillation absolue d'environ 0.03. Sur l'immense majorité des barres, les deux courbes sont plus éloignées que ça et le croisement est sans ambiguïté. Mais sur une barre limite — où h − h3 est lui-même à moins de 0.03 de zéro — cette oscillation peut inverser le signe de la comparaison, décalant un croisement d'une seule barre, ajoutant ou retirant un trade.
C'est pourquoi la "fraction de combos qui diffèrent" est une métrique de santé sans valeur, et notre premier contrôle de parité s'est ridiculisé en l'utilisant. Sur 150k barres, chaque combo compte des milliers de croisements, donc au moins une barre limite apparaît sur presque chaque combo — 37 des 80 combos "différaient", ce qui sonne alarmant et ne signifie rien. La métrique qui compte, c'est de combien :
- Delta de PnL sur les 80 combos : max |Δ| = 1.843 points de pourcentage, max relatif = 1.25×10⁻² ; seuil de rupture 5 p.p.
- Dérive du nombre de trades par combo : max |Δn| = 4 trades sur des milliers, max relatif = 2.5×10⁻³ ; seuil de rupture 1%.
- En agrégat : 90 trades décalés sur 479,016 — 0.019%.
Quatre-vingt-dix trades sur près d'un demi-million, chacun d'eux un croisement limite déplacé par une oscillation d'arrondi plus petite qu'un tick de prix, rien de tout ça n'approchant les seuils de rupture. C'est la signature d'une méthode fp32 correcte — un désaccord petit, borné, explicable — et c'est une tout autre bête qu'une erreur relative de 211. Les seuils existent pour attraper une formulation cassée qui se fait passer pour un "bon, c'est juste du fp32" ; les vrais deltas arrivent un ordre de grandeur en dessous. Le nombre de trades est l'oracle que la courbe d'equity a refusé d'être.
Le gain, et où le GPU cesse d'aider
La correction étant établie, la vitesse mérite d'être énoncée — puis honnêtement nuancée, parce que l'avantage du GPU n'est pas uniforme sur tout le pipeline.
Sur les pures convolutions de WMA prises isolément — l'opération que toute la méthode existe pour accélérer — le batch conv1d en fp32 tourne 55.9× plus vite que numba mono-thread, avec cette erreur relative de 8.2×10⁻⁷. C'est le chiffre propre, comparaison directe GPU contre CPU : mêmes mathématiques, un seul thread de code CPU compilé face au GPU Metal.
Mais un balayage n'est pas fait que de convolutions. Une fois les matrices HMA/HMA3 calculées sur le GPU, les trades doivent encore être extraits — un parcours en O(n) des croisements de chaque combo — et ça, on le fait sur le CPU en fp64, en réutilisant exactement la sémantique de trade des autres méthodes plutôt qu'en la réimplémentant sur le GPU. Le chiffre timed() de bout en bout inclut tout : la chauffe du kernel est exclue (symétrique à l'exclusion de la compilation de numba), mais le transfert GPU→CPU et l'extraction de trades sur CPU sont inclus. Sur 150k barres × 80 combos, meilleur de 3, M2 Max :
| Method | Wall | Speedup vs baseline | combos/s |
|---|---|---|---|
| M0 pandas + boucle Python* | 287.08s | 1.0× | 0.3 |
| M1 numpy vectorisé | 3.14s | 91.5× | 25.5 |
| M2 numba (série) | 2.02s | 142.3× | 39.7 |
| M3 multiprocess + vectorisé | 0.50s | 570.2× | 158.9 |
| M4 multiprocess + numba (12 cœurs) | 0.33s | 882.5× | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796.0× | 779.2 |
*M0 extrapolé à partir d'un échantillon uniforme de 5 combos.
Le moteur M5 complet fait 779 combos/s — 2,796× par rapport à la baseline pandas, 19.6× par rapport à numba en série, et 3.2× par rapport à l'ensemble du pool CPU à 12 cœurs tournant sous numba (M4). Un seul GPU surpasse d'un facteur trois l'ensemble des cœurs CPU que possède la machine.
Voici maintenant la nuance honnête : remarquez que l'avantage GPU de bout en bout (19.6× par rapport à M2) est plus petit que l'avantage des seules convolutions (55.9× par rapport à numba). Cet écart, c'est la loi d'Amdahl qui arrive à l'heure. Le GPU anéantit si complètement les convolutions qu'elles cessent d'être le goulot d'étranglement ; ce qui reste — l'extraction de trades en O(n) sur CPU, que le GPU n'a absolument pas accélérée — domine désormais le temps réel de M5. C'est la même leçon sur laquelle retombent sans cesse les articles speed-ladder et IPC-tax de cette série : passé un certain point, le gain ne consiste pas à "rendre la partie rapide encore plus rapide", mais relève de l'orchestration — où vivent les données, quelle étape est désormais sérialisée, ce qu'on paie pour déplacer les choses entre le device et l'host. Poursuivre un hypothétique M6 qui pousserait l'extraction de trades dans un kernel Metal sur mesure ne récupérerait que la part CPU déjà en train de se réduire — c'est pourquoi on ne l'a pas construit.
La leçon générale : le résultat numérique silencieusement erroné est la norme par défaut
Prenons du recul par rapport aux moyennes de Hull et à MLX, parce que le piège se généralise bien au-delà de cet unique indicateur.
L'argument de vente séduisant du backtesting GPU, c'est "une grosse matrice unique" : empiler chaque combinaison de paramètres dans un tenseur, exécuter tout le balayage comme une poignée d'opérations denses sur des tableaux, laisser le matériel tout dévorer. Cet argument est réel — les accélérations ci-dessus sont réelles. Mais il change discrètement le régime numérique sous vos pieds, et ce changement est invisible dans le code. Sur le CPU, vos réglages par défaut vous protégeaient : fp64, propagation des NaN, un cumsum qui pouvait grimper jusqu'à 10¹⁴ sans que rien ne bronche. Déplacez la même expression sur tableau vers Metal, et vous voilà en fp32 avec un plafond d'entiers de 1.6×10⁷ absolu, et la ligne de code identique — cumsum(j * price) — passe d'exacte à erronée. Rien dans la syntaxe ne vous avertit. Le compilateur est content. La sortie est finie et plausible. Le fp32 n'échoue pas bruyamment ; il échoue poliment, avec des chiffres.
Les trois habitudes qui vous protègent réellement ne coûtent presque rien :
- Sachez où vivent vos intermédiaires, pas seulement vos entrées et vos sorties. Les entrées (des prix ~10⁴) et les sorties (une WMA ~10⁴) se trouvaient toutes deux confortablement dans la plage exacte du fp32. Le désastre se logeait entièrement dans un intermédiaire caché (
S2~10¹⁴) que ni l'API ni les types ne rendaient visible. Avant de faire confiance à une réduction en fp32, demandez-vous jusqu'où monte la plus grande somme partielle — et si elle franchit ~10⁷, changez de formulation. - Préférez les formulations qui gardent les magnitudes bornées. La convolution directe plutôt que les sommes cumulées ; les fenêtres locales plutôt que les scans globaux ; le centrage ou la différenciation avant de sommer plutôt qu'après. Le "gonfler-puis-annuler" est l'anti-pattern. L'algorithme correct est souvent celui qui a l'air asymptotiquement pire sur le papier, mais qui ne fabrique jamais une quantité qu'il devra ensuite annuler.
- Validez face à un oracle fp64 via un invariant discret. Ne comparez pas des courbes ; comparez quelque chose de quantifié et en aval — nombre de trades, nombre de croisements, événements de changement de position. Un invariant discret transforme une erreur silencieuse de 211× en un échec d'assertion retentissant, et transforme une erreur acceptable de 8×10⁻⁷ en un delta petit, borné, explicable. C'est la même discipline que le test de décalage d'une barre pour le biais de look-ahead : un diagnostic peu coûteux qui convertit un échec invisible en échec visible.
Rien de tout ça n'est de l'analyse numérique exotique. C'est l'hygiène ordinaire qui consiste à ne pas faire confiance à un backtest rapide tant qu'un backtest lent et fiable ne l'a pas ratifié — étendue au seul endroit où le langage cesse de vous avertir que la précision a silencieusement chuté de douze ordres de grandeur.
Points à retenir

- Le GPU d'Apple n'a pas de float64 — chaque nombre GPU de votre backtest est en fp32. Les entiers ne sont exacts que jusqu'à ~1.6×10⁷ et la précision est d'environ ~1.2×10⁻⁷. Il n'y a ni flag, ni repli. La majeure partie d'un backtest survit à ça ; en général, exactement une opération n'y survit pas.
- La WMA en somme cumulée est le piège.
cumsum(j · price)grimpe jusqu'à ~10¹⁴, sept ordres de grandeur au-delà du plafond exact du fp32, et récupérer une fenêtre force à soustraire deux nombres de cet ordre dont l'erreur d'arrondi (±8×10⁶) écrase déjà la réponse. Erreur relative maximale mesurée : 211×. Ça ne plante jamais — ça renvoie des résultats plausibles mais faux. - Le correctif, c'est une somme différente, pas plus de bits. Une convolution directe par fenêtre glissante (
mx.conv1d) garde chaque somme partielle proche de l'échelle du prix, si bien que le fp32 tient sept chiffres honnêtes : erreur relative de 8.2×10⁻⁷, et 55.9× plus rapide que numba mono-thread. On ne peut pas acheter du fp64 sur Metal, et on n'en a pas besoin. - Vérifiez avec un invariant discret, jamais avec la courbe. La parité du nombre de trades l'a détecté : le conv1d en fp32 divergeait du fp64 sur 90 des 479,016 trades (0.019%), tous des croisements limites, tous très en dessous des seuils de rupture — la signature d'une méthode correcte, indiscutablement différente d'une erreur de 211×. La "fraction de combos qui diffèrent" est une métrique leurre ; mesurez de combien.
- Le balayage complet fait 779 combos/s — 2,796× par rapport à la baseline pandas, 3.2× par rapport à l'ensemble du pool CPU à 12 cœurs — mais le gain de bout en bout (19.6× par rapport à numba en série) est plus petit que le gain des seules convolutions (55.9×) parce que l'extraction de trades sur CPU est désormais le goulot d'étranglement. Passé un certain point, la vitesse relève de l'orchestration, pas de l'arithmétique.
Le portage GPU était 2,796× plus rapide et, dans sa toute première version fonctionnelle, complètement faux — et ces deux faits n'avaient rien à voir l'un avec l'autre. La vitesse était réelle. Le résultat erroné venait d'un intermédiaire caché à 10¹⁴ que le fp32 ne pouvait pas contenir et qu'aucun message d'erreur n'aurait mentionné. Si un backtest devient spectaculairement plus rapide et que les chiffres continuent d'avoir l'air bons, ce n'est pas une confirmation. Sur Metal, "avoir l'air bon" est exactement à quoi ressemble une erreur relative de 211.
Voici le barreau GPU de l'échelle que cette série gravit depuis le début : l'échelle de vitesse du moteur de backtest, la taxe IPC du multiprocessing, la taxonomie du look-ahead des fuites, et la conception de la fonction objectif qui décide ce que "bon" veut même dire. La vitesse ne vaut rien si elle calcule vite le mauvais nombre.
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.