框架稅:當你的回測庫比手寫的 pandas 迴圈還慢
"沒有幻覺的回測"系列文章之一。
大多數量化交易專案的底層都有一個令人心安的假設:一個成熟、star 數很高的回測庫一定是快的。它歷經多年的貢獻積累,擁有真正的事件迴圈、一個經紀商模型、一套佣金方案。它當然會勝過你自己隨手寫的 pandas 迴圈。於是你選用了它,接好你的策略,開始跑一次參數掃描——幾千種配置,一個通宵任務。第二天早上你回來一看,它還在跑。
我們在同一個參數掃描任務上對八個回測引擎做了基準測試,結果發現了一些應該改變你選擇搜尋工具方式的事實。兩個最受歡迎的開源事件驅動框架——backtrader 和 bt——跑這個掃描任務比我們當作一次性基線隨手寫的樸素 pandas 迴圈還要慢。而且不是慢一點點。backtrader 花的時間大約是 pandas 基線的 2.5 倍;bt 大約是 4.7 倍。與此同時,一個向量化/編譯型引擎完成同樣的工作,速度大約是 bt 的 13000 倍。
這就是框架稅。主流的回測器是為單次認真的執行而構建的——一個策略、一個數據集、精細的成交處理、一個行為像真正經紀商的經紀商。這正是你在做最終驗證或實盤一致性檢查時想要的。但對於演算法研究真正花費大部分時間的事情——用略微不同的參數把同一個策略跑上萬次——這恰恰是錯誤的工具。本文將測量這個稅負、解釋其機制,並給出一個判斷何時"真正的"回測框架是錯誤選擇的決策規則。
本文所有數字都來自同一套可復現的測試工具(benchmarks/bench_oss_engines.py,提交號 250dbb5),並作用於同一個、筆數鎖定的工作負載。如果某個引擎我們沒有親自跑過,我們會明確說明,並把它單獨列在一個誠實說明部分,而不是編造一個數字。
一次執行 vs 一萬次執行

參數搜尋的決定性事實是:引擎要執行成千上萬次,但分析只發生一次。無論你為搭建一次回測所付出的固定成本是什麼——構建事件迴圈、例項化一個經紀商、為每根K線分配一個物件——你在每一種組合上都要重複支付這筆成本。在單次執行中不可見的成本("誰在乎多花6秒?"),到了一次掃描裡就變成了全部賬單("6秒 × 10000 = 16.6 小時")。
回測引擎分為三種範式,而範式決定了掃描效能的命運:
- 事件驅動——引擎逐根K線遍歷,發出事件,呼叫你的
next()/onBars()回撥,把訂單路由經過一個經紀商物件。這是 backtrader、backtesting.py、PyAlgoTrade、zipline 和 nautilus_trader 的架構。它模擬了實盤交易真實的運作方式,這正是它被信任具有真實感的原因——也正是它慢的原因:每根K線的 Python 開銷要被支付 150000 次每個組合。 - 權重/再平衡——
bt屬於這一類。你給它一個目標權重矩陣,它在你指定的日期上進行再平衡。沒有逐K線的回撥,但仍然有一個逐事件的物件圖(一棵運算元樹、一個交易臺賬)在 Python 中被評估。 - 向量化/編譯型——整個策略被表達為陣列運算(vectorbt)、一個 JIT 編譯的核心(numba),或原生程式碼(Rust、一個 MLX GPU 核心)。這裡完全沒有逐K線的 Python 程式碼。如果有迴圈的話,它以機器速度執行。
本文接下來的內容都是這套分類法的實證後果。我們搭建了一個工作負載,讓每個引擎都做可證明相同的工作,並對其計時。
工作負載:一個策略,八十個參數,對所有人一致
一個基準測試只有在每個引擎都做同樣的工作時才是誠實的。我們的測試刻意保持簡單——一個工作負載,引擎之間唯一的差異就是引擎本身。
- 資料。 單一的合成幾何布朗運動收盤價序列:
150,000根K線,seed=42,逐K線波動率sigma=0.0008,x0=30000。確定性生成,所以任何人都可以逐位元組復現它。構造上僅有收盤價——每一條腿的 OHLC 都設為收盤價,因為策略本身就是一個收盤價交叉策略。 - 策略。 一個 Hull 移動平均線交叉:長度為
L的 HMA 與一個更快的三分之一變體 HMA(HMA對HMA3)。始終持倉,每次交叉切換多空方向。這是一個真實的、非平凡的指標——兩個巢狀的加權移動平均加上一個平方根視窗的平滑器——而不是一個玩具級的 SMA,所以每根K線的計算量是有代表性的。 - 掃描。
80個跨度從6..200的 HMA 長度。這就是把"一萬次執行"縮小到可以直接測量的規模:80 個獨立的組合,每個都是一次覆蓋 15 萬根K線的完整回測。 - 成本。 往返手續費
0.09%,對於按邊收取佣金的引擎則按單邊拆分。同K線成交,成交價為close[i]——第i根K線上的訊號在該K線的收盤價成交,這是我們生產引擎所採用的約定。
每個組合的計時器精確地包裹了兩件事:numpy 的 HMA 預計算和引擎的執行。真正的一次性搭建成本(載入資料、構建K線物件)位於計時器之外。有一次預熱執行,然後進行最優 N 次重複;而且——因為事件驅動引擎慢到足以讓 80 個完整組合耗費從幾分鐘到超過一小時——我們對網格中的一個均勻樣本計時,然後線性外推。由於各組合是獨立的,線性外推在期望意義上是精確的;同樣的約定也應用於 pandas 基線,因此沒有任何引擎因此獲得優勢。
一致性:證明每個引擎都做了相同的工作
一個樸素的引擎對比很容易掉進這樣一個陷阱:一個"更快"的引擎可能只是做得更少。如果 backtrader 記錄了 2700 筆交易,而你的向量化引擎只記錄了 40 筆,那麼向量化引擎並不是更快——它是錯的,這樣的比較毫無意義。
因此我們用一個交易筆數一致性檢查來鎖定比較。在 L=104 時,numpy 參考實現恰好產生 2707 筆已平倉交易。每個引擎都必須在 ±1 的容差內復現這個數字,否則執行就會以 work-parity FAILED 斷言中止。這個容差的存在僅僅是因為各引擎在記賬約定上存在分歧——比如最終的持倉是否被強制平倉並計入、初始建倉是否算作一筆"交易"——而不是因為交易本身有差異:
| 引擎 | L=104 時報告的交易筆數 | 約定 |
|---|---|---|
| numpy 參考實現 | 2707 | 已平倉的往返交易 |
| backtesting.py | 2708 | +1:末尾持倉被強制平倉並計入 |
| backtrader | 2707 | 最終未平倉的持倉不計入 |
| bt | 2708 | +1:初始建倉被算作一筆交易 |
| PyAlgoTrade | 2708 | +1:初始建倉被算作一次成交 |
每個引擎都落在 2707 ± 1 之內。無論速度差異最終是什麼,它們都不是某個引擎悄悄少做工作的產物。正是這種嚴謹性,讓我們能把一個事件驅動框架和一個 GPU 核心放進同一張表裡,並且這樣做是有意義的。
結果
下面是完整的表格,按從快到慢排序。combos/s 是吞吐量;最後一列是完整跑完 80 個組合的掃描所需的時間。基線行是 M0——樸素的 pandas 引擎,一個對K線做標量記賬的 for 迴圈,一個你花一個下午寫出來然後就會扔掉的東西。任何比這個基線還慢的都用粗體標出。
| 引擎 | combos/s | 範式 | 完整的80組合掃描 |
|---|---|---|---|
| MLX GPU 核心 | 779 | 向量化(Apple GPU) | 0.10 秒 |
| 原生 Rust | ~350 | 編譯型 | 0.23 秒 |
| mp + numba | 246 | 編譯型 JIT + 多程序 | 0.33 秒 |
| vectorbt | 56.9 | 向量化(numpy/numba) | 1.4 秒 |
| numba(單核) | 39.7 | 編譯型 JIT | 2.0 秒 |
| backtesting.py | 1.42 | 事件驅動 | 56 秒 |
| PyAlgoTrade | 0.51 | 事件驅動 | 2.6 分鐘 |
| M0 — 樸素 pandas + 迴圈 | 0.28 | 標量基線 | 4.8 分鐘 |
| backtrader | 0.11 | 事件驅動 | 12.7 分鐘 |
| bt | 0.06 | 權重 / 再平衡 | 22.5 分鐘 |
從上到下讀這張表,範式會自動分層:前五名全是向量化或編譯型,後五名全是事件驅動或物件圖型——樸素的 pandas 迴圈高於兩個成熟的、流行的框架。從頭到尾的跨度是四個數量級。在完全相同的、2707筆交易的工作負載上,MLX 核心在十分之一秒內跑完了整個掃描;bt 需要二十二分半。這大約是 13000 倍的差距。
表格中間的醜聞

抓人眼球的數字在兩端,但有啟發性的結果在中間:backtrader(0.11 combos/s)和 bt(0.06 combos/s)都比樸素的 pandas 基線(0.28 combos/s)慢。
這一點值得細細品味。M0 並不是一個聰明的引擎。它就是一個對 DataFrame 做索引訪問、用普通標量追蹤持倉和現金、把交易 append 進一個列表的 Python for 迴圈——我們特意加入的、未經最佳化的"對照組",目的就是有一個明顯糟糕的東西可以被擊敗。pandas 逐行訪問是出了名的慢,而我們索性順著這一點走。然而生態系統中兩個最被推薦的回測庫卻輸給了它:backtrader 輸了 2.5 倍,bt 輸了 4.7 倍。
保持這一切誠實的細微差別在於:並非每個事件驅動引擎都比 pandas 慢。 backtesting.py(1.42 combos/s)以 5 倍的速度勝過基線,因為它是一個精簡的、以 numpy 為基礎的事件迴圈,把每根K線的物件建立降到了最低。PyAlgoTrade(0.51)也略微領先於基線。所以"事件驅動"並不必然是死刑判決——但逐K線的機制越重,情況就越糟,而 backtrader 和 bt 在這裡承載的機制最重。範式設定了上限;實現決定了你落在這個上限之下的哪個位置。
關鍵不在於這些是糟糕的庫。backtrader 的經紀商模型和 bt 的運算元樹設計的存在,是為了換來正確性和表達力——真實的訂單處理、組合再平衡、分析器。這些特性有執行時成本,而這個成本在你只跑一次時是不可見的。而在一次掃描中,它就是全部故事。
事件驅動引擎為何要支付這筆稅

這個機制並不神秘。一個事件驅動的回測,在每根K線上大致會做以下這些事:
- 推進時鐘,從資料來源中切出下一根K線,並將其物化為一個物件(一個
Bar、一個Line、一個 dict)。 - 向用戶程式碼觸發一個回撥(
next()、onBars()),這是一次帶有自己棧幀的 Python 函數呼叫。 - 在回撥內部,查詢經紀商/持倉狀態,同樣是通過方法呼叫和屬性查詢。
- 如果建立了一個訂單,把它路由經過經紀商:校驗它、檢查保證金/現金、安排成交、修改一個組合物件、追加到一個交易臺賬。
- 更新分析器、觀察者,以及框架維護的任何記賬資訊。
現在把這一切乘以 15 萬根K線,再乘以 80 個組合:每次掃描一千兩百萬次K線迭代,每一次都是一大把 Python 層面的物件分配和動態派發。Python 每次操作的開銷——一次屬性查詢或一次小分配是幾十到幾百納秒——一次是微不足道的,一千兩百萬次就是災難性的。bt 的情況是同一種病症的一個變體:即便它只在交易日而不是每根K線上進行再平衡,每次再平衡都要評估一棵運算元物件樹,並觸及一個以 pandas 為底層的組合臺賬,而每個組合有 2707 次這樣的操作,再乘以 80。
樸素的 pandas 迴圈擊敗 backtrader 和 bt 的原因很直接:它每根K線做的事更少。 它跳過了經紀商、事件物件、分析器堆疊、訂單路由狀態機。它支付了 pandas 那種難看的逐行稅,但這一份難看的稅仍然比框架那種整潔的、功能齊全的、逐事件物件的稅要便宜。當你把一個回測精簡到"持倉 × 下一期收益率 − 手續費"時,框架每根K線所做的大部分事情,在一次搜尋中都是你用不上的開銷。
而這正是陷阱所在:這些開銷正是你選擇這個框架的原因。你想要真實的經紀商。你想要分析器。在最終驗證時,你想要全部這些。而在一次一萬個組合的搜尋中,你只從另一端讀出一個標量目標值,你卻在為一輛豪華轎車跑圈的費用買單。
表格的另一端:向量化與編譯型
表格的頂部是當你把逐K線的 Python 完全刪除之後會發生的事。
- vectorbt(56.9 combos/s) 把整個策略表達為 numpy/numba 陣列運算。Python 中沒有K線迴圈——訊號、持倉、盈虧全都是陣列層面的。它在 1.4 秒內跑完這次掃描,而
bt需要 22.5 分鐘:在完全相同的工作上快約 950 倍。(我們在vectorbt 綜述以及更廣泛的pandas 與 polars 對比中更深入地討論了 vectorbt 的設計。) - numba(39.7) 就是 pandas 迴圈,形態不變,只是被 JIT 編譯成了原生程式碼。和 M0 一樣的演算法,但
@njit把 0.28 combos/s 變成了 39.7——一個裝飾器帶來約 140 倍的加速,因為原本主導標量迴圈的直譯器開銷直接蒸發了。 - mp + numba(246) 把編譯後的核心跨 CPU 核心執行。各個組合是天然可並行的——每一個都是獨立的——所以多程序在 JIT 的基礎上近乎線性地擴充。
- 原生 Rust(~350) 移除了最後一點 Python 膠水程式碼:整個掃描都是原生程式碼。
- MLX GPU(779) 把這次掃描對映到一個 Apple 晶片的 GPU 核心上。80 個組合變成 80 條並行的算術通道;掃描在你鬆開回車鍵之前就已經結束了。
有兩點值得精確地指出。第一,numba 證明了範式比語言更重要。 M0 和 numba 執行的是同一個演算法——差異純粹在於一個是逐K線解釋執行的 Python,另一個是編譯後的程式碼。這就是整個框架稅在一次受控 A/B 測試中的全部體現:從內層迴圈中去掉直譯器帶來約 140 倍的差距。第二,從 numba(39.7)到 mp+numba(246)再到 MLX(779)的躍升,已經完全不再關乎引擎本身了——而是關乎編排和硬體。一旦逐K線的稅負被消除,速度就變成了一個關於你能在多少並行度和什麼硬體上執行組合的問題。我們在回測引擎速度階梯中走完了這整個演進過程,並在 IPC 稅文章中解釋了為何最後一段里程會被程序/序列化成本所主導。
我們沒有測量的部分(以及為什麼要告訴你)
一個基準測試的可信度,在於它拒絕偽造什麼。我們在一致性約束下端到端地跑了八個引擎。有幾個知名框架我們沒有給出一個數字,我們寧願誠實地把它們列出來,也不願臆造一個我們沒有測量過的數字:
- zipline / zipline-reloaded ——事件驅動,Quantopian 血統。搭建成本很重(一整套交易日曆和資料包),這使得逐組合的蘋果對蘋果計時變得棘手。從架構上看,它和 backtrader 一起屬於事件驅動陣營;我們預期它會靠近表格的那一端,但我們還沒有證實這一點。
- nautilus_trader ——事件驅動,核心用 Rust/Cython 編寫,明確為實盤一致性而設計。它的核心是編譯型的,所以它是最有可能不支付完整 Python 稅的事件驅動引擎——這是一個真正有意思的、我們尚未進行的測量。
- QuantConnect Lean ——基於 C#,完全是另一種執行時環境;在一個 Python 測試工具裡沒有直接可比性。
- Jesse ——事件驅動,專注於加密貨幣;我們在另一篇筆記中回顧過它的設計,但沒有在這裡對它做基準測試。
- QSTrader ——事件驅動,面向組合;同樣的範式注意事項。
- fastquant ——我們嘗試過;但在我們的環境中安裝/API 是壞的,所以沒有數字。我們不打算去猜一個。
關於我們確實報告的數字,有兩個誠實的說明。vectorbt、numba、mp+numba、原生 Rust 和 MLX 的數字來自我們自己在同一個工作負載上的引擎階梯測試,而不是產生那四行事件驅動資料的開源測試工具——它們是同一個工作負載,但用了不同的測量裝置,而且原生 Rust 的數字是一個近似值 ~350,而不是一個精確測量。另外絕對的 combos/s 數值是與硬體相關的;能夠跨硬體傳遞的是排序和比例,而這些比例已經足夠大(從頭到尾13000倍,pandas 對框架的反轉是2.5-4.7倍),任何合理的硬體差異都不會把它們翻轉過來。
為事件驅動引擎辯護

把這篇文章理解為"事件驅動回測器很糟糕"是很容易的,但那是錯誤的結論,而且並不公平。
事件驅動引擎是為了另一份不同的工作而構建的,而且它們很擅長這份工作。逐K線的經紀商、訂單生命週期、成交邏輯、分析器——這些機制存在的目的,是讓一次回測儘可能地貼近實盤交易。當你的目標是對一個即將部署的策略做一次單獨的、值得信賴的彩排時,你想要引擎為每一次成交操心、模擬部分成交、尊重保證金、拒絕讓你以你本不可能拿到的價格交易。這種保真度就是產品本身。它的執行時成本是真實感的代價,而對於一次執行來說,這個代價可以忽略不計。
問題不在於引擎本身,而在於把它用在了錯誤的階段。研究有兩個截然不同、要求相反的階段:
- 搜尋需要吞吐量。你在探索一片地形,其中大部分都是垃圾,你需要評估成千上萬個點,才能找到少數幾個值得再看一眼的點。每個點的保真度幾乎無關緊要——你是在排序,而不是在部署。在這裡,框架稅純粹是浪費。
- 驗證需要保真度。你手上有少數幾個候選策略,你需要儘可能精確地知道,它們能否在真實的執行、手續費、滑點,以及那些會虛增賬面收益的前視陷阱面前存活下來。在這裡,事件驅動引擎賺回了它的成本。
框架稅所懲罰的錯誤,是把你的搜尋跑在了你的驗證引擎上——為了探索一片你最終會扔掉99%的地形,而支付豪華轎車的價錢。
決策規則
這個實用的結論可以壓縮成一句話:
在向量化/編譯型引擎上做搜尋。在倖存者身上,用事件驅動引擎做驗證。
具體來說:
- 為掃描搭建或借用一個快速核心。 如果想要開箱即用就用 vectorbt;如果你的策略不能被幹淨地向量化,就用一個 numba 編譯的迴圈(僅僅是
@njit裝飾器,在這裡就帶來了約 140 倍的提升)。讓整個參數空間都跑過它。 - 永遠不要在 backtrader、
bt、zipline 或任何重量級事件驅動框架上跑一次大規模掃描。 如果這類引擎上的一次掃描成了你的瓶頸,解決方法不是換一臺更強的機器——而是換一個引擎。就連樸素的 pandas 迴圈都能擊敗其中兩個。 - 把入圍的候選名單提升到事件驅動引擎上做保真度檢查。 拿出那少數幾個倖存者,在真實的引擎上重新跑一遍,在那裡,經紀商模型和成交邏輯能夠暴露出那些被快速核心抽象掉的問題。
- 強制兩者之間的一致性。 快速引擎和保真度引擎必須在一個固定配置上,對交易筆數和盈虧達成一致(我們在 L=104 時的 ±1筆交易檢查),否則搜尋和驗證就是在測量兩個不同的策略,整條流水線就是一個謊言。
這正是每當搜尋和驗證的成本結構相反時都會出現的同一種雙速架構,也是為什麼我們自己的技術棧,始終為參數搜尋保留一條快速的向量化/編譯型路徑,而把重量級的機制留給最終的目標函數評估和平臺期檢查。
要點總結
- 流行 ≠ 適合掃描時的快。 在同一個、筆數鎖定的工作負載上(15萬根K線,80個HMA交叉組合,2707筆交易),backtrader(0.11 combos/s)和
bt(0.06)都比樸素的 pandas 迴圈(0.28)更慢。一個成熟的、star數很高的框架並不自動就是快的選擇。 - 框架稅是按K線計算的,而一次掃描會把它成倍放大。 每次掃描一千兩百萬次K線迭代,每一次都攜帶一個事件物件、一個回撥、一次經紀商往返。一次執行中不可見的成本,是一萬次執行的全部賬單。
- 範式決定了上限。 向量化/編譯型引擎(vectorbt 56.9,numba 39.7,mp+numba 246,原生 Rust ~350,MLX 779)比事件驅動引擎快兩到四個數量級——從頭到尾最多約13000倍。同一個演算法,僅僅經過 JIT 編譯,速度就快了140倍。
- 並非所有事件驅動引擎都是一樣的。 backtesting.py(1.42)和 PyAlgoTrade(0.51)仍然擊敗了樸素基線;稅負的高低與逐K線機制的重量成正比。實現方式決定了你落在上限之下的哪個位置。
- 兩個引擎,兩個階段。 在一個向量化/編譯型核心上做搜尋;在真實的事件驅動引擎上驗證倖存者。在兩者之間強制交易筆數/盈虧的一致性,讓它們測量的是同一個策略。
- 對你測量了什麼保持誠實。 我們在一致性約束下對八個引擎做了基準測試,並列出了我們沒有測量的那些(zipline、nautilus_trader、Lean、Jesse、QSTrader,以及那個裝不上的 fastquant),而不是為它們編造數字。
這個令人不安的總結是:如果一次參數掃描是你的瓶頸,問題很可能既不在你的機器上,也不在你的策略上。而是你在一個為單次認真執行而構建的引擎上做搜尋——而你原本用那個你羞於保留的 pandas 迴圈,反而會更快。
完整的測試工具、一致性斷言,以及每個引擎的原始 JSON 結果,都儲存在 benchmarks/bench_oss_engines.py 和 benchmarks/results_oss/ 中,提交號 250dbb5。關於編譯型/GPU那一端的階梯,參見回測引擎速度階梯和 IPC 稅分析。
Authors
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.