Dimostrare l'assenza di look-ahead nei backtest multi-timeframe: perturbare il futuro per dimostrare che il passato non può vederlo
Parte della serie "Backtest senza illusioni".
L'articolo precedente si concludeva con una diagnosi: il test dello shift di una barra. Sposta ogni fill di una barra più avanti; se la performance crolla, stavi facendo trading nel passato. Questo test è sufficiente per una strategia single-timeframe, dove il look-ahead si nasconde nell'esecuzione — hai deciso sulla barra i e hai eseguito il fill sulla barra i.
Le strategie multi-timeframe hanno un secondo punto di leak, più silenzioso, che il test dello shift non riesce a raggiungere. Quando una decisione su una barra da 1 minuto consulta un trend orario, il leak non è più nel fill — è nell'indicatore, calcolato a partire da una barra di timeframe superiore che non ha ancora finito di formarsi. Questo articolo parla di quel leak, e di come dimostrare — meccanicamente, non fissando il codice — che il tuo motore non ne è affetto.
L'affermazione che facciamo alla fine è forte: il nostro motore multi-TF ha superato 25 controlli su 25 di parità e leakage su 86,400 barre di dati reali ETHUSDT 1m, incluso una probe a futuro perturbato che perturba ogni barra futura e conferma che nessuna decisione passata si muove. E poiché il motore è bit-identico al nucleo canonico del bot live, la dimostrazione è una proprietà del sistema live, non solo del backtest.
Il campo minato multi-timeframe

Una strategia multi-timeframe (multi-TF) prende una decisione su un clock veloce consultando al contempo clock più lenti. Il nostro motore ne è un'istanza concreta: una regola di momentum a tripla-TF in cui un trend di timeframe superiore (HTF) e un trend di timeframe intermedio (MTF) filtrano (gate) gli entry, mentre un incrocio di timeframe inferiore (LTF) li innesca. Sul clock base a 1m, un entry scatta solo quando un incrocio HMA/HMA3 a 1m concorda con la direzione del trend a 1h e del trend a 15m; un exit scatta quando un trend di timeframe superiore si inverte contro la posizione, oppure quando l'LTF incrocia in senso opposto.
Il pericolo è strutturale. Ognuno di quei valori di timeframe superiore deve essere fornito su ogni barra a 1m — 60 volte all'ora — e ogni risposta deve usare solo ciò che un bot live saprebbe in quell'istante. Nel momento in cui anche una sola di quelle circa 180 letture per barra (tre timeframe, trend di entry ed exit, filtri di separazione) tocca una barra che non è ancora stata stampata, hai look-ahead. La superficie d'attacco per un errore off-by-one è enorme, e a differenza di un leak di esecuzione, non si manifesta spostando il fill.
Una barra in formazione non ha ancora una chiusura finale
Ecco la trappola esatta. Supponiamo che siano le 10:37 e tu stia decidendo sulla barra a 1m appena chiusa. La tua regola vuole il trend a 1h. La candela a 1h che copre 10:00–11:00 è ancora in formazione — la sua chiusura finale non sarà nota fino alle 10:59:59. Cosa sai realmente alle 10:37? Solo la chiusura corrente di quella candela fino a quel momento, ovvero la chiusura della barra delle 10:37. La chiusura finale della candela è 23 minuti nel futuro.
Il backtest multi-TF ingenuo fa qualcosa che sembra del tutto innocuo: ricampiona (resample) l'intera serie a 1m in barre a 1h una sola volta, in anticipo, e poi, per ogni barra a 1m, legge "la chiusura a 1h". Ma il valore che legge per ogni barra a 1m tra le 10:00 e le 11:00 è la chiusura finale 10:00–11:00 — un numero che, in tempo reale, non esiste finché l'ora non finisce. Ogni decisione all'interno dell'ora viene silenziosamente consegnata fino a 59 minuti di futuro. E non è un leak piccolo: la chiusura del timeframe superiore è probabilmente il predittore più forte possibile dei rendimenti a 1m nel prossimo futuro che stai per tradare, quindi farla trapelare equivale quasi a leggere la soluzione in anticipo. Questo è il canale di leakage dell'indicatore descritto nella tassonomia, amplificato: lo sbirciare non è di una barra, ma può arrivare a un intero periodo HTF.
Il test dello shift di una barra dell'articolo precedente non lo intercetta. Puoi spostare il fill di una barra più avanti, ma la serie a 1h ricampionata resta comunque contaminata — il leak risiede in come è stato costruito l'indicatore, non in quando hai eseguito la transazione.
La regola del bot live, riprodotta esattamente: la semantica closed-bar

La regola corretta è quella che il bot live già esegue. Nel nostro codebase è una piccola classe, RunningCandleBuffer, spostata testualmente (verbatim) dal simulatore di tick live. Trasforma in streaming le barre base in candele a periodo fisso e, su ogni barra base, calcola l'indicatore HTF a partire da un array molto specifico:
all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)
Leggilo alla lettera. self.closes sono le chiusure finali delle candele che si sono già chiuse — una candela viene aggiunta solo quando viene attraversato un nuovo confine di periodo, e il suo valore memorizzato è la chiusura dell'ultima barra base all'interno del periodo (candle_buffer.py:39–44). La candela in formazione contribuisce con esattamente un numero, self.current_close, che è la chiusura corrente (running) — la chiusura della barra base più recente, base_close[i]. Questa è una quantità nota alla barra i per definizione. La chiusura finale della candela in formazione non viene mai usata, perché non esiste ancora.
Quindi l'indicatore HTF alle 10:37 vede [..., close(9:00 candle), close(10:37 so far)]. Quando viene stampata la barra delle 10:38, l'ultimo slot si aggiorna a close(10:38). Quando si attraversano le 11:00, close(10:59) diventa il valore finale della candela appena chiusa e si apre un nuovo slot in formazione. In nessun momento una decisione all'interno dell'ora tocca la chiusura finale 10:00–11:00. Questa è la semantica closed-bar: le candele chiuse contribuiscono con chiusure definitive, la candela in formazione contribuisce solo con la sua chiusura corrente.
Il nostro motore veloce (engine_multitf.py) è una reimplementazione vettorializzata e compilata con numba di questo meccanismo. Invece di un ciclo Python con una lista che cresce, precalcola, per ogni barra base i, quante candele si sono chiuse completamente (n_closed[i]) e costruisce la finestra HMA/HMA3 come [closed candle closes…, base_close[i]] — la chiusura corrente fissata nello slot finale (engine_multitf.py:168–169). È la stessa matematica, srotolata (unrolled) per velocità su tre timeframe con filtri di separazione direzionali. Il contratto è esplicito: il valore alla barra i dipende solo da base_close[0..i].
Questa è l'affermazione. Il resto dell'articolo racconta come la dimostriamo, perché un'affermazione in un docstring non vale nulla.
La parità è necessaria ma non sufficiente
Un motore numba vettorializzato che srotola una classe di streaming in cicli espliciti è esattamente il terreno in cui proliferano gli errori off-by-one. Quindi il primo gate è un test di parità bit-per-bit rispetto al riferimento canonico, su una porzione di dati reali — ETHUSDT 1m, gennaio–febbraio 2024, 86,400 barre.
Verifichiamo due cose indipendenti rispetto a due riferimenti indipendenti:
- Indicatori e incroci (cross) rispetto a
RunningCandleBuffereseguito barra per barra. Per ogni timeframe rieseguiamo la classe live su tutte le 86,400 barre e confrontiamo gli eventi di incrocio — la barra, la direzione, la separazione — per una corrispondenza esatta, più i valori HMA/HMA3 entro tolleranza in virgola mobile (il riferimento usanp.dot, il motore usa cicli espliciti, quindi l'ordine di somma differisce di ~1e-15). Gli incroci coincidono esattamente: 408 incroci sull'HTF (1h, lunghezza HMA 21), 2,792 sull'MTF (15m/14), 3,691 sull'LTF (1m/50). Nemmeno un evento di incrocio differisce nella barra o nella direzione. - Trade rispetto a una simulazione indipendente in puro Python delle regole di trading, guidata dagli incroci propri del riferimento. Questo riproduce il ciclo del backtest live — i trend di entry filtrano gli entry, un'inversione del trend di exit o un incrocio LTF opposto chiude la posizione, i fill avvengono su
open[i+1], commissione round-trip dello 0.09%, chiusura forzata sull'ultima barra — senza nessuno dei meccanismi numba del motore. Confrontiamo poi trade per trade: barre di entry/exit, side, prezzi di entry/exit, PnL, motivi di uscita, e tempo attivo totale in posizione.
Le soglie di separazione nel test non sono valori di default innocui. Sono scelte per colpire un caso limite scomodo — una soglia di exit dell'MTF impostata più alta della corrispondente soglia di entry — che forza il ramo "prima definizione di un trend di exit mentre una posizione è aperta", che il riferimento tratta come un'inversione. La parità deve reggere sui casi limite, non solo sul percorso semplice.
Campo per campo, i trade sono identici: 466 trade per la configurazione dual, 211 trade per quella tripla, con PnL totale corrispondente a 1e-12 e ogni campo dei trade uguale entro tolleranza. Due implementazioni che non condividono codice — un motore compilato e vettorializzato e un ciclo Python ingenuo sugli incroci di una terza implementazione — producono gli stessi trade fino all'ultimo decimale.
Questo è un risultato solido, e non è una dimostrazione di assenza di look-ahead. La parità dice che il motore veloce riproduce fedelmente il riferimento. Se il riferimento stesso avesse un leak — se RunningCandleBuffer sbirciasse — la parità riprodurrebbe fedelmente il leak e passerebbe comunque. L'accordo tra implementazioni ti dice che sono uguali, non che sono causali. Per la causalità serve un tipo diverso di test, uno che chieda direttamente al motore se il passato può vedere il futuro.
La probe a futuro perturbato: la vera dimostrazione

La definizione di look-ahead è operativa, quindi va testata operativamente. Look-ahead significa che una decisione passata dipende da dati futuri. Il contrappositivo è un test che puoi eseguire: se cambi il futuro e una qualsiasi decisione passata si muove, il passato stava leggendo il futuro. Quindi cambia il futuro — brutalmente — e osserva il passato.
Scegli un punto di taglio j al 60% della serie (barra 51,840 su 86,400). Perturba ogni barra da j in poi: moltiplica tutte le chiusure e le aperture future per 1.05. Ricalcola l'intero stack di segnali per tutti e tre i timeframe sui dati perturbati. Poi verifica che tutto ciò che sta strettamente prima di j sia bitwise identico all'esecuzione non perturbata:
j = int(n * 0.6) # bar 51,840
cl2 = cl.copy(); cl2[j:] *= 1.05 # shove the future up 5%
op2 = op.copy(); op2[j:] *= 1.05
base = [precompute_tf_signals(cl, ts, p, L) for (p, L) in tf_params]
pert = [precompute_tf_signals(cl2, ts, p, L) for (p, L) in tf_params]
for s0, s1 in zip(base, pert):
assert eq_nan(s0.hma[:j], s1.hma[:j]) # HMA identical, NaNs included
assert eq_nan(s0.hma3[:j], s1.hma3[:j])
assert np.array_equal(s0.cross[:j], s1.cross[:j]) # every cross event
assert np.array_equal(s0.sep[:j], s1.sep[:j]) # every separation
Non "vicino". Non "entro una tolleranza". np.array_equal, con i NaN che devono corrispondere ai NaN — ogni valore HMA, ogni valore HMA3, ogni flag di incrocio e ogni separazione sulle 51,840 barre passate deve essere lo stesso identico float. Poi la stessa asserzione sui trade: ogni trade il cui exit cade prima di j deve restare invariato campo per campo. Se una spinta del 5% al futuro sposta un singolo HMA passato alla dodicesima cifra decimale, una decisione passata ha consultato il futuro, e la probe fallisce.
Il nostro motore la supera — per tutti e tre i timeframe, e per entrambe le simulazioni di trade, dual e tripla. Perturbare 34,560 barre future lascia le 51,840 barre passate e ogni trade chiuso al loro interno esattamente come erano. Questo non è un accordo tra implementazioni; è una dimostrazione diretta che il confine informativo nel tempo regge.
Un test che non può fallire non dimostra nulla
C'è un modo di superare la probe qui sopra che non dimostra assolutamente nulla: se la perturbazione fosse un no-op — se moltiplicare il futuro per 1.05 non cambiasse niente da nessuna parte — allora "il passato è invariato" sarebbe banalmente vero e del tutto privo di informazione. Un segno di spunta verde su un test che non può fallire è peggio di nessun test, perché fabbrica una falsa fiducia. Per questo la probe porta con sé altre due asserzioni che le danno i denti.
Il futuro deve effettivamente cambiare. Verifichiamo che la perturbazione abbia alterato gli incroci da qualche parte in [j, n):
assert not np.array_equal(s0.cross[j:], s1.cross[j:]) # probe has teeth
Ora il risultato è significativo: la stessa spinta del 5% che ha riscritto il futuro ha lasciato il passato identico bit per bit. La perturbazione è reale, si propaga in avanti, e si ferma di colpo al taglio. Un leak a senso unico — il passato legge il futuro — si sarebbe propagato all'indietro attraverso j; qui non succede.
Il confine è esattamente alla barra corrente — non una prima, non una dopo. Un fallimento più sottile sarebbe un motore causale ma stantio (stale): ignora la chiusura corrente della barra attuale e reagisce con una barra di ritardo (nessun leak, ma un ritardo che il trading live non avrebbe), oppure reagisce con una barra di anticipo (un leak di una barra). Quindi perturbiamo una singola barra j (di un fattore 1.02) e verifichiamo due cose contemporaneamente: il passato [0, j) resta intatto, e hma[j] reagisce immediatamente.
cl3 = cl.copy(); cl3[j] *= 1.02 # nudge exactly one bar
s3 = precompute_tf_signals(cl3, ts, p_ltf, L_ltf)
assert eq_nan(s0.hma[:j], s3.hma[:j]) # nothing before j moves
assert s0.hma[j] != s3.hma[j] # bar j reacts on the same bar
Questo fissa il confine con precisione. La chiusura corrente della candela in formazione entra nell'indicatore alla barra j con zero ritardo e zero preveggenza: la barra j vede la propria chiusura istantaneamente, e nessuna barra precedente la vede affatto. Questo è esattamente il filo del rasoio su cui la semantica closed-bar deve stare in equilibrio, e il test conferma che il motore ci sta.
Ecco il gate completo — tutti i 25 controlli che si frappongono tra questo motore e un backtest fabbricato:
| Gruppo | Cosa verifica ogni controllo | Conteggio |
|---|---|---|
| Indicatori e incroci (×3 timeframe) | eventi di incrocio esatti rispetto a RunningCandleBuffer; separazione agli incroci; valori HMA/HMA3 (rtol 1e-9) |
9 |
| Trade (dual + tripla) | numero di trade; identici campo per campo; PnL totale a 1e-12; tempo attivo in posizione | 8 |
| Probe a futuro perturbato (dual + tripla) | segnali passati bitwise invariati; la probe ha i denti (il futuro è cambiato); trade prima di j invariati; perturbazione a barra singola localizzata |
8 |
| Totale | 25 |
I primi due gruppi stabiliscono che il motore veloce è il riferimento live. Il terzo stabilisce che il riferimento è causale. Servono tutti e tre: un motore veloce con un leak, un motore con un leak che è comunque causale, e un motore causale ma in ritardo sono tre fallimenti diversi, e il gate li esclude tutti.
Perché la probe è agnostica rispetto al timeframe
L'eleganza della probe a futuro perturbato sta nel fatto che non sa e non le importa dove risiederebbe un leak. Non menziona mai timeframe, resampling o confini di candela. Chiede solo: perturbare il futuro sposta il passato? Questo la rende lo strumento giusto proprio per il leak multi-TF, che un test di shift del fill non riesce a cogliere.
Consideriamo direttamente il bug ingenuo del resampling dell'intera serie. Se lo stream a 1h fosse costruito ricampionando l'intera serie in anticipo, la "chiusura a 1h" letta alla barra j-1000 (ben dentro l'ora che si chiude dopo j) sarebbe la chiusura finale di una candela la cui chiusura finale dipende da barre a partire da j e oltre. Moltiplica il futuro per 1.05 e quella chiusura finale cambia — quindi l'indicatore HTF a j-1000 cambia, il filtro a j-1000 cambia, e una decisione passata si muove. La probe si accenderebbe istantaneamente sullo stream HTF, a una barra mille passi prima del taglio.
Lo stream HTF del nostro motore non si muove, perché a j-1000 la candela in formazione contribuisce solo con base_close[j-1000] — una chiusura passata — e la chiusura finale della candela semplicemente non viene mai consultata finché il confine non viene superato. La probe è cieca rispetto al meccanismo e cattura comunque il bug, il che è esattamente ciò che si vuole da una dimostrazione: vincola il comportamento (nessuna decisione passata dipende da dati futuri) invece di verificare l'implementazione (abbiamo indicizzato correttamente il resample?). Il comportamento è ciò che fa trading; l'implementazione è ciò che speri corrisponda ad esso.
Backtest e live condividono un'unica verità

C'è un'ulteriore ragione per cui questo conta più di un tipico audit di backtest. Il riferimento rispetto al quale il motore viene dimostrato — RunningCandleBuffer — non è un fixture di test scritto per far sembrare bello il backtest. È la logica delle candele dello stesso bot live, estratta testualmente dal simulatore di tick che gira in produzione. La regola closed-bar che la probe convalida è la regola che il bot live esegue, barra per barra.
Il gate di parità svolge quindi un doppio compito. Dimostra che il motore veloce corrisponde al riferimento, e poiché il riferimento è il nucleo live, dimostra che il motore veloce corrisponde al live. L'articolo precedente avvertiva che un leak è la spiegazione più pulita per un gap di parità backtest-live — il bot live è l'unico posto dove meccanicamente non puoi sbirciare, quindi un backtest che sbircia diverge nel momento stesso in cui va in live. Qui quel gap è chiuso per costruzione: backtest e bot condividono un solo buffer di candele, una sola regola di incrocio, un'unica definizione di "noto alla barra i". Il numero che la ricerca ottimizza è il numero che il bot può effettivamente tradare.
Cosa la probe dimostra e cosa non dimostra
Il rigore riguardo al test funziona in entrambe le direzioni, quindi bisogna essere precisi sul suo ambito. La probe a futuro perturbato dimostra un'unica proprietà specifica: su questi dati, nessuna decisione di segnale o di trade alla barra j o prima dipende da alcuna barra successiva a j — il confine informativo nel tempo regge lungo il percorso di indicatore, incrocio e trade. Questo è esattamente il leak multi-TF che ci eravamo proposti di eliminare, ed è la proprietà che una code review non può stabilire.
Non dimostra affatto che la strategia abbia un edge. Un motore perfettamente causale può perdere denaro onestamente, e la probe tace su questo — ed è giusto così; dimostrare l'assenza di leakage e trovare un edge reale sono compiti separati, e confonderli è il modo in cui i backtest con leak finiscono per essere messi in produzione. Non copre i bias non temporali: il survivorship bias nello strumento, il selection bias dovuto all'aver eseguito la probe solo dopo che il motore sembrava già promettente, o un modello di commissioni troppo generoso. E non dimostra, di per sé, che i fill live corrispondano ai fill del backtest — slippage e latenza sono gap reali che la probe non può vedere, perché opera sul percorso decisionale, non sulla sede di esecuzione. Ciò che chiude quel gap è il fatto separato che il nucleo delle candele del motore è bit-identico a quello del bot live.
Una precisazione onesta sul design stesso della probe: taglia a un singolo j (60% della serie). La proprietà che verifica è uniforme rispetto a j — non c'è nulla di speciale nella barra 51,840 — quindi un singolo taglio è un test corretto di una proprietà strutturale, ma una versione paranoica farebbe scorrere j lungo l'intera serie. Consideriamo sufficiente un singolo taglio ben scelto più il controllo di localizzazione a barra singola, perché un leak che si nasconde da una perturbazione futura di 34,560 barre ma appare a qualche altro taglio dovrebbe essere un bug davvero strano. Il punto è conoscere i limiti delle proprie prove, non fingere che un singolo test sia un quantificatore universale.
Punti chiave
- Le strategie multi-timeframe hanno un leak attraverso la barra in formazione, non attraverso il fill. Una decisione all'interno di un periodo HTF non ancora concluso deve usare solo la chiusura corrente di quella candela (l'ultima chiusura base), mai la sua chiusura finale. I backtest che ricampionano l'intera serie consegnano ogni decisione infra-periodo fino a un intero periodo HTF di futuro.
- Il test dello shift di una barra non raggiunge questo leak. Intercetta il look-ahead di esecuzione; il leak multi-TF risiede in come è stato costruito l'indicatore. Serve una probe diversa.
- Riproduci esattamente la regola live, poi dimostrala. Abbiamo ricostruito la semantica closed-bar di
RunningCandleBufferdel bot come un motore vettorializzato e l'abbiamo sottoposto a un gate di 25 controlli: incroci esatti rispetto al riferimento (408 / 2,792 / 3,691), trade identici campo per campo (466 dual, 211 tripla), PnL a 1e-12. - La parità è necessaria ma non sufficiente. Corrispondere a un riferimento dimostra che sei uguale, non che sei causale. Un riferimento con leak riprodotto fedelmente continua ad avere il leak.
- La probe a futuro perturbato è la vera dimostrazione. Perturba ogni barra a partire da
jin poi; verifica che ogni segnale e trade prima dijresti bitwise invariato. Se il futuro può muovere il passato, hai look-ahead. - Dai i denti alla probe. Verifica che il futuro sia effettivamente cambiato (la perturbazione non è un no-op) e che una spinta su una singola barra reagisca su quella stessa barra (nessun leak, nessun ritardo). Un test che non può fallire non dimostra nulla.
- Quando backtest e live condividono un solo nucleo, la dimostrazione si trasferisce. Poiché il motore è bit-identico alla logica delle candele del bot live, l'assenza di look-ahead è una proprietà anche del sistema live — e il gap di parità backtest-live si chiude per costruzione.
L'articolo precedente ha mostrato come un leak di una sola riga fabbrichi uno Sharpe di 15. Questo mostra la disciplina opposta: non "come i leak ti ingannano", ma come dimostrare, meccanicamente e su dati reali, che un motore specifico non ha leak. Perturba il futuro. Se il passato non trema, stai facendo trading nel presente.
Autori
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.