11 września 2026 · odtwarzalność

Ten sam kod, te same dane, dwa różne Sharpe: audyt niedeterministyczności potrzebny w backteście

Ten sam kod, te same dane, dwa różne Sharpe: audyt niedeterministyczności potrzebny w backteście

Ten sam commit, te same pliki parquet, ta sama maszyna, dwa uruchomienia w odstępie godziny. Sharpe 1.34 i Sharpe 1.19. Łączna stopa zwrotu 41.2% i 37.8%. Liczba transakcji 1,418 i 1,421. A obie krzywe kapitału zgadzały się co do każdego wyświetlonego miejsca po przecinku przez 11,205 kolejnych świec, zanim się rozeszły.

0.15różnicy Sharpe przy identycznych danych wejściowych
3 / 1,418różniące się transakcje
11,205identycznych świec przed rozbieżnością

Pierwsze trzy liczby są irytujące. Ostatnia jest ciekawa, bo wskazuje, że problemem nie były niedokładności zmiennoprzecinkowe rozmyte w całym przebiegu. Coś dyskretnego wydarzyło się na konkretnej świecy, a potem różnica narastała. Ten wpis jest o tym, jak znaleźć tę świecę i co wartość 0.15 oznacza dla każdego przeprowadzonego przez ciebie przeglądu parametrów.

Strategia: momentum przekrojowe na 30 kontraktach perpetual USDⓈ-M o największym wolumenie, rebalansowanie co 4 godziny, długa pozycja w pięciu instrumentach z najwyższą stopą zwrotu z 12 godzin, krótka w pięciu z najniższą, 18 miesięcy historii, prowizje i funding naliczane dla każdej nogi.

Znalezienie świecy, na której przebiegi się rozeszły

Jeśli zapisujesz kapitał dla każdej świecy z pełną precyzją, zajmuje to około czterech minut. Zapisz oba przebiegi do pliku CSV w formacie (bar_index, equity, open_positions_hash), wczytaj je i znajdź pierwszy indeks, przy którym wartości się różnią. Ten skrypt mieliśmy już napisany do innego błędu — tylko dlatego nie poświęciłem na to całego poranka.

Świeca 11,206, 2025-03-14T08:00 UTC. Na poprzedniej świecy kapitał był identyczny do 13 cyfr znaczących. Na świecy 11,206 różnią się hashe pozycji: przebieg A ma długą pozycję w SOL, przebieg B w AVAX. Ta sama strona, ten sam nominał, inny symbol. Wszystko później jest już konsekwencją tej różnicy.

Wypisałem więc dane wejściowe rankingu dla tej świecy w obu przebiegach. Były identyczne. Bajt w bajt, te same 30 symboli, te same 30 wyników. Dwa wyniki wynosiły 0.0. Dokładnie zero, oba, ponieważ dla obu instrumentów świeca 4-godzinna miała zero transakcji w oknie lookback, więc stosunek zamknięcia do poprzedniego zamknięcia wyniósł jeden, a logarytm — zero. To nie zero po zaokrągleniu. To zero.

Dwa symbole zremisowały na piątym miejscu. Wybór obejmował pięć najwyżej sklasyfikowanych. To, który z nich się załapał, zależało od kolejności po sortowaniu, a sortowanie nie działało tak, jak zakładałem.

Remis, niestabilne sortowanie i rzecz, którą ignorowałem przez dwa lata

Ranking powstawał w agregacji grupującej, a zasilająca ją ramka danych pochodziła z wyrażenia słownikowego iterującego po zbiorze symboli, tworzonym na nowo dla każdej świecy na podstawie asynchronicznego pobierania danych. Kolejność iteracji zbioru zmienia się wraz z ziarnem haszowania, a Python losuje ziarno haszowania ciągów znaków dla każdego procesu, chyba że przypniesz PYTHONHASHSEED. Wstępna kolejność wierszy różniła się więc między przebiegami, a przy niestabilnym sortowaniu po kluczu z remisem wynik remisu był inny.

Takie remisy nie są rzadkimi wypadkami. Wynikają ze struktury. Wszędzie tam, gdzie cecha osiąga wartość graniczną lub jest obcinana, powstaje dokładna równość: świece z zerowym wolumenem dają dokładnie zerowe stopy zwrotu, obcięty z-score zatrzymuje się na ±3.0, transformacja rangowa z niewieloma różnymi wartościami tworzy dziesiątki remisów, a filtr logiczny przypisuje każdemu spełniającemu warunek wynik 1.0. W tym przebiegu, obejmującym 18 miesięcy i świece 4-godzinne, na 47 świecach wystąpił remis na granicy wyboru. Trzy z nich zmieniły wybrany koszyk. W pozostałych przypadkach remis dotyczył dwóch instrumentów, które oba już wybrano albo oba odrzucono.

Przez około dzień byłem przekonany, że niedeterministyczny jest loader danych, bo to ciekawsza odpowiedź. Nie był. Nigdy nie jest. To zbiór, remis i założenie o stabilności sortowania, którego nikt nie zapisał.

Dlaczego trzy transakcje są warte 0.15 Sharpe

Z tym najczęściej ludzie polemizują, a odpowiedź brzmi: backtest z wielkością pozycji zależną od ułamka kapitału jest układem zależnym od ścieżki. Wielkość pozycji równa 8% bieżącego kapitału na każdą nogę oznacza, że różnica kapitału na świecy n przekłada się na różnicę w każdym nominale od świecy n wzwyż.

Pierwsza rozbieżność sama w sobie kosztowała niewiele. Noga AVAX w przebiegu B straciła 2.1% w ciągu dziewięciu godzin, a noga SOL w przebiegu A zyskała 0.4%. Różnica kapitału po tej transakcji: 0.21%. Drobnostka. Ale od tej chwili przebiegi nie realizują już tej samej strategii. Utrzymują pozycje o nieco innych wielkościach, więc naliczają im się nieco inne kwoty fundingu; dwa późniejsze remisy na granicy wyboru również rozstrzygnęły się inaczej, bo zasilające je wyniki pochodziły już z nieznacznie różniących się utrzymywanych pozycji. Jeden z tych przypadków wystąpił 2025-03-27, dzień przed sześciodniowym trendem, który przyniósł mniej więcej jedną trzecią łącznego PnL przebiegu. Przebieg A uczestniczył w całym ruchu, a przebieg B wszedł z opóźnieniem jednego rebalansowania.

Różnica stopy zwrotu: 3.4 punktu procentowego. Różnica Sharpe jest większa, niż sugeruje różnica stóp zwrotu, ponieważ przestawione transakcje w przebiegu B przypadły na okres większej zmienności, więc mianownik wzrósł, a licznik spadł. Mała przyczyna, dwa wzmacniające ją czynniki.

Jeśli używasz stałego nominału, a wejścia nie zależą od aktualnie utrzymywanych pozycji, jesteś znacznie lepiej chroniony. Większość ciekawych strategii nie spełnia żadnego z tych warunków.

Pięć miejsc, w których faktycznie pojawia się problem

ŹródłoObjawRozwiązanie
Nieprzypięte PYTHONHASHSEED i kolejność iteracji zbioru/słownika zasilająca sortowanieRozstrzygnięcia remisów zmieniają się między przebiegami; pierwsza rozbieżność występuje na konkretnej świecyPrzypnij ziarno; sortuj po jawnym kluczu pomocniczym (symbolu), aby remisy rozstrzygać deterministycznie
Niestabilne sortowanie przy remisie (quicksort domyślnie w NumPy/pandas)Jak wyżej, problem pozostaje po przypięciu ziarnakind="stable" albo utwórz klucz całkowicie porządkujący elementy
Generator liczb losowych bez ziarna w bootstrapie, tasowaniu zbiorów treningowych/testowych lub sztucznym zaburzeniu wypełnień zleceńRozbieżności w całym przebiegu, bez wyraźnego punktu rozbieżnościJedno jawne ziarno dla każdego komponentu, zapisane w manifeście przebiegu
Równoległa redukcja zmiennoprzecinkowa (kolejność sumowania zależna od liczby wątków)Różnice na kilku ostatnich bitach, zwykle nieszkodliwe, dopóki nie przekroczą progu w porównaniuPrzypnij liczbę wątków w przebiegach badawczych; nigdy nie porównuj liczb zmiennoprzecinkowych za pomocą == na granicy decyzji
Nieprzypięte wersje bibliotekPowtarzalne dziś, nie w listopadzieHash pliku lockfile w manifeście obok hasha migawki danych

Czwarty wiersz nie jest tak istotny, jak często się obawiamy, a pierwszy stale daje się we znaki.

Odtwarzalność bit w bit to narzędzie, nie cnota

Potrzebujesz determinizmu, żeby po zmianie jednej linii móc przypisać różnicę na krzywej kapitału właśnie tej linii. Tylko po to. Każdy przebieg agenta na Stratmill zapisuje teraz manifest z hashem migawki danych, hashem pliku lockfile i wszystkimi ziarnami, a ponowne uruchomienie, które nie odtworzy poprzedniej krzywej bit w bit, oznacza nieudany build, a nie ciekawostkę.

Gdy już potrafisz odtworzyć przebieg, celowo wprowadź losowość. Uruchom strategię 64 razy z 64 ziarnami i sprawdź rozrzut:

Pasmo wahań. Ta sama strategia, te same dane, 64 permutacje rozstrzygania remisów i kolejności realizacji zleceń, każda z własnym ziarnem. Sharpe: p5 1.12, mediana 1.27, p95 1.41. Szerokość pasma: 0.29.

Wróćmy teraz do przeglądu parametrów. Najlepsza konfiguracja uzyskała wynik 1.46. Konfiguracja na 40. miejscu z 96 uzyskała 1.31. Różnica między nimi wynosi 0.15, czyli połowę szerokości pasma. Przegląd nie uszeregował tych dwóch konfiguracji. Wylosował po jednej wartości z rozkładu każdej z nich i posortował wyniki.

Ta zmiana perspektywy wpłynęła na nasz wybór. Wynik przeglądu to ranking tylko wtedy, gdy różnice między konfiguracjami są większe niż wahania pojedynczej konfiguracji; w strategii zależnej od ścieżki, obejmującej 1,400 transakcji, wahania są zwykle na tyle duże, że spłaszczają górną trzecią część tabeli wyników do remisu. Wtedy wybieraj według kryterium, którego pasmo nie zamaskuje: niższa rotacja, mniej parametrów, założenie dotyczące kosztów, którego obronisz przed sceptykiem, lepsze zachowanie w najmniej lubianym przez ciebie foldzie walk-forward. To są rzeczywiste kryteria rozstrzygające. Przewaga 0.15 Sharpe do nich nie należy.

Jest jeszcze jedna rzecz, którą warto zrobić, zanim zaufasz któremukolwiek wynikowi. Uruchom backtest dwa razy już teraz, porównaj kapitał dla każdej świecy i sprawdź, czy należysz do obozu bitowej identyczności, czy różnicy 0.15. To eksperyment na piętnaście minut, który powie ci, jak duża część historii twoich badań mierzyła strategię, a jak duża — ziarno haszowania.

determinizmbacktestinginżynieria danychoverfittingpython
← Wszystkie wpisy