Stesso commit, stessi file parquet, stessa macchina, due esecuzioni a un’ora di distanza. Sharpe 1.34 e Sharpe 1.19. Rendimento totale 41.2% e 37.8%. Numero di trade 1,418 e 1,421. E le due curve azionarie coincidevano fino a ogni decimale visualizzato per 11,205 barre consecutive, prima di divergere.
I primi tre numeri sono fastidiosi. L’ultimo è quello interessante, perché indica che il problema non è un’approssimazione imprecisa in virgola mobile distribuita per tutta l’esecuzione. È successo qualcosa di discreto su una barra specifica, e tutto il resto è stato un effetto cumulativo. In questo articolo vediamo come trovare quella barra e cosa comporta quella differenza di 0.15 per ogni ottimizzazione dei parametri che hai mai eseguito.
La strategia: momentum cross-sectional sulle 30 perp USDⓈ-M con il volume più alto, ribilanciamento ogni 4 ore, long sulle prime cinque per rendimento a 12 ore e short sulle ultime cinque, 18 mesi di storico, commissioni e funding addebitati per gamba.
Trovare la barra in cui le esecuzioni divergono
Se registri l’equity per barra alla massima precisione, bastano circa quattro minuti. Esporta le due esecuzioni in CSV con (bar_index, equity, open_positions_hash), caricale entrambe e trova il primo indice in cui non coincidono. Avevamo già scritto quello script per un altro bug: è l’unico motivo per cui non ci ho perso una mattinata.
Barra 11,206, 2025-03-14T08:00 UTC. Alla barra precedente l’equity coincideva a 13 cifre significative. Alla 11,206 gli hash delle posizioni differiscono: l’esecuzione A è long su SOL, la B è long su AVAX. Stessa direzione, stesso nozionale, simbolo diverso. Tutto ciò che segue ne è una conseguenza.
Così ho stampato gli input della classifica per quella barra in entrambe le esecuzioni. Erano identici. Byte per byte: stessi 30 simboli, stessi 30 punteggi. Due punteggi erano 0.0. Esattamente zero, entrambi, perché entrambi i simboli avevano una barra a 4 ore senza trade nella finestra di lookback, quindi il rapporto tra chiusure era pari a uno e il logaritmo era pari a zero. Non un valore arrotondato a zero. Zero.
Due simboli a pari merito al quinto posto. La selezione prendeva i primi cinque. Quale dei due veniva incluso dipendeva dall’ordine in cui li lasciava l’ordinamento, che non si comportava come pensavo.
Un pareggio, un ordinamento instabile e la cosa che ignoravo da due anni
La classifica passava da un’aggregazione per gruppi e il frame usato come input proveniva da una comprensione di dizionario su un insieme di simboli ricreato a ogni barra a partire da un fetch asincrono. L’ordine di iterazione dell’insieme cambia in base al seed dell’hash e Python randomizza il seed degli hash delle stringhe a ogni processo, a meno che non si imposti PYTHONHASHSEED. Di conseguenza, l’ordine delle righe prima dell’ordinamento variava tra le esecuzioni e, con un ordinamento non stabile su una chiave a pari merito, il pareggio si risolveva in modo diverso.
Pareggi come questo non sono casi eccezionali. Sono strutturali. Ogni volta che una feature raggiunge un limite o viene tagliata, crei uguaglianze esatte: le barre a volume zero producono rendimenti esattamente pari a zero, uno z-score tagliato si fissa a ±3.0, una trasformazione in ranghi con pochi valori distinti genera decine di pareggi, un filtro booleano assegna 1.0 a tutto ciò che passa. In 18 mesi di barre a 4 ore, questa esecuzione ha avuto 47 barre con un pareggio al limite di selezione. Tre hanno cambiato il paniere selezionato. Negli altri casi, il pareggio riguardava due simboli già entrambi inclusi o entrambi esclusi.
Per circa un giorno ero convinto che il caricatore dei dati fosse non deterministico, perché quella sarebbe stata la risposta più interessante. Non lo era. Non lo è mai. Era un insieme, un pareggio e un’ipotesi sulla stabilità dell’ordinamento che nessuno aveva messo per iscritto.
Perché tre trade valgono 0.15 di Sharpe
È qui che spesso ricevo obiezioni. La risposta è che un backtest con dimensionamento basato su una frazione dell’equity dipende dal percorso. Dimensionare ogni gamba all’8% dell’equity corrente significa che una differenza di equity alla barra n comporta una differenza in ogni nozionale dalla barra n in poi.
La prima divergenza, da sola, è costata poco. La gamba AVAX dell’esecuzione B ha perso il 2.1% in nove ore; la gamba SOL dell’esecuzione A ha guadagnato lo 0.4%. Differenza di equity dopo quel trade: 0.21%. Irrilevante. Ma da quel momento le due esecuzioni non rappresentano più la stessa strategia. Mantengono posizioni di dimensioni leggermente diverse, quindi maturano importi di funding leggermente diversi; inoltre, due successivi pareggi al limite si sono risolti di nuovo in modo diverso, perché i punteggi usati provenivano da posizioni mantenute marginalmente diverse. Uno si è verificato il 2025-03-27, il giorno prima di un trend di sei giorni che ha generato circa un terzo del PnL totale dell’esecuzione. L’esecuzione A ha mantenuto la posizione per tutto il movimento; la B è entrata un ribilanciamento più tardi.
Differenza di rendimento: 3.4 punti. La differenza nello Sharpe è maggiore di quanto suggerisca quella nel rendimento, perché i trade riordinati dell’esecuzione B si sono sovrapposti a un periodo più volatile: il denominatore è salito mentre il numeratore è sceso. Causa piccola, due amplificatori.
Se usi un nozionale fisso e le entrate non dipendono dalle posizioni correnti, sei molto più protetto. Le strategie più interessanti di solito non hanno nessuna di queste due caratteristiche.
I cinque punti in cui si manifesta davvero
| Origine | Sintomo | Soluzione |
|---|---|---|
PYTHONHASHSEED non impostato, con l’ordine di iterazione di insiemi/dizionari che alimenta un ordinamento | I pareggi si risolvono diversamente tra le esecuzioni; la prima divergenza si verifica su una barra specifica | Imposta il seed; ordina con una chiave secondaria esplicita (il simbolo) per rendere deterministici i pareggi |
Ordinamento non stabile su una chiave a pari merito (quicksort predefinito in NumPy/pandas) | Come sopra, ma persiste anche dopo aver impostato il seed | kind="stable", oppure rendi la chiave univoca |
| Generatore di numeri casuali non inizializzato in bootstrap, shuffle di train/test o jitter sintetico dei fill | Deriva dell’intera esecuzione, senza un punto di divergenza netto | Imposta un seed esplicito per ogni componente e registralo nel manifest dell’esecuzione |
| Riduzione parallela di valori float (ordine di somma dipendente dal numero di thread) | Differenze negli ultimi bit, di solito innocue finché non superano la soglia di un confronto | Imposta il numero di thread per le esecuzioni di ricerca; non confrontare mai valori float con == al limite di una decisione |
| Versioni delle librerie non bloccate | Riproducibile oggi, non a novembre | Hash del lockfile nel manifest, insieme all’hash dello snapshot dei dati |
La quarta riga conta meno spesso di quanto si tema, mentre la prima causa problemi di continuo.
La riproducibilità bit per bit è uno strumento, non un valore morale
Vogliamo il determinismo perché, quando cambiamo una riga, la differenza nella curva azionaria possa essere attribuita a quella riga. È tutto qui. Ora ogni esecuzione di un agente su Stratmill produce un manifest con l’hash dello snapshot dei dati, l’hash del lockfile e tutti i seed; se una nuova esecuzione non riproduce la curva precedente bit per bit, la build non è superata: è fallita.
Ma una volta ottenuta la riproducibilità, rompiamola di proposito. Eseguiamo il backtest 64 volte con 64 seed e osserviamo la dispersione:
La fascia di variabilità. Stessa strategia, stessi dati, 64 permutazioni con seed diversi per risolvere i pareggi e ordinare i fill. Sharpe p5 1.12, mediana 1.27, p95 1.41. Ampiezza della fascia: 0.29.
Torniamo ora all’ottimizzazione dei parametri. La configurazione migliore ha ottenuto 1.46. Quella al 40º posto su 96 ha ottenuto 1.31. La differenza tra loro è 0.15, metà della fascia. L’ottimizzazione non ha stabilito una classifica tra quelle due configurazioni. Ha estratto un singolo risultato dalle rispettive distribuzioni e ha ordinato quei risultati.
Questo nuovo modo di vedere la questione ha cambiato il nostro criterio di scelta. I risultati di un’ottimizzazione costituiscono una classifica solo se le differenze tra le configurazioni superano la variabilità di una singola configurazione; in una strategia dipendente dal percorso con 1,400 trade, la variabilità è di solito abbastanza ampia da appiattire il terzo superiore della classifica in un unico pareggio. In quel caso, scegliamo in base a qualcosa che la fascia non possa nascondere: turnover più basso, meno parametri, un’ipotesi sui costi che sapremmo difendere con chi è scettico, un comportamento migliore nella fold walk-forward che preferiamo di meno. Sono criteri di spareggio concreti. Un vantaggio di 0.15 nello Sharpe non lo è.
Un’ultima cosa da fare prima di fidarsi dei risultati. Eseguiamo subito due volte il backtest, confrontiamo l’equity barra per barra e scopriamo se rientriamo nel gruppo delle esecuzioni identiche bit per bit o in quello della differenza di 0.15. È un esperimento di quindici minuti e ci dice quanta parte della nostra storia di ricerca misurava la strategia e quanta un seed dell’hash.
← Tutti gli articoli

