11. September 2026 · Reproduzierbarkeit

Gleicher Code, gleiche Daten, zwei verschiedene Sharpes: Das Audit auf Nichtdeterminismus, das dein Backtest braucht

Gleicher Code, gleiche Daten, zwei verschiedene Sharpes: Das Audit auf Nichtdeterminismus, das dein Backtest braucht

Gleicher Commit, gleiche Parquet-Dateien, gleicher Rechner, zwei Durchläufe im Abstand von einer Stunde. Sharpe 1.34 und Sharpe 1.19. Gesamtrendite 41.2% und 37.8%. Trade-Anzahl 1,418 und 1,421. Und die beiden Equity-Kurven stimmten über 11,205 aufeinanderfolgende Bars bis auf jede ausgegebene Dezimalstelle überein, bevor sie auseinanderliefen.

0.15Sharpe-Abstand bei identischen Eingaben
3 / 1,418abweichende Trades
11,205identische Bars vor der Abweichung

Die ersten drei Zahlen sind ärgerlich. Die letzte ist interessant, denn sie zeigt, dass das Problem kein schlampig über den gesamten Durchlauf verteiltes Gleitkomma-Rauschen ist. An einem bestimmten Bar ist etwas Diskretes passiert, und alles Weitere war Aufzinsung. In diesem Beitrag geht es darum, diesen Bar zu finden und darum, was dieser Abstand von 0.15 für jeden Parameter-Sweep bedeutet, den du jemals ausgeführt hast.

Die Strategie: Cross-Sectional Momentum auf den 30 Perps mit dem höchsten Volumen in USDⓈ-M, Rebalancing alle 4 Stunden, Long in den fünf besten nach 12-Stunden-Rendite, Short in den fünf schlechtesten, 18 Monate Historie, Gebühren und Funding pro Leg berechnet.

Den Bar finden, an dem die Durchläufe auseinanderlaufen

Wenn du das Equity pro Bar mit voller Genauigkeit protokollierst, dauert das etwa vier Minuten. Exportiere beide Durchläufe als CSV mit (bar_index, equity, open_positions_hash), lade sie und finde den ersten Index, an dem sie voneinander abweichen. Wir hatten dieses Skript bereits für einen anderen Fehler geschrieben. Nur deshalb habe ich nicht den ganzen Vormittag damit verbracht.

Bar 11,206, 2025-03-14T08:00 UTC. Das Equity war beim vorherigen Bar auf 13 signifikante Stellen identisch. Bei 11,206 unterscheiden sich die Positions-Hashes: Durchlauf A ist Long SOL, Durchlauf B Long AVAX. Gleiche Richtung, gleiches Nominal, anderer Symbol. Alles danach ist eine Folge davon.

Also habe ich die Ranking-Eingaben für diesen Bar in beiden Durchläufen ausgegeben. Sie waren identisch. Byte für Byte identisch, dieselben 30 Symbole, dieselben 30 Scores. Zwei der Scores waren 0.0. Genau null, beide, denn bei beiden Symbolen hatte es innerhalb des Lookback-Fensters einen 4-Stunden-Bar ohne Trades gegeben. Dadurch ergab Schlusskurs geteilt durch Schlusskurs eins und der Logarithmus null. Nicht auf null gerundet. Null.

Zwei Symbole lagen auf Rang fünf gleichauf. Ausgewählt wurden die fünf besten. Welches der beiden es in die Auswahl schaffte, hing von der Reihenfolge ab, in der der Sortiervorgang sie beließ. Und der Sortiervorgang tat nicht das, was ich angenommen hatte.

Ein Gleichstand, eine instabile Sortierung und etwas, das ich zwei Jahre lang ignoriert hatte

Das Ranking lief über eine gruppierte Aggregation. Der dafür verwendete DataFrame stammte aus einer Dict Comprehension über eine Menge von Symbolen, die für jeden Bar anhand eines asynchronen Abrufs neu erstellt wurde. Die Iterationsreihenfolge der Menge ändert sich mit dem Hash-Seed, und Python randomisiert den String-Hash-Seed für jeden Prozess, sofern man PYTHONHASHSEED nicht festlegt. Daher unterschied sich die Reihenfolge der Zeilen vor der Sortierung zwischen den Durchläufen. Bei einer nicht stabilen Sortierung nach einem Schlüssel mit Gleichständen wurden diese unterschiedlich aufgelöst.

Solche Gleichstände sind keine seltenen Ausnahmen. Sie sind strukturell bedingt. Immer wenn ein Feature sättigt oder abgeschnitten wird, erzeugst du exakte Gleichstände: Bars ohne Volumen ergeben Renditen von genau null, ein begrenzter Z-Score wird bei ±3.0 festgesetzt, eine Rangtransformation mit wenigen unterschiedlichen Werten erzeugt Dutzende Gleichstände, und ein boolescher Filter bewertet alles, was ihn passiert, mit 1.0. Über 18 Monate mit 4-Stunden-Bars gab es in diesem Durchlauf 47 Bars mit einem Gleichstand an der Auswahlgrenze. Bei drei davon änderte sich das ausgewählte Basket. Bei den übrigen lag ein Gleichstand zwischen zwei Symbolen vor, die beide bereits enthalten oder beide nicht enthalten waren.

Etwa einen Tag lang war ich überzeugt, der Daten-Loader sei nichtdeterministisch, weil das die spannende Antwort wäre. War er nicht. Ist er nie. Es sind eine Menge, ein Gleichstand und eine Annahme über die Stabilität der Sortierung, die niemand festgehalten hatte.

Warum drei Trades einen Sharpe-Abstand von 0.15 bewirken

An diesem Punkt widersprechen viele, und die Antwort lautet: Ein Backtest mit Positionsgrößen als Anteil des Eigenkapitals ist ein pfadabhängiges System. Wenn du pro Leg 8% des aktuellen Eigenkapitals einsetzt, führt ein Unterschied im Equity bei Bar n zu einem Unterschied bei jedem Nominal ab Bar n.

Die erste Abweichung kostete für sich genommen nur wenig. Das AVAX-Leg von Durchlauf B verlor in neun Stunden 2.1%; das SOL-Leg von Durchlauf A gewann 0.4%. Der Equity-Abstand nach diesem Trade: 0.21%. Unbedeutend. Doch von da an waren die beiden Durchläufe nicht mehr dieselbe Strategie. Sie hielten leicht unterschiedliche Größen, wodurch sich ihr Funding geringfügig anders aufbaute. Außerdem wurden zwei der späteren Gleichstände an der Auswahlgrenze erneut unterschiedlich aufgelöst, weil ihre Scores nun von leicht unterschiedlichen gehaltenen Positionen stammten. Einer davon fiel auf den 2025-03-27, einen Tag vor einem sechstägigen Trend, der ungefähr ein Drittel des gesamten PnL des Durchlaufs ausmachte. Durchlauf A war während der gesamten Bewegung investiert; Durchlauf B stieg ein Rebalancing später ein.

Renditeabstand: 3.4 Prozentpunkte. Der Sharpe-Abstand ist größer, als der Renditeabstand vermuten lässt, weil sich die neu angeordneten Trades von Durchlauf B mit einer volatileren Phase überschnitten. Dadurch stieg der Nenner, während der Zähler sank. Kleine Ursache, zwei Verstärker.

Wenn deine Positionsgrößen ein festes Nominal haben und deine Einstiege nicht von den aktuellen Positionen abhängen, bist du deutlich besser abgeschirmt. Die meisten interessanten Strategien erfüllen weder das eine noch das andere.

Die fünf Stellen, an denen es tatsächlich passiert

UrsacheSymptomAbhilfe
Nicht festgelegter PYTHONHASHSEED mit Mengen-/Dict-Iterationsreihenfolge als Eingabe für eine SortierungGleichstände werden zwischen Durchläufen unterschiedlich aufgelöst; erste Abweichung an einem bestimmten BarSeed festlegen; nach einem expliziten sekundären Schlüssel (Symbol) sortieren, damit Gleichstände deterministisch aufgelöst werden
Nicht stabile Sortierung bei Gleichstand im Schlüssel (quicksort Standard in NumPy/pandas)Wie oben, bleibt auch nach Festlegung des Seeds bestehenkind="stable" verwenden oder den Schlüssel eindeutig machen
Nicht gesetzter RNG-Seed bei Bootstrap, Train/Test-Shuffles oder synthetischem Fill-JitterAbweichungen über den gesamten Durchlauf, kein klarer Punkt der ersten AbweichungEinen expliziten Seed pro Komponente festlegen und im Run-Manifest protokollieren
Parallele Gleitkomma-Reduktion (Summationsreihenfolge abhängig von der Anzahl Threads)Unterschiede in den letzten paar Bits, meist harmlos, bis sie einen Schwellenwertvergleich beeinflussenFür Research-Durchläufe die Anzahl Threads festlegen; Floats an Entscheidungsgrenzen niemals mit == vergleichen
Nicht festgelegte BibliotheksversionenHeute reproduzierbar, im November nicht mehrHash der Lockfile zusammen mit dem Hash des Datensnapshots im Manifest festhalten

Die vierte Zeile ist seltener relevant, als viele befürchten. Die erste schlägt ständig zu.

Bit-Reproduzierbarkeit ist ein Werkzeug, keine Tugend

Du brauchst Determinismus, damit sich die Änderung an der Equity-Kurve eindeutig der einen Zeile zuordnen lässt, die du geändert hast. Genau darum geht es. Jeder Agent-Durchlauf auf Stratmill schreibt jetzt ein Manifest mit dem Hash des Datensnapshots, dem Hash der Lockfile und allen Seeds. Ein erneuter Durchlauf, der die vorherige Kurve nicht bitgenau reproduziert, gilt als fehlgeschlagener Build, nicht als Kuriosität.

Sobald du die Reproduzierbarkeit sichergestellt hast, solltest du sie aber gezielt aufbrechen. Führe den Backtest 64-mal mit 64 Seeds aus und betrachte die Streuung:

Das Jitter-Band. Gleiche Strategie, gleiche Daten, 64 Permutationen der Gleichstandsauflösung und Fill-Reihenfolge mit gesetztem Seed. Sharpe p5 1.12, Median 1.27, p95 1.41. Bandbreite 0.29.

Kehre jetzt zum Parameter-Sweep zurück. Die beste Konfiguration erzielte 1.46. Die Konfiguration auf Platz 40 von 96 erzielte 1.31. Der Abstand zwischen ihnen beträgt 0.15, also die Hälfte des Bands. Der Sweep hat diese beiden Konfigurationen nicht geordnet. Er hat je eine Stichprobe aus ihren Verteilungen gezogen und die Stichproben sortiert.

Diese neue Sichtweise hat unsere Auswahl verändert. Ein Sweep-Ergebnis ist nur dann ein Ranking, wenn die Abstände zwischen den Konfigurationen größer sind als das Jitter einer einzelnen Konfiguration. Bei einer pfadabhängigen Strategie mit 1,400 Trades reicht das Jitter meist aus, um das obere Drittel der Rangliste zu einem Gleichstand zusammenzuschieben. Dann solltest du anhand von etwas auswählen, das das Band nicht verdecken kann: niedrigerer Turnover, weniger Parameter, eine Kostenannahme, die du auch einem Skeptiker gegenüber verteidigen würdest, oder besseres Verhalten im Walk-Forward-Fold, der dir am wenigsten gefällt. Das sind echte Kriterien für Gleichstände. Ein Sharpe-Vorsprung von 0.15 ist es nicht.

Noch eine Sache, die du tun solltest, bevor du dem Ganzen vertraust. Führe deinen Backtest jetzt zweimal aus, vergleiche das Equity pro Bar und finde heraus, ob du zur bit-identischen Gruppe oder zur 0.15-Gruppe gehörst. Das Experiment dauert fünfzehn Minuten und zeigt dir, wie viel deiner bisherigen Research-Arbeit die Strategie gemessen hat und wie viel einen Hash-Seed.

DeterminismusBacktestingData EngineeringOverfittingPython
← Alle Beiträge