La tassa del framework: quando la tua libreria di backtest è più lenta di un ingenuo ciclo pandas
Parte della serie "Backtest senza illusioni".
C'è un'assunzione comoda alla base della maggior parte dei progetti di algotrading: che una libreria di backtest matura e molto stellata sia veloce. Ha anni di contributi, un vero event loop, un modello di broker, uno schema di commissioni. Di sicuro batte quel ciclo pandas raffazzonato che avresti scritto tu stesso. Così la scegli, ci colleghi la tua strategia e avvii una ricerca di parametri — qualche migliaio di configurazioni, un lavoro notturno. Torni la mattina ed è ancora in esecuzione.
Abbiamo messo a confronto otto motori di backtest su un'unica identica ricerca di parametri e abbiamo scoperto qualcosa che dovrebbe cambiare il modo in cui scegli uno strumento per la ricerca. Due dei framework event-driven open-source più popolari — backtrader e bt — hanno eseguito la ricerca più lentamente di un ingenuo ciclo pandas che abbiamo scritto come riferimento usa e getta. Non un po' più lenti. backtrader ha impiegato all'incirca 2.5× il tempo del riferimento pandas; bt, circa 4.7×. Nel frattempo un motore vettorizzato/compilato sullo stesso lavoro è stato circa 13,000× più veloce di bt.
Questa è la tassa del framework. I backtester popolari sono costruiti per una esecuzione onesta — una strategia, un dataset, riempimenti accurati, un broker che si comporta come un broker. È esattamente ciò che vuoi per una validazione finale o un controllo di parità con il live. Ed è esattamente lo strumento sbagliato per la cosa su cui la ricerca in algotrading spende in realtà il suo tempo: eseguire la stessa strategia diecimila volte con manopole leggermente diverse. Questo articolo misura la tassa, ne spiega il meccanismo e fornisce una regola decisionale su quando un framework di backtest "vero" è la scelta sbagliata.
Ogni numero qui proviene da un unico banco di prova riproducibile (benchmarks/bench_oss_engines.py, commit 250dbb5) su un carico di lavoro identico e bloccato in parità. Dove non abbiamo eseguito noi stessi un motore, lo diciamo e lo mettiamo in una sezione dedicata all'onestà anziché inventare una cifra.
Una esecuzione contro diecimila

Il fatto che definisce la ricerca di parametri è che il motore gira migliaia di volte ma l'analisi avviene una volta sola. Qualunque costo fisso paghi per impostare un backtest — costruire l'event loop, istanziare un broker, allocare un oggetto per barra — lo paghi su ogni singola combinazione. Un costo invisibile in un'esecuzione ("chi se ne importa di 6 secondi?") diventa l'intero conto di una ricerca ("6 secondi × 10,000 = 16.6 ore").
I motori di backtest si dividono in tre paradigmi, e il paradigma è destino per le prestazioni di ricerca:
- Event-driven — il motore procede barra per barra, emettendo eventi, chiamando la tua callback
next()/onBars(), instradando gli ordini attraverso un oggetto broker. Questa è l'architettura di backtrader, backtesting.py, PyAlgoTrade, zipline e nautilus_trader. Rispecchia il modo in cui il trading dal vivo funziona davvero, il che è precisamente il motivo per cui è considerato affidabile per il realismo — e precisamente il motivo per cui è lento: l'overhead Python per barra viene pagato 150,000 volte per combinazione. - Pesi/ribilanciamento —
btsi colloca qui. Gli passi una matrice di pesi target e lui ribilancia nelle date che specifichi. Nessuna callback per barra, ma comunque un grafo di oggetti per evento (un albero di algo, un registro delle transazioni) valutato in Python. - Vettorizzato / compilato — l'intera strategia è espressa come operazioni su array (vectorbt), un kernel compilato JIT (numba) o codice nativo (Rust, un kernel GPU MLX). Non c'è alcun Python per barra. Il ciclo, se c'è, gira a velocità macchina.
Il resto di questo articolo è la conseguenza empirica di quella tassonomia. Abbiamo costruito un unico carico di lavoro, fatto in modo che ogni motore facesse in modo dimostrabile lo stesso lavoro, e ne abbiamo cronometrato il tempo.
Il carico di lavoro: una strategia, ottanta manopole, identico per tutti
Un benchmark è onesto solo se ogni motore sta facendo lo stesso lavoro. Il nostro è deliberatamente semplice — un carico di lavoro in cui l'unica cosa che differisce tra i motori è il motore stesso.
- Dati. Un'unica serie sintetica di chiusure con moto browniano geometrico:
150,000barre,seed=42, volatilità per barrasigma=0.0008,x0=30000. Deterministica, così chiunque può riprodurla bit per bit. Solo chiusura per costruzione — l'OHLC è impostato alla chiusura su ogni gamba, perché la strategia è un incrocio di chiusure. - Strategia. Un incrocio di Hull Moving Average: una HMA di lunghezza
Lcontro una variante HMA-di-un-terzo più veloce (HMAcontroHMA3). Sempre sul mercato, si inverte long/short a ogni incrocio. Questo è un indicatore reale e non banale — due medie mobili ponderate annidate più uno smoother a finestra radice quadrata — non una SMA giocattolo, quindi il lavoro per barra è rappresentativo. - La ricerca.
80lunghezze HMA che spaziano da6..200. È il "diecimila esecuzioni" reso abbastanza piccolo da misurarlo direttamente: 80 combinazioni indipendenti, ciascuna un backtest completo su 150k barre. - Costi. Una commissione andata e ritorno dello
0.09%, divisa per lato per i motori che modellano le commissioni per lato. Riempimento nella stessa barra aclose[i]— il segnale sulla barraiviene eseguito alla chiusura di quella barra, la convenzione che usa il nostro motore di produzione.
Il timer per combinazione avvolge esattamente due cose: il precalcolo HMA in numpy e l'esecuzione del motore. La configurazione che è genuinamente una tantum (caricamento dati, costruzione degli oggetti barra) resta fuori dal timer. C'è un'esecuzione di riscaldamento, poi ripetizioni best-of-N e — poiché i motori event-driven sono abbastanza lenti da far sì che 80 combinazioni complete richiederebbero da molti minuti a oltre un'ora — cronometriamo un campione uniforme della griglia ed estrapoliamo linearmente. Le combinazioni sono indipendenti, quindi l'estrapolazione lineare è esatta in valore atteso; la stessa convenzione è applicata al riferimento pandas, così nessun motore ne è avvantaggiato.
Parità: dimostrare che tutti fanno lo stesso lavoro
Ecco la trappola in cui cade un ingenuo confronto tra motori: un motore "veloce" potrebbe semplicemente fare di meno. Se backtrader registra 2,700 operazioni e il tuo motore vettorizzato ne registra 40, il motore vettorizzato non è più veloce — è sbagliato, e il confronto è privo di significato.
Quindi blocchiamo il confronto con un controllo di parità del conteggio operazioni. A L=104, il riferimento numpy produce esattamente 2,707 operazioni chiuse. Ogni motore deve riprodurre quel valore entro una tolleranza di ±1, altrimenti l'esecuzione si interrompe con un'asserzione work-parity FAILED. La tolleranza esiste solo perché i motori sono in disaccordo sulle convenzioni contabili — se la posizione aperta finale viene chiusa forzatamente e conteggiata, se l'ingresso iniziale è un'"operazione" — non sulle operazioni stesse:
| Motore | Operazioni riportate @ L=104 | Convenzione |
|---|---|---|
| riferimento numpy | 2707 | andate e ritorni chiusi |
| backtesting.py | 2708 | +1: posizione finale chiusa forzatamente alla fine |
| backtrader | 2707 | posizione aperta finale non conteggiata |
| bt | 2708 | +1: ingresso iniziale conteggiato come transazione |
| PyAlgoTrade | 2708 | +1: ingresso iniziale conteggiato come riempimento |
Ogni motore atterra su 2707 ± 1. Qualunque risultino essere le differenze di velocità, non sono un artefatto di un motore che salta silenziosamente del lavoro. Questa è la disciplina che ci permette di mettere un framework event-driven e un kernel GPU nella stessa tabella facendo sul serio.
I risultati
Ecco l'intera tabella, ordinata dal più veloce al più lento. combos/s è il throughput; l'ultima colonna è quanto tempo richiede l'intera ricerca di 80 combinazioni. La riga di riferimento è M0 — l'ingenuo motore pandas, un ciclo for sulle barre con contabilità scalare, la cosa che scriveresti in un pomeriggio e getteresti via. Tutto ciò che è più lento di quel riferimento è in grassetto.
| Motore | combos/s | Paradigma | Ricerca completa 80 combinazioni |
|---|---|---|---|
| kernel GPU MLX | 779 | vettorizzato (GPU Apple) | 0.10 s |
| Rust nativo | ~350 | compilato | 0.23 s |
| mp + numba | 246 | JIT compilato + multiprocesso | 0.33 s |
| vectorbt | 56.9 | vettorizzato (numpy/numba) | 1.4 s |
| numba (single core) | 39.7 | JIT compilato | 2.0 s |
| backtesting.py | 1.42 | event-driven | 56 s |
| PyAlgoTrade | 0.51 | event-driven | 2.6 min |
| M0 — pandas ingenuo + ciclo | 0.28 | riferimento scalare | 4.8 min |
| backtrader | 0.11 | event-driven | 12.7 min |
| bt | 0.06 | pesi / ribilanciamento | 22.5 min |
Leggi la tabella dall'alto verso il basso e il paradigma si ordina da solo: i primi cinque sono tutti vettorizzati o compilati, gli ultimi cinque sono tutti event-driven o basati su grafo di oggetti — con l'ingenuo ciclo pandas che si colloca sopra due framework maturi e popolari. Lo scarto dall'alto verso il basso è di quattro ordini di grandezza. Sull'identico carico di lavoro da 2,707 operazioni, il kernel MLX finisce la ricerca in un decimo di secondo; bt ha bisogno di ventidue minuti e mezzo. È un fattore di circa 13,000×.
Lo scandalo al centro della tabella

I numeri che catturano l'occhio sono agli estremi, ma il risultato istruttivo è al centro: backtrader (0.11 combos/s) e bt (0.06 combos/s) sono entrambi più lenti del riferimento pandas ingenuo (0.28 combos/s).
Merita di essere assorbito. M0 non è un motore ingegnoso. È un ciclo for Python che indicizza in un DataFrame, tiene traccia di una posizione e della liquidità in semplici scalari, e aggiunge le operazioni a una lista — il "controllo" deliberatamente non ottimizzato che abbiamo incluso per avere qualcosa di palesemente scadente da battere. L'accesso per riga di pandas è notoriamente lento, e ci abbiamo puntato. Eppure due delle librerie di backtest più raccomandate dell'ecosistema gli soccombono: backtrader di 2.5×, bt di 4.7×.
La sfumatura che mantiene tutto onesto: non ogni motore event-driven è più lento di pandas. backtesting.py (1.42 combos/s) batte il riferimento di 5×, perché è un event loop snello, basato su numpy, che mantiene al minimo la creazione di oggetti per barra. Anche PyAlgoTrade (0.51) supera di poco il riferimento. Quindi "event-driven" non è automaticamente una condanna a morte — ma più pesante è il macchinario per barra, peggio va, e backtrader e bt portano il macchinario più pesante qui. Il paradigma fissa il soffitto; l'implementazione decide dove sotto quel soffitto atterri.
Il punto non è che queste siano cattive librerie. Il modello di broker di backtrader e il design ad albero-di-algo di bt esistono per comprarti correttezza ed espressività — gestione realistica degli ordini, ribilanciamento del portafoglio, analizzatori. Quelle funzionalità hanno un costo a runtime, e quel costo è invisibile quando esegui una volta sola. Su una ricerca è l'intera storia.
Perché i motori event-driven pagano la tassa

Il meccanismo non è misterioso. Un backtest event-driven, per barra, fa qualcosa del genere:
- Avanza l'orologio, ritaglia la barra successiva dal feed di dati e la materializza come oggetto (una
Bar, unaLine, un dict). - Attiva una callback nel codice utente (
next(),onBars()), che è una chiamata di funzione Python con il proprio frame. - All'interno della callback, interroga lo stato del broker/posizione, di nuovo tramite chiamate di metodo e ricerche di attributi.
- Se viene creato un ordine, instradalo attraverso il broker: convalidalo, verifica margine/liquidità, programma un riempimento, muta un oggetto portafoglio, aggiungi a un registro delle transazioni.
- Aggiorna analizzatori, osservatori e qualunque contabilità il framework mantenga.
Ora moltiplica per 150,000 barre, poi per 80 combinazioni: dodici milioni di iterazioni-barra per ricerca, ognuna una manciata di allocazioni di oggetti a livello Python e dispatch dinamici. L'overhead per operazione di Python — da decine a centinaia di nanosecondi per una ricerca di attributo o una piccola allocazione — è banale una volta e rovinoso dodici milioni di volte. Il caso bt è una variante della stessa malattia: anche se ribilancia solo nelle date di operazione anziché a ogni barra, ogni ribilanciamento valuta un albero di oggetti algo e tocca un registro di portafoglio basato su pandas, e ce ne sono 2,707 per combinazione, per 80.
L'ingenuo ciclo pandas batte backtrader e bt per una ragione brutale: fa di meno per barra. Salta il broker, gli oggetti evento, lo stack di analizzatori, la macchina a stati di instradamento degli ordini. Paga la brutta tassa per riga di pandas, ma quell'unica brutta tassa è comunque più economica dell'ordinata, completa di funzionalità, tassa a oggetto-per-evento del framework. Quando riduci un backtest a "posizione × rendimento-successivo − commissioni", la maggior parte di ciò che un framework fa per barra è overhead che non stai usando durante una ricerca.
Ed ecco la trappola: l'overhead è il motivo per cui hai scelto il framework. Volevi il broker realistico. Volevi gli analizzatori. Durante la validazione finale, li vuoi tutti. Durante una ricerca di 10,000 combinazioni in cui leggi un solo obiettivo scalare all'altro capo, stai pagando una limousine per fare giri di pista.
L'altro capo della tabella: vettorizzato e compilato
La parte alta della tabella è ciò che accade quando elimini del tutto il Python per barra.
- vectorbt (56.9 combos/s) esprime l'intera strategia come operazioni su array numpy/numba. Non c'è alcun ciclo di barre in Python — il segnale, la posizione, il PnL sono tutti a livello di array. Esegue la ricerca in 1.4 secondi contro i 22.5 minuti di
bt: circa 950× più veloce sullo stesso lavoro. (Trattiamo il design di vectorbt più in profondità nella panoramica di vectorbt e nel più ampio confronto pandas-vs-polars.) - numba (39.7) è il ciclo pandas, invariato nella forma, compilato JIT in codice nativo. Stesso algoritmo di M0, ma
@njittrasforma 0.28 combos/s in 39.7 — un acceleramento di ~140× da un decoratore, perché l'overhead dell'interprete che dominava il ciclo scalare semplicemente evapora. - mp + numba (246) esegue il kernel compilato su tutti i core della CPU. Le combinazioni sono imbarazzantemente parallele — ognuna è indipendente — quindi il multiprocessing scala quasi linearmente sopra il JIT.
- Rust nativo (~350) rimuove l'ultimo residuo di collante Python: l'intera ricerca è nativa.
- GPU MLX (779) mappa la ricerca su un kernel GPU su silicio Apple. 80 combinazioni diventano 80 corsie parallele di aritmetica; la ricerca finisce prima che tu rilasci il tasto invio.
Due cose vale la pena nominare con precisione. Primo, numba dimostra che il paradigma conta più del linguaggio. M0 e numba eseguono lo stesso algoritmo — la differenza è puramente che uno è Python per barra interpretato e l'altro è compilato. Questa è l'intera tassa del framework in un A/B controllato: ~140× per aver eliminato l'interprete dal ciclo interno. Secondo, il salto da numba (39.7) a mp+numba (246) a MLX (779) non riguarda più affatto il motore — riguarda l'orchestrazione e l'hardware. Una volta sparita la tassa per barra, la velocità diventa una questione di quante combinazioni esegui in parallelo e su quale silicio. Percorriamo l'intera progressione nella scala di velocità dei motori di backtest, e il motivo per cui l'ultimo miglio è dominato dal costo di processo/serializzazione nell'articolo sulla tassa IPC.
Cosa non abbiamo misurato (e perché te lo diciamo)
La credibilità di un benchmark vive in ciò che si rifiuta di falsificare. Abbiamo eseguito otto motori dall'inizio alla fine in parità. Su diversi framework noti non abbiamo messo un numero, e preferiamo elencarli onestamente anziché estrapolare una cifra che non abbiamo misurato:
- zipline / zipline-reloaded — event-driven, lignaggio Quantopian. Setup pesante (un intero calendario di trading e un bundle di dati), che rende delicato un timing per combinazione mela-con-mela. Architetturalmente si colloca con backtrader nel campo event-driven; ci aspetteremmo che stia vicino a quel capo della tabella, ma non lo abbiamo dimostrato.
- nautilus_trader — event-driven con un core Rust/Cython, progettato esplicitamente per la parità con il live. Il suo core è compilato, quindi è il motore event-driven più probabile a non pagare l'intera tassa Python — una misurazione genuinamente interessante che non abbiamo ancora eseguito.
- QuantConnect Lean — basato su C#, un runtime del tutto diverso; non direttamente comparabile in un banco di prova Python.
- Jesse — event-driven, orientato alle cripto; ne abbiamo esaminato il design in una nota separata ma non l'abbiamo messo a benchmark qui.
- QSTrader — event-driven, orientato al portafoglio; stesse riserve di paradigma.
- fastquant — ci abbiamo provato; l'installazione/API era rotta nel nostro ambiente, quindi non c'è un numero. Non ne indovineremo uno.
Due riserve oneste sui numeri che riportiamo davvero. Le cifre di vectorbt, numba, mp+numba, Rust-nativo e MLX provengono dalla nostra scala di motori sull'identico carico di lavoro, non dal banco OSS che ha prodotto le quattro righe event-driven — è lo stesso carico di lavoro ma un diverso apparato di misurazione, e la cifra Rust-nativo è un approssimativo ~350, non una misurazione precisa. E il combos/s assoluto è specifico dell'hardware; ciò che viaggia è l'ordinamento e i rapporti, che sono abbastanza grandi (13,000× dall'alto verso il basso, 2.5–4.7× per l'inversione pandas-vs-framework) da far sì che nessuna ragionevole differenza di hardware li ribalti.
In difesa dei motori event-driven

Sarebbe facile leggere tutto questo come "i backtester event-driven sono cattivi". È la lezione sbagliata, e ingiusta.
I motori event-driven sono costruiti per un lavoro diverso, e in quello sono bravi. Il broker per barra, il ciclo di vita dell'ordine, la logica di riempimento, gli analizzatori — quel macchinario esiste per far sì che un backtest assomigli il più possibile al trading dal vivo. Quando il tuo obiettivo è una singola, affidabile prova generale di una strategia che stai per mettere in produzione, vuoi che il motore sudi ogni riempimento, modelli esecuzioni parziali, rispetti il margine e si rifiuti di lasciarti operare a prezzi che non avresti potuto ottenere. Quella fedeltà è il prodotto. Il suo costo a runtime è il prezzo del realismo, e per una singola esecuzione il prezzo è trascurabile.
Il fallimento non è il motore; è usarlo per la fase sbagliata. La ricerca ha due fasi distinte con requisiti opposti:
- La ricerca vuole throughput. Stai esplorando un paesaggio, la maggior parte del quale è spazzatura, e devi valutare migliaia di punti per trovare i pochi che meritano un secondo sguardo. La fedeltà per punto conta a malapena — stai classificando, non mettendo in produzione. Qui la tassa del framework è puro spreco.
- La validazione vuole fedeltà. Hai una manciata di candidati e devi sapere, il più precisamente possibile, se sopravvivono a esecuzione realistica, commissioni, slippage e le trappole di look-ahead che gonfiano i rendimenti sulla carta. Qui il motore event-driven guadagna il suo costo.
L'errore che la tassa del framework punisce è eseguire la tua ricerca sul tuo motore di validazione — pagando tariffe da limousine per esplorare un paesaggio del quale butterai via il 99%.
La regola decisionale
La conclusione pratica si comprime in un'unica decisione:
Cerca su un motore vettorizzato/compilato. Valida i sopravvissuti su uno event-driven.
Concretamente:
- Costruisci o prendi in prestito un kernel veloce per la ricerca. vectorbt se lo vuoi pronto all'uso; un ciclo compilato con numba se la tua strategia non si vettorizza in modo pulito (il solo decoratore
@njitqui ha comprato ~140×). Fai passare l'intero spazio dei parametri attraverso di esso. - Non eseguire mai una grande ricerca su backtrader,
bt, zipline o qualsiasi pesante framework event-driven. Se una ricerca su uno di questi è il tuo collo di bottiglia, la soluzione non è una macchina più grande — è il motore sbagliato. Persino l'ingenuo ciclo pandas ne batterebbe due. - Promuovi una lista ristretta al motore event-driven per un controllo di fedeltà. Prendi la manciata di sopravvissuti e rieseguili sul motore realistico, dove il modello di broker e la logica di riempimento possono esporre problemi che il kernel veloce aveva astratto via.
- Imponi la parità tra i due. Il motore veloce e il motore di fedeltà devono concordare su conteggi delle operazioni e PnL su una configurazione fissa (il nostro controllo ±1 operazione a L=104), altrimenti la ricerca e la validazione stanno misurando strategie diverse e l'intera pipeline è una menzogna.
Questa è la stessa architettura a due velocità che compare ogni volta che ricerca e validazione hanno profili di costo opposti, ed è il motivo per cui il nostro stack mantiene un percorso veloce vettorizzato/compilato per la ricerca di parametri e riserva il macchinario pesante alla valutazione dell'obiettivo finale e ai controlli di plateau.
Punti chiave
- Popolare ≠ veloce per le ricerche. Su un unico identico carico di lavoro bloccato in parità (150k barre, 80 combinazioni di incrocio HMA, 2,707 operazioni), backtrader (0.11 combos/s) e
bt(0.06) hanno entrambi girato più lentamente di un ingenuo ciclo pandas (0.28). Un framework maturo e molto stellato non è automaticamente la scelta veloce. - La tassa del framework è per barra, e una ricerca la moltiplica. Dodici milioni di iterazioni-barra per ricerca, ognuna che porta un oggetto evento, una callback e un andata-e-ritorno del broker. Un costo invisibile in un'esecuzione è l'intero conto attraverso diecimila.
- Il paradigma fissa il soffitto. I motori vettorizzati/compilati (vectorbt 56.9, numba 39.7, mp+numba 246, Rust nativo ~350, MLX 779) battono i motori event-driven di due-quattro ordini di grandezza — fino a ~13,000× dall'alto verso il basso. Lo stesso algoritmo, semplicemente compilato JIT, è andato 140× più veloce.
- Non tutti i motori event-driven sono uguali. backtesting.py (1.42) e PyAlgoTrade (0.51) battono comunque il riferimento ingenuo; la tassa scala con quanto è pesante il macchinario per barra. L'implementazione decide dove sotto il soffitto atterri.
- Due motori, due fasi. Cerca su un kernel vettorizzato/compilato; valida i sopravvissuti sul realistico motore event-driven. Imponi la parità di conteggio operazioni/PnL tra loro così che entrambi misurino la stessa strategia.
- Sii onesto su ciò che hai misurato. Abbiamo messo a benchmark otto motori in parità ed elencato quelli che non abbiamo (zipline, nautilus_trader, Lean, Jesse, QSTrader e l'installazione di fastquant che non voleva partire) anziché inventare numeri per loro.
Il riassunto scomodo: se una ricerca di parametri è il tuo collo di bottiglia, il problema probabilmente non è la tua macchina né la tua strategia. È che stai eseguendo una ricerca su un motore che è stato costruito per una singola esecuzione onesta — e saresti più veloce con il ciclo pandas che eri troppo imbarazzato a tenere.
Il banco di prova completo, le asserzioni di parità e i risultati JSON grezzi per motore vivono in benchmarks/bench_oss_engines.py e benchmarks/results_oss/ al commit 250dbb5. Per il capo compilato/GPU della scala, vedi la scala di velocità dei motori di backtest e l'analisi della tassa IPC.
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.