Polars vs Pandas pour l'Algotrading : Benchmarks sur Données Réelles
Série « Backtests Sans Illusions », Article 9
Le backtesting de stratégies ne se résume pas à la logique de signaux et à la simulation d'exécution. C'est aussi un pipeline de données : chargement de millions de chandeliers, rééchantillonnage des unités de temps, calcul d'indicateurs, filtrage selon des conditions, regroupement par instruments. Quand le pipeline prend 30 secondes au lieu de 3, ce n'est pas qu'un simple désagrément. Cela signifie 10 fois moins d'expériences par heure, une itération 10 fois plus lente, et un chemin 10 fois plus long entre l'idée et la production.
Pandas est la norme de facto pour les données tabulaires en Python. Mais Pandas a été conçu en 2008, à une époque où les cœurs de CPU étaient plus lents et les jeux de données plus petits. Pandas est mono-thread, gourmand en mémoire, et dépourvu d'optimiseur de requêtes. Polars est une bibliothèque de nouvelle génération écrite en Rust, avec exécution parallèle, Apache Arrow au cœur, et un planificateur de requêtes paresseux (lazy).
La question est : dans quelle mesure Polars est-il plus rapide sur des tâches réelles d'algotrading ? Pas sur des benchmarks synthétiques tirés d'un README, mais sur le filtrage de ticks, le calcul d'indicateurs rolling, le regroupement par instruments et le chargement depuis Parquet/QuestDB ?
Cet article propose des benchmarks systématiques avec des chiffres, du code et des recommandations pratiques.
Méthodologie de Benchmark
Futuristic measurement laboratory: precision benchmarking environment with controlled parameters
Avant de comparer, définissons les règles afin que les résultats soient reproductibles et équitables.
Environnement
- Python 3.11, Pandas 2.2, Polars 1.x (dernière version stable)
- Machine : 8 cœurs, 32 Go de RAM, SSD NVMe
- Chaque benchmark est exécuté 100 fois ; la médiane est retenue
- Warmup : 5 itérations avant les mesures
- GC désactivé pendant la mesure (
gc.disable())
Données
Trois niveaux d'échelle :
- Petit : 10K lignes (un instrument, un jour, chandeliers à la minute)
- Moyen : 1M lignes (un instrument, ~2 ans, chandeliers à la minute)
- Grand : 10M+ lignes (100 instruments, 2 ans, chandeliers à la minute)
De plus : le jeu de données réel NYC Taxi (12,7M lignes) pour les benchmarks ETL — un benchmark standard de l'industrie.
Ce Que Nous Mesurons
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,
}
Benchmarks d'Opérations : Tableaux
Performance comparison across operations: filter, groupby, join, and select at different data scales
Petits jeux de données (10K lignes)
| 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 |
À 10K lignes, Pandas est parfois plus rapide sur les filtres simples — l'overhead d'appel d'une fonction Polars via PyO3 est comparable au temps de l'opération elle-même. Mais sur les jointures, l'avantage est déjà visible : la table de hachage de Polars en Rust est 13 fois plus rapide.
Jeux de données moyens (1M lignes)
| 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 |
À un million de lignes, Polars est systématiquement 1,6 fois plus rapide sur le filtrage et le regroupement. Sur select (choix d'un sous-ensemble de colonnes) — 10,9 fois, car le format columnaire Arrow permet un slicing zero-copy.
Grands jeux de données (10M+ lignes)
| 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 |
Sur de grands volumes de données, l'avantage de Polars croît de manière non linéaire : l'exécution parallèle sur 8 cœurs et l'optimiseur de requêtes produisent un effet cumulatif. GroupBy est 8,6 fois plus rapide — la différence entre « attendre une seconde » et « attendre 100 millisecondes ».
ETL sur Données Réelles (NYC Taxi, 12,7M lignes)
| 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 |
Le I/O CSV est le résultat le plus spectaculaire : Polars lit le CSV en parallèle avec son moteur Rust, 25 fois plus rapide. C'est essentiel pour le chargement initial de données historiques.
Benchmark Officiel PDS-H (Mai 2025)
DataFrame library performance race: Polars and DuckDB lead while Pandas trails by orders of magnitude
PDS-H (Performance Data Science — Holistic) est un benchmark standard pour les bibliothèques DataFrame, analogue à TPC-H pour les bases de données. Résultats de mai 2025 :
- Pandas ne participe qu'à l'échelle SF-10 — mono-thread, sans optimiseur de requêtes, deux ordres de grandeur plus lent que les leaders
- Polars et DuckDB évoluent dans leur propre ligue à SF-10 et SF-100
- Le nouveau moteur streaming de Polars offre un gain supplémentaire de 3 à 7x par rapport au mode en mémoire — permettant de traiter des données qui ne tiennent pas en RAM
Pour l'algotrading, cela signifie : si votre pipeline est limité par la mémoire lors du chargement de 100M+ lignes de données tick — le moteur streaming de Polars vous permet de les traiter sans augmenter la RAM.
Calculs Rolling pour les Signaux de Trading : La Fonctionnalité Clé

C'est le benchmark le plus important pour l'algotrading. Une tâche typique : vous avez 100 instruments, et pour chacun vous devez calculer une moyenne rolling, un écart-type rolling, un z-score, et générer un signal à partir de ceux-ci. En Pandas c'est groupby().rolling(), en Polars c'est 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")
)
Résultats
| 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 |
Un gain de 10x à 3500x sur les calculs rolling par groupe. Ce n'est pas une coquille. groupby().transform(lambda x: x.rolling().mean()) de Pandas crée une boucle Python sur chaque groupe, chaque appel entraînant un overhead d'interpréteur. Polars exécute tout en Rust, en parallèle sur les groupes, sans objets Python intermédiaires.
Pour un pipeline qui doit calculer 10 indicateurs sur 100 instruments — c'est la différence entre 2 minutes et 0,3 seconde.
Indicateurs Techniques : Bandes de Bollinger, Canaux de Keltner, TTM Squeeze
Bollinger Bands and Keltner Channels enveloping a price series, with TTM Squeeze zones highlighted
Examinons le calcul d'indicateurs techniques réels utilisés dans les stratégies de trading.
Bandes de Bollinger
Implémentation 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
Implémentation 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"),
])
Canaux de Keltner
où ATR (Average True Range) :
TTM Squeeze
Le TTM Squeeze est une méthode permettant d'identifier la transition du marché d'un état de compression (faible volatilité) vers un état d'expansion. Le signal apparaît lorsque les Bandes de Bollinger se trouvent à l'intérieur des Canaux de Keltner :
Benchmark des Indicateurs Techniques (1M lignes, un seul 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 |
Un gain constant d'environ 7x sur un seul ticker. Lors du calcul par groupe (100 tickers), le gain grimpe à plusieurs centaines de fois en raison de l'overhead du groupby de Pandas.
Une Remarque sur les Packages d'Indicateurs Prêts à l'Emploi
Pour Pandas, il existe pandas-ta — une bibliothèque avec plus de 130 indicateurs. Pour Polars, il n'existe pas encore d'équivalent. Cela signifie qu'en utilisant Polars, vous devrez implémenter les indicateurs vous-même. Cependant, les briques de base (rolling_mean, rolling_std, ewm_mean, shift, arithmétique de colonnes) couvrent la grande majorité des indicateurs standards, et l'implémentation Polars est généralement plus courte qu'il n'y paraît.
Benchmarks I/O : CSV, Parquet, Base de Données
Data streams from CSV, Parquet, and database sources: parallel Rust I/O versus single-threaded Python
Le pipeline de données commence par le chargement des données. Le format de stockage et la méthode de lecture déterminent la vitesse de base de l'ensemble du 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()
)
Résultats I/O (10M lignes, 6 colonnes)
| 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 |
Points clés à retenir :
- CSV : Polars jusqu'à 25 fois plus rapide — parsing parallèle en Rust
- Lecture Parquet : Polars est 2,6 fois plus rapide en lecture complète et 4,5 fois avec projection pushdown (lecture des seules colonnes nécessaires)
- Écriture Parquet : quasi identique — les deux utilisent le backend PyArrow/Arrow
- Lazy scan : Polars peut appliquer le filtre au niveau des row groups du fichier Parquet sans charger les données en mémoire. C'est impossible avec Pandas sans utiliser PyArrow manuellement
Pour le cache Parquet — notre format principal pour stocker les unités de temps et indicateurs précalculés — Polars avec évaluation paresseuse offre une intégration idéale : chargement des seules colonnes et périodes nécessaires sans lire l'intégralité du fichier en mémoire.
Consommation Mémoire et Évaluation Paresseuse
Eager vs lazy memory patterns: redundant copies in orange versus optimized Arrow columnar layout in cyan
Eager vs Lazy
Pandas fonctionne uniquement en mode eager : chaque opération s'exécute immédiatement, et les résultats intermédiaires sont matérialisés en mémoire.
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 prend en charge l'évaluation paresseuse — les requêtes sont construites sous forme de graphe, optimisées, et exécutées en une seule passe :
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()
)
L'optimiseur de Polars applique automatiquement :
- Projection pushdown : lit seulement 3 colonnes au lieu de toutes
- Predicate pushdown : applique le filtre
volume > 1000pendant la lecture, sans charger de lignes inutiles - Élimination des sous-expressions communes : évite de calculer la même chose deux fois
Consommation Mémoire (10M lignes, 6 colonnes float64)
| 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 crée des copies intermédiaires ; inplace=True aide partiellement, mais pas pour toutes les opérations.
Polars utilise nativement le format columnaire Arrow : les données sont stockées par colonnes, les lignes ne sont pas dupliquées, et des opérations zero-copy sont utilisées dès que possible. Pour les pipelines à transformations multiples, Polars consomme 2 à 6 fois moins de mémoire.
Moteur Streaming : Données Plus Grandes que la RAM
Pour les jeux de données qui ne tiennent pas en RAM, Polars propose un moteur streaming :
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")
)
Le moteur streaming traite les données par blocs sans charger l'intégralité du jeu de données en mémoire. Selon les données du benchmark PDS-H, le mode streaming est 3 à 7 fois plus rapide que le mode en mémoire à grande échelle — grâce à une meilleure localité de cache et à l'absence de pression sur la mémoire virtuelle.
Architecture Hybride : Polars + Numba

Un backtest se compose de deux parties fondamentalement différentes :
-
Pipeline de données — chargement, transformation, indicateurs, filtrage. C'est massivement parallèle, orienté colonnes, et parfaitement adapté à Polars.
-
Simulation de portefeuille — exécution des ordres, calcul du PnL, gestion des positions. C'est path-dependent : chaque étape dépend de l'état précédent. Cela nécessite un parcours élément par élément de la série temporelle.
Pandas est mal adapté aux deux parties. Polars excelle dans la première mais pas dans la seconde. Pour la logique path-dependent, l'outil optimal est Numba (un compilateur JIT pour Python) ou du Rust/C++ natif.
Architecture
┌─────────────────────────────────────────────────────┐
│ 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 │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Exemple : Pipeline Complet
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)
Pourquoi Pas vectorbt ?
vectorbt est un framework de backtesting populaire qui traite 1M d'ordres en 70-100ms. Il est construit sur Pandas + NumPy + Numba. Le problème : Pandas est le goulot d'étranglement du pipeline de données — lent, mono-thread, gourmand en mémoire. vectorbt doit contourner les limitations de Pandas via Numba pour les parties critiques, mais le chargement des données et le calcul des indicateurs passent toujours par Pandas.
L'architecture hybride Polars + Numba tire le meilleur des deux mondes :
- Polars pour le pipeline de données — 5 à 350 fois plus rapide que Pandas sur les mêmes opérations
- Numba pour la simulation de portefeuille — la même vitesse que dans vectorbt
- Aucune couche Pandas intermédiaire — les données circulent d'Arrow directement vers NumPy en zero-copy
Migration : Principaux Modèles de Pandas vers Polars
Bridge between legacy and modern code: translating Pandas patterns to Polars expressions
Si votre pipeline est écrit en Pandas, la migration ne nécessite pas de tout réécrire depuis zéro. Les principaux modèles se transposent selon des gabarits.
Lecture des Données
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()
Filtrage
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")
)
Création de Colonnes
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 + Agrégation
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 par Groupe
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")
)
Intégration avec QuestDB
Polars fonctionne nativement avec Apache Arrow — le même format que QuestDB utilise pour le transfert de données. Cela signifie zero-copy à la réception des résultats de requête :
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
Pour en savoir plus sur l'utilisation de QuestDB pour le stockage et l'analyse de données de trading, consultez notre série d'articles sur l'architecture des données.
Intégration avec le Cache Parquet
Columnar Parquet cache with predicate pushdown and projection pushdown for selective data loading
Dans l'article Cache Parquet Agrégé, nous avons décrit comment précalculer une fois les unités de temps et les indicateurs et les sauvegarder dans un fichier Parquet. Polars rend cette approche encore plus efficace :
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,
)
Lors d'une optimisation massive — lorsqu'il faut exécuter des milliers de combinaisons de paramètres — la lecture depuis le cache Parquet via scan_parquet de Polars avec predicate pushdown permet de ne charger que les périodes et colonnes nécessaires sans lire l'intégralité du fichier.
Intégration avec le Drill-down adaptatif : l'évaluation paresseuse de Polars est parfaitement adaptée au chargement à deux niveaux — données grossières pour la passe principale, données détaillées (secondes, millisecondes) uniquement pour les zones d'ambiguïté de remplissage.
Quand Utiliser Quoi : Recommandations Pratiques
Decision matrix: diverging paths for small-scale prototyping versus large-scale production pipelines
Pandas est justifié si :
- Jeu de données jusqu'à 1M de lignes et vous ne faites pas de GroupBy sur des centaines de groupes — la différence entre Pandas 2.2 et Polars est souvent négligeable (1,5-2x)
- Vous avez besoin de
pandas-taou d'autres bibliothèques avec une API Pandas — réécrire 130 indicateurs n'est pas pratique pour une étude ponctuelle - Prototypage — l'API Pandas est plus familière pour la plupart, et la vitesse n'est pas critique pour tester rapidement des hypothèses
- Intégration avec du code legacy — un pipeline Pandas existant qui fonctionne et n'a pas besoin d'être optimisé
Polars est nécessaire si :
- Jeu de données à partir de 10M de lignes — des dizaines et des centaines de millions de lignes de données tick, caches multi-timeframe
- Rolling par groupe — 100+ instruments, indicateurs pour chacun : gain de 100 à 3500x
- Pipeline ETL — chargement, nettoyage, transformation de gros volumes de données
- RAM limitée — l'évaluation paresseuse et le moteur streaming permettent de traiter des données qui ne tiennent pas en mémoire
- Stack Parquet/QuestDB — Arrow natif = zero-copy, predicate pushdown, projection pushdown
À Ne Pas Attendre
Le chiffre marketing « 30 fois plus rapide » est le gain maximal sur des opérations spécifiques. Le gain réaliste sur des opérations typiques de pipeline est de : 2-10x. Sur le rolling par groupe — nettement plus. Sur les petits jeux de données — Polars est parfois même plus lent en raison de l'overhead.
Notre Expérience chez marketmaker.cc
Production metrics: 6-8x pipeline speedup and 8x more optimization iterations per hour
Chez marketmaker.cc, nous utilisons une architecture hybride Polars + Numba pour le moteur de backtest. L'ensemble du pipeline de données — chargement depuis le cache Parquet, calcul des indicateurs, filtrage, feature engineering — tourne sur Polars. La simulation de portefeuille tourne sur Numba.
Le passage de Pandas à Polars dans le pipeline de données a apporté un gain de 6-8x sur nos jeux de données typiques (50-100M lignes, 200+ instruments). Le calcul d'indicateurs rolling par groupe est passé de minutes à des centaines de millisecondes. Cela nous a permis d'augmenter le nombre d'itérations d'optimisation d'environ 500 à environ 4000 par heure sans changer de matériel.
Un point clé : nous n'avons pas migré tout le code en une seule journée. Nous avons d'abord déplacé l'I/O (lecture Parquet), puis le calcul des indicateurs, puis le filtrage et le feature engineering. Pandas n'est resté que dans l'interface avec les composants legacy qui attendent un pd.DataFrame. La conversion df.to_pandas() / pl.from_pandas() prend des millisecondes et n'est pas un goulot d'étranglement.
Les métriques calculées pendant l'étape de backtest — y compris le PnL par Temps Actif — sont déjà calculées sur des DataFrames Polars, ce qui simplifie le pipeline et élimine les conversions intermédiaires.
Conclusion
Three technology streams converging: Polars, Numba, and Arrow uniting into a single optimized pipeline
Polars ne remplace pas Pandas dans tous les scénarios. C'est un outil d'une autre classe qui brille aux échelles typiques de l'algotrading sérieux : millions et centaines de millions de lignes, dizaines et centaines d'instruments, optimisation continue des paramètres.
Chiffres clés :
- Opérations de base : gain de 2-10x sur les tâches typiques de pipeline
- Rolling par groupe : 10-3500x — la principale fonctionnalité clé pour les pipelines de trading
- I/O CSV : jusqu'à 25x — essentiel pour le chargement initial des données
- Mémoire : économie de 2-6x grâce à Arrow et à l'évaluation paresseuse
- Streaming : traitement de données qui ne tiennent pas en RAM
Architecture recommandée pour un moteur de backtest en production :
- Polars — l'ensemble du pipeline de données : chargement, indicateurs, filtrage, features
- Numba/Rust — simulation de portefeuille : logique d'ordres et de positions path-dependent
- Arrow — le format de données à toutes les jonctions : Parquet, QuestDB, Polars, NumPy
Aucune couche Pandas intermédiaire. Les données circulent du stockage à travers Polars vers des tableaux NumPy puis vers le moteur Numba — sans copies inutiles, sans GIL, sans goulots d'étranglement mono-thread.
Liens Utiles
- 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
Citation
@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.