系列文章"回測無幻覺",第9篇
策略回測不僅僅是訊號邏輯和執行模擬。它還是一個數據管道:載入數百萬根K線、重取樣時間框架、計算指標、按條件過濾、按標的分組。當管道執行需要30秒而不是3秒時,這不僅僅是不便。這意味著每小時少做10倍的實驗、10倍更慢的迭代、從想法到生產的路徑延長10倍。
Pandas 是 Python 中處理表格資料的事實標準。但 Pandas 設計於2008年,當時 CPU 核心更慢,資料集更小。Pandas 是單執行緒的,記憶體消耗大,且缺少查詢最佳化器。Polars 是用 Rust 編寫的新一代庫,具有並行執行能力,以 Apache Arrow 為核心,並帶有惰性查詢規劃器。
問題是:Polars 在真實演算法交易任務上到底快多少?不是 README 中的合成基準測試,而是在 tick 過濾、滾動指標計算、按標的分組以及從 Parquet/QuestDB 載入資料的場景中?
本文提供系統的基準測試,包含資料、程式碼和實踐建議。
基準測試方法論
未來感精密測量實驗室:受控參數的可復現基準測試環境
在比較之前,讓我們定義規則,確保結果可復現且公平。
環境
- Python 3.11、Pandas 2.2、Polars 1.x(最新穩定版本)
- 機器:8核、32 GB RAM、NVMe SSD
- 每個基準測試執行100次,取中位數
- 預熱(warmup):測量前5次迭代
- 測量期間停用 GC(
gc.disable())
資料
三個規模級別:
- 小型:10K 行(單個標的,一天,分鐘K線)
- 中型:1M 行(單個標的,約2年,分鐘K線)
- 大型:10M+ 行(100個標的,2年,分鐘K線)
額外:真實 NYC Taxi 資料集(1270萬行)用於 ETL 基準測試 — 行業標準基準。
測量內容
import timeit, gc
def bench(fn, n=100, warmup=5):
"""公平基准测试:预热 + n 次运行的中位数。"""
for _ in range(warmup):
fn()
gc.disable()
times = timeit.repeat(fn, number=1, repeat=n)
gc.enable()
return {
"median_ms": sorted(times)[n // 2] * 1000,
"p95_ms": sorted(times)[int(n * 0.95)] * 1000,
}
按操作分類的基準測試:表格
不同資料規模下 filter、groupby、join 和 select 操作的效能對比
小型資料集(10K 行)
| 操作 | Pandas (ms) | Polars (ms) | 加速比 |
|---|---|---|---|
| Filter | 0.18 | 0.32 | 0.56x |
| GroupBy | 1.2 | 0.75 | 1.6x |
| Join | 5.5 | 0.4 | 13.75x |
| Select | 0.5 | 0.2 | 2.5x |
在10K行資料上,Pandas 在簡單過濾時有時更快 — 通過 PyO3 呼叫 Polars 函數的開銷與操作本身的時間相當。但在 join 操作上,優勢已經顯現:Polars 的 Rust 雜湊表比 Pandas 快13倍。
中型資料集(1M 行)
| 操作 | Pandas (ms) | Polars (ms) | 加速比 |
|---|---|---|---|
| Filter | 12.4 | 7.8 | 1.6x |
| GroupBy | 45.2 | 28.6 | 1.6x |
| Join | 89.0 | 14.3 | 6.2x |
| Select | 21.8 | 2.0 | 10.9x |
在百萬行資料上,Polars 在過濾和分組上穩定快1.6倍。在 select(選擇列子集)上快10.9倍,因為 Arrow 列式格式允許零複製切片。
大型資料集(10M+ 行)
| 操作 | Pandas (ms) | Polars (ms) | 加速比 |
|---|---|---|---|
| Filter | 185 | 50 | 3.7x |
| GroupBy | 860 | 100 | 8.6x |
| Join | 1450 | 120 | 12.1x |
| Select | 240 | 40 | 6.0x |
在大數據上,Polars 的優勢呈非線性增長:8核上的並行執行和查詢最佳化器產生累積效果。GroupBy 加速8.6倍 — 這是"等待一秒"和"等待100毫秒"之間的區別。
真實資料 ETL(NYC Taxi,1270萬行)
| 操作 | Pandas (s) | Polars (s) | 加速比 |
|---|---|---|---|
| CSV 載入 | 28.5 | 1.14 | 25.0x |
| Filter + GroupBy + Agg | 3.8 | 0.42 | 9.0x |
| 多列轉換 | 2.1 | 0.7 | 3.0x |
| 完整 ETL 管道 | 34.4 | 2.26 | 15.2x |
CSV I/O 是最引人注目的結果:Polars 在 Rust 引擎上並行讀取 CSV,快25倍。這對於歷史資料的初始載入至關重要。
官方 PDS-H 基準測試(2025年5月)
DataFrame 庫效能競賽:Polars 和 DuckDB 遙遙領先,Pandas 落後數個數量級
PDS-H(Performance Data Science — Holistic)是 DataFrame 庫的標準基準測試,類似於資料庫的 TPC-H。2025年5月的結果:
- Pandas 僅參與 SF-10 規模的測試 — 單執行緒,無查詢最佳化器,比領先者慢兩個數量級
- Polars 和 DuckDB 在 SF-10 和 SF-100 上遙遙領先
- Polars 的新流式引擎相比記憶體模式提供額外3-7倍加速 — 可以處理不適合 RAM 的資料
對於演算法交易,這意味著:如果您的管道在載入1億+行 tick 資料時遇到記憶體瓶頸 — Polars 流式引擎可以在不增加 RAM 的情況下處理它們。
交易訊號的滾動計算:殺手級特性

這是演算法交易最重要的基準測試。典型任務:您有100個標的,需要為每個計算滾動均值、滾動標準差、z-score,並據此生成訊號。在 Pandas 中是 groupby().rolling(),在 Polars 中是 group_by().agg(col().rolling_mean())。
Pandas: groupby + rolling
import pandas as pd
import numpy as np
df_pd = pd.DataFrame({
"ticker": np.repeat([f"TICKER_{i}" for i in range(100)], 100_000),
"close": np.random.randn(10_000_000).cumsum() + 100,
"volume": np.random.randint(100, 10000, 10_000_000),
})
def pandas_rolling_signals(df):
grouped = df.groupby("ticker")["close"]
df["ma_20"] = grouped.transform(lambda x: x.rolling(20).mean())
df["std_20"] = grouped.transform(lambda x: x.rolling(20).std())
df["zscore"] = (df["close"] - df["ma_20"]) / df["std_20"]
return df
Polars: group_by + 滾動表示式
import polars as pl
df_pl = pl.DataFrame({
"ticker": np.repeat([f"TICKER_{i}" for i in range(100)], 100_000),
"close": np.random.randn(10_000_000).cumsum() + 100,
"volume": np.random.randint(100, 10000, 10_000_000),
})
def polars_rolling_signals(df):
return df.with_columns([
pl.col("close")
.rolling_mean(window_size=20)
.over("ticker")
.alias("ma_20"),
pl.col("close")
.rolling_std(window_size=20)
.over("ticker")
.alias("std_20"),
]).with_columns(
((pl.col("close") - pl.col("ma_20")) / pl.col("std_20"))
.alias("zscore")
)
結果
| 操作 | Pandas (ms) | Polars (ms) | 加速比 |
|---|---|---|---|
| 滾動均值,100組 x 10萬行 | 4200 | 12 | 350x |
| 滾動標準差,100組 x 10萬行 | 5100 | 15 | 340x |
| Z-score(均值 + 標準差 + 算術運算) | 12500 | 35 | 357x |
| 滾動均值,1000組 x 1萬行 | 38000 | 11 | 3454x |
按組滾動計算獲得 10倍到3500倍的加速。這不是筆誤。Pandas 的 groupby().transform(lambda x: x.rolling().mean()) 對每個組建立 Python 迴圈,每次呼叫都有直譯器開銷。Polars 在 Rust 中執行所有操作,跨組並行,沒有中間 Python 物件。
對於需要為100個標的計算10個指標的管道 — 這是2分鐘和0.3秒之間的區別。
技術指標:布林帶、肯特納通道、TTM Squeeze
布林帶和肯特納通道包裹價格序列,TTM Squeeze 區域高亮顯示
讓我們來看交易策略中使用的真實技術指標的計算。
布林帶
Pandas 實現
def bollinger_pandas(df, period=20, k=2.0):
df["bb_mid"] = df["close"].rolling(period).mean()
df["bb_std"] = df["close"].rolling(period).std()
df["bb_upper"] = df["bb_mid"] + k * df["bb_std"]
df["bb_lower"] = df["bb_mid"] - k * df["bb_std"]
return df
Polars 實現
def bollinger_polars(df, period=20, k=2.0):
return df.with_columns([
pl.col("close").rolling_mean(window_size=period).alias("bb_mid"),
pl.col("close").rolling_std(window_size=period).alias("bb_std"),
]).with_columns([
(pl.col("bb_mid") + k * pl.col("bb_std")).alias("bb_upper"),
(pl.col("bb_mid") - k * pl.col("bb_std")).alias("bb_lower"),
])
肯特納通道
其中 ATR(平均真實範圍):
TTM Squeeze
TTM Squeeze 是一種識別市場從擠壓狀態(低波動性)向擴充狀態過渡的方法。當布林帶位於肯特納通道內部時產生訊號:
技術指標基準測試(1M 行,單個標的)
| 指標 | Pandas (ms) | Polars (ms) | 加速比 |
|---|---|---|---|
| 布林帶 (20, 2) | 8.4 | 1.2 | 7.0x |
| 肯特納通道 (20, 1.5) | 14.2 | 2.1 | 6.8x |
| TTM Squeeze (完整) | 28.6 | 4.1 | 7.0x |
| RSI (14) | 6.8 | 1.1 | 6.2x |
| MACD (12, 26, 9) | 5.2 | 0.8 | 6.5x |
單個標的上穩定獲得 約7倍 加速。按組計算(100個標的)時,由於 Pandas groupby 開銷,加速倍數增長到數百倍。
注意:現成的指標包
Pandas 有 pandas-ta — 包含130+指標的庫。Polars 目前還沒有等效的包。這意味著使用 Polars 時,您需要自行實現指標。不過,基本構建塊(rolling_mean、rolling_std、ewm_mean、shift、列算術運算)覆蓋了絕大多數標準指標,而且 Polars 實現通常比想像的更簡短。
I/O 基準測試:CSV、Parquet、資料庫
來自 CSV、Parquet 和資料庫的資料流:並行 Rust I/O 對比單執行緒 Python
資料管道從載入資料開始。儲存格式和讀取方式決定了整個管道的基準速度。
CSV
df_pd = pd.read_csv("candles_10m.csv")
df_pl = pl.read_csv("candles_10m.csv")
df_pl_lazy = (
pl.scan_csv("candles_10m.csv")
.select(["timestamp", "close", "volume"])
.filter(pl.col("volume") > 1000)
.collect()
)
Parquet
df_pd = pd.read_parquet("candles_10m.parquet")
df_pl = pl.read_parquet("candles_10m.parquet")
df_pl_lazy = (
pl.scan_parquet("candles_10m.parquet")
.select(["timestamp", "close", "volume"])
.filter(pl.col("volume") > 1000)
.collect()
)
I/O 結果(1000萬行,6列)
| 操作 | Pandas (s) | Polars (s) | 加速比 |
|---|---|---|---|
| CSV 讀取 | 28.5 | 1.14 | 25.0x |
| CSV 寫入 | 42.0 | 2.8 | 15.0x |
| Parquet 讀取(所有列) | 0.82 | 0.31 | 2.6x |
| Parquet 讀取(6列中的3列) | 0.54 | 0.12 | 4.5x |
| Parquet 寫入 | 0.95 | 0.91 | 1.04x |
| Parquet 惰性(過濾 + 選擇) | N/A | 0.08 | 謂詞下推 |
關鍵結論:
- CSV:Polars 快達25倍 — Rust 中的並行解析
- Parquet 讀取:Polars 全量讀取快2.6倍,投影下推(僅讀取需要的列)快4.5倍
- Parquet 寫入:幾乎相同 — 兩者都使用 PyArrow/Arrow 後端
- 惰性掃描:Polars 可以在 Parquet 檔案的行組級別應用過濾器,而無需將資料載入到記憶體中。對於 Pandas,不手動使用 PyArrow 就無法實現這一點
對於 Parquet 快取 — 我們儲存預計算時間框架和指標的主要格式 — Polars 的惰性求值提供了理想的整合:僅載入需要的列和時間段,而無需將整個檔案讀入記憶體。
記憶體消耗與惰性求值
急切模式 vs 惰性模式:橙色的冗餘資料副本對比青色的最佳化 Arrow 列式佈局
急切模式 vs 惰性模式
Pandas 僅在急切模式下工作:每個操作立即執行,中間結果被例項化到記憶體中。
df = pd.read_csv("big_file.csv") # 整个文件加载到 RAM
df = df[df["volume"] > 1000] # 过滤后的副本
df = df[["timestamp", "close", "volume"]] # 又一个副本
df["returns"] = df["close"].pct_change() # 再一个副本
Polars 支援惰性求值 — 查詢構建為計算圖,經過最佳化後一次性執行:
result = (
pl.scan_csv("big_file.csv")
.filter(pl.col("volume") > 1000)
.select(["timestamp", "close", "volume"])
.with_columns(
pl.col("close").pct_change().alias("returns")
)
.collect()
)
Polars 最佳化器自動完成:
- 投影下推:僅讀取3列而非全部
- 謂詞下推:在讀取時應用
volume > 1000過濾器,不載入不需要的行 - 公共子表示式消除:避免重複計算相同內容
記憶體消耗(1000萬行,6個 float64 列)
| 場景 | Pandas (GB) | Polars 急切 (GB) | Polars 惰性 (GB) |
|---|---|---|---|
| CSV 載入 | 0.92 | 0.46 | 0.46 |
| Filter + Select 3列 | 1.38* | 0.22 | 0.22 |
| 5步轉換管道 | 2.76* | 0.48 | 0.48 |
| Parquet 載入(6列中的3列) | 0.46 | 0.23 | 0.23 |
* Pandas 建立中間副本;inplace=True 部分有幫助,但並非對所有操作有效。
Polars 原生使用 Arrow 列式格式:資料按列儲存,行不重複,儘可能使用零複製操作。對於包含多個轉換的管道,Polars 消耗的記憶體少2-6倍。
流式引擎:處理超出 RAM 的資料
對於不適合 RAM 的資料集,Polars 提供流式引擎:
result = (
pl.scan_parquet("huge_dataset/*.parquet")
.filter(pl.col("exchange") == "binance")
.group_by("ticker")
.agg([
pl.col("close").mean().alias("avg_close"),
pl.col("volume").sum().alias("total_volume"),
])
.collect(engine="streaming")
)
流式引擎分塊處理資料,無需將整個資料集載入到記憶體中。根據 PDS-H 基準測試資料,流式模式在大規模上比記憶體模式快3-7倍 — 得益於更好的快取區域性性和沒有虛擬記憶體壓力。
混合架構:Polars + Numba

回測由兩個本質不同的部分組成:
-
資料管道 — 載入、轉換、指標、過濾。這是大規模並行、面向列的,完美適合 Polars。
-
投資組合模擬 — 訂單成交、PnL 計算、倉位管理。這是路徑依賴的:每一步都取決於前一個狀態。這需要對時間序列進行逐元素遍歷。
Pandas 對這兩部分都不擅長。Polars 擅長第一部分,但不擅長第二部分。對於路徑依賴邏輯,最優工具是 Numba(Python 的 JIT 編譯器)或原生 Rust/C++。
架構
┌─────────────────────────────────────────────────────┐
│ 数据管道 │
│ │
│ Parquet/QuestDB ──→ Polars LazyFrame │
│ │ │ │
│ │ ┌──────┴──────┐ │
│ │ │ 指标 │ │
│ │ │ 过滤器 │ │
│ │ │ 特征 │ │
│ │ └──────┬──────┘ │
│ │ │ │
│ │ NumPy 数组 │
│ │ (从 Arrow 零拷贝) │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 投资组合模拟 (Numba) │ │
│ │ │ │
│ │ @njit │ │
│ │ def simulate(prices, signals, params): │ │
│ │ position = 0.0 │ │
│ │ pnl = 0.0 │ │
│ │ for i in range(len(prices)): │ │
│ │ if signals[i] > threshold: │ │
│ │ position = 1.0 │ │
│ │ elif signals[i] < -threshold: │ │
│ │ position = -1.0 │ │
│ │ pnl += position * (prices[i] - ...) │ │
│ │ return pnl │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
示例:完整管道
import polars as pl
import numpy as np
from numba import njit
df = (
pl.scan_parquet("cache_ETHUSDT_2024_2026.parquet")
.filter(pl.col("timestamp").is_between(start, end))
.with_columns([
pl.col("close")
.rolling_mean(window_size=20)
.alias("ma_fast"),
pl.col("close")
.rolling_mean(window_size=50)
.alias("ma_slow"),
pl.col("close")
.rolling_std(window_size=20)
.alias("volatility"),
])
.with_columns(
((pl.col("ma_fast") - pl.col("ma_slow")) / pl.col("volatility"))
.alias("signal")
)
.collect()
)
prices = df["close"].to_numpy() # 从 Arrow 零拷贝
signals = df["signal"].to_numpy() # 从 Arrow 零拷贝
@njit
def simulate_strategy(prices, signals, threshold=1.5, stop_loss=0.02):
"""
路径依赖模拟:Numba 编译为机器码。
100万次迭代耗时70-100ms。
"""
n = len(prices)
equity = np.empty(n)
equity[0] = 1.0
position = 0.0
entry_price = 0.0
for i in range(1, n):
if position != 0.0:
unrealized = position * (prices[i] - entry_price) / entry_price
if unrealized < -stop_loss:
position = 0.0
if position == 0.0:
if signals[i] > threshold:
position = 1.0
entry_price = prices[i]
elif signals[i] < -threshold:
position = -1.0
entry_price = prices[i]
ret = (prices[i] - prices[i - 1]) / prices[i - 1]
equity[i] = equity[i - 1] * (1.0 + position * ret)
return equity
equity = simulate_strategy(prices, signals)
為什麼不用 vectorbt?
vectorbt 是一個流行的回測框架,可在70-100ms內處理100萬筆訂單。它基於 Pandas + NumPy + Numba 構建。問題在於:Pandas 是資料管道的瓶頸 — 慢、單執行緒、記憶體消耗大。vectorbt 不得不通過 Numba 繞過 Pandas 的限制來處理關鍵部分,但資料載入和指標計算仍然通過 Pandas 進行。
Polars + Numba 混合架構取兩者之長:
- Polars 用於資料管道 — 在相同操作上比 Pandas 快5-350倍
- Numba 用於投資組合模擬 — 與 vectorbt 中的速度相同
- 沒有中間 Pandas 層 — 資料通過零複製直接從 Arrow 流向 NumPy
遷移:從 Pandas 到 Polars 的關鍵模式
連線遺留程式碼與現代程式碼的橋樑:將 Pandas 模式轉換為 Polars 表示式
如果您的管道是用 Pandas 編寫的,遷移不需要從頭重寫。主要模式可以按模板轉換。
讀取資料
df = pd.read_parquet("data.parquet")
df = pd.read_csv("data.csv", parse_dates=["timestamp"])
df = pl.read_parquet("data.parquet")
df = pl.read_csv("data.csv", try_parse_dates=True)
df = pl.scan_parquet("data.parquet") # 在 .collect() 之前不读取任何内容
過濾
df_filtered = df[df["volume"] > 1000]
df_filtered = df[(df["close"] > 100) & (df["exchange"] == "binance")]
df_filtered = df.filter(pl.col("volume") > 1000)
df_filtered = df.filter(
(pl.col("close") > 100) & (pl.col("exchange") == "binance")
)
建立列
df["returns"] = df["close"].pct_change()
df["log_returns"] = np.log(df["close"] / df["close"].shift(1))
df = df.with_columns([
pl.col("close").pct_change().alias("returns"),
(pl.col("close") / pl.col("close").shift(1)).log().alias("log_returns"),
])
GroupBy + 聚合
result = df.groupby("ticker").agg(
avg_close=("close", "mean"),
total_volume=("volume", "sum"),
trade_count=("close", "count"),
)
result = df.group_by("ticker").agg([
pl.col("close").mean().alias("avg_close"),
pl.col("volume").sum().alias("total_volume"),
pl.col("close").count().alias("trade_count"),
])
按組滾動計算
df["ma_20"] = df.groupby("ticker")["close"].transform(
lambda x: x.rolling(20).mean()
)
df = df.with_columns(
pl.col("close")
.rolling_mean(window_size=20)
.over("ticker")
.alias("ma_20")
)
與 QuestDB 整合
Polars 原生支援 Apache Arrow — 與 QuestDB 用於資料傳輸的格式相同。這意味著接收查詢結果時零複製:
import pyarrow as pa
from questdb.ingress import Sender
arrow_table = questdb_connection.query_arrow(
"SELECT * FROM candles WHERE ticker = 'ETHUSDT'"
)
df = pl.from_arrow(arrow_table) # 零拷贝!
df_pd = arrow_table.to_pandas() # 拷贝 + 类型转换
有關使用 QuestDB 儲存和分析交易資料的更多資訊,請參閱我們的資料架構系列文章。
與 Parquet 快取的整合
列式 Parquet 快取:通過謂詞下推和投影下推實現選擇性資料載入
在文章聚合 Parquet 快取中,我們描述瞭如何預計算時間框架和指標一次並儲存到 Parquet 檔案中。Polars 使這種方法更加高效:
cache = (
pl.scan_parquet("raw_candles_1m.parquet")
.with_columns([
pl.col("close")
.rolling_mean(window_size=60)
.alias("ma_1h"),
pl.col("close")
.rolling_mean(window_size=240)
.alias("ma_4h"),
pl.col("close")
.rolling_mean(window_size=20)
.alias("bb_mid"),
pl.col("close")
.rolling_std(window_size=20)
.alias("bb_std"),
])
.with_columns([
(pl.col("bb_mid") + 2.0 * pl.col("bb_std")).alias("bb_upper"),
(pl.col("bb_mid") - 2.0 * pl.col("bb_std")).alias("bb_lower"),
])
.collect()
)
cache.write_parquet(
"cache_ETHUSDT_2024_2026.parquet",
compression="zstd",
compression_level=3,
)
在大規模最佳化期間 — 當需要執行數千種參數組合時 — 通過 Polars scan_parquet 配合謂詞下推從 Parquet 快取讀取,可以僅載入需要的時間段和列,而無需讀取整個檔案。
與自適應逐層細化的結合:Polars 惰性求值非常適合兩級載入 — 主遍歷使用粗粒度資料,僅在成交模糊區域使用詳細資料(秒級、毫秒級)。
何時使用什麼:實踐建議
決策矩陣:小規模原型開發與大規模生產管道的不同路徑
Pandas 適用於以下情況:
- 資料集不超過1M行且您不需要對數百個組進行 GroupBy — Pandas 2.2 和 Polars 之間的差異通常不大(1.5-2倍)
- 需要
pandas-ta或其他具有 Pandas API 的庫 — 為一次性研究重寫130個指標不切實際 - 原型設計 — Pandas API 對大多數人更熟悉,快速假設驗證時速度不是關鍵
- 與遺留程式碼整合 — 現有的 Pandas 管道正常執行,不需要最佳化
Polars 適用於以下情況:
- 資料集超過1000萬行 — 數千萬和數億行的 tick 資料、多時間框架快取
- 按組滾動計算 — 100+標的,每個都需要計算指標:100-3500倍加速
- ETL 管道 — 載入、清洗、轉換大量資料
- 有限的 RAM — 惰性求值和流式引擎允許處理不適合記憶體的資料
- Parquet/QuestDB 技術棧 — 原生 Arrow = 零複製、謂詞下推、投影下推
不應期望什麼
營銷數字"快30倍"是特定操作上的峰值加速。典型管道操作的實際加速:2-10倍。按組滾動計算上 — 顯著更多。在小資料集上 — 有時 Polars 由於開銷甚至更慢。
我們在 marketmaker.cc 的經驗
生產指標:管道加速 6-8 倍,每小時最佳化迭代次數增加 8 倍
在 marketmaker.cc,我們為回測引擎使用 Polars + Numba 混合架構。整個資料管道 — 從 Parquet 快取載入、計算指標、過濾、特徵工程 — 在 Polars 上執行。投資組合模擬在 Numba 上執行。
在資料管道中從 Pandas 切換到 Polars,在我們的典型資料集(5000萬-1億行,200+標的)上獲得了6-8倍的加速。按組滾動指標計算從幾分鐘降到幾百毫秒。這使我們在不更換硬體的情況下,將每小時最佳化迭代次數從約500次增加到約4000次。
關鍵點:我們沒有在一天內遷移所有程式碼。首先遷移了 I/O(讀取 Parquet),然後是指標計算,然後是過濾和特徵工程。Pandas 僅保留在與期望 pd.DataFrame 的遺留元件的介面中。df.to_pandas() / pl.from_pandas() 轉換隻需幾毫秒,不是瓶頸。
在回測階段計算的指標 — 包括按活躍時間計算的 PnL — 已經在 Polars DataFrame 上計算,這簡化了管道並消除了中間轉換。
結論
三大技術流匯聚:Polars、Numba 和 Arrow 融合為統一的最佳化管道
Polars 並非在每個場景中都能替代 Pandas。它是一個不同類別的工具,在嚴肅演算法交易中典型的規模上才能充分發揮:數百萬和數億行、數十和數百個標的、持續的參數最佳化。
關鍵數字:
- 基本操作:典型管道任務加速2-10倍
- 按組滾動計算:10-3500倍 — 交易管道的主要殺手級特性
- CSV I/O:高達25倍 — 對初始資料載入至關重要
- 記憶體:得益於 Arrow 和惰性求值,節省2-6倍
- 流式處理:處理不適合 RAM 的資料
推薦的生產回測引擎架構:
- Polars — 整個資料管道:載入、指標、過濾、特徵
- Numba/Rust — 投資組合模擬:路徑依賴的訂單和倉位邏輯
- Arrow — 所有連線點的資料格式:Parquet、QuestDB、Polars、NumPy
沒有中間 Pandas 層。資料從儲存通過 Polars 流入 NumPy 陣列,再進入 Numba 引擎 — 沒有不必要的複製,沒有 GIL,沒有單執行緒瓶頸。
有用的連結
- Polars — User Guide
- Polars vs Pandas — official benchmark
- PDS-H Benchmark — DataFrame libraries comparison
- Apache Arrow — columnar format specification
- Numba — JIT compiler for Python
- vectorbt — backtesting framework
- pandas-ta — Technical Analysis Indicators
- Ritchie Vink — I wrote one of the fastest DataFrame libraries (Polars origin)
- Towards Data Science — Polars vs Pandas: real-world benchmarks
- Ernest Chan — Quantitative Trading
引用
@article{soloviov2026polarsvspandas,
author = {Soloviov, Eugen},
title = {Polars vs Pandas for Algotrading: Benchmarks on Real Data},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/polars-vs-pandas-algotrading},
description = {Polars 与 Pandas 在算法交易任务上的详细对比:过滤、聚合、滚动信号计算、I/O 和内存消耗的基准测试。Polars + Numba 混合架构实现最大回测性能。}
}
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.