11. september 2026 · reproduserbarhet

Samme kode, samme data, to ulike Sharpe-verdier: revisjonen av ikke-determinisme som backtesten din trenger

Samme kode, samme data, to ulike Sharpe-verdier: revisjonen av ikke-determinisme som backtesten din trenger

Samme commit, samme parquet-filer, samme maskin, to kjøringer med en times mellomrom. Sharpe 1.34 og Sharpe 1.19. Totalavkastning 41.2% og 37.8%. Antall handler 1,418 og 1,421. Og de to egenkapitalkurvene stemte overens på hvert eneste viste desimal i 11,205 stolper på rad før de skilte lag.

0.15Sharpe-gap, identiske inndata
3 / 1,418handler som var ulike
11,205identiske stolper før skillet

De tre første tallene er irriterende. Det siste er interessant, for det viser at problemet ikke skyldes upresis flyttallsregning som preger hele kjøringen. Noe diskret skjedde ved én bestemt stolpe, og resten var en forsterkende effekt. Dette innlegget handler om å finne den stolpen, og om hva størrelsen på denne 0.15 betyr for hvert parametersøk du noen gang har kjørt.

Strategien: tverrsnittsmomentum på de 30 USDⓈ-M-perpene med høyest volum, rebalansering hver 4. time, long de fem øverste etter 12-timers avkastning, short de fem nederste, 18 måneders historikk, gebyrer og funding beregnet per ben.

Finne stolpen der kjøringene skiller lag

Hvis du logger egenkapitalen per stolpe med full presisjon, tar dette omtrent fire minutter. Skriv ut begge kjøringene til en CSV med (bar_index, equity, open_positions_hash), last inn begge, og finn den første indeksen der de er uenige. Vi hadde allerede skrevet et slikt skript for en annen feil, og det er eneste grunnen til at jeg ikke brukte hele formiddagen på dette.

Stolpe 11,206, 2025-03-14T08:00 UTC. Egenkapitalen ved forrige stolpe var identisk med 13 signifikante sifre. Ved 11,206 er posisjonshashene ulike: kjøring A er long SOL, kjøring B er long AVAX. Samme retning, samme nominelle beløp, ulikt symbol. Alt etter dette er en følge av det.

Så skrev jeg ut rangeringsinndataene for den stolpen fra begge kjøringene. De var identiske. Byte for byte identiske, med de samme 30 symbolene og de samme 30 skårene. To av skårene var 0.0. Nøyaktig null, begge to, fordi begge navnene hadde hatt en 4-timersstolpe uten handler i tilbakeblikkvinduet. Dermed ble sluttkurs delt på sluttkurs lik én, og logaritmen ble null. Ikke avrundet til null. Null.

To symboler delte femteplassen. Utvalget tok de fem øverste. Hvilket av de to som kom med, var avhengig av rekkefølgen sorteringen hadde latt dem stå i, og sorteringen oppførte seg ikke slik jeg hadde antatt.

Uavgjort, ustabil sortering og det jeg hadde oversett i to år

Rangeringen gikk gjennom en gruppert aggregering, og rammen den fikk som inndata, kom fra en dict comprehension over et sett med symboler som ble bygd opp på nytt for hver stolpe fra et asynkront dataoppslag. Iterasjonsrekkefølgen i settet endres med hash-seed, og Python randomiserer hash-seeden for strenger for hver prosess med mindre du låser PYTHONHASHSEED. Dermed var radrekkefølgen før sortering ulik mellom kjøringene, og fordi sorteringen ikke var stabil for like verdier, ble likheten brutt forskjellig.

Slike likheter er ikke sjeldne unntak. De ligger i strukturen. Når en egenskap når metningspunktet eller klippes, skaper du eksakt likhet: stolper uten volum gir nøyaktig nullavkastning, en klippet z-skår låses på ±3.0, en rangtransformasjon med få ulike verdier gir dusinvis av likheter, og et boolsk filter gir alle som passer, skåren 1.0. I løpet av 18 måneder med 4-timersstolper hadde denne kjøringen 47 stolper med likhet ved utvalgsgrensen. Tre av dem endret den valgte kurven. I de øvrige tilfellene var det likhet mellom to navn som allerede begge var med eller begge var ute.

I omtrent et døgn var jeg overbevist om at datalasteren ikke var deterministisk, for det er det spennende svaret. Det var den ikke. Det er den aldri. Det er et sett, en likhet og en antakelse om sorteringsstabilitet som ingen hadde skrevet ned.

Hvorfor tre handler er verdt 0.15 Sharpe

Dette er delen folk protesterer mot, og svaret er at en backtest med posisjonsstørrelse som andel av egenkapitalen er et system som avhenger av forløpet. Når vi dimensjonerer hver posisjon til 8% av gjeldende egenkapital, betyr en forskjell i egenkapital ved stolpe n en forskjell i alle nominelle beløp fra stolpe n og videre.

Det første avviket kostet lite i seg selv. AVAX-posisjonen i kjøring B tapte 2.1% over ni timer; SOL-posisjonen i kjøring A steg 0.4%. Forskjell i egenkapital etter den handelen: 0.21%. Ubetydelig. Men derfra er ikke de to kjøringene lenger den samme strategien. De har posisjoner av litt ulik størrelse, så de får litt ulike fundingbeløp, og to av de senere likhetene ved utvalgsgrensen ble også brutt ulikt fordi skårene da bygde på marginalt ulike posisjoner. Én av disse situasjonene oppsto 2025-03-27, dagen før en trend på seks dager som ga omtrent en tredel av samlet PnL for kjøringen. Kjøring A var med gjennom hele bevegelsen; kjøring B gikk inn én rebalansering senere.

Avkastningsforskjell: 3.4 prosentpoeng. Sharpe-forskjellen er større enn avkastningsforskjellen tilsier fordi de omstokte handlene i kjøring B overlappet med en mer volatil periode. Nevneren økte mens telleren falt. Liten årsak, to forsterkere.

Hvis posisjonsstørrelsen din er et fast nominelt beløp, og inngangene ikke avhenger av gjeldende beholdning, er du langt bedre skjermet. De fleste interessante strategier er ingen av delene.

De fem stedene det faktisk oppstår

KildeSymptomLøsning
Ulåst PYTHONHASHSEED med iterasjonsrekkefølge for sett/dict som påvirker sorteringenLikheter brytes ulikt mellom kjøringene; første avvik ved en bestemt stolpeLås seed; sorter etter en eksplisitt sekundærnøkkel (symbol) slik at like verdier håndteres deterministisk
Ustabil sortering på en nøkkel med like verdier (quicksort standard i NumPy/pandas)Samme som ovenfor, vedvarer selv om seed låseskind="stable", eller gjør nøkkelen total
RNG uten seed i bootstrap, blanding av trenings-/testdata eller syntetisk fyllingsjitterDrift gjennom hele kjøringen, uten et tydelig avvikspunktÉn eksplisitt seed per komponent, loggført i kjøringsmanifestet
Parallell reduksjon av flyttall (summeringsrekkefølge avhenger av antall tråder)Forskjeller i de siste få bitene, som regel ufarlige helt til de krysser en terskel i en sammenligningLås antall tråder i forskningskjøringer; sammenlign aldri flyttall med == ved en beslutningsgrense
Ulåste bibliotekversjonerReproduserbart i dag, ikke i novemberHash for låsefilen i manifestet sammen med hash for datasnapshotet

Den fjerde raden betyr sjeldnere noe enn folk frykter, og den første fellen folk stadig går i.

Bit-for-bit-reproduserbarhet er et verktøy, ikke en dyd

Vi vil ha determinisme slik at endringen i egenkapitalkurven kan tilskrives den ene linjen når vi endrer den. Det er hele poenget. Hver agentkjøring på Stratmill skriver nå et manifest med hash for datasnapshotet, hash for låsefilen og alle seedene. En ny kjøring som ikke gjenskaper den forrige kurven bit for bit, er en feilslått bygging, ikke en kuriositet.

Men når du først kan gjenskape resultatet, bør du med vilje bryte determinismen. Kjør dette 64 ganger med 64 seed-verdier, og se på spredningen:

Jitterbåndet. Samme strategi, samme data, 64 seed-styrte permutasjoner av rekkefølgen for å bryte likheter og fylle ordre. Sharpe p5 1.12, median 1.27, p95 1.41. Båndbredde 0.29.

Gå nå tilbake til parametersøket. Den beste konfigurasjonen fikk 1.46. Konfigurasjonen på 40. plass av 96 fikk 1.31. Forskjellen mellom dem er 0.15, altså halvparten av båndet. Søket rangerte ikke disse to konfigurasjonene. Det trakk én verdi fra hver av fordelingene deres og sorterte verdiene.

Dette endret hvordan vi velger. Et søkeresultat er bare en rangering hvis forskjellene mellom konfigurasjonene er større enn jitteren for én enkelt konfigurasjon. I en strategi som avhenger av forløpet, med 1,400 handler, er jitteren vanligvis stor nok til at den øverste tredelen av resultatlisten blir én stor uavgjort. Når det skjer, velg ut fra noe båndet ikke kan skjule: lavere omløpshastighet, færre parametere, en kostnadsantakelse du kan forsvare overfor en skeptiker, eller bedre oppførsel i walk-forward-folden du liker minst. Det er reelle kriterier for å bryte likheten. Et Sharpe-forsprang på 0.15 er det ikke.

Én ting til som er verdt å gjøre før du stoler på noe av dette. Kjør backtesten din to ganger nå, sammenlign egenkapitalen per stolpe, og finn ut om du er i leiren med identiske bitmønstre eller i 0.15-leiren. Det tar femten minutter og viser hvor mye av forskningshistorikken din som målte strategien, og hvor mye som målte en hash-seed.

determinismebacktestingdatateknikkovertilpasningpython
← Alle innlegg