← К списку статей
March 13, 2026
5 мин. чтения

Polars vs Pandas для алготрейдинга: бенчмарки на реальных данных

Polars vs Pandas для алготрейдинга: бенчмарки на реальных данных
#алготрейдинг
#Polars
#Pandas
#бенчмарки
#производительность
#data engineering

Серия «Бэктесты без иллюзий», статья 9

Бэктест стратегии — это не только логика сигналов и симуляция исполнения. Это ещё и data pipeline: загрузка миллионов свечей, пересчёт таймфреймов, вычисление индикаторов, фильтрация по условиям, группировка по инструментам. Когда pipeline работает 30 секунд вместо 3 — это не просто неудобство. Это в 10 раз меньше экспериментов за час, в 10 раз медленнее итерации, в 10 раз дольше путь от идеи до продакшена.

Pandas — де-факто стандарт для работы с табличными данными в Python. Но Pandas проектировался в 2008 году, когда ядра процессоров были медленнее, а датасеты — меньше. Pandas однопоточный, жадный до памяти и лишён оптимизатора запросов. Polars — библиотека нового поколения, написанная на Rust, с параллельным выполнением, Apache Arrow в основе и ленивым планировщиком запросов.

Вопрос: насколько Polars быстрее на реальных задачах алготрейдинга? Не на синтетических бенчмарках из README, а на фильтрации тиков, rolling-расчёте индикаторов, группировке по инструментам и загрузке из Parquet/QuestDB?

В этой статье — систематические бенчмарки с числами, кодом и практическими рекомендациями.

Методология бенчмарков

Среда для бенчмарков Футуристическая лаборатория точных измерений: контролируемая среда для воспроизводимых бенчмарков

Прежде чем сравнивать — определим правила, чтобы результаты были воспроизводимыми и честными.

Окружение

  • Python 3.11, Pandas 2.2, Polars 1.x (последняя стабильная версия)
  • Машина: 8 ядер, 32 GB RAM, SSD NVMe
  • Каждый бенчмарк выполняется 100 раз, берётся медиана
  • Прогрев (warmup): 5 итераций до замеров
  • GC отключён во время замера (gc.disable())

Данные

Три уровня масштаба:

  • Small: 10K строк (один инструмент, один день, минутные свечи)
  • Medium: 1M строк (один инструмент, ~2 года, минутные свечи)
  • Large: 10M+ строк (100 инструментов, 2 года, минутные свечи)

Дополнительно: реальный датасет NYC Taxi (12.7M строк) для ETL-бенчмарков — стандартный индустриальный benchmark.

Что измеряем

import timeit, gc

def bench(fn, n=100, warmup=5):
    """Честный бенчмарк: прогрев + медиана n запусков."""
    for _ in range(warmup):
        fn()
    gc.disable()
    times = timeit.repeat(fn, number=1, repeat=n)
    gc.enable()
    return {
        "median_ms": sorted(times)[n // 2] * 1000,
        "p95_ms": sorted(times)[int(n * 0.95)] * 1000,
    }

Бенчмарки по операциям: таблицы

Сравнение операций Сравнение производительности операций: filter, groupby, join и select на разных масштабах данных

Small datasets (10K строк)

Операция 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 строк Pandas иногда быстрее на простых фильтрах — overhead вызова Polars-функции через PyO3 сопоставим со временем самой операции. Но на join уже видно преимущество: хэш-таблица Polars на Rust работает в 13 раз быстрее.

Medium datasets (1M строк)

Операция 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

На миллионе строк Polars стабильно быстрее в 1.6x на фильтрации и группировке. На select (выбор подмножества столбцов) — в 10.9x, потому что Arrow columnar format позволяет zero-copy slice.

Large datasets (10M+ строк)

Операция 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

На больших данных преимущество Polars растёт нелинейно: параллельное выполнение на 8 ядрах и оптимизатор запросов дают кумулятивный эффект. GroupBy ускоряется в 8.6x — это разница между «ждать секунду» и «ждать 100 миллисекунд».

ETL на реальных данных (NYC Taxi, 12.7M строк)

Операция Pandas (s) Polars (s) Speedup
Загрузка CSV 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 — самый драматичный результат: Polars читает CSV параллельно на Rust-движке, 25x быстрее. Это критично для первичной загрузки исторических данных.

Официальный PDS-H benchmark (май 2025)

Рейтинг PDS-H бенчмарка Гонка DataFrame-библиотек: Polars и DuckDB лидируют, Pandas отстаёт на порядки

PDS-H (Performance Data Science — Holistic) — стандартный benchmark для DataFrame-библиотек, аналог TPC-H для баз данных. Результаты мая 2025:

  • Pandas участвует только на масштабе SF-10 — однопоточный, без оптимизатора запросов, на два порядка медленнее лидеров
  • Polars и DuckDB — в своей лиге на SF-10 и SF-100
  • Новый streaming engine Polars даёт дополнительное ускорение 3-7x по сравнению с in-memory режимом — можно обрабатывать данные, которые не помещаются в RAM

Для алготрейдинга это означает: если ваш pipeline упирается в память при загрузке 100M+ строк тиковых данных — Polars streaming engine позволяет обработать их без увеличения RAM.

Rolling-расчёты для торговых сигналов: killer feature

Polars vs Pandas rolling speedup comparison

Это самый важный бенчмарк для алготрейдинга. Типичная задача: у вас 100 инструментов, для каждого нужно рассчитать rolling mean, rolling std, z-score, и на их основе сгенерировать сигнал. В Pandas это — groupby().rolling(), в Polars — group_by().agg(col().rolling_mean()).

Pandas: groupby + rolling

import pandas as pd
import numpy as np

df_pd = pd.DataFrame({
    "ticker": np.repeat([f"TICKER_{i}" for i in range(100)], 100_000),
    "close": np.random.randn(10_000_000).cumsum() + 100,
    "volume": np.random.randint(100, 10000, 10_000_000),
})

def pandas_rolling_signals(df):
    grouped = df.groupby("ticker")["close"]
    df["ma_20"] = grouped.transform(lambda x: x.rolling(20).mean())
    df["std_20"] = grouped.transform(lambda x: x.rolling(20).std())
    df["zscore"] = (df["close"] - df["ma_20"]) / df["std_20"]
    return df

Polars: group_by + 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")
    )

Результаты

Операция Pandas (ms) Polars (ms) Speedup
Rolling mean, 100 групп x 100K строк 4200 12 350x
Rolling std, 100 групп x 100K строк 5100 15 340x
Z-score (mean + std + arithmetic) 12500 35 357x
Rolling mean, 1000 групп x 10K строк 38000 11 3454x

От 10x до 3500x ускорение на rolling-расчётах по группам. Это не опечатка. Pandas groupby().transform(lambda x: x.rolling().mean()) создаёт Python-цикл по каждой группе, каждый вызов — overhead интерпретатора. Polars выполняет всё на Rust, параллельно по группам, без промежуточных Python-объектов.

Для pipeline, где нужно рассчитать 10 индикаторов по 100 инструментам — это разница между 2 минутами и 0.3 секунды.

Технические индикаторы: Bollinger Bands, Keltner Channels, TTM Squeeze

Визуализация технических индикаторов Bollinger Bands и Keltner Channels вокруг ценового ряда, зоны TTM Squeeze подсвечены

Рассмотрим вычисление реальных технических индикаторов, используемых в торговых стратегиях.

Bollinger Bands

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)

Pandas-реализация

def bollinger_pandas(df, period=20, k=2.0):
    df["bb_mid"] = df["close"].rolling(period).mean()
    df["bb_std"] = df["close"].rolling(period).std()
    df["bb_upper"] = df["bb_mid"] + k * df["bb_std"]
    df["bb_lower"] = df["bb_mid"] - k * df["bb_std"]
    return df

Polars-реализация

def bollinger_polars(df, period=20, k=2.0):
    return df.with_columns([
        pl.col("close").rolling_mean(window_size=period).alias("bb_mid"),
        pl.col("close").rolling_std(window_size=period).alias("bb_std"),
    ]).with_columns([
        (pl.col("bb_mid") + k * pl.col("bb_std")).alias("bb_upper"),
        (pl.col("bb_mid") - k * pl.col("bb_std")).alias("bb_lower"),
    ])

Keltner Channels

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)

где 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 — это метод определения перехода рынка из состояния сжатия (низкая волатильность) в состояние расширения. Сигнал возникает, когда Bollinger Bands находятся внутри Keltner Channels:

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

Бенчмарк технических индикаторов (1M строк, один тикер)

Индикатор 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

Стабильное ускорение ~7x на одном тикере. При расчёте по группам (100 тикеров) — ускорение возрастает до сотен раз из-за overhead Pandas groupby.

Нюанс: готовые пакеты индикаторов

Для Pandas существует pandas-ta — библиотека с 130+ индикаторами. Для Polars аналогичного пакета пока нет. Это означает, что при использовании Polars индикаторы придётся реализовывать самостоятельно. Однако базовые блоки (rolling_mean, rolling_std, ewm_mean, shift, арифметика столбцов) покрывают подавляющее большинство стандартных индикаторов, и реализация на Polars обычно короче, чем кажется.

I/O бенчмарки: CSV, Parquet, база данных

Визуализация I/O пайплайнов Потоки данных из CSV, Parquet и баз данных: параллельный Rust I/O против однопоточного Python

Data pipeline начинается с загрузки данных. Формат хранения и способ чтения определяют baseline скорости всего 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 (10M строк, 6 столбцов)

Операция 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

Ключевые выводы:

  1. CSV: Polars до 25x быстрее — параллельный парсинг на Rust
  2. Parquet read: Polars быстрее в 2.6x на полном чтении и в 4.5x при projection pushdown (чтение только нужных столбцов)
  3. Parquet write: почти одинаково — оба используют PyArrow/Arrow backend
  4. Lazy scan: Polars может применить фильтр на уровне row groups Parquet-файла, не загружая данные в память. Для Pandas это невозможно без ручного использования PyArrow

Для Parquet-кэша — нашего основного формата хранения предвычисленных таймфреймов и индикаторов — Polars с lazy evaluation даёт идеальную интеграцию: загрузка только нужных столбцов и периодов без полного чтения файла в память.

Потребление памяти и lazy evaluation

Потребление памяти и lazy evaluation Eager vs lazy: избыточные копии данных в оранжевом против оптимизированного колоночного Arrow-формата в голубом

Eager vs Lazy

Pandas работает только в eager-режиме: каждая операция выполняется немедленно, промежуточные результаты материализуются в памяти.

df = pd.read_csv("big_file.csv")           # весь файл в RAM
df = df[df["volume"] > 1000]                # копия с фильтром
df = df[["timestamp", "close", "volume"]]   # ещё одна копия
df["returns"] = df["close"].pct_change()    # ещё одна копия

Polars поддерживает lazy evaluation — запросы строятся как граф, оптимизируются и выполняются за один проход:

result = (
    pl.scan_csv("big_file.csv")
    .filter(pl.col("volume") > 1000)
    .select(["timestamp", "close", "volume"])
    .with_columns(
        pl.col("close").pct_change().alias("returns")
    )
    .collect()
)

Оптимизатор Polars автоматически:

  • Projection pushdown: читает только 3 столбца вместо всех
  • Predicate pushdown: применяет фильтр volume > 1000 при чтении, не загружая ненужные строки
  • Common subexpression elimination: не вычисляет одно и то же дважды

Потребление памяти (10M строк, 6 столбцов float64)

Сценарий Pandas (GB) Polars eager (GB) Polars lazy (GB)
Загрузка CSV 0.92 0.46 0.46
Filter + Select 3 columns 1.38* 0.22 0.22
Pipeline из 5 трансформаций 2.76* 0.48 0.48
Загрузка Parquet (3 of 6 cols) 0.46 0.23 0.23

* Pandas создаёт промежуточные копии; inplace=True помогает частично, но не для всех операций.

Polars нативно использует Arrow columnar format: данные хранятся по столбцам, строки не дублируются, zero-copy операции там, где возможно. Для pipeline с несколькими трансформациями Polars потребляет в 2-6 раз меньше памяти.

Streaming engine: данные больше RAM

Для датасетов, которые не помещаются в оперативную память, Polars предлагает 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")
)

Streaming engine обрабатывает данные чанками, не загружая весь датасет в память. По данным PDS-H benchmark, streaming mode в 3-7x быстрее in-memory на больших масштабах — за счёт лучшей cache locality и отсутствия давления на виртуальную память.

Гибридная архитектура: Polars + Numba

Hybrid Polars + Numba architecture data flow

Бэктест состоит из двух принципиально разных частей:

  1. Data pipeline — загрузка, трансформация, индикаторы, фильтрация. Это massively parallel, column-oriented, идеально ложится на Polars.

  2. Portfolio simulation — заполнение ордеров, расчёт PnL, управление позициями. Это path-dependent: каждый шаг зависит от предыдущего состояния. Здесь нужен поэлементный проход по временному ряду.

Pandas плохо подходит для обеих частей. Polars отлично подходит для первой, но не для второй. Для path-dependent логики оптимальный инструмент — Numba (JIT-компилятор для Python) или нативный Rust/C++.

Архитектура

┌─────────────────────────────────────────────────────┐
│                   Data Pipeline                      │
│                                                      │
│  Parquet/QuestDB  ──→  Polars LazyFrame             │
│       │                     │                        │
│       │              ┌──────┴──────┐                 │
│       │              │ Indicators  │                 │
│       │              │ Filters     │                 │
│       │              │ Features    │                 │
│       │              └──────┬──────┘                 │
│       │                     │                        │
│       │              NumPy arrays                    │
│       │              (zero-copy из 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                                │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

Пример: полный 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 из Arrow
signals = df["signal"].to_numpy()  # zero-copy из Arrow

@njit
def simulate_strategy(prices, signals, threshold=1.5, stop_loss=0.02):
    """
    Path-dependent симуляция: Numba компилирует в machine code.
    1M итераций за 70-100ms.
    """
    n = len(prices)
    equity = np.empty(n)
    equity[0] = 1.0
    position = 0.0
    entry_price = 0.0

    for i in range(1, n):
        if position != 0.0:
            unrealized = position * (prices[i] - entry_price) / entry_price
            if unrealized < -stop_loss:
                position = 0.0

        if position == 0.0:
            if signals[i] > threshold:
                position = 1.0
                entry_price = prices[i]
            elif signals[i] < -threshold:
                position = -1.0
                entry_price = prices[i]

        ret = (prices[i] - prices[i - 1]) / prices[i - 1]
        equity[i] = equity[i - 1] * (1.0 + position * ret)

    return equity

equity = simulate_strategy(prices, signals)

Почему не vectorbt?

vectorbt — популярный фреймворк для бэктестов, обрабатывающий 1M ордеров за 70-100ms. Он построен на Pandas + NumPy + Numba. Проблема: Pandas является узким местом в data pipeline — медленный, однопоточный, жадный до памяти. vectorbt вынужден обходить ограничения Pandas через Numba для критичных частей, но загрузка данных и расчёт индикаторов всё равно идут через Pandas.

Гибридная архитектура Polars + Numba берёт лучшее из обоих миров:

  • Polars для data pipeline — в 5-350x быстрее Pandas на тех же операциях
  • Numba для portfolio simulation — та же скорость, что и в vectorbt
  • Нет промежуточного Pandas-слоя — данные идут из Arrow напрямую в NumPy через zero-copy

Миграция: ключевые паттерны из Pandas в Polars

Миграция из Pandas в Polars Мост между legacy- и современным кодом: трансляция паттернов Pandas в выражения Polars

Если ваш pipeline написан на Pandas, миграция не требует переписывания с нуля. Основные паттерны переносятся по шаблонам.

Чтение данных

df = pd.read_parquet("data.parquet")
df = pd.read_csv("data.csv", parse_dates=["timestamp"])

df = pl.read_parquet("data.parquet")
df = pl.read_csv("data.csv", try_parse_dates=True)

df = pl.scan_parquet("data.parquet")  # ничего не читает до .collect()

Фильтрация

df_filtered = df[df["volume"] > 1000]
df_filtered = df[(df["close"] > 100) & (df["exchange"] == "binance")]

df_filtered = df.filter(pl.col("volume") > 1000)
df_filtered = df.filter(
    (pl.col("close") > 100) & (pl.col("exchange") == "binance")
)

Создание столбцов

df["returns"] = df["close"].pct_change()
df["log_returns"] = np.log(df["close"] / df["close"].shift(1))

df = df.with_columns([
    pl.col("close").pct_change().alias("returns"),
    (pl.col("close") / pl.col("close").shift(1)).log().alias("log_returns"),
])

GroupBy + 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 по группам

df["ma_20"] = df.groupby("ticker")["close"].transform(
    lambda x: x.rolling(20).mean()
)

df = df.with_columns(
    pl.col("close")
        .rolling_mean(window_size=20)
        .over("ticker")
        .alias("ma_20")
)

Интеграция с QuestDB

Polars нативно работает с Apache Arrow — тем же форматом, который использует QuestDB для передачи данных. Это означает zero-copy при получении результатов запроса:

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()  # копирование + конвертация типов

Подробнее о работе с QuestDB для хранения и анализа торговых данных — в нашей серии статей по архитектуре данных.

Интеграция с Parquet-кэшем

Архитектура Parquet-кэша Колоночный Parquet-кэш с predicate pushdown и projection pushdown для выборочной загрузки данных

В статье Агрегированный Parquet-кэш мы описали, как предвычислить таймфреймы и индикаторы один раз и сохранить в Parquet-файл. Polars делает этот подход ещё эффективнее:

cache = (
    pl.scan_parquet("raw_candles_1m.parquet")
    .with_columns([
        pl.col("close")
            .rolling_mean(window_size=60)
            .alias("ma_1h"),
        pl.col("close")
            .rolling_mean(window_size=240)
            .alias("ma_4h"),
        pl.col("close")
            .rolling_mean(window_size=20)
            .alias("bb_mid"),
        pl.col("close")
            .rolling_std(window_size=20)
            .alias("bb_std"),
    ])
    .with_columns([
        (pl.col("bb_mid") + 2.0 * pl.col("bb_std")).alias("bb_upper"),
        (pl.col("bb_mid") - 2.0 * pl.col("bb_std")).alias("bb_lower"),
    ])
    .collect()
)

cache.write_parquet(
    "cache_ETHUSDT_2024_2026.parquet",
    compression="zstd",
    compression_level=3,
)

При массовой оптимизации — когда нужно прогнать тысячи комбинаций параметров — чтение из Parquet-кэша через Polars scan_parquet с predicate pushdown позволяет загружать только нужные периоды и столбцы, не читая весь файл.

Связка с Adaptive drill-down: Polars lazy evaluation идеально подходит для двухуровневой загрузки — грубые данные для основного прохода, детальные данные (секунды, миллисекунды) только для fill-ambiguity зон.

Когда что использовать: практические рекомендации

Руководство по выбору Pandas vs Polars Матрица решений: разные пути для небольших прототипов и масштабных продакшен-пайплайнов

Pandas оправдан, если:

  • Датасет до 1M строк и вы не делаете GroupBy по сотням групп — разница между Pandas 2.2 и Polars часто незначительна (1.5-2x)
  • Вам нужен pandas-ta или другие библиотеки с Pandas API — переписывание 130 индикаторов нецелесообразно для одноразового исследования
  • Прототипирование — Pandas API знакомее большинству, и для быстрой проверки гипотезы скорость не критична
  • Интеграция с legacy-кодом — существующий pipeline на Pandas работает и не требует оптимизации

Polars необходим, если:

  • Датасет от 10M строк — десятки и сотни миллионов строк тиковых данных, мультитаймфрейм-кэши
  • Rolling по группам — 100+ инструментов, индикаторы по каждому: ускорение 100-3500x
  • ETL pipeline — загрузка, очистка, трансформация больших объёмов данных
  • Ограниченная RAM — lazy evaluation и streaming engine позволяют обрабатывать данные, не помещающиеся в память
  • Parquet/QuestDB стек — нативный Arrow = zero-copy, predicate pushdown, projection pushdown

Чего не стоит ожидать

Маркетинговая цифра «30x быстрее» — это пиковое ускорение на специфических операциях. Реалистичное ускорение на типичных pipeline-операциях: 2-10x. На rolling по группам — значительно больше. На мелких датасетах — иногда Polars даже медленнее из-за overhead.

Наш опыт в marketmaker.cc

Дашборд продакшен-производительности Продакшен-метрики: ускорение пайплайна в 6-8x и увеличение итераций оптимизации в 8 раз за час

В marketmaker.cc мы используем гибридную архитектуру Polars + Numba для бэктест-движка. Весь data pipeline — загрузка из Parquet-кэша, расчёт индикаторов, фильтрация, feature engineering — работает на Polars. Portfolio simulation — на Numba.

Переход с Pandas на Polars в data pipeline дал ускорение pipeline подготовки данных в 6-8x на наших типичных датасетах (50-100M строк, 200+ инструментов). Расчёт rolling-индикаторов по группам — от минут до сотен миллисекунд. Это позволило увеличить количество итераций оптимизации с ~500 до ~4000 в час без изменения железа.

Ключевой момент: мы не мигрировали весь код за один день. Сначала перевели I/O (чтение Parquet), потом — расчёт индикаторов, потом — фильтрацию и feature engineering. Pandas остался только в интерфейсе с legacy-компонентами, которые ожидают pd.DataFrame. Конвертация df.to_pandas() / pl.from_pandas() занимает миллисекунды и не является узким местом.

Метрики, рассчитанные на этапе бэктеста — включая PnL по активному времени — вычисляются уже на Polars DataFrame, что упрощает pipeline и избавляет от промежуточных конвертаций.

Заключение

Конвергенция архитектуры Три технологических потока сливаются воедино: Polars, Numba и Arrow формируют единый оптимизированный пайплайн

Polars — не замена Pandas в каждом сценарии. Это инструмент другого класса, который раскрывается на масштабах, типичных для серьёзного алготрейдинга: миллионы и сотни миллионов строк, десятки и сотни инструментов, непрерывная оптимизация параметров.

Ключевые числа:

  • Базовые операции: 2-10x ускорение на типичных pipeline-задачах
  • Rolling по группам: 10-3500x — главный killer feature для торговых pipeline
  • CSV I/O: до 25x — критично для первичной загрузки данных
  • Память: 2-6x экономия за счёт Arrow и lazy evaluation
  • Streaming: обработка данных, не помещающихся в RAM

Рекомендуемая архитектура для production-бэктест-движка:

  1. Polars — весь data pipeline: загрузка, индикаторы, фильтрация, features
  2. Numba/Rust — portfolio simulation: path-dependent логика ордеров и позиций
  3. Arrow — формат данных на всех стыках: Parquet, QuestDB, Polars, NumPy

Никакого промежуточного Pandas-слоя. Данные текут из хранилища через Polars в NumPy-массивы и далее в Numba-движок — без лишних копирований, без GIL, без однопоточных bottleneck.


Полезные ссылки

  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

Цитирование

@article{soloviov2026polarsvspandas,
  author = {Soloviov, Eugen},
  title = {Polars vs Pandas для алготрейдинга: бенчмарки на реальных данных},
  year = {2026},
  url = {https://marketmaker.cc/ru/blog/post/polars-vs-pandas-algotrading},
  description = {Детальное сравнение Polars и Pandas на задачах алготрейдинга: бенчмарки фильтрации, агрегации, rolling-расчётов сигналов, I/O, потребления памяти. Гибридная архитектура Polars + Numba для максимальной производительности бэктеста.}
}
Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

MarketMaker.cc Team

Количественные исследования и стратегии

Обсудить в Telegram
Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.