Același commit, aceleași fișiere parquet, aceeași mașină, două rulări la o oră distanță. Sharpe 1.34 și Sharpe 1.19. Randament total 41.2% și 37.8%. Număr de tranzacții 1,418 și 1,421. Iar cele două curbe de capital au coincis până la ultima zecimală afișată timp de 11,205 bare consecutive, înainte să se despartă.
Primele trei cifre sunt enervante. Ultima e cea interesantă, fiindcă arată că problema nu e o eroare de virgulă mobilă răspândită pe toată rularea. S-a întâmplat ceva discret la o anumită bară, iar apoi efectul s-a amplificat. Articolul acesta e despre găsirea acelei bare și despre impactul valorii 0.15 asupra fiecărui grid search de parametri pe care l-ai rulat vreodată.
Strategia: momentum transversal pe cele 30 de contracte perpetue USDⓈ-M cu cel mai mare volum, reechilibrare la 4 ore, poziție long pe primele cinci după randamentul pe 12 ore și short pe ultimele cinci, istoric de 18 luni, cu comisioane și funding percepute pentru fiecare picior al tranzacției.
Găsirea barei la care rulările se despart
Dacă înregistrezi capitalul pentru fiecare bară la precizie maximă, durează cam patru minute. Exportă ambele rulări într-un CSV cu (bar_index, equity, open_positions_hash), încarcă-le pe amândouă și găsește primul index la care diferă. Aveam deja scriptul acesta, scris pentru o altă eroare; doar datorită lui n-am pierdut o dimineață întreagă.
Bara 11,206, 2025-03-14T08:00 UTC. Capitalul era identic la bara precedentă, până la 13 cifre semnificative. La bara 11,206, hash-urile pozițiilor diferă: rularea A e long pe SOL, rularea B e long pe AVAX. Aceeași direcție, aceeași valoare nominală, simbol diferit. Tot ce urmează e efectul acestei diferențe.
Așa că am afișat intrările folosite la clasament pentru bara respectivă în ambele rulări. Erau identice. Identice byte cu byte, aceleași 30 de simboluri, aceleași 30 de scoruri. Două scoruri erau 0.0. Exact zero, ambele, fiindcă ambele instrumente avuseseră o bară de 4 ore fără tranzacții în fereastra de analiză, deci raportul dintre prețurile de închidere succesive era unu, iar logaritmul era zero. Nu zero din cauza rotunjirii. Zero.
Două simboluri la egalitate pe locul cinci. Selecția lua primele cinci. Care dintre cele două ajungea în coș depindea de ordinea în care le lăsa sortarea, iar sortarea nu se comporta cum presupuneam.
O egalitate, o sortare instabilă și lucrul pe care l-am ignorat doi ani
Clasamentul folosea o agregare grupată, iar cadrul de date de intrare era creat printr-o comprehensiune de dicționar peste un set de simboluri, reconstruit la fiecare bară în urma unui fetch asincron. Ordinea de iterare a setului se schimbă în funcție de seed-ul hash, iar Python randomizează seed-ul hash pentru șiruri de caractere la fiecare proces dacă nu fixezi PYTHONHASHSEED. Prin urmare, ordinea rândurilor înainte de sortare diferea între rulări, iar sortarea instabilă a departajat în mod diferit cheile egale.
Egalitățile de acest fel nu sunt cazuri rare. Sunt o consecință structurală. Oriunde o caracteristică ajunge la o limită sau este plafonată, se creează egalități exacte: barele cu volum zero produc randamente exact egale cu zero, un z-score plafonat rămâne fixat la ±3.0, o transformare în rang cu puține valori distincte produce zeci de egalități, iar un filtru boolean acordă scorul 1.0 tuturor elementelor care trec. Pe parcursul celor 18 luni cu bare de 4 ore, această rulare a avut 47 de bare cu o egalitate la limita selecției. În trei dintre cazuri s-a schimbat coșul selectat. În celelalte, erau la egalitate două simboluri incluse deja în coș sau excluse amândouă.
Cam o zi am fost convins că încărcătorul de date era nedeterminist, fiindcă era răspunsul cel mai captivant. N-a fost. Niciodată nu e. E vorba de un set, o egalitate și o presupunere despre stabilitatea sortării pe care nimeni n-a consemnat-o.
De ce trei tranzacții valorează 0.15 Sharpe
Aici apar obiecțiile, iar răspunsul este că un backtest cu dimensionarea pozițiilor ca fracție din capital depinde de traseul parcurs. Dacă aloci 8% din capitalul curent pentru fiecare picior, o diferență de capital la bara n înseamnă o diferență a fiecărei valori nominale începând cu bara n.
Prima divergență a avut un efect mic de una singură. Piciorul AVAX din rularea B a pierdut 2.1% în nouă ore; piciorul SOL din rularea A a câștigat 0.4%. Diferența de capital după tranzacția aceea: 0.21%. Nesemnificativă. Dar de atunci, cele două rulări nu mai reprezintă aceeași strategie. Mărimea pozițiilor diferă ușor, deci diferă și sumele de funding acumulate, iar două dintre egalitățile de la limită ulterioare s-au departajat din nou diferit, pentru că scorurile din spatele lor proveneau acum din poziții deținute puțin diferite. Una dintre acestea a apărut pe 2025-03-27, cu o zi înaintea unui trend de șase zile care a generat cam o treime din PnL-ul total al rulării. Rularea A a prins întreaga mișcare; rularea B a intrat cu o reechilibrare mai târziu.
Diferența de randament: 3.4 puncte. Diferența Sharpe e mai mare decât sugerează diferența de randament, fiindcă tranzacțiile reordonate din rularea B s-au suprapus peste o perioadă mai volatilă, astfel că numitorul a crescut în timp ce numărătorul a scăzut. O cauză mică, doi factori de amplificare.
Dacă folosești valori nominale fixe, iar intrările nu depind de pozițiile deținute la momentul respectiv, ești mult mai protejat. Majoritatea strategiilor interesante nu se încadrează în niciuna dintre aceste condiții.
Cele cinci locuri în care apare, de fapt
| Sursă | Simptom | Soluție |
|---|---|---|
Seed PYTHONHASHSEED nefixat; ordinea de iterare a seturilor/dicționarelor influențează sortarea | Departajarea diferă între rulări; prima divergență apare la o anumită bară | Fixează seed-ul; sortează după o cheie secundară explicită (simbolul) pentru a obține departajări deterministe |
Sortare instabilă după o cheie cu valori egale (quicksort implicită în NumPy/pandas) | La fel ca mai sus, persistă și după fixarea seed-ului | kind="stable", sau fă cheia să aibă valori unice |
| Generator de numere aleatoare fără seed fixat în bootstrap, amestecări train/test sau variația sintetică a prețurilor de execuție | Derivă pe tot parcursul rulării, fără un punct clar de divergență | Folosește câte un seed explicit pentru fiecare componentă și înregistrează-l în manifestul rulării |
| Reducerea paralelă a valorilor în virgulă mobilă (ordinea însumării depinde de numărul de fire de execuție) | Diferențe în ultimele câteva biți, de obicei inofensive până când declanșează trecerea unui prag | Fixează numărul de fire pentru rulările de cercetare; nu compara niciodată valori în virgulă mobilă folosind == la o limită de decizie |
| Versiuni de biblioteci nefixate | Reproductibil azi, dar nu în noiembrie | Include în manifest hash-ul fișierului lock, alături de hash-ul instantaneului de date |
Al patrulea rând contează mai rar decât se tem oamenii, iar primul provoacă probleme întruna.
Reproductibilitatea bit cu bit e un instrument, nu o virtute
Ai nevoie de determinism ca, atunci când schimbi o linie, să poți atribui acelei linii diferența din curba de capital. Acesta e tot motivul. Fiecare rulare a unui agent pe Stratmill scrie acum un manifest cu hash-ul instantaneului de date, hash-ul fișierului lock și fiecare seed, iar dacă o rerulare nu reproduce curba anterioară bit cu bit, build-ul eșuează; nu e doar un fapt curios.
Dar după ce poți reproduce rezultatele, strică intenționat condițiile. Rulează strategia de 64 de ori, cu 64 de seed-uri, și urmărește distribuția:
Banda de variație. Aceeași strategie, aceleași date, 64 de permutări ale departajării și ordinii de execuție, fiecare cu seed propriu. Sharpe p5 1.12, mediană 1.27, p95 1.41. Lățimea benzii: 0.29.
Acum întoarce-te la grid search-ul de parametri. Cea mai bună configurație a obținut 1.46. Configurația de pe locul 40 din 96 a obținut 1.31. Diferența dintre ele este 0.15, adică jumătate din lățimea benzii. Grid search-ul nu a clasat cele două configurații. A eșantionat câte o valoare din distribuția fiecăreia și a sortat acele valori.
Această reinterpretare ne-a schimbat modul de selecție. Un rezultat de grid search oferă un clasament doar dacă diferențele dintre configurații depășesc variația unei singure configurații, iar într-o strategie dependentă de traseu, cu 1,400 de tranzacții, variația e de obicei suficient de mare cât să transforme treimea superioară a clasamentului într-o egalitate. Când se întâmplă asta, alege pe baza unui criteriu pe care banda nu-l poate ascunde: rulaj mai mic, mai puțini parametri, o ipoteză de cost pe care ai putea s-o aperi în fața unui sceptic sau un comportament mai bun în segmentul walk-forward care îți place cel mai puțin. Acestea sunt criterii reale de departajare. Un avantaj de 0.15 Sharpe nu este.
Mai e un lucru pe care merită să-l faci înainte să ai încredere în rezultate. Rulează-ți backtestul de două ori chiar acum, compară capitalul bară cu bară și află dacă rezultatele sunt identice bit cu bit sau se încadrează în categoria cu diferența de 0.15. Experimentul durează un sfert de oră și îți arată cât din istoricul tău de cercetare a măsurat strategia și cât a măsurat seed-ul hash.
← Toate articolele


