Die GPU-Präzisionsfalle: Wie ein fp32-Backtest auf Apple Metal lautlos Datenmüll zurückgibt
Teil der Serie "Backtests ohne Illusionen".
Wir haben unseren Parameter-Sweep-Backtest auf die GPU portiert, und er wurde 2,796× schneller. Die zurückgegebenen Zahlen waren plausibel. Sie waren zugleich, in der ersten lauffähigen Version, kompletter Datenmüll – um den Faktor zweihundert daneben – und nichts stürzte ab, nichts gab eine Warnung aus, nichts sah falsch aus.
Das ist die Falle, die GPU-Backtesting auf Apple Silicon auf eine Weise gefährlich macht, wie es CPU-Code nicht ist: Metal kennt kein float64. Jede Zahl, die ein Backtest auf einer Apple-GPU berührt, ist ein 32-Bit-Float – ob man das wollte oder nicht. Und der mit Abstand verlockendste Weg, einen gleitenden Durchschnitt zu vektorisieren – der O(n)-Prefix-Sum-Trick, zu dem jeder performance-bewusste Quant greift – ist genau die Formulierung, die fp32 bei Preisgrößenordnungen nicht übersteht. Sie bricht nicht mit einem Fehler ab. Sie läuft mit voller Geschwindigkeit durch und liefert eine plausibel aussehende Equity-Kurve, aufgebaut auf einem gleitenden Durchschnitt, der um den Faktor 211 falsch ist.
Die Lösung ist der interessante Teil, denn sie lautet nicht „mehr Bits verwenden" (geht nicht) und auch nicht „sorgfältiger sein" (der naive Code ist bereits sorgfältig). Die Lösung besteht darin, dieselbe mathematische Größe über eine andere Summe zu berechnen – eine direkte Fenster-Faltung –, die jeden Zwischenwert klein genug hält, damit fp32 ihn exakt darstellen kann. Dieselbe WMA, dasselbe Ergebnis auf sieben signifikante Stellen genau, und 55.9× schneller als Single-Thread-Numba auf der CPU. Dieser Artikel ist die Obduktion: warum Metal fp32 erzwingt, wo genau die naheliegende Formulierung überläuft, warum die richtige das nicht tut, und wie wir bewiesen haben – durch Zählen von Trades, nicht durch bloßes Anschauen von Kurven –, dass die schnelle Version und die ehrliche fp64-Version übereinstimmen.
Alle Zahlen hier sind auf einem Apple M2 Max gemessen, 150,000 Bars × 80 Parameterkombinationen, Best-of-3, aus scripts/bench_param_sweep.py des Repos (die M5-GPU-Methode, Commit 97eadaf), scripts/engine_multitf_gpu.py (04d71e8), sowie den Design-Notizen in scripts/GPU_NOTES.md.
Metal kennt kein float64

Auf einer CPU läuft ein Python/numpy-Backtest standardmäßig in doppelter Genauigkeit. float64 liefert eine 52-Bit-Mantisse: Ganzzahlen werden exakt dargestellt bis 2⁵³ ≈ 9×10¹⁵, und die relative Genauigkeit liegt bei etwa 1.1×10⁻¹⁶. Darüber denkt man fast nie nach, denn bei Preisgrößenordnungen – ein BTC-Schlusskurs um 30,000, eine kumulative Summe, die 10¹⁴ erreicht – hat fp64 reichlich Spielraum.
Apples GPU bietet diesen Spielraum nicht, weil sie float64 überhaupt nicht anbietet. Die Metal Shading Language kennt keinen double-Typ; MLX, PyTorch-MPS und jedes andere Framework, das Apple Silicon anspricht, erbt diese Einschränkung. Es gibt kein Flag, um es einzuschalten, und keinen langsamen, aber korrekten Fallback. Sobald eine Berechnung die GPU berührt, läuft sie in fp32:
- Mantisse: 23 Bit (24 mit dem impliziten führenden Bit). Ganzzahlen sind nur exakt bis 2²⁴ = 16,777,216 ≈ 1.6×10⁷.
- Relative Genauigkeit: ~1.2×10⁻⁷. Rund sieben signifikante Dezimalstellen, nicht mehr.
Diese Obergrenze von 1.6×10⁷ ist die ganze Geschichte. Sie klingt großzügig – sechzehn Millionen –, bis man bemerkt, dass ein Backtest routinemäßig Zwischengrößen aufbaut, die weit darüber liegen. Sobald ein Zwischenwert 1.6×10⁷ überschreitet, kann fp32 nicht einmal mehr aufeinanderfolgende Ganzzahlen darstellen, geschweige denn die feinere Struktur, die man braucht. Die Präzision baut nicht sanft ab – sie stürzt ab einer Klippe, und diese Klippe liegt bei einer Zahl, an der die eigenen Daten ohne mit der Wimper zu zucken vorbeirauschen.
Das Tückische daran: fp32 ist fast immer in Ordnung. Der Großteil eines Backtests – Preise, Returns, PnL, Sharpe – bewegt sich bequem in einem Bereich, in dem sieben Stellen mehr als genug sind. Also funktioniert die naive Portierung, besteht den Smoke-Test, liefert vernünftig aussehende Ausgaben. Der Fehler ist auf genau eine Operation lokalisiert, die eine Stelle, an der ein Zwischenwert über 10⁷ hinausschießt – und genau diese Operation ist die, die jeder als Erstes vektorisiert.
Die verlockende Formulierung: WMA in einem einzigen Prefix-Sum-Durchlauf
Unsere Strategie stützt sich auf Hull Moving Averages. Ein HMA setzt sich aus drei gewichteten gleitenden Durchschnitten zusammen; die HMA3-Variante aus vier. Ein gewichteter gleitender Durchschnitt mit linearem Kernel über ein Fenster der Länge p ist
Beim Durchlaufen Tausender Parameterkombinationen über 150k Bars sind die WMA-Faltungen die eigentlichen Kosten. Der Instinkt – der auf einer CPU korrekte Instinkt – ist deshalb, jede WMA statt O(n·p) auf O(n) zu bringen, mit Prefix-Sums. Man berechnet einmalig zwei kumulative Summen vor,
und danach reduziert sich die linear gewichtete Summe jedes Fensters auf eine Handvoll Differenzen und Indexverschiebungen von S1 und S2. Keine Schleife pro Fenster, keine Reduktion pro Fenster – zwei cumsum-Durchläufe, und die gesamte Matrix der WMAs fällt einfach aus der Array-Arithmetik heraus. Es vektorisiert sich wunderbar, es passt perfekt auf die Parallel-Scan-Primitive einer GPU, und in fp64 ist es exakt richtig.
Es ist zugleich das mit Abstand Schlimmste, was man in fp32 tun kann, und der Grund dafür versteckt sich in S2.
S2 = mx.cumsum(j * price) # j is the global bar index: 0, 1, 2, ... , n-1
Der Term j · price ist das Problem. Da j bis 150,000 läuft und price um die 30,000 liegt, beträgt allein der letzte Term 4.5×10⁹, und S2 ist die laufende Summe von 150,000 solchen Termen. Sie bleibt nicht auf Preisgrößenordnung. Sie klettert in einen Bereich, in dem fp32 längst aufgehört hat, zählen zu können.
Wo es überläuft: die Arithmetik der Falle

Stellen wir die Größenordnungen nebeneinander, denn genau dort lebt das gesamte Versagen.
S2 = cumsum(j · price) erreicht in etwa price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴. Nennen wir es ~10¹⁴. Erinnern wir uns an die fp32-Obergrenze für exakte Ganzzahlen: ~1.6×10⁷. Die laufende Summe überschießt die letzte exakt darstellbare Ganzzahl von fp32 um sieben Größenordnungen.
Was bedeutet das konkret? In der Nähe von 10¹⁴ beträgt der Abstand zwischen zwei darstellbaren fp32-Zahlen – eine Einheit an der letzten Stelle, das ULP – etwa 2²³ ≈ 8×10⁶. Sobald S2 also im Bereich von 10¹⁴ liegt, ist der Wert nur noch auf ±8 Millionen genau bekannt. Jeder gespeicherte Wert wurde auf das nächste Vielfache von ~8×10⁶ gerundet.
Nun schauen wir, was die WMA-Rückgewinnung damit anstellt. Um die gewichtete Summe eines einzelnen Fensters zu extrahieren, subtrahiert man zwei benachbarte S2-Werte (plus S1-Korrekturen). Diese beiden S2-Werte liegen jeweils bei ~10¹⁴ und tragen jeweils ±8×10⁶ an Rundungsrauschen. Ihre wahre Differenz – die Fenstergröße, die man eigentlich will – entspricht nach der Normierung einer WMA in der Größenordnung des Preises selbst, ~3×10⁴. Die Arithmetik lautet also:
Das ist katastrophische Auslöschung in Reinform: Der Rundungsfehler jedes Operanden (±8×10⁶) ist größer als die Antwort, die man zurückgewinnen will. Das Signal ist kleiner als der Rauschboden der Zahlen, aus denen es extrahiert wird. Es geht nicht darum, ein paar Stellen zu verlieren – man verliert sie alle, und was zurückkommt, wird vom akkumulierten Rundungsfehler des cumsum dominiert.
Die gemessene Konsequenz, aus GPU_NOTES.md: Für eine so berechnete WMA auf 150k Bars bei einem Preis von ~30,000 erreicht der maximale relative Fehler gegenüber fp64 ~211. Nicht 211 Prozent – 211×. Der berechnete gleitende Durchschnitt kann zwei Größenordnungen von dem wahren Wert entfernt liegen. Und hier kommt der Teil, der es zu einer Falle macht statt zu einem simplen Bug: es läuft bis zum Ende durch und liefert endliche, plausible Zahlen. Kein Overflow bis Unendlich, kein NaN, keine Exception. Ein gleitender Durchschnitt, der um den Faktor 211 falsch ist, sieht immer noch aus wie ein gleitender Durchschnitt – er ist glatt, er ist endlich, er liegt auf den Bars, wo die Auslöschung zufällig mild ausfällt, ungefähr in der richtigen Größenordnung –, sodass er jede Plausibilitätsprüfung übersteht, die nicht ein direkter Vergleich mit einer vertrauenswürdigen Referenz ist. Man bekommt einen vollständigen Backtest, eine vollständige Equity-Kurve, einen vollständigen Satz „optimaler" Parameter – alles aufgebaut auf einem Indikator, der reine Fiktion ist.
Die Lösung ist nicht mehr Präzision — es ist eine andere Summe

Der Reflex, sobald man den Fehler sieht, ist, nach mehr Präzision zu greifen – in fp64 akkumulieren oder kompensierte (Kahan-)Summation verwenden. Auf Metal ist Ersteres schlicht nicht verfügbar. Aber man braucht keines von beiden, denn das Problem war nie die Anzahl der Bits. Das Problem war die Formulierung. Der Prefix-Sum-Trick erzeugt Zwischenwerte in der Größenordnung von 10¹⁴ und subtrahiert sie anschließend wieder herunter; die Größenordnungen, die dabei entstehen, sind ein Artefakt des Algorithmus, nicht der Antwort. Wählt man eine Formulierung, die sie nie erzeugt, ist fp32 vollkommen in Ordnung.
Diese Formulierung ist die Definition selbst: eine direkte Fenster-Faltung. Statt zwei globaler kumulativer Summen schiebt man den linearen Kernel der Länge p über die Zeitreihe und summiert direkt vor Ort. Jede Ausgabe ist eine Summe aus höchstens p ≈ 200 Termen, und jeder Term ist weight × price, wobei die Gewichte so normiert sind, dass sie sich zu 1 summieren – jeder Term liegt also in der Größenordnung von price / p, jede Partialsumme bleibt in der Nähe der Preisgrößenordnung (~3×10⁴), und kein Zwischenwert kommt jemals auch nur annähernd (sechs Größenordnungen entfernt) an die fp32-Obergrenze heran. Es gibt nichts auszulöschen, weil nie etwas aufgebläht wurde.
In MLX ist das eine einzige Primitive – mx.conv1d –, und genau dafür sind GPUs gebaut, schnell zu sein:
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)
Dieselbe WMA, mathematisch identisch zur Prefix-Sum-Version und zu den fp64-Funktionen vec_wma/nb_wma der CPU. Aber jetzt liegt der gemessene maximale relative Fehler gegenüber fp64 bei 8.2×10⁻⁷ – genau am fp32-Rauschboden von ~1.2×10⁻⁷, sieben signifikante Stellen Übereinstimmung. Die Formulierung, die auf dem Papier langsamer aussieht (O(n·p) statt O(n)), ist die einzige, die korrekt ist – und weil es sich um eine dichte Faltung handelt, die die GPU gleichzeitig über Bars und Fenster hinweg parallelisiert, ist sie zugleich rasend schnell. Wir sind von einem relativen Fehler von 211 auf 8×10⁻⁷ gekommen, indem wir geändert haben, wie wir summieren, nicht mit wie vielen Bits wir summieren.
Zwei praktische Anmerkungen, die sich aus diesem Vorgehen ergeben. Erstens propagiert MLX NaNs nicht durch ein conv1d so, wie numpy es tut, sodass die Aufwärmregion (die ersten p−1 Bars, in denen ein gefensterter Durchschnitt nicht definiert ist) auf der GPU nicht mit NaN markiert werden kann. Das müssen wir auch nicht: Der gültige Start jeder Zeitreihe ist analytisch bekannt, ungültige Präfixe werden mit Nullen gefüllt, die nie gelesen werden, und das NaN-Padding wird anschließend auf der CPU wiederhergestellt – Bit für Bit dieselbe Gültigkeitsmaske wie bei der vektorisierten und der numba-Version. Zweitens teilt sich der gesamte Sweep eine cand_close-Zeitreihe und nutzt Fenster über die Kombinationen hinweg intensiv wieder, sodass ein einziges gebündeltes conv1d mit vielen Ausgabekanälen jede einzigartige WMA berechnet, die der Sweep braucht, in einem einzigen GPU-Aufruf, materialisiert mit einem einzigen mx.eval().
Der Beweis, dass man nicht hineingetappt ist: Parität über die Trade-Anzahl
Hier ist die unbequeme Frage, die der letzte Abschnitt aufwerfen sollte: Wenn eine um 211× falsche WMA immer noch aussieht wie eine WMA, woher weiß man dann, dass die 8×10⁻⁷-Version tatsächlich richtig ist und nicht nur subtiler falsch? Das lässt sich nicht mit bloßem Auge beurteilen. Man braucht eine Invariante, die ein nachgelagerter, diskreter Teil der Pipeline offenlegt – und ein Backtest liefert eine perfekte: die Trades.
Die anderen Methoden auf der Leiter (M0–M4) laufen vollständig in fp64, deshalb halten wir sie an einem strikten Äquivalenz-Assert fest – identische Trade-Zahlen, PnL-Übereinstimmung bis auf atol=1e-6. Die fp32-GPU-Methode (M5) kann das konstruktionsbedingt nicht bestehen, und den Assert für alle stillschweigend zu lockern, nur um sie unterzubringen, wäre genau die Art von Unehrlichkeit, gegen die diese Serie antritt. Deshalb bekommt M5 einen eigenen quantitativen Paritätsbericht, report_equiv_fp32, der ihre extrahierten Trades mit der fp64-Referenz vergleicht.
Der Mechanismus jeder verbleibenden Abweichung verdient es, präzise benannt zu werden, denn es ist nicht die Auslöschungskatastrophe – es ist das gewöhnliche, winzige fp32-Rundungsrauschen, das man erwarten würde. Die Strategie feuert beim Crossover zweier Hull-Durchschnitte, h gegen h3. Ein relativer Fehler von ~1×10⁻⁶ auf einen Indikator bei einem Preis von ~30,000 entspricht einer absoluten Schwankung von ~0.03. Auf der überwiegenden Mehrheit der Bars liegen die beiden Kurven weiter auseinander als das, und das Crossover ist eindeutig. Aber auf einem Grenzfall-Bar – wo h − h3 selbst innerhalb von 0.03 um null liegt – kann diese Schwankung das Vorzeichen des Vergleichs kippen, ein Crossover um einen einzigen Bar verschieben und einen Trade hinzufügen oder entfernen.
Deshalb ist der „Anteil der Kombinationen, die abweichen" eine wertlose Gesundheitsmetrik, und unsere erste Paritätsprüfung hat sich blamiert, indem sie genau diese verwendet hat. Bei 150k Bars hat jede Kombination Tausende von Crossovers, sodass mindestens ein Grenzfall-Bar in fast jeder Kombination auftaucht – 37 von 80 Kombinationen „wichen ab", was alarmierend klingt und nichts bedeutet. Die Metrik, auf die es ankommt, ist um wie viel:
- PnL-Delta über alle 80 Kombinationen: max |Δ| = 1.843 Prozentpunkte, max. relativ = 1.25×10⁻²; Crash-Schwelle 5 p.p.
- Trade-Zahl-Drift pro Kombination: max |Δn| = 4 Trades von Tausenden, max. relativ = 2.5×10⁻³; Crash-Schwelle 1%.
- In Summe: 90 verschobene Trades von 479,016 — 0.019%.
Neunzig Trades von fast einer halben Million, jeder Einzelne ein Grenzfall-Crossover, das durch eine Rundungsschwankung kleiner als ein Preis-Tick verschoben wurde, keiner davon auch nur in der Nähe der Crash-Schwellen. Das ist die Signatur einer fp32-Methode, die korrekt ist – kleine, begrenzte, erklärbare Abweichung – und das ist ein völlig anderes Tier als ein relativer Fehler von 211. Die Schwellen existieren, um eine kaputte Formulierung zu erwischen, die sich als „na ja, ist halt fp32" tarnt; die tatsächlichen Deltas liegen eine Größenordnung darunter. Die Trade-Anzahl ist das Orakel, das die Equity-Kurve nicht sein wollte.
Die Auszahlung, und wo die GPU aufhört zu helfen
Nachdem die Korrektheit feststeht, lohnt es sich, die Geschwindigkeit zu benennen – und dann ehrlich zu relativieren, denn der Vorteil der GPU ist nicht über die gesamte Pipeline hinweg gleichmäßig.
Für die reinen WMA-Faltungen isoliert betrachtet – die Operation, zu deren Beschleunigung die ganze Methode existiert – läuft der fp32-conv1d-Batch 55.9× schneller als Single-Thread-Numba, bei jenem relativen Fehler von 8.2×10⁻⁷. Das ist die saubere, direkt vergleichbare GPU-vs-CPU-Zahl: dieselbe Mathematik, ein Thread kompilierten CPU-Codes gegen die Metal-GPU.
Aber ein Sweep besteht nicht nur aus Faltungen. Sobald die HMA/HMA3-Matrizen auf der GPU berechnet sind, müssen die Trades trotzdem noch extrahiert werden – ein O(n)-Durchlauf über die Crossovers jeder Kombination –, und das erledigen wir auf der CPU in fp64, wobei wir die exakte Trade-Semantik der anderen Methoden wiederverwenden, statt sie auf der GPU neu zu implementieren. Die End-to-End-Zahl von timed() enthält alles: Kernel-Warm-up ausgeschlossen (symmetrisch dazu, dass auch numbas Kompilierung ausgeschlossen wird), aber GPU→CPU-Transfer und CPU-Trade-Extraktion eingeschlossen. Auf 150k Bars × 80 Kombinationen, Best-of-3, M2 Max:
| Methode | Wall | Speedup vs baseline | combos/s |
|---|---|---|---|
| M0 pandas + Python-Schleife* | 287.08s | 1.0× | 0.3 |
| M1 vektorisiertes numpy | 3.14s | 91.5× | 25.5 |
| M2 numba (seriell) | 2.02s | 142.3× | 39.7 |
| M3 Multiprocess + vektorisiert | 0.50s | 570.2× | 158.9 |
| M4 Multiprocess + numba (12 Kerne) | 0.33s | 882.5× | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796.0× | 779.2 |
*M0 extrapoliert aus einer gleichverteilten Stichprobe von 5 Kombinationen.
Die vollständige M5-Engine schafft 779 combos/s — 2,796× gegenüber der pandas-Baseline, 19.6× gegenüber seriellem numba, und 3.2× gegenüber dem gesamten 12-Kern-CPU-Pool, der numba ausführt (M4). Eine einzige GPU schlägt jeden CPU-Kern der Maschine, dreifach.
Jetzt der ehrliche Vorbehalt: Man beachte, dass der End-to-End-GPU-Vorteil (19.6× gegenüber M2) kleiner ist als der reine Faltungs-Vorteil (55.9× gegenüber numba). Diese Lücke ist Amdahls Gesetz, das planmäßig eintrifft. Die GPU vernichtet die Faltungen so vollständig, dass sie aufhören, der Flaschenhals zu sein; was übrig bleibt – die O(n)-CPU-Trade-Extraktion, die die GPU um keinen Deut beschleunigt hat – dominiert jetzt die Laufzeit von M5. Das ist dieselbe Lektion, auf die die Artikel zur Geschwindigkeits-Leiter und zur IPC-Steuer in dieser Serie immer wieder hinauslaufen: Ab einem gewissen Punkt besteht der Gewinn nicht darin, „den schnellen Teil noch schneller zu machen", sondern in der Orchestrierung – wo die Daten liegen, welche Stufe jetzt seriell ist, was man dafür bezahlt, Daten zwischen Device und Host zu verschieben. Ein hypothetisches M6 zu jagen, das die Trade-Extraktion in einen maßgeschneiderten Metal-Kernel verlagert, würde nur den schrumpfenden CPU-Anteil zurückholen – deshalb haben wir es nicht gebaut.
Die allgemeine Lektion: lautloser numerischer Datenmüll ist der Normalfall
Treten wir einen Schritt zurück von Hull-Durchschnitten und MLX, denn die Falle verallgemeinert sich weit über diesen einen Indikator hinaus.
Das verführerische Versprechen von GPU-Backtesting ist „eine große Matrix": jede Parameterkombination in einen Tensor stapeln, den gesamten Sweep als eine Handvoll dichter Array-Operationen laufen lassen, die Hardware das Ganze fressen lassen. Dieses Versprechen ist real – die oben genannten Speedups sind real. Aber es verändert im Stillen das numerische Regime unter einem, und die Veränderung ist im Code unsichtbar. Auf der CPU schützten einen die Standardeinstellungen: fp64, NaN-Propagation, ein cumsum, das bis 10¹⁴ laufen konnte, ohne dass es jemand bemerkt hätte. Verschiebt man denselben Array-Ausdruck nach Metal, befindet man sich in fp32 mit einer harten Obergrenze von 1.6×10⁷ für Ganzzahlen, und die identische Codezeile – cumsum(j * price) – wechselt von exakt zu Datenmüll. Nichts in der Syntax warnt einen. Der Compiler ist zufrieden. Die Ausgabe ist endlich und plausibel. fp32 versagt nicht laut; es versagt höflich, mit Zahlen.
Die drei Gewohnheiten, die tatsächlich schützen, sind billig zu haben:
- Wissen, wo die eigenen Zwischenwerte liegen – nicht nur Ein- und Ausgaben. Die Eingaben (Preise ~10⁴) und die Ausgaben (eine WMA ~10⁴) lagen beide bequem innerhalb des exakten fp32-Bereichs. Die Katastrophe steckte vollständig in einem versteckten Zwischenwert (
S2~10¹⁴), den weder die API noch die Typen sichtbar machten. Bevor man einer fp32-Reduktion vertraut, sollte man fragen, wie groß die größte Partialsumme wird – und wenn sie ~10⁷ überschreitet, die Formulierung ändern. - Formulierungen bevorzugen, die Größenordnungen begrenzt halten. Direkte Faltung statt Prefix-Sums; lokale Fenster statt globaler Scans; Zentrieren/Differenzieren vor dem Summieren statt danach. Erst-groß-dann-auslöschen ist das Anti-Pattern. Der korrekte Algorithmus ist oft der, der auf dem Papier asymptotisch schlechter aussieht, aber nie eine Größe erzeugt, die er wieder auslöschen muss.
- Gegen ein fp64-Orakel über eine diskrete Invariante validieren. Nicht Kurven vergleichen; etwas Quantisiertes und Nachgelagertes vergleichen – Trade-Anzahl, Anzahl der Crossovers, Positionswechsel-Events. Eine diskrete Invariante verwandelt einen lautlosen 211×-Fehler in einen schreienden Assertion-Failure und einen akzeptablen 8×10⁻⁷-Fehler in ein kleines, begrenztes, erklärbares Delta. Das ist dieselbe Disziplin wie beim One-Bar-Shift-Test für Look-Ahead-Bias: eine billige Diagnose, die ein unsichtbares Versagen in ein sichtbares verwandelt.
Nichts davon ist exotische numerische Analysis. Es ist die gewöhnliche Hygiene, einem schnellen Backtest nicht zu vertrauen, bis ein langsamer, vertrauenswürdiger ihn bestätigt hat – erweitert auf die eine Stelle, an der die Sprache aufhört zu warnen, dass die Präzision lautlos um zwölf Größenordnungen gefallen ist.
Erkenntnisse

- Apples GPU kennt kein float64 — jede GPU-Zahl im Backtest ist fp32. Ganzzahlen sind nur bis ~1.6×10⁷ exakt, und die Genauigkeit liegt bei ~1.2×10⁻⁷. Es gibt kein Flag, keinen Fallback. Der Großteil eines Backtests übersteht das; genau eine Operation in der Regel nicht.
- Die Prefix-Sum-WMA ist die Falle.
cumsum(j · price)klettert auf ~10¹⁴, sieben Größenordnungen über die exakte fp32-Obergrenze hinaus, und die Rückgewinnung eines Fensters zwingt dazu, zwei solche Zahlen zu subtrahieren, deren Rundungsfehler (±8×10⁶) die Antwort bereits um ein Vielfaches übersteigt. Gemessener maximaler relativer Fehler: 211×. Es stürzt nie ab – es liefert plausiblen Datenmüll. - Die Lösung ist eine andere Summe, nicht mehr Bits. Eine direkte Fenster-Faltung (
mx.conv1d) hält jede Partialsumme nahe der Preisgrößenordnung, sodass fp32 sieben ehrliche Stellen liefert: 8.2×10⁻⁷ relativer Fehler, und 55.9× schneller als Single-Thread-Numba. fp64 lässt sich auf Metal nicht kaufen, und man braucht es auch nicht. - Mit einer diskreten Invariante verifizieren, nie mit der Kurve. Die Trade-Zahl-Parität hat es aufgedeckt: fp32-conv1d wich bei 90 von 479,016 Trades (0.019%) von fp64 ab, alles Grenzfall-Crossovers, alle weit unter den Crash-Schwellen – die Signatur einer korrekten Methode, unverkennbar anders als ein 211×-Fehler. „Anteil der abweichenden Kombinationen" ist eine Täuschungsmetrik; man misst um wie viel.
- Der vollständige Sweep schafft 779 combos/s — 2,796× gegenüber der pandas-Baseline, 3.2× gegenüber dem gesamten 12-Kern-CPU-Pool — aber der End-to-End-Gewinn (19.6× gegenüber seriellem numba) ist kleiner als der reine Faltungsgewinn (55.9×), weil die CPU-Trade-Extraktion jetzt der Flaschenhals ist. Ab einem gewissen Punkt ist Geschwindigkeit Orchestrierung, nicht Arithmetik.
Die GPU-Portierung war 2,796× schneller und, in ihrer ersten lauffähigen Version, komplett falsch – und die beiden Tatsachen hatten nichts miteinander zu tun. Die Geschwindigkeit war real. Der Datenmüll war ein versteckter 10¹⁴-Zwischenwert, den fp32 nicht halten konnte und den keine Fehlermeldung erwähnt hätte. Wenn ein Backtest dramatisch schneller wird und die Zahlen trotzdem plausibel aussehen, ist das keine Bestätigung. Auf Metal sieht ein relativer Fehler von 211 genau so aus wie „plausibel".
Das ist die GPU-Sprosse der Leiter, die diese Serie erklimmt: die Geschwindigkeits-Leiter der Backtest-Engine, die IPC-Steuer des Multiprocessing, die Look-Ahead-Taxonomie der Leaks, und das Design der Zielfunktion, das entscheidet, was „gut" überhaupt bedeutet. Geschwindigkeit ist wertlos, wenn sie schnell die falsche Zahl berechnet.
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.