Samma commit, samma parquet-filer, samma maskin, två körningar med en timmes mellanrum. Sharpe 1.34 och Sharpe 1.19. Total avkastning 41.2% och 37.8%. Antal affärer 1,418 och 1,421. Och de två aktiekurvorna stämde överens på varje utskriven decimal under 11,205 staplar i rad innan de skildes åt.
De första tre siffrorna är irriterande. Den sista är intressant, eftersom den visar att problemet inte är slarvigt flyttalsprecisionsfel som spridits över hela körningen. Något diskret inträffade vid en specifik stapel, och resten var en kedjeeffekt. Det här inlägget handlar om att hitta den stapeln och om hur storleken på den där 0.15 påverkar varje parametersvep du någonsin har kört.
Strategin: tvärsnittsmomentum på de 30 USDⓈ-M-perpar med högst volym, ombalansering var 4:e timme, lång på de fem främsta efter 12-timmarsavkastning, kort på de fem sämsta, 18 månaders historik, avgifter och funding debiteras per ben.
Hitta stapeln där körningarna går skilda vägar
Om du loggar aktiekapitalet per stapel med full precision tar det ungefär fyra minuter. Skriv ut båda körningarna till varsin CSV med (bar_index, equity, open_positions_hash), läs in båda och hitta det första indexet där de skiljer sig åt. Vi hade redan skrivit det skriptet för en annan bugg, och det är enda anledningen till att jag inte ägnade åt mig en hel förmiddag.
Staple 11,206, 2025-03-14T08:00 UTC. Aktiekapitalet var identiskt vid föregående stapel med 13 signifikanta siffror. Vid 11,206 skiljer sig positionshasharna: körning A är lång SOL, körning B är lång AVAX. Samma sida, samma nominella värde, olika symbol. Allt efter det är följdeffekter.
Så jag skrev ut rangordningens indata för den stapeln i båda körningarna. De var identiska. Byte för byte, samma 30 symboler, samma 30 poäng. Två av poängen var 0.0. Exakt noll, båda två, eftersom båda namnen hade haft en 4-timmarsstapel utan affärer under tillbakablicksfönstret. Därför blev stängning delat med föregående stängning lika med ett, och logaritmen blev noll. Inte avrundat till noll. Noll.
Två symboler delade femteplatsen. Urvalet tog de fem främsta. Vilken av de två som kom med berodde på i vilken ordning sorteringen lämnade dem, och sorteringen gjorde inte det jag hade antagit.
Oavgjort, instabil sortering och det jag hade ignorerat i två år
Rangordningen byggde på en grupperad aggregering, och dess indataframe kom från en dict comprehension över en mängd symboler som återskapades för varje stapel från en asynkron hämtning. Mängdens iterationsordning ändras med hashfröet, och Python randomiserar strängarnas hashfrö för varje process om du inte låser PYTHONHASHSEED. Därför skilde sig radordningen före sorteringen mellan körningarna, och med en icke-stabil sortering på en nyckel med lika värden avgjordes oavgjort på olika sätt.
Sådana här lika värden är ingen sällsynt tillfällighet. De uppstår strukturellt. Överallt där ett featurevärde mättas eller klipps skapar du exakt likhet: staplar utan volym ger exakt nollavkastning, ett z-värde som klipps låses vid ±3.0, en rangtransform med få unika värden ger dussintals lika värden, ett booleskt filter ger allt som passerar poängen 1.0. Under 18 månader med 4-timmarsstaplar hade den här körningen 47 staplar med lika värden vid urvalsgränsen. Tre av dem ändrade den valda korgen. I de övriga fallen gällde likheten två namn som båda redan ingick eller båda redan var bortvalda.
I ungefär ett dygn var jag övertygad om att dataladdaren var icke-deterministisk, eftersom det är det spännande svaret. Det var den inte. Det är den aldrig. Det handlar om en mängd, lika värden och ett antagande om sorteringens stabilitet som ingen hade dokumenterat.
Varför tre affärer är värda 0.15 Sharpe
Det här är delen folk invänder mot, och svaret är att ett backtest med positioner dimensionerade som andel av aktiekapitalet är ett system vars utfall beror på vägen dit. Om varje ben dimensioneras till 8% av aktuellt aktiekapital innebär en skillnad i aktiekapital vid stapel n en skillnad i varje nominellt värde från stapel n och framåt.
Den första avvikelsen kostade väldigt lite i sig. AVAX-benet i körning B gick ned 2.1% på nio timmar; SOL-benet i körning A gick upp 0.4%. Skillnaden i aktiekapital efter den affären: 0.21%. Försumbar. Men från den punkten är de två körningarna inte längre samma strategi. De håller positioner med något olika storlek, så de får något olika fundingutfall, och ytterligare två oavgjorda gränsfall avgjordes olika senare eftersom poängen byggde på positioner som nu skilde sig en aning. Ett av dem inträffade 2025-03-27, dagen före en sexdagarstrend som stod för ungefär en tredjedel av körningens totala PnL. Körning A var med under hela rörelsen; körning B gick in en ombalansering senare.
Avkastningsskillnad: 3.4 procentenheter. Sharpe-skillnaden är större än avkastningsskillnaden antyder eftersom de omordnade affärerna i körning B sammanföll med en mer volatil period. Därför steg nämnaren samtidigt som täljaren sjönk. En liten orsak, två förstärkande effekter.
Om din positionsstorlek har fast nominellt värde och dina ingångar inte beror på aktuella innehav är du betydligt mer skyddad. De flesta intressanta strategier är ingetdera.
De fem ställen där det faktiskt uppstår
| Källa | Symptom | Åtgärd |
|---|---|---|
Ej låst PYTHONHASHSEED där iteration över mängd/dict påverkar sorteringsordningen | Oavgjort avgörs olika mellan körningar; första avvikelsen inträffar vid en specifik stapel | Lås fröet; sortera efter en uttrycklig sekundärnyckel (symbol) så att lika värden hanteras deterministiskt |
Icke-stabil sortering på en nyckel med lika värden (quicksort standard i NumPy/pandas) | Samma som ovan, kvarstår även när fröet låsts | kind="stable", eller gör nyckeln total |
| Slumpgenerator utan låst frö i bootstrap, omblandning av tränings-/testdata eller jitter i syntetiska fyllningar | Avvikelse genom hela körningen, ingen tydlig punkt där den börjar | Ett uttryckligt frö per komponent, loggat i körningsmanifestet |
| Parallell flyttalsreduktion (summeringsordningen beror på antalet trådar) | Skillnader i de sista bitarna, oftast ofarliga tills de passerar en tröskeljämförelse | Lås antalet trådar för forskningskörningar; jämför aldrig flyttal med == vid en beslutströskel |
| Ej låsta biblioteksversioner | Reproducerbart i dag, inte i november | Hash för lockfile i manifestet tillsammans med hash för ögonblicksbilden av datan |
Den fjärde raden spelar mer sällan roll än folk tror, och den första ställer ständigt till problem.
Bitvis reproducerbarhet är ett verktyg, inte en dygd
Du vill ha determinism så att förändringen i aktiekurvan går att härleda till den enda raden när du ändrar en rad. Det är hela poängen. Varje agentkörning i Stratmill skriver nu ett manifest med hash för dataögonblicksbilden, hash för lockfile och varje frö. En omkörning som inte återskapar den tidigare kurvan bit för bit är ett underkänt bygge, inte en kuriositet.
Men när du väl kan återskapa resultatet kan du medvetet rubba det. Kör samma sak 64 gånger med 64 frön och titta på spridningen:
Jitterintervallet. Samma strategi, samma data, 64 permutationer av oavgjorda fall och fyllningsordning med låsta frön. Sharpe p5 1.12, median 1.27, p95 1.41. Intervallbredd 0.29.
Gå nu tillbaka till parametersvepet. Bästa konfigurationen fick 1.46. Konfigurationen på 40:e plats av 96 fick 1.31. Skillnaden mellan dem är 0.15, alltså hälften av intervallet. Svepet rangordnade inte de två konfigurationerna. Det tog ett stickprov ur vardera fördelningen och sorterade stickproven.
Den omtolkningen förändrade hur vi väljer. Ett svepresultat är bara en rangordning om skillnaderna mellan konfigurationerna överstiger jittervariationen för en enskild konfiguration. I en stigberoende strategi med 1,400 affärer är jittervariationen oftast stor nog att göra den översta tredjedelen av topplistan till ett enda oavgjort resultat. Då får man välja utifrån något som intervallet inte kan dölja: lägre omsättning, färre parametrar, ett kostnadsantagande du kan försvara inför en skeptiker eller bättre beteende i den walk-forward-del du gillar minst. Det är verkliga skiljekriterier. En Sharpe-fördel på 0.15 är det inte.
En sak till som är värd att göra innan du litar på något av det här. Kör ditt backtest två gånger nu, jämför aktiekapitalet per stapel och ta reda på om du tillhör gruppen med identiska bitmönster eller gruppen med en skillnad på 0.15. Det tar femton minuter och visar hur mycket av din forskningshistorik som mätte strategin och hur mycket som mätte ett hashfrö.
← Alla inlägg

