Sei settimane dopo aver eseguito un backtest, lo abbiamo rieseguito con lo stesso codice, la stessa configurazione e lo stesso intervallo di date. Il capitale finale era diverso. Non di molto, ma abbastanza da far scendere lo Sharpe da 1.31 a 1.08 e cambiare il set di parametri che sembrava migliore.
La strategia non era cambiata. Erano cambiati i dati storici. Un fornitore aveva revisionato alcuni giorni di scambi e barre, e nella prima esecuzione non avevamo conservato lo snapshot esatto degli input. Nei giorni successivi abbiamo cercato di capire se il nuovo risultato fosse dovuto a una correzione dei dati, a un bug nella pipeline o a un processo di ricerca impossibile da riprodurre.
Lunedì: la nuova esecuzione non coincideva
Il primo indizio era una piccola discrepanza in una coppia spot liquida. La barra originale indicava un massimo di 62,418. Nella nuova esecuzione era 62,431. La differenza sembrava trascurabile, finché non abbiamo ricostruito l'ingresso della strategia: la soglia era compresa tra quei prezzi. Bastavano poche barre modificate per generare segnali diversi e, di conseguenza, posizioni diverse alcuni giorni dopo.
Abbiamo confrontato il numero di scambi per giorno e individuato una sola data in cui iniziava la discrepanza. Poi abbiamo confrontato le righe grezze usate per quella data. La risposta attuale del fornitore conteneva alcuni scambi corretti e una barra aggregata revisionata. Alla risposta che avevamo usato non era allegato alcun registro delle modifiche.
È il normale comportamento dei dati. Gli exchange correggono i record; i fornitori colmano lacune, rettificano le transazioni anomale e modificano la logica di aggregazione. Una richiesta di «dati di gennaio» può restituire una risposta diversa a marzo.
Martedì: abbiamo trovato la ricevuta mancante
Nel record dell'esecuzione avevamo salvato il nome del fornitore, il simbolo e l'intervallo di date. Non avevamo salvato la risposta né l'hash del contenuto di ciascuna partizione di input. Potevamo quindi dimostrare che cosa avevamo richiesto, ma non quali byte ci avesse restituito il fornitore.
Abbiamo recuperato i file memorizzati nella cache più vecchi che siamo riusciti a trovare e li abbiamo confrontati con la risposta attuale. La differenza non dipendeva da una rettifica per frazionamento azionario né da una conversione del fuso orario. Si trattava di un piccolo insieme di record di scambi corretti che passavano attraverso il nostro generatore di barre. Questo ha escluso una delle ipotesi, ma la cache era incompleta, quindi non potevamo ancora ricostruire con precisione tutte le barre interessate.
| Che cosa avevamo registrato | A quale domanda ci permetteva di rispondere | Che cosa mancava |
|---|---|---|
| Fornitore, simbolo, intervallo di date | Quale richiesta aveva effettuato l'esecuzione | Il contenuto della risposta |
| Versione della pipeline | Quale codice aveva generato le barre | Le righe di input esatte |
| Timestamp dell'esecuzione | Quando avevamo eseguito il job | Quale revisione del fornitore era disponibile in quel momento |
Avevamo equiparato «stessa query» a «stessi dati». Questa supposizione faceva sembrare l'esperimento più riproducibile di quanto fosse davvero.
Mercoledì: la correzione ha cambiato il quadro
Abbiamo rieseguito i giorni interessati usando entrambe le versioni dei dati disponibili. Le barre revisionate hanno eliminato 2 ingressi e spostato di 1 barra un'uscita. Queste 3 operazioni spiegavano gran parte della variazione dello Sharpe. La strategia era sensibile a poche osservazioni vicine alla soglia di attivazione, anche se sul grafico la serie dei prezzi appariva quasi identica.
Questo non significava che il risultato originale fosse fraudolento né che quello revisionato fosse automaticamente migliore. Significava che la conclusione andava circoscritta: la vecchia esecuzione descriveva lo snapshot dei dati disponibile allora, mentre la nuova descriveva la serie storica attuale del fornitore. Non potevamo definire onestamente la seconda una riproduzione della prima.
Giovedì: abbiamo aggiornato il registro di ricerca
Abbiamo aggiunto snapshot immutabili degli input alle nuove esecuzioni di ricerca. Ogni partizione riceve un hash del contenuto e il manifest dell'esecuzione registra la fonte, l'ora di acquisizione, la versione dello schema, la policy di rettifica e la versione del codice di trasformazione. Per i dataset di grandi dimensioni conserviamo i file effettivi in uno storage indirizzato tramite hash del contenuto, così possiamo condividere partizioni identiche senza sovrascrivere le versioni precedenti.
Quando le regole di storage o di licenza ci impediscono di conservare i dati grezzi, manteniamo la ricevuta più completa consentita dai termini: hash, parametri della richiesta, identificativi della revisione della fonte e un piccolo campione di righe analizzate per la diagnosi. Un hash non può recuperare i dati, ma ci dice se due esecuzioni non hanno utilizzato byte identici.
Per riprodurre un backtest serve un'identità dell'input, non solo un URL dei dati. Registrate che cosa è arrivato, quando è arrivato e come è stato trasformato nelle righe usate dalla strategia.
Venerdì: che cosa eviteremmo la prossima volta
Eviteremmo di confrontare solo le metriche principali. Uno Sharpe diverso dice che qualcosa è cambiato, ma non individua la causa. Partite dal conteggio delle righe e dagli hash delle partizioni, poi trovate la prima data di input diversa. Da lì confrontate, nell'ordine, le righe trasformate, i segnali, gli ordini e le esecuzioni.
Eviteremmo anche di sostituire senza avviso i vecchi dati con la serie storica corretta. Le correzioni sono utili, ma le vecchie esecuzioni devono restare associate ai loro input originali. Un nuovo dataset deve generare una nuova esecuzione, collegata in modo visibile a quella precedente.
La nostra regola pratica ora è semplice: se una nuova esecuzione deve riprodurre un esperimento, fissiamo lo snapshot dei dati. Se deve beneficiare di una serie storica corretta, la etichettiamo come nuovo esperimento. Ci sono voluti alcuni giorni per imparare questa distinzione, che ora occupa una sola riga nel manifest dell'esecuzione.
← Tutti gli articoli


