11 september 2026 · reproduceerbaarheid

Dezelfde code, dezelfde data, twee verschillende Sharpes: de nondeterminisme-audit die je backtest nodig heeft

Dezelfde code, dezelfde data, twee verschillende Sharpes: de nondeterminisme-audit die je backtest nodig heeft

Dezelfde commit, dezelfde parquet-bestanden, dezelfde machine, twee runs met een uur ertussen. Sharpe 1.34 en Sharpe 1.19. Totaalrendement 41.2% en 37.8%. Aantal trades 1,418 en 1,421. En de twee vermogenscurves kwamen 11,205 bars achter elkaar tot op elk weergegeven decimaal overeen, voordat ze uiteenliepen.

0.15Sharpe-verschil, identieke invoer
3 / 1,418trades die verschilden
11,205identieke bars vóór de splitsing

De eerste drie cijfers zijn vervelend. Het laatste is interessant, want het laat zien dat het probleem geen slordige floatingpointfouten zijn die door de hele run heen zijn uitgesmeerd. Er gebeurde iets discreets op één specifieke bar, en de rest was een opstapeleffect. Dit artikel gaat over het vinden van die bar, en over wat die 0.15 betekent voor elke parametersweep die je ooit hebt uitgevoerd.

De strategie: cross-sectioneel momentum op de 30 perpetuals met het hoogste volume in USDⓈ-M, rebalance om de 4 uur, long op de top vijf op basis van het rendement over 12 uur, short op de onderste vijf, 18 maanden aan historie, kosten en funding berekend per leg.

De bar vinden waarop de runs uiteenlopen

Als je het eigen vermogen per bar met volledige precisie logt, kost dit ongeveer vier minuten. Exporteer beide runs naar een CSV van (bar_index, equity, open_positions_hash), laad ze allebei in en zoek de eerste index waarop ze verschillen. We hadden dat script al geschreven voor een andere bug; dat is de enige reden dat ik er geen ochtend aan heb besteed.

Bar 11,206, 2025-03-14T08:00 UTC. Het eigen vermogen was op de vorige bar identiek tot op 13 significante cijfers. Op 11,206 verschillen de positiehashes: run A is long SOL, run B is long AVAX. Zelfde kant, zelfde notional, ander symbool. Alles daarna is een gevolg hiervan.

Dus printte ik de rangschikkingsinvoer voor die bar in beide runs. Die was identiek. Byte voor byte identiek, dezelfde 30 symbolen, dezelfde 30 scores. Twee scores waren 0.0. Precies nul, allebei, omdat beide namen binnen het terugkijkvenster een 4-uursbar zonder trades hadden, waardoor close gedeeld door close één opleverde en de logaritme nul. Geen afronding naar nul. Nul.

Twee symbolen gelijk op rang vijf. De selectie nam de top vijf. Welke van de twee werd opgenomen, hing af van de volgorde waarin de sortering ze achterliet, en de sortering deed niet wat ik aannam.

Een gelijke stand, een instabiele sortering en iets wat ik al twee jaar negeerde

De rangschikking liep via een gegroepeerde aggregatie, en het dataframe dat als invoer diende, kwam uit een dict-comprehension over een set symbolen die per bar opnieuw werd opgebouwd op basis van een asynchrone fetch. De iteratievolgorde van de set verschuift met de hash seed, en Python randomiseert de string-hashseed per proces tenzij je PYTHONHASHSEED vastzet. Daardoor verschilde de rijvolgorde vóór het sorteren tussen runs, en omdat de sortering op een gelijke sleutel niet stabiel was, werd de gelijke stand anders beslist.

Dit soort gelijke standen is geen zeldzame uitzondering. Ze zijn structureel. Overal waar een feature verzadigt of wordt afgevlakt, creëer je exacte gelijkheid: bars zonder volume leveren exact nulrendement op, een afgekapte z-score blijft steken op ±3.0, een rangtransformatie met weinig verschillende waarden levert tientallen gelijke scores op, en een booleaanse filter geeft alles wat slaagt een score van 1.0. In 18 maanden met 4-uursbars had deze run 47 bars met een gelijke stand op de selectiedrempel. Drie daarvan veranderden de geselecteerde mand. Bij de rest ging het om twee namen die allebei al in de mand zaten, of er allebei buiten vielen.

Ongeveer een dag lang was ik ervan overtuigd dat de dataloader niet-deterministisch was, want dat is het spannende antwoord. Dat was niet zo. Dat is het nooit. Het is een set, een gelijke stand en een aanname over de stabiliteit van sorteringen die niemand heeft vastgelegd.

Waarom drie trades 0.15 Sharpe waard zijn

Dit is het punt waar mensen tegenin gaan. Het antwoord is dat een backtest met positiegrootte op basis van een fractie van het eigen vermogen een padafhankelijk systeem is. Als je per leg 8% van het huidige eigen vermogen inzet, betekent een verschil in eigen vermogen op bar n een verschil in elke notional vanaf bar n en daarna.

De eerste afwijking kostte op zichzelf weinig. De AVAX-leg van run B verloor 2.1% in negen uur; de SOL-leg van run A won 0.4%. Verschil in eigen vermogen na die trade: 0.21%. Verwaarloosbaar. Maar vanaf dat moment zijn de twee runs niet meer dezelfde strategie. Ze houden posities aan die iets in omvang verschillen, waardoor ze net andere fundingopbrengsten opbouwen, en twee van de latere gelijke standen op de selectiedrempel werden opnieuw anders beslist omdat de scores nu waren gebaseerd op posities die net iets verschilden. Een daarvan viel op 2025-03-27, een dag vóór een trend van zes dagen die ongeveer een derde van de totale PnL van de run opleverde. Run A zat gedurende de hele beweging in de markt; run B stapte één rebalance later in.

Rendementsverschil: 3.4 procentpunt. Het Sharpe-verschil is groter dan je op basis van het rendementsverschil zou verwachten, omdat de herschikte trades van run B samenvielen met een volatielere periode. Daardoor steeg de noemer terwijl de teller daalde. Kleine oorzaak, twee versterkende effecten.

Als je met een vaste notional werkt en je instapmomenten niet afhangen van de huidige posities, ben je veel beter beschermd. De meeste interessante strategieën voldoen aan geen van beide voorwaarden.

De vijf plekken waar het echt misgaat

BronSymptoomOplossing
Niet-vastgezette PYTHONHASHSEED met set-/dict-iteratievolgorde als invoer voor een sorteringTie-breaks wisselen tussen runs; eerste afwijking op een specifieke barZet de seed vast; sorteer op een expliciete secundaire sleutel (symbool), zodat gelijke standen deterministisch worden beslist
Instabiele sortering bij een gelijke sleutel (quicksort de standaard in NumPy/pandas)Zelfde als hierboven; blijft bestaan nadat je de seed vastzetkind="stable", of maak de sleutel uniek
Niet-vastgezette RNG in bootstrap, train-/test-herschikkingen of synthetische fill-jitterAfwijking door de hele run, zonder duidelijk punt waarop het verschil begintEén expliciete seed per component, vastgelegd in het runmanifest
Parallelle float-reductie (sommeervolgorde afhankelijk van het aantal threads)Verschillen in de laatste paar bits, meestal onschuldig totdat ze een drempelvergelijking beïnvloedenZet het aantal threads vast voor onderzoeksruns; vergelijk floats nooit met == op een beslissingsgrens
Niet-vastgezette bibliotheekversiesVandaag reproduceerbaar, in november niet meerZet de hash van het lockfile in het manifest, naast de hash van de datasnapshot

De vierde rij is minder vaak van belang dan mensen vrezen, en de eerste rij zorgt voortdurend voor problemen.

Bitreproduceerbaarheid is een hulpmiddel, geen deugd

Je wilt determinisme zodat je, wanneer je één regel wijzigt, het verschil in de vermogenscurve aan die regel kunt toeschrijven. Dat is de hele reden. Elke agentrun op Stratmill schrijft nu een manifest met de hash van de datasnapshot, de hash van het lockfile en elke seed. Een rerun die de vorige curve niet bit voor bit reproduceert, geldt als een mislukte build, niet als een curiositeit.

Maar zodra je resultaten kunt reproduceren, moet je dat vermogen bewust doorbreken. Voer de backtest 64 keer uit met 64 seeds en bekijk de spreiding:

De jitterband. Dezelfde strategie, dezelfde data, 64 permutaties van de tie-breaks en fill-volgordes met verschillende seeds. Sharpe p5 1.12, mediaan 1.27, p95 1.41. Bandbreedte 0.29.

Ga nu terug naar de parametersweep. De beste configuratie scoorde 1.46. De configuratie op plek 40 van de 96 scoorde 1.31. Het verschil is 0.15, de helft van de band. De sweep rangschikte die twee configuraties niet. Hij trok uit elke verdeling één steekproef en sorteerde die scores.

Die nieuwe kijk veranderde hoe we keuzes maken. Een sweepresultaat is alleen een rangschikking als de verschillen tussen configuraties groter zijn dan de jitter van één configuratie. Bij een padafhankelijke strategie met 1,400 trades is de jitter meestal groot genoeg om het bovenste derde deel van het klassement tot één gelijke stand te reduceren. Kies dan op basis van iets wat de band niet kan verbergen: minder omzet, minder parameters, een kostenaanname die je tegenover een skepticus kunt verdedigen, of beter gedrag in de walk-forward-fold die je het minst bevalt. Dat zijn echte tie-breakers. Een Sharpe-voorsprong van 0.15 is dat niet.

Nog één ding dat je kunt doen voordat je dit allemaal vertrouwt. Voer je backtest nu twee keer uit, vergelijk het eigen vermogen per bar en ontdek of je in het bit-identieke kamp zit of in het 0.15-kamp. Het kost een kwartier en vertelt je welk deel van je onderzoeksgeschiedenis de strategie mat en welk deel een hash seed.

determinismebacktestendata-engineeringoverfittingpython
← Alle artikelen