Polars vs Pandas para Algotrading: Benchmarks con Datos Reales
Serie "Backtests Sin Ilusiones", Artículo 9
El backtesting de estrategias no trata solo de la lógica de señales y la simulación de ejecución. También es un pipeline de datos: cargar millones de velas, remuestrear timeframes, calcular indicadores, filtrar por condiciones, agrupar por instrumentos. Cuando el pipeline tarda 30 segundos en lugar de 3, no es solo un inconveniente. Significa 10 veces menos experimentos por hora, 10 veces más lenta la iteración, y un camino 10 veces más largo desde la idea hasta la producción.
Pandas es el estándar de facto para datos tabulares en Python. Pero Pandas fue diseñado en 2008, cuando los núcleos de CPU eran más lentos y los conjuntos de datos más pequeños. Pandas es de un solo hilo, consume mucha memoria y carece de un optimizador de consultas. Polars es una librería de nueva generación escrita en Rust, con ejecución paralela, Apache Arrow en su núcleo y un planificador de consultas lazy.
La pregunta es: ¿cuánto más rápido es Polars en tareas reales de algotrading? No en benchmarks sintéticos de un README, sino en el filtrado de ticks, el cálculo de indicadores rolling, la agrupación por instrumentos y la carga desde Parquet/QuestDB.
Este artículo ofrece benchmarks sistemáticos con números, código y recomendaciones prácticas.
Metodología de los Benchmarks
Futuristic measurement laboratory: precision benchmarking environment with controlled parameters
Antes de comparar, definamos las reglas para que los resultados sean reproducibles y justos.
Entorno
- Python 3.11, Pandas 2.2, Polars 1.x (última versión estable)
- Máquina: 8 núcleos, 32 GB RAM, SSD NVMe
- Cada benchmark se ejecuta 100 veces; se toma la mediana
- Warmup: 5 iteraciones antes de las mediciones
- GC deshabilitado durante la medición (
gc.disable())
Datos
Tres niveles de escala:
- Pequeño: 10K filas (un instrumento, un día, velas de un minuto)
- Medio: 1M filas (un instrumento, ~2 años, velas de un minuto)
- Grande: 10M+ filas (100 instrumentos, 2 años, velas de un minuto)
Adicionalmente: el conjunto de datos real NYC Taxi (12.7M filas) para benchmarks de ETL — un benchmark estándar de la industria.
Qué Medimos
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 de Operaciones: Tablas
Performance comparison across operations: filter, groupby, join, and select at different data scales
Conjuntos de datos pequeños (10K filas)
| 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 |
Con 10K filas, Pandas es a veces más rápido en filtros simples — el overhead de llamar a una función de Polars vía PyO3 es comparable al tiempo de la operación en sí. Pero en los joins, la ventaja ya es visible: la tabla hash de Polars en Rust es 13 veces más rápida.
Conjuntos de datos medianos (1M filas)
| 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 |
Con un millón de filas, Polars es consistentemente 1.6 veces más rápido en filtrado y agrupación. En select (elegir un subconjunto de columnas) — 10.9 veces, porque el formato columnar de Arrow permite slicing sin copia (zero-copy).
Conjuntos de datos grandes (10M+ filas)
| 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 |
En datos grandes, la ventaja de Polars crece de forma no lineal: la ejecución paralela en 8 núcleos y el optimizador de consultas producen un efecto acumulativo. GroupBy es 8.6 veces más rápido — la diferencia entre "esperar un segundo" y "esperar 100 milisegundos".
ETL con Datos Reales (NYC Taxi, 12.7M filas)
| 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 |
El I/O de CSV es el resultado más dramático: Polars lee CSV en paralelo con su motor Rust, 25 veces más rápido. Esto es crítico para la carga inicial de datos históricos.
Benchmark Oficial PDS-H (Mayo 2025)
DataFrame library performance race: Polars and DuckDB lead while Pandas trails by orders of magnitude
PDS-H (Performance Data Science — Holistic) es un benchmark estándar para librerías de DataFrame, análogo a TPC-H para bases de datos. Resultados de mayo de 2025:
- Pandas solo participa en la escala SF-10 — de un solo hilo, sin optimizador de consultas, dos órdenes de magnitud más lento que los líderes
- Polars y DuckDB están en su propia liga en SF-10 y SF-100
- El nuevo motor de streaming en Polars ofrece una aceleración adicional de 3-7x en comparación con el modo en memoria — permitiendo procesar datos que no caben en la RAM
Para el algotrading, esto significa: si su pipeline está limitado por memoria al cargar 100M+ filas de datos de ticks, el motor de streaming de Polars le permite procesarlos sin aumentar la RAM.
Cálculos Rolling para Señales de Trading: La Funcionalidad Clave

Este es el benchmark más importante para el algotrading. Una tarea típica: tiene 100 instrumentos, y para cada uno necesita calcular una media rolling, desviación estándar rolling, z-score, y generar una señal basada en ellos. En Pandas esto es groupby().rolling(), en Polars 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")
)
Resultados
| 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 |
Aceleración de 10x a 3500x en cálculos rolling por grupo. Esto no es un error tipográfico. groupby().transform(lambda x: x.rolling().mean()) de Pandas crea un bucle de Python sobre cada grupo, con cada llamada incurriendo en overhead del intérprete. Polars ejecuta todo en Rust, en paralelo a través de los grupos, sin objetos intermedios de Python.
Para un pipeline que necesita calcular 10 indicadores en 100 instrumentos, esto es la diferencia entre 2 minutos y 0.3 segundos.
Indicadores Técnicos: Bandas de Bollinger, Canales de Keltner, TTM Squeeze
Bollinger Bands and Keltner Channels enveloping a price series, with TTM Squeeze zones highlighted
Examinemos el cálculo de indicadores técnicos reales usados en estrategias de trading.
Bandas de Bollinger
Implementación en 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
Implementación en 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"),
])
Canales de Keltner
donde ATR (Average True Range):
TTM Squeeze
TTM Squeeze es un método para identificar la transición del mercado de un estado de squeeze (baja volatilidad) a un estado de expansión. La señal ocurre cuando las Bandas de Bollinger están dentro de los Canales de Keltner:
Benchmark de Indicadores Técnicos (1M filas, un solo 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 |
Una aceleración consistente de ~7x en un solo ticker. Al calcular por grupo (100 tickers), la aceleración crece a cientos de veces debido al overhead de groupby en Pandas.
Una Nota sobre los Paquetes de Indicadores Ya Existentes
Para Pandas existe pandas-ta, una librería con más de 130 indicadores. Para Polars, todavía no hay un equivalente. Esto significa que al usar Polars, tendrá que implementar los indicadores usted mismo. Sin embargo, los bloques de construcción básicos (rolling_mean, rolling_std, ewm_mean, shift, aritmética de columnas) cubren la gran mayoría de los indicadores estándar, y la implementación en Polars suele ser más corta de lo que parece.
Benchmarks de I/O: CSV, Parquet, Base de Datos
Data streams from CSV, Parquet, and database sources: parallel Rust I/O versus single-threaded Python
El pipeline de datos comienza con la carga de datos. El formato de almacenamiento y el método de lectura determinan la velocidad base de todo el 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()
)
Resultados de I/O (10M filas, 6 columnas)
| 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 |
Conclusiones clave:
- CSV: Polars hasta 25 veces más rápido — parseo paralelo en Rust
- Lectura de Parquet: Polars es 2.6 veces más rápido en lectura completa y 4.5 veces con projection pushdown (leyendo solo las columnas necesarias)
- Escritura de Parquet: casi idéntica — ambos usan el backend PyArrow/Arrow
- Lazy scan: Polars puede aplicar el filtro a nivel de row group del archivo Parquet sin cargar los datos en memoria. Esto es imposible con Pandas sin usar manualmente PyArrow
Para el caché Parquet — nuestro formato principal para almacenar timeframes e indicadores precalculados — Polars con evaluación lazy ofrece una integración ideal: cargando solo las columnas y períodos necesarios sin leer el archivo completo en memoria.
Consumo de Memoria y Evaluación Lazy
Eager vs lazy memory patterns: redundant copies in orange versus optimized Arrow columnar layout in cyan
Eager vs Lazy
Pandas funciona solo en modo eager: cada operación se ejecuta de inmediato, y los resultados intermedios se materializan en memoria.
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 admite la evaluación lazy: las consultas se construyen como un grafo, se optimizan y se ejecutan en un único pase:
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()
)
El optimizador de Polars aplica automáticamente:
- Projection pushdown: lee solo 3 columnas en lugar de todas
- Predicate pushdown: aplica el filtro
volume > 1000durante la lectura, sin cargar filas innecesarias - Eliminación de subexpresiones comunes: evita calcular lo mismo dos veces
Consumo de Memoria (10M filas, 6 columnas 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 crea copias intermedias; inplace=True ayuda parcialmente, pero no en todas las operaciones.
Polars usa nativamente el formato columnar de Arrow: los datos se almacenan por columnas, las filas no se duplican, y se usan operaciones sin copia (zero-copy) siempre que sea posible. Para pipelines con múltiples transformaciones, Polars consume de 2 a 6 veces menos memoria.
Motor de Streaming: Datos Más Grandes que la RAM
Para conjuntos de datos que no caben en la RAM, Polars ofrece un motor de 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")
)
El motor de streaming procesa los datos en fragmentos sin cargar todo el conjunto de datos en memoria. Según los datos del benchmark PDS-H, el modo streaming es de 3 a 7 veces más rápido que el modo en memoria en escalas grandes, gracias a una mejor localidad de caché y la ausencia de presión de memoria virtual.
Arquitectura Híbrida: Polars + Numba

Un backtest consiste en dos partes fundamentalmente diferentes:
-
Pipeline de datos — carga, transformación, indicadores, filtrado. Esto es masivamente paralelo, orientado a columnas y perfectamente adecuado para Polars.
-
Simulación de portafolio — ejecución de órdenes, cálculo de PnL, gestión de posiciones. Esto depende de la ruta (path-dependent): cada paso depende del estado anterior. Esto requiere un recorrido elemento por elemento sobre la serie temporal.
Pandas está mal adaptado para ambas partes. Polars brilla en la primera, pero no en la segunda. Para la lógica dependiente de la ruta, la herramienta óptima es Numba (un compilador JIT para Python) o Rust/C++ nativo.
Arquitectura
┌─────────────────────────────────────────────────────┐
│ 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 │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Ejemplo: Pipeline Completo
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)
¿Por Qué No vectorbt?
vectorbt es un framework de backtesting popular que procesa 1M de órdenes en 70-100ms. Está construido sobre Pandas + NumPy + Numba. El problema: Pandas es el cuello de botella en el pipeline de datos — lento, de un solo hilo, hambriento de memoria. vectorbt tiene que sortear las limitaciones de Pandas mediante Numba para las partes críticas, pero la carga de datos y el cálculo de indicadores siguen pasando por Pandas.
La arquitectura híbrida Polars + Numba toma lo mejor de ambos mundos:
- Polars para el pipeline de datos — de 5 a 350 veces más rápido que Pandas en las mismas operaciones
- Numba para la simulación de portafolio — la misma velocidad que en vectorbt
- Sin capa intermedia de Pandas — los datos fluyen de Arrow directamente a NumPy vía zero-copy
Migración: Patrones Clave de Pandas a Polars
Bridge between legacy and modern code: translating Pandas patterns to Polars expressions
Si su pipeline está escrito en Pandas, la migración no requiere reescribir desde cero. Los patrones principales se traducen mediante plantillas.
Lectura de Datos
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()
Filtrado
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")
)
Creación de Columnas
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 + Agregación
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 por Grupo
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")
)
Integración con QuestDB
Polars trabaja de forma nativa con Apache Arrow — el mismo formato que QuestDB usa para la transferencia de datos. Esto significa zero-copy al recibir resultados de consultas:
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
Para más información sobre cómo trabajar con QuestDB para el almacenamiento y análisis de datos de trading, consulte nuestra serie de artículos sobre arquitectura de datos.
Integración con el Caché Parquet
Columnar Parquet cache with predicate pushdown and projection pushdown for selective data loading
En el artículo Caché Parquet Agregado, describimos cómo precalcular timeframes e indicadores una vez y guardarlos en un archivo Parquet. Polars hace este enfoque aún más eficiente:
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,
)
Durante una optimización masiva — cuando se necesita ejecutar miles de combinaciones de parámetros — leer desde el caché Parquet vía scan_parquet de Polars con predicate pushdown permite cargar solo los períodos y columnas necesarios sin leer el archivo completo.
Integración con Drill-down adaptativo: la evaluación lazy de Polars es perfectamente adecuada para la carga en dos niveles — datos gruesos para el pase principal, datos detallados (segundos, milisegundos) solo para zonas de ambigüedad de llenado.
Cuándo Usar Qué: Recomendaciones Prácticas
Decision matrix: diverging paths for small-scale prototyping versus large-scale production pipelines
Pandas está justificado si:
- Conjunto de datos de hasta 1M de filas y no está haciendo GroupBy sobre cientos de grupos — la diferencia entre Pandas 2.2 y Polars suele ser insignificante (1.5-2x)
- Necesita
pandas-tau otras librerías con una API de Pandas — reescribir 130 indicadores es poco práctico para un estudio puntual - Prototipado — la API de Pandas es más familiar para la mayoría, y la velocidad no es crítica para probar hipótesis rápidamente
- Integración con código legacy — un pipeline de Pandas existente que funciona y no necesita optimización
Polars es necesario si:
- Conjunto de datos desde 10M de filas — decenas y cientos de millones de filas de datos de ticks, cachés multi-timeframe
- Rolling por grupo — 100+ instrumentos, indicadores para cada uno: aceleración de 100-3500x
- Pipeline de ETL — carga, limpieza, transformación de grandes volúmenes de datos
- RAM limitada — la evaluación lazy y el motor de streaming permiten procesar datos que no caben en memoria
- Stack Parquet/QuestDB — Arrow nativo = zero-copy, predicate pushdown, projection pushdown
Qué No Esperar
La cifra de marketing "30 veces más rápido" es la aceleración máxima en operaciones específicas. La aceleración realista en operaciones típicas de pipeline es: 2-10x. En rolling por grupo, significativamente más. En conjuntos de datos pequeños, a veces Polars incluso es más lento debido al overhead.
Nuestra Experiencia en marketmaker.cc
Production metrics: 6-8x pipeline speedup and 8x more optimization iterations per hour
En marketmaker.cc, usamos una arquitectura híbrida Polars + Numba para el motor de backtesting. Todo el pipeline de datos — carga desde el caché Parquet, cálculo de indicadores, filtrado, feature engineering — se ejecuta en Polars. La simulación de portafolio se ejecuta en Numba.
El cambio de Pandas a Polars en el pipeline de datos produjo una aceleración de 6-8x en nuestros conjuntos de datos típicos (50-100M filas, 200+ instrumentos). El cálculo de indicadores rolling por grupo pasó de minutos a cientos de milisegundos. Esto nos permitió aumentar el número de iteraciones de optimización de ~500 a ~4000 por hora sin cambiar el hardware.
Un punto clave: no migramos todo el código en un solo día. Primero movimos el I/O (lectura de Parquet), luego el cálculo de indicadores, luego el filtrado y el feature engineering. Pandas permaneció solo en la interfaz con componentes legacy que esperan un pd.DataFrame. La conversión df.to_pandas() / pl.from_pandas() toma milisegundos y no es un cuello de botella.
Las métricas calculadas durante la etapa de backtest — incluyendo PnL por Tiempo Activo — ya se calculan sobre DataFrames de Polars, lo que simplifica el pipeline y elimina conversiones intermedias.
Conclusión
Three technology streams converging: Polars, Numba, and Arrow uniting into a single optimized pipeline
Polars no es un reemplazo de Pandas en todos los escenarios. Es una herramienta de otra clase que brilla en las escalas típicas del algotrading serio: millones y cientos de millones de filas, decenas y cientos de instrumentos, optimización continua de parámetros.
Números clave:
- Operaciones básicas: aceleración de 2-10x en tareas típicas de pipeline
- Rolling por grupo: 10-3500x — la principal funcionalidad clave para pipelines de trading
- I/O de CSV: hasta 25x — crítico para la carga inicial de datos
- Memoria: ahorro de 2-6x gracias a Arrow y la evaluación lazy
- Streaming: procesamiento de datos que no caben en la RAM
Arquitectura recomendada para un motor de backtesting en producción:
- Polars — todo el pipeline de datos: carga, indicadores, filtrado, features
- Numba/Rust — simulación de portafolio: lógica de órdenes y posiciones dependiente de la ruta
- Arrow — el formato de datos en todas las conexiones: Parquet, QuestDB, Polars, NumPy
Sin capa intermedia de Pandas. Los datos fluyen del almacenamiento a través de Polars hacia arrays de NumPy y luego al motor Numba, sin copias innecesarias, sin GIL, sin cuellos de botella de un solo hilo.
Enlaces Útiles
- 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
Cita
@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.