← Volver a los artículos
March 13, 2026
5 min de lectura

Polars vs Pandas para Algotrading: Benchmarks con Datos Reales

Polars vs Pandas para Algotrading: Benchmarks con Datos Reales
#algotrading
#Polars
#Pandas
#benchmarks
#performance
#data engineering

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

Benchmark methodology setup 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

Operation benchmarks comparison 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)

PDS-H benchmark leaderboard 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

Polars vs Pandas rolling speedup comparison

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

Technical indicators visualization 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

Upper=SMA(close,n)+kσ(close,n)\text{Upper} = \text{SMA}(close, n) + k \cdot \sigma(close, n) Lower=SMA(close,n)kσ(close,n)\text{Lower} = \text{SMA}(close, n) - k \cdot \sigma(close, n)

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

Upper=EMA(close,n)+kATR(n)\text{Upper} = \text{EMA}(close, n) + k \cdot \text{ATR}(n) Lower=EMA(close,n)kATR(n)\text{Lower} = \text{EMA}(close, n) - k \cdot \text{ATR}(n)

donde ATR (Average True Range):

TR=max(highlow,  highcloseprev,  lowcloseprev)\text{TR} = \max(high - low, \; |high - close_{prev}|, \; |low - close_{prev}|)

ATR(n)=EMA(TR,n)\text{ATR}(n) = \text{EMA}(\text{TR}, n)

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:

squeeze=BBlower>KClower    BBupper<KCupper\text{squeeze} = \text{BB}_{lower} > \text{KC}_{lower} \;\land\; \text{BB}_{upper} < \text{KC}_{upper}

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 I/O pipeline visualization 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:

  1. CSV: Polars hasta 25 veces más rápido — parseo paralelo en Rust
  2. 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)
  3. Escritura de Parquet: casi idéntica — ambos usan el backend PyArrow/Arrow
  4. 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

Memory consumption and lazy evaluation 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 > 1000 durante 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

Hybrid Polars + Numba architecture data flow

Un backtest consiste en dos partes fundamentalmente diferentes:

  1. Pipeline de datos — carga, transformación, indicadores, filtrado. Esto es masivamente paralelo, orientado a columnas y perfectamente adecuado para Polars.

  2. 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

Migration from Pandas to 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

Parquet cache architecture 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 guide for choosing Pandas vs Polars 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-ta u 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 performance dashboard 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

Architecture convergence 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:

  1. Polars — todo el pipeline de datos: carga, indicadores, filtrado, features
  2. Numba/Rust — simulación de portafolio: lógica de órdenes y posiciones dependiente de la ruta
  3. 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

  1. Polars — User Guide
  2. Polars vs Pandas — official benchmark
  3. PDS-H Benchmark — DataFrame libraries comparison
  4. Apache Arrow — columnar format specification
  5. Numba — JIT compiler for Python
  6. vectorbt — backtesting framework
  7. pandas-ta — Technical Analysis Indicators
  8. Ritchie Vink — I wrote one of the fastest DataFrame libraries (Polars origin)
  9. Towards Data Science — Polars vs Pandas: real-world benchmarks
  10. 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.}
}
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Mantente a la vanguardia

Suscríbete a nuestro boletín para recibir información exclusiva sobre trading con IA, análisis de mercado y actualizaciones de la plataforma.

Respetamos tu privacidad. Puedes darte de baja en cualquier momento.