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

Детальное сравнение Polars и Pandas на задачах алготрейдинга: бенчмарки фильтрации, агрегации, rolling-расчётов сигналов, I/O, потребления памяти. Гибридная архитектура Polars + Numba для максимальной производительности бэктеста.

Серия «Бэктесты без иллюзий», статья 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
Filter0.180.320.56x
GroupBy1.20.751.6x
Join5.50.413.75x
Select0.50.22.5x

На 10K строк Pandas иногда быстрее на простых фильтрах — overhead вызова Polars-функции через PyO3 сопоставим со временем самой операции. Но на join уже видно преимущество: хэш-таблица Polars на Rust работает в 13 раз быстрее.

Medium datasets (1M строк)

ОперацияPandas (ms)Polars (ms)Speedup
Filter12.47.81.6x
GroupBy45.228.61.6x
Join89.014.36.2x
Select21.82.010.9x

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

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

ОперацияPandas (ms)Polars (ms)Speedup
Filter185503.7x
GroupBy8601008.6x
Join145012012.1x
Select240406.0x

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

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

ОперацияPandas (s)Polars (s)Speedup
Загрузка CSV28.51.1425.0x
Filter + GroupBy + Agg3.80.429.0x
Multi-column transform2.10.73.0x
Full ETL pipeline34.42.2615.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

# 10M строк: 100 тикеров x 100K баров
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),
})

# Rolling z-score по группам
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),
})

# Rolling z-score по группам — один expression chain
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 строк420012350x
Rolling std, 100 групп x 100K строк510015340x
Z-score (mean + std + arithmetic)1250035357x
Rolling mean, 1000 групп x 10K строк38000113454x

От 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)+k⋅ATR(n)\text{Upper} = \text{EMA}(close, n) + k \cdot \text{ATR}(n) Lower=EMA(close,n)−k⋅ATR(n)\text{Lower} = \text{EMA}(close, n) - k \cdot \text{ATR}(n)

где ATR (Average True Range):

TR=max⁡(high−low,  ∣high−closeprev∣,  ∣low−closeprev∣)\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.41.27.0x
Keltner Channels (20, 1.5)14.22.16.8x
TTM Squeeze (full)28.64.17.0x
RSI (14)6.81.16.2x
MACD (12, 26, 9)5.20.86.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

# Pandas
df_pd = pd.read_csv("candles_10m.csv")

# Polars
df_pl = pl.read_csv("candles_10m.csv")

# Polars lazy — читает только нужные столбцы
df_pl_lazy = (
    pl.scan_csv("candles_10m.csv")
    .select(["timestamp", "close", "volume"])
    .filter(pl.col("volume") > 1000)
    .collect()
)

Parquet

# Pandas (через PyArrow)
df_pd = pd.read_parquet("candles_10m.parquet")

# Polars (нативный Arrow)
df_pl = pl.read_parquet("candles_10m.parquet")

# Polars lazy — predicate pushdown + projection pushdown
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 read28.51.1425.0x
CSV write42.02.815.0x
Parquet read (all columns)0.820.312.6x
Parquet read (3 of 6 columns)0.540.124.5x
Parquet write0.950.911.04x
Parquet lazy (filter + select)N/A0.08predicate 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-режиме: каждая операция выполняется немедленно, промежуточные результаты материализуются в памяти.

# Pandas: каждая строка создаёт новый DataFrame в памяти
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 — запросы строятся как граф, оптимизируются и выполняются за один проход:

# Polars lazy: один проход, минимальное потребление памяти
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)
Загрузка CSV0.920.460.46
Filter + Select 3 columns1.38*0.220.22
Pipeline из 5 трансформаций2.76*0.480.48
Загрузка Parquet (3 of 6 cols)0.460.230.23

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

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

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

Для датасетов, которые не помещаются в оперативную память, Polars предлагает streaming engine:

# Обработка 100GB данных на машине с 16GB RAM
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

# ── Шаг 1: Data pipeline на Polars ──────────────────
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()
)

# ── Шаг 2: Zero-copy transfer в NumPy ──────────────
prices = df["close"].to_numpy()    # zero-copy из Arrow
signals = df["signal"].to_numpy()  # zero-copy из Arrow

# ── Шаг 3: Portfolio simulation на Numba ────────────
@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]

        # PnL
        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, миграция не требует переписывания с нуля. Основные паттерны переносятся по шаблонам.

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

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

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

# Polars lazy (рекомендуется)
df = pl.scan_parquet("data.parquet")  # ничего не читает до .collect()

Фильтрация

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

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

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

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

# Polars
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

# Pandas
result = df.groupby("ticker").agg(
    avg_close=("close", "mean"),
    total_volume=("volume", "sum"),
    trade_count=("close", "count"),
)

# Polars
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 по группам

# Pandas (медленно: Python-цикл по группам)
df["ma_20"] = df.groupby("ticker")["close"].transform(
    lambda x: x.rolling(20).mean()
)

# Polars (быстро: параллельно на Rust)
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

# Чтение из QuestDB → Arrow → Polars (zero-copy)
arrow_table = questdb_connection.query_arrow(
    "SELECT * FROM candles WHERE ticker = 'ETHUSDT'"
)
df = pl.from_arrow(arrow_table)  # zero-copy!

# Vs Pandas: Arrow → Pandas (copy + type conversion)
df_pd = arrow_table.to_pandas()  # копирование + конвертация типов

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

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

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

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

# Создание кэша на Polars — в 5-10x быстрее, чем на Pandas
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"),
        # Bollinger Bands
        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()
)

# Сохранение — время записи примерно одинаковое (оба через Arrow)
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 для максимальной производительности бэктеста.}
}