Polars vs. Pandas für Algotrading: Benchmarks mit echten Daten
Serie "Backtests ohne Illusionen", Artikel 9
Beim Backtesting von Strategien geht es nicht nur um Signal-Logik und Ausführungssimulation. Es ist auch eine Datenpipeline: das Laden von Millionen von Candles, das Resampling von Zeitrahmen, die Berechnung von Indikatoren, das Filtern nach Bedingungen, das Gruppieren nach Instrumenten. Wenn die Pipeline 30 Sekunden statt 3 braucht, ist das nicht nur unpraktisch. Es bedeutet 10-mal weniger Experimente pro Stunde, 10-mal langsamere Iteration und einen 10-mal längeren Weg von der Idee zur Produktion.
Pandas ist der De-facto-Standard für tabellarische Daten in Python. Aber Pandas wurde 2008 entwickelt, als CPU-Kerne langsamer und Datensätze kleiner waren. Pandas ist single-threaded, speicherhungrig und verfügt über keinen Query-Optimizer. Polars ist eine Bibliothek der nächsten Generation, geschrieben in Rust, mit paralleler Ausführung, Apache Arrow im Kern und einem Lazy-Query-Planer.
Die Frage lautet: Wie viel schneller ist Polars bei echten Algotrading-Aufgaben? Nicht bei synthetischen Benchmarks aus einer README, sondern beim Filtern von Ticks, der Berechnung von Rolling-Indikatoren, dem Gruppieren nach Instrumenten und dem Laden aus Parquet/QuestDB?
Dieser Artikel liefert systematische Benchmarks mit Zahlen, Code und praktischen Empfehlungen.
Benchmark-Methodik
Futuristic measurement laboratory: precision benchmarking environment with controlled parameters
Bevor wir vergleichen, definieren wir die Regeln, damit die Ergebnisse reproduzierbar und fair sind.
Umgebung
- Python 3.11, Pandas 2.2, Polars 1.x (neueste stabile Version)
- Maschine: 8 Kerne, 32 GB RAM, NVMe-SSD
- Jeder Benchmark wird 100-mal ausgeführt; der Median wird verwendet
- Warmup: 5 Iterationen vor den Messungen
- GC während der Messung deaktiviert (
gc.disable())
Daten
Drei Skalierungsebenen:
- Klein: 10K Zeilen (ein Instrument, ein Tag, Minuten-Candles)
- Mittel: 1M Zeilen (ein Instrument, ~2 Jahre, Minuten-Candles)
- Groß: 10M+ Zeilen (100 Instrumente, 2 Jahre, Minuten-Candles)
Zusätzlich: der reale NYC-Taxi-Datensatz (12,7M Zeilen) für ETL-Benchmarks — ein Standard-Branchenbenchmark.
Was wir messen
import timeit, gc
def bench(fn, n=100, warmup=5):
"""Fair benchmark: warmup + median of n runs."""
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,
}
Operations-Benchmarks: Tabellen
Performance comparison across operations: filter, groupby, join, and select at different data scales
Kleine Datensätze (10K Zeilen)
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| 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 |
Bei 10K Zeilen ist Pandas bei einfachen Filtern manchmal schneller — der Overhead beim Aufruf einer Polars-Funktion über PyO3 ist vergleichbar mit der Zeit der Operation selbst. Bei Joins zeigt sich jedoch bereits der Vorteil: Die Polars-Hashtabelle in Rust ist 13-mal schneller.
Mittlere Datensätze (1M Zeilen)
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| 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 |
Bei einer Million Zeilen ist Polars beim Filtern und Gruppieren durchgängig 1,6-mal schneller. Bei Select (Auswahl einer Spaltenteilmenge) — 10,9-mal, weil das Arrow-Spaltenformat Zero-Copy-Slicing ermöglicht.
Große Datensätze (10M+ Zeilen)
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Filter | 185 | 50 | 3.7x |
| GroupBy | 860 | 100 | 8.6x |
| Join | 1450 | 120 | 12.1x |
| Select | 240 | 40 | 6.0x |
Bei großen Datenmengen wächst der Polars-Vorteil nichtlinear: parallele Ausführung auf 8 Kernen und der Query-Optimizer erzeugen einen kumulativen Effekt. GroupBy ist 8,6-mal schneller — der Unterschied zwischen "eine Sekunde warten" und "100 Millisekunden warten".
ETL mit echten Daten (NYC Taxi, 12,7M Zeilen)
| Operation | Pandas (s) | Polars (s) | Speedup |
|---|---|---|---|
| CSV Load | 28.5 | 1.14 | 25.0x |
| Filter + GroupBy + Agg | 3.8 | 0.42 | 9.0x |
| Multi-column transform | 2.1 | 0.7 | 3.0x |
| Full ETL pipeline | 34.4 | 2.26 | 15.2x |
CSV-I/O ist das dramatischste Ergebnis: Polars liest CSV parallel mit seiner Rust-Engine, 25-mal schneller. Das ist entscheidend für das initiale Laden historischer Daten.
Offizieller PDS-H-Benchmark (Mai 2025)
DataFrame library performance race: Polars and DuckDB lead while Pandas trails by orders of magnitude
PDS-H (Performance Data Science — Holistic) ist ein Standard-Benchmark für DataFrame-Bibliotheken, analog zu TPC-H für Datenbanken. Ergebnisse von Mai 2025:
- Pandas nimmt nur bei der Skala SF-10 teil — single-threaded, ohne Query-Optimizer, zwei Größenordnungen langsamer als die Spitzenreiter
- Polars und DuckDB spielen bei SF-10 und SF-100 in ihrer eigenen Liga
- Die neue Streaming-Engine in Polars bringt einen zusätzlichen Speedup von 3-7x im Vergleich zum In-Memory-Modus — und ermöglicht die Verarbeitung von Daten, die nicht in den RAM passen
Für Algotrading bedeutet das: Wenn Ihre Pipeline beim Laden von 100M+ Zeilen Tick-Daten speicherlimitiert ist — mit der Polars-Streaming-Engine können Sie diese verarbeiten, ohne den RAM zu vergrößern.
Rolling-Berechnungen für Trading-Signale: Das Killer-Feature

Dies ist der wichtigste Benchmark für Algotrading. Eine typische Aufgabe: Sie haben 100 Instrumente, und für jedes müssen Sie einen Rolling Mean, Rolling Std, Z-Score berechnen und daraus ein Signal generieren. In Pandas ist das groupby().rolling(), in Polars ist es 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 + rolling expressions
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")
)
Ergebnisse
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Rolling mean, 100 groups x 100K rows | 4200 | 12 | 350x |
| Rolling std, 100 groups x 100K rows | 5100 | 15 | 340x |
| Z-score (mean + std + arithmetic) | 12500 | 35 | 357x |
| Rolling mean, 1000 groups x 10K rows | 38000 | 11 | 3454x |
10-fache bis 3500-fache Beschleunigung bei Rolling-Berechnungen nach Gruppe. Das ist kein Tippfehler. Pandas groupby().transform(lambda x: x.rolling().mean()) erzeugt eine Python-Schleife über jede Gruppe, wobei jeder Aufruf Interpreter-Overhead verursacht. Polars führt alles in Rust aus, parallel über die Gruppen hinweg, ohne Zwischenobjekte in Python.
Für eine Pipeline, die 10 Indikatoren über 100 Instrumente berechnen muss — das ist der Unterschied zwischen 2 Minuten und 0,3 Sekunden.
Technische Indikatoren: Bollinger Bands, Keltner Channels, TTM Squeeze
Bollinger Bands and Keltner Channels enveloping a price series, with TTM Squeeze zones highlighted
Betrachten wir die Berechnung realer technischer Indikatoren, die in Trading-Strategien verwendet werden.
Bollinger Bands
Pandas-Implementierung
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-Implementierung
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"),
])
Keltner Channels
wobei ATR (Average True Range):
TTM Squeeze
TTM Squeeze ist eine Methode zur Identifizierung des Übergangs des Marktes von einem Squeeze-Zustand (niedrige Volatilität) zu einem Expansionszustand. Das Signal tritt auf, wenn die Bollinger Bands innerhalb der Keltner Channels liegen:
Benchmark technischer Indikatoren (1M Zeilen, ein Ticker)
| Indicator | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Bollinger Bands (20, 2) | 8.4 | 1.2 | 7.0x |
| Keltner Channels (20, 1.5) | 14.2 | 2.1 | 6.8x |
| TTM Squeeze (full) | 28.6 | 4.1 | 7.0x |
| RSI (14) | 6.8 | 1.1 | 6.2x |
| MACD (12, 26, 9) | 5.2 | 0.8 | 6.5x |
Ein konsistenter Speedup von ~7x bei einem einzelnen Ticker. Bei der Berechnung nach Gruppe (100 Ticker) wächst der Speedup aufgrund des Pandas-groupby-Overheads auf mehrere hundert Mal.
Ein Hinweis zu fertigen Indikator-Paketen
Für Pandas gibt es pandas-ta — eine Bibliothek mit 130+ Indikatoren. Für Polars gibt es noch kein Äquivalent. Das bedeutet, dass Sie bei der Verwendung von Polars die Indikatoren selbst implementieren müssen. Die grundlegenden Bausteine (rolling_mean, rolling_std, ewm_mean, shift, Spaltenarithmetik) decken jedoch die überwiegende Mehrheit der Standardindikatoren ab, und die Polars-Implementierung ist meist kürzer, als man denkt.
I/O-Benchmarks: CSV, Parquet, Datenbank
Data streams from CSV, Parquet, and database sources: parallel Rust I/O versus single-threaded Python
Die Datenpipeline beginnt mit dem Laden der Daten. Das Speicherformat und die Lesemethode bestimmen die Grundgeschwindigkeit der gesamten Pipeline.
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-Ergebnisse (10M Zeilen, 6 Spalten)
| Operation | Pandas (s) | Polars (s) | Speedup |
|---|---|---|---|
| CSV read | 28.5 | 1.14 | 25.0x |
| CSV write | 42.0 | 2.8 | 15.0x |
| Parquet read (all columns) | 0.82 | 0.31 | 2.6x |
| Parquet read (3 of 6 columns) | 0.54 | 0.12 | 4.5x |
| Parquet write | 0.95 | 0.91 | 1.04x |
| Parquet lazy (filter + select) | N/A | 0.08 | predicate pushdown |
Wichtige Erkenntnisse:
- CSV: Polars bis zu 25-mal schneller — paralleles Parsen in Rust
- Parquet-Lesen: Polars ist 2,6-mal schneller beim Volllesen und 4,5-mal mit Projection Pushdown (nur benötigte Spalten lesen)
- Parquet-Schreiben: nahezu identisch — beide verwenden das PyArrow/Arrow-Backend
- Lazy Scan: Polars kann den Filter auf Row-Group-Ebene der Parquet-Datei anwenden, ohne die Daten in den Speicher zu laden. Das ist mit Pandas ohne manuelle Verwendung von PyArrow nicht möglich
Für den Parquet-Cache — unser primäres Format zur Speicherung vorberechneter Zeitrahmen und Indikatoren — bietet Polars mit Lazy Evaluation eine ideale Integration: Es werden nur die benötigten Spalten und Zeiträume geladen, ohne die gesamte Datei in den Speicher zu lesen.
Speicherverbrauch und Lazy Evaluation
Eager vs lazy memory patterns: redundant copies in orange versus optimized Arrow columnar layout in cyan
Eager vs. Lazy
Pandas arbeitet nur im Eager-Modus: Jede Operation wird sofort ausgeführt, und Zwischenergebnisse werden im Speicher materialisiert.
df = pd.read_csv("big_file.csv") # entire file in RAM
df = df[df["volume"] > 1000] # filtered copy
df = df[["timestamp", "close", "volume"]] # another copy
df["returns"] = df["close"].pct_change() # yet another copy
Polars unterstützt Lazy Evaluation — Abfragen werden als Graph aufgebaut, optimiert und in einem einzigen Durchgang ausgeführt:
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()
)
Der Polars-Optimizer führt automatisch aus:
- Projection Pushdown: liest nur 3 Spalten statt aller
- Predicate Pushdown: wendet den Filter
volume > 1000bereits beim Lesen an, ohne unnötige Zeilen zu laden - Common Subexpression Elimination: vermeidet doppelte Berechnungen
Speicherverbrauch (10M Zeilen, 6 float64-Spalten)
| Scenario | Pandas (GB) | Polars eager (GB) | Polars lazy (GB) |
|---|---|---|---|
| CSV Load | 0.92 | 0.46 | 0.46 |
| Filter + Select 3 columns | 1.38* | 0.22 | 0.22 |
| Pipeline of 5 transformations | 2.76* | 0.48 | 0.48 |
| Parquet Load (3 of 6 cols) | 0.46 | 0.23 | 0.23 |
* Pandas erzeugt Zwischenkopien; inplace=True hilft teilweise, aber nicht bei allen Operationen.
Polars nutzt nativ das Arrow-Spaltenformat: Daten werden spaltenweise gespeichert, Zeilen werden nicht dupliziert, und wo immer möglich werden Zero-Copy-Operationen verwendet. Bei Pipelines mit mehreren Transformationen verbraucht Polars 2-6-mal weniger Speicher.
Streaming-Engine: Daten größer als der RAM
Für Datensätze, die nicht in den RAM passen, bietet Polars eine Streaming-Engine:
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")
)
Die Streaming-Engine verarbeitet Daten in Blöcken, ohne den gesamten Datensatz in den Speicher zu laden. Laut PDS-H-Benchmark-Daten ist der Streaming-Modus bei großen Skalen 3-7-mal schneller als der In-Memory-Modus — dank besserer Cache-Lokalität und fehlendem Virtual-Memory-Druck.
Hybride Architektur: Polars + Numba

Ein Backtest besteht aus zwei grundlegend unterschiedlichen Teilen:
-
Datenpipeline — Laden, Transformation, Indikatoren, Filterung. Das ist massiv parallel, spaltenorientiert und perfekt für Polars geeignet.
-
Portfoliosimulation — Order-Ausführung, PnL-Berechnung, Positionsverwaltung. Das ist pfadabhängig: Jeder Schritt hängt vom vorherigen Zustand ab. Dies erfordert einen elementweisen Durchlauf über die Zeitreihe.
Pandas eignet sich für beide Teile schlecht. Polars glänzt beim ersten, aber nicht beim zweiten. Für pfadabhängige Logik ist das optimale Werkzeug Numba (ein JIT-Compiler für Python) oder natives Rust/C++.
Architektur
┌─────────────────────────────────────────────────────┐
│ Data Pipeline │
│ │
│ Parquet/QuestDB ──→ Polars LazyFrame │
│ │ │ │
│ │ ┌──────┴──────┐ │
│ │ │ Indicators │ │
│ │ │ Filters │ │
│ │ │ Features │ │
│ │ └──────┬──────┘ │
│ │ │ │
│ │ NumPy arrays │
│ │ (zero-copy from Arrow) │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Portfolio Simulation (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 │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Beispiel: Vollständige Pipeline
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() # zero-copy from Arrow
signals = df["signal"].to_numpy() # zero-copy from Arrow
@njit
def simulate_strategy(prices, signals, threshold=1.5, stop_loss=0.02):
"""
Path-dependent simulation: Numba compiles to machine code.
1M iterations in 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)
Warum nicht vectorbt?
vectorbt ist ein beliebtes Backtesting-Framework, das 1M Orders in 70-100ms verarbeitet. Es basiert auf Pandas + NumPy + Numba. Das Problem: Pandas ist der Flaschenhals in der Datenpipeline — langsam, single-threaded, speicherhungrig. vectorbt muss die Pandas-Beschränkungen durch Numba für kritische Teile umgehen, aber das Laden von Daten und die Berechnung von Indikatoren laufen weiterhin über Pandas.
Die hybride Polars + Numba-Architektur vereint das Beste aus beiden Welten:
- Polars für die Datenpipeline — 5-350-mal schneller als Pandas bei denselben Operationen
- Numba für die Portfoliosimulation — dieselbe Geschwindigkeit wie in vectorbt
- Keine Pandas-Zwischenschicht — Daten fließen von Arrow direkt zu NumPy per Zero-Copy
Migration: Wichtige Muster von Pandas zu Polars
Bridge between legacy and modern code: translating Pandas patterns to Polars expressions
Wenn Ihre Pipeline in Pandas geschrieben ist, erfordert die Migration kein Neuschreiben von Grund auf. Die wichtigsten Muster lassen sich per Vorlage übertragen.
Daten lesen
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") # reads nothing until .collect()
Filterung
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")
)
Spalten erstellen
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 + Aggregation
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"),
])
Rolling nach Gruppe
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")
)
Integration mit QuestDB
Polars arbeitet nativ mit Apache Arrow — demselben Format, das QuestDB für die Datenübertragung verwendet. Das bedeutet Zero-Copy beim Empfang von Abfrageergebnissen:
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) # zero-copy!
df_pd = arrow_table.to_pandas() # copy + type conversion
Mehr zur Arbeit mit QuestDB zur Speicherung und Analyse von Trading-Daten finden Sie in unserer Artikelserie zur Datenarchitektur.
Integration mit dem Parquet-Cache
Columnar Parquet cache with predicate pushdown and projection pushdown for selective data loading
Im Artikel Aggregierter Parquet-Cache haben wir beschrieben, wie man Zeitrahmen und Indikatoren einmalig vorberechnet und in einer Parquet-Datei speichert. Polars macht diesen Ansatz noch effizienter:
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,
)
Bei einer Massenoptimierung — wenn Tausende von Parameterkombinationen ausgeführt werden müssen — ermöglicht das Lesen aus dem Parquet-Cache über Polars scan_parquet mit Predicate Pushdown das Laden nur der benötigten Zeiträume und Spalten, ohne die gesamte Datei zu lesen.
Integration mit Adaptives Drill-Down: Polars Lazy Evaluation ist perfekt für zweistufiges Laden geeignet — grobe Daten für den Hauptdurchlauf, detaillierte Daten (Sekunden, Millisekunden) nur für Fill-Ambiguity-Zonen.
Wann was verwenden: Praktische Empfehlungen
Decision matrix: diverging paths for small-scale prototyping versus large-scale production pipelines
Pandas ist gerechtfertigt, wenn:
- Datensatz bis 1M Zeilen und Sie kein GroupBy über Hunderte von Gruppen durchführen — der Unterschied zwischen Pandas 2.2 und Polars ist oft vernachlässigbar (1,5-2x)
- Sie
pandas-taoder andere Bibliotheken mit einer Pandas-API benötigen — das Neuschreiben von 130 Indikatoren ist für eine einmalige Studie unpraktisch - Prototyping — die Pandas-API ist den meisten vertrauter, und Geschwindigkeit ist für schnelles Testen von Hypothesen nicht entscheidend
- Integration mit Legacy-Code — eine bestehende Pandas-Pipeline, die funktioniert und nicht optimiert werden muss
Polars ist notwendig, wenn:
- Datensatz ab 10M Zeilen — zehn und hundert Millionen Zeilen von Tick-Daten, Multi-Timeframe-Caches
- Rolling nach Gruppe — 100+ Instrumente, Indikatoren für jedes: 100-3500-facher Speedup
- ETL-Pipeline — Laden, Bereinigen, Transformieren großer Datenmengen
- Begrenzter RAM — Lazy Evaluation und die Streaming-Engine ermöglichen die Verarbeitung von Daten, die nicht in den Speicher passen
- Parquet/QuestDB-Stack — natives Arrow = Zero-Copy, Predicate Pushdown, Projection Pushdown
Was Sie nicht erwarten sollten
Die Marketing-Zahl "30-mal schneller" ist der Spitzen-Speedup bei bestimmten Operationen. Realistischer Speedup bei typischen Pipeline-Operationen: 2-10x. Bei Rolling nach Gruppe — deutlich mehr. Bei kleinen Datensätzen — manchmal ist Polars aufgrund des Overheads sogar langsamer.
Unsere Erfahrung bei marketmaker.cc
Production metrics: 6-8x pipeline speedup and 8x more optimization iterations per hour
Bei marketmaker.cc verwenden wir eine hybride Polars + Numba-Architektur für die Backtest-Engine. Die gesamte Datenpipeline — Laden aus dem Parquet-Cache, Berechnung von Indikatoren, Filterung, Feature Engineering — läuft auf Polars. Die Portfoliosimulation läuft auf Numba.
Der Wechsel von Pandas zu Polars in der Datenpipeline brachte bei unseren typischen Datensätzen (50-100M Zeilen, 200+ Instrumente) eine 6-8-fache Beschleunigung. Die Berechnung von Rolling-Indikatoren nach Gruppe ging von Minuten auf Hunderte von Millisekunden zurück. Dies ermöglichte es uns, die Anzahl der Optimierungsiterationen von ~500 auf ~4000 pro Stunde zu steigern, ohne die Hardware zu ändern.
Ein wichtiger Punkt: Wir haben nicht den gesamten Code an einem einzigen Tag migriert. Zuerst haben wir I/O (Parquet-Lesen) verschoben, dann die Indikatorberechnung, dann Filterung und Feature Engineering. Pandas blieb nur an der Schnittstelle zu Legacy-Komponenten erhalten, die ein pd.DataFrame erwarten. Die Konvertierung df.to_pandas() / pl.from_pandas() dauert Millisekunden und ist kein Flaschenhals.
Metriken, die während der Backtest-Phase berechnet werden — einschließlich PnL nach aktiver Zeit — werden bereits auf Polars-DataFrames berechnet, was die Pipeline vereinfacht und Zwischenkonvertierungen eliminiert.
Fazit
Three technology streams converging: Polars, Numba, and Arrow uniting into a single optimized pipeline
Polars ist kein Ersatz für Pandas in jedem Szenario. Es ist ein Werkzeug einer anderen Klasse, das bei den für seriöses Algotrading typischen Skalen glänzt: Millionen und Hunderte Millionen Zeilen, zehn und hundert Instrumente, kontinuierliche Parameteroptimierung.
Wichtige Zahlen:
- Grundoperationen: 2-10-fache Beschleunigung bei typischen Pipeline-Aufgaben
- Rolling nach Gruppe: 10-3500x — das wichtigste Killer-Feature für Trading-Pipelines
- CSV-I/O: bis zu 25x — entscheidend für das initiale Laden von Daten
- Speicher: 2-6-fache Einsparung dank Arrow und Lazy Evaluation
- Streaming: Verarbeitung von Daten, die nicht in den RAM passen
Empfohlene Architektur für eine Produktions-Backtest-Engine:
- Polars — die gesamte Datenpipeline: Laden, Indikatoren, Filterung, Features
- Numba/Rust — Portfoliosimulation: pfadabhängige Order- und Positionslogik
- Arrow — das Datenformat an allen Schnittstellen: Parquet, QuestDB, Polars, NumPy
Keine Pandas-Zwischenschicht. Daten fließen vom Speicher über Polars in NumPy-Arrays und dann in die Numba-Engine — ohne unnötige Kopien, ohne GIL, ohne single-threaded Flaschenhälse.
Nützliche Links
- 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
Zitierung
@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 = {Detailed comparison of Polars and Pandas on algotrading tasks: benchmarks for filtering, aggregation, rolling signal computations, I/O, and memory consumption. Hybrid Polars + Numba architecture for maximum backtest performance.}
}
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.