← Torna agli articoli
March 13, 2026
5 min di lettura

Polars vs Pandas per l'Algotrading: Benchmark su Dati Reali

Polars vs Pandas per l'Algotrading: Benchmark su Dati Reali
#algotrading
#Polars
#Pandas
#benchmark
#performance
#data engineering

Serie "Backtest Senza Illusioni", Articolo 9

Il backtesting di strategie non riguarda solo la logica dei segnali e la simulazione dell'esecuzione. È anche una pipeline di dati: caricamento di milioni di candele, ricampionamento dei timeframe, calcolo degli indicatori, filtraggio per condizioni, raggruppamento per strumenti. Quando la pipeline impiega 30 secondi invece di 3, non è solo un inconveniente. Significa 10 volte meno esperimenti all'ora, iterazione 10 volte più lenta e un percorso dall'idea alla produzione 10 volte più lungo.

Pandas è lo standard de facto per i dati tabulari in Python. Ma Pandas è stato progettato nel 2008, quando i core della CPU erano più lenti e i dataset erano più piccoli. Pandas è single-threaded, consuma molta memoria e non dispone di un query optimizer. Polars è una libreria di nuova generazione scritta in Rust, con esecuzione parallela, Apache Arrow al suo core e un pianificatore di query lazy.

La domanda è: quanto è più veloce Polars su attività reali di algotrading? Non su benchmark sintetici tratti da un README, ma su filtraggio di tick, calcolo di indicatori rolling, raggruppamento per strumenti e caricamento da Parquet/QuestDB?

Questo articolo fornisce benchmark sistematici con numeri, codice e raccomandazioni pratiche.

Metodologia dei Benchmark

Configurazione della metodologia di benchmark Laboratorio di misurazione futuristico: ambiente di benchmark di precisione con parametri controllati

Prima di confrontare, definiamo le regole affinché i risultati siano riproducibili ed equi.

Ambiente

  • Python 3.11, Pandas 2.2, Polars 1.x (ultima versione stabile)
  • Macchina: 8 core, 32 GB RAM, NVMe SSD
  • Ogni benchmark viene eseguito 100 volte; viene presa la mediana
  • Warmup: 5 iterazioni prima delle misurazioni
  • GC disabilitato durante la misurazione (gc.disable())

Dati

Tre livelli di scala:

  • Piccolo: 10K righe (un strumento, un giorno, candele al minuto)
  • Medio: 1M righe (uno strumento, ~2 anni, candele al minuto)
  • Grande: 10M+ righe (100 strumenti, 2 anni, candele al minuto)

Inoltre: il dataset reale NYC Taxi (12,7M righe) per i benchmark ETL — un benchmark standard del settore.

Cosa Misuriamo

import timeit, gc

def bench(fn, n=100, warmup=5):
    """Benchmark equo: warmup + mediana di n esecuzioni."""
    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,
    }

Benchmark delle Operazioni: Tabelle

Confronto dei benchmark delle operazioni Confronto delle prestazioni tra operazioni: filter, groupby, join e select a diverse scale di dati

Dataset piccoli (10K righe)

Operazione 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 righe, Pandas è a volte più veloce sui filtri semplici — l'overhead di chiamare una funzione Polars tramite PyO3 è paragonabile al tempo dell'operazione stessa. Ma sui join, il vantaggio è già visibile: la hash table di Polars in Rust è 13 volte più veloce.

Dataset medi (1M righe)

Operazione 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 milione di righe, Polars è costantemente 1,6 volte più veloce su filtraggio e raggruppamento. Su select (scelta di un sottoinsieme di colonne) — 10,9x, perché il formato columnar Arrow consente lo slicing zero-copy.

Dataset grandi (10M+ righe)

Operazione 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

Su dati di grandi dimensioni, il vantaggio di Polars cresce in modo non lineare: l'esecuzione parallela su 8 core e il query optimizer producono un effetto cumulativo. GroupBy è 8,6 volte più veloce — la differenza tra "aspettare un secondo" e "aspettare 100 millisecondi".

ETL su Dati Reali (NYC Taxi, 12,7M righe)

Operazione Pandas (s) Polars (s) Speedup
Caricamento CSV 28.5 1.14 25.0x
Filter + GroupBy + Agg 3.8 0.42 9.0x
Trasformazione multi-colonna 2.1 0.7 3.0x
Pipeline ETL completa 34.4 2.26 15.2x

L'I/O CSV è il risultato più eclatante: Polars legge i CSV in parallelo sul suo motore Rust, 25 volte più veloce. Questo è fondamentale per il caricamento iniziale dei dati storici.

Benchmark Ufficiale PDS-H (Maggio 2025)

Classifica del benchmark PDS-H Gara di performance tra librerie DataFrame: Polars e DuckDB in testa mentre Pandas rimane indietro di ordini di grandezza

PDS-H (Performance Data Science — Holistic) è un benchmark standard per le librerie DataFrame, analogo a TPC-H per i database. Risultati di maggio 2025:

  • Pandas partecipa solo alla scala SF-10 — single-threaded, nessun query optimizer, due ordini di grandezza più lento dei leader
  • Polars e DuckDB sono in un'altra categoria a SF-10 e SF-100
  • Il nuovo motore streaming in Polars offre un ulteriore speedup di 3-7x rispetto alla modalità in-memory — consentendo l'elaborazione di dati che non entrano in RAM

Per l'algotrading, questo significa: se la tua pipeline è limitata dalla memoria quando carichi 100M+ righe di dati tick — il motore streaming di Polars consente di elaborarli senza aumentare la RAM.

Calcoli Rolling per i Segnali di Trading: La Killer Feature

Confronto dello speedup rolling Polars vs Pandas

Questo è il benchmark più importante per l'algotrading. Un'attività tipica: hai 100 strumenti e per ciascuno devi calcolare una media rolling, una deviazione standard rolling, uno z-score e generare un segnale basato su di essi. In Pandas questo è groupby().rolling(), in 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 + espressioni rolling

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")
    )

Risultati

Operazione Pandas (ms) Polars (ms) Speedup
Media rolling, 100 gruppi x 100K righe 4200 12 350x
Std rolling, 100 gruppi x 100K righe 5100 15 340x
Z-score (media + std + aritmetica) 12500 35 357x
Media rolling, 1000 gruppi x 10K righe 38000 11 3454x

Speedup da 10x a 3500x sui calcoli rolling per gruppo. Non è un errore tipografico. groupby().transform(lambda x: x.rolling().mean()) in Pandas crea un loop Python su ciascun gruppo, con ogni chiamata che comporta overhead dell'interprete. Polars esegue tutto in Rust, in parallelo tra i gruppi, senza oggetti Python intermedi.

Per una pipeline che deve calcolare 10 indicatori su 100 strumenti — questa è la differenza tra 2 minuti e 0,3 secondi.

Indicatori Tecnici: Bollinger Bands, Keltner Channels, TTM Squeeze

Visualizzazione degli indicatori tecnici Bollinger Bands e Keltner Channels che avvolgono una serie di prezzi, con le zone TTM Squeeze evidenziate

Esaminiamo il calcolo di reali indicatori tecnici utilizzati nelle strategie di trading.

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)

Implementazione 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

Implementazione 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)

dove 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 è un metodo per identificare la transizione del mercato da uno stato di squeeze (bassa volatilità) a uno stato di espansione. Il segnale si verifica quando le Bollinger Bands sono all'interno dei Keltner Channels:

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

Benchmark degli Indicatori Tecnici (1M righe, singolo ticker)

Indicatore 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 (completo) 28.6 4.1 7.0x
RSI (14) 6.8 1.1 6.2x
MACD (12, 26, 9) 5.2 0.8 6.5x

Uno speedup costante di ~7x su un singolo ticker. Quando si calcola per gruppo (100 ticker), lo speedup cresce a centinaia di volte a causa dell'overhead di groupby in Pandas.

Una Nota sui Pacchetti di Indicatori Pronti all'Uso

Per Pandas, esiste pandas-ta — una libreria con 130+ indicatori. Per Polars, non esiste ancora un pacchetto equivalente. Questo significa che quando si usa Polars, sarà necessario implementare gli indicatori autonomamente. Tuttavia, i blocchi costruttivi di base (rolling_mean, rolling_std, ewm_mean, shift, aritmetica tra colonne) coprono la grande maggioranza degli indicatori standard, e l'implementazione in Polars è solitamente più breve di quanto sembri.

Benchmark I/O: CSV, Parquet, Database

Visualizzazione della pipeline I/O dei dati Flussi di dati da sorgenti CSV, Parquet e database: I/O Rust parallelo versus Python single-threaded

La pipeline di dati inizia con il caricamento dei dati. Il formato di archiviazione e il metodo di lettura determinano la velocità di base dell'intera 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()
)

Risultati I/O (10M righe, 6 colonne)

Operazione Pandas (s) Polars (s) Speedup
Lettura CSV 28.5 1.14 25.0x
Scrittura CSV 42.0 2.8 15.0x
Lettura Parquet (tutte le colonne) 0.82 0.31 2.6x
Lettura Parquet (3 di 6 colonne) 0.54 0.12 4.5x
Scrittura Parquet 0.95 0.91 1.04x
Parquet lazy (filter + select) N/A 0.08 predicate pushdown

Principali conclusioni:

  1. CSV: Polars fino a 25x più veloce — parsing parallelo in Rust
  2. Lettura Parquet: Polars è 2,6x più veloce sulla lettura completa e 4,5x con projection pushdown (lettura solo delle colonne necessarie)
  3. Scrittura Parquet: quasi identica — entrambi usano il backend PyArrow/Arrow
  4. Lazy scan: Polars può applicare il filtro a livello di row group del file Parquet senza caricare i dati in memoria. Questo è impossibile con Pandas senza usare manualmente PyArrow

Per la cache Parquet — il nostro formato principale per archiviare timeframe e indicatori precomputati — Polars con lazy evaluation offre un'integrazione ideale: caricamento solo delle colonne e dei periodi necessari senza leggere l'intero file in memoria.

Consumo di Memoria e Lazy Evaluation

Consumo di memoria e lazy evaluation Pattern di memoria eager vs lazy: copie ridondanti in arancione versus layout columnar Arrow ottimizzato in ciano

Eager vs Lazy

Pandas funziona solo in modalità eager: ogni operazione viene eseguita immediatamente e i risultati intermedi vengono materializzati in memoria.

df = pd.read_csv("big_file.csv")           # intero file in RAM
df = df[df["volume"] > 1000]                # copia filtrata
df = df[["timestamp", "close", "volume"]]   # un'altra copia
df["returns"] = df["close"].pct_change()    # ancora un'altra copia

Polars supporta la lazy evaluation — le query vengono costruite come un grafo, ottimizzate ed eseguite in un unico passaggio:

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

L'ottimizzatore di Polars esegue automaticamente:

  • Projection pushdown: legge solo 3 colonne invece di tutte
  • Predicate pushdown: applica il filtro volume > 1000 durante la lettura, senza caricare righe non necessarie
  • Eliminazione delle sottoespressioni comuni: evita di calcolare la stessa cosa due volte

Consumo di Memoria (10M righe, 6 colonne float64)

Scenario Pandas (GB) Polars eager (GB) Polars lazy (GB)
Caricamento CSV 0.92 0.46 0.46
Filter + Select 3 colonne 1.38* 0.22 0.22
Pipeline di 5 trasformazioni 2.76* 0.48 0.48
Caricamento Parquet (3 di 6 colonne) 0.46 0.23 0.23

* Pandas crea copie intermedie; inplace=True aiuta parzialmente, ma non per tutte le operazioni.

Polars utilizza nativamente il formato columnar Arrow: i dati sono archiviati per colonne, le righe non vengono duplicate e le operazioni zero-copy vengono utilizzate ovunque possibile. Per pipeline con molteplici trasformazioni, Polars consuma 2-6 volte meno memoria.

Motore Streaming: Dati Più Grandi della RAM

Per dataset che non entrano in RAM, Polars offre un motore 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")
)

Il motore streaming elabora i dati a blocchi senza caricare l'intero dataset in memoria. Secondo i dati del benchmark PDS-H, la modalità streaming è 3-7x più veloce rispetto alla modalità in-memory su larga scala — grazie alla migliore cache locality e all'assenza di pressione sulla memoria virtuale.

Architettura Ibrida: Polars + Numba

Flusso di dati dell'architettura ibrida Polars + Numba

Un backtest è composto da due parti fondamentalmente diverse:

  1. Pipeline di dati — caricamento, trasformazione, indicatori, filtraggio. È massicciamente parallela, orientata alle colonne e perfettamente adatta a Polars.

  2. Simulazione del portafoglio — esecuzione degli ordini, calcolo del PnL, gestione delle posizioni. Questo è path-dependent: ogni passo dipende dallo stato precedente. Richiede un passaggio elemento per elemento sulla serie temporale.

Pandas è poco adatto a entrambe le parti. Polars eccelle nella prima ma non nella seconda. Per la logica path-dependent, lo strumento ottimale è Numba (un compilatore JIT per Python) o Rust/C++ nativo.

Architettura

┌─────────────────────────────────────────────────────┐
│                   Pipeline di Dati                   │
│                                                      │
│  Parquet/QuestDB  ──→  Polars LazyFrame             │
│       │                     │                        │
│       │              ┌──────┴──────┐                 │
│       │              │ Indicatori  │                 │
│       │              │ Filtri      │                 │
│       │              │ Feature     │                 │
│       │              └──────┬──────┘                 │
│       │                     │                        │
│       │              Array NumPy                     │
│       │              (zero-copy da Arrow)             │
│       ▼                     ▼                        │
│  ┌──────────────────────────────────────────────┐   │
│  │       Simulazione del Portafoglio (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                                │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

Esempio: Pipeline Completa

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 da Arrow
signals = df["signal"].to_numpy()  # zero-copy da Arrow

@njit
def simulate_strategy(prices, signals, threshold=1.5, stop_loss=0.02):
    """
    Simulazione path-dependent: Numba compila in codice macchina.
    1M iterazioni 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)

Perché Non vectorbt?

vectorbt è un popolare framework di backtesting che elabora 1M ordini in 70-100ms. È costruito su Pandas + NumPy + Numba. Il problema: Pandas è il collo di bottiglia nella pipeline di dati — lento, single-threaded, consumatore di memoria. vectorbt deve aggirare le limitazioni di Pandas tramite Numba per le parti critiche, ma il caricamento dei dati e il calcolo degli indicatori passano ancora attraverso Pandas.

L'architettura ibrida Polars + Numba prende il meglio di entrambi i mondi:

  • Polars per la pipeline di dati — 5-350x più veloce di Pandas sulle stesse operazioni
  • Numba per la simulazione del portafoglio — la stessa velocità di vectorbt
  • Nessuno strato Pandas intermedio — i dati fluiscono da Arrow direttamente a NumPy via zero-copy

Migrazione: Pattern Principali da Pandas a Polars

Migrazione da Pandas a Polars Ponte tra codice legacy e moderno: traduzione dei pattern Pandas in espressioni Polars

Se la tua pipeline è scritta in Pandas, la migrazione non richiede una riscrittura da zero. I pattern principali si traducono tramite template.

Lettura dei Dati

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")  # non legge nulla fino a .collect()

Filtraggio

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")
)

Creazione di Colonne

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 + Aggregazione

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 per Gruppo

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")
)

Integrazione con QuestDB

Polars funziona nativamente con Apache Arrow — lo stesso formato che QuestDB utilizza per il trasferimento dei dati. Questo significa zero-copy quando si ricevono i risultati delle query:

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()  # copia + conversione di tipo

Per ulteriori informazioni sull'utilizzo di QuestDB per archiviare e analizzare dati di trading, consulta la nostra serie di articoli sull'architettura dei dati.

Integrazione con la Cache Parquet

Architettura della cache Parquet Cache Parquet columnar con predicate pushdown e projection pushdown per il caricamento selettivo dei dati

Nell'articolo Cache Parquet Aggregata, abbiamo descritto come precomputare timeframe e indicatori una volta e salvarli in un file Parquet. Polars rende questo approccio ancora più efficiente:

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 l'ottimizzazione massiva — quando è necessario eseguire migliaia di combinazioni di parametri — la lettura dalla cache Parquet tramite scan_parquet di Polars con predicate pushdown consente di caricare solo i periodi e le colonne necessari senza leggere l'intero file.

Integrazione con Drill-down adattivo: la lazy evaluation di Polars è perfettamente adatta al caricamento a due livelli — dati grezzi per il passaggio principale, dati dettagliati (secondi, millisecondi) solo per le zone di ambiguità di riempimento.

Quando Usare Cosa: Raccomandazioni Pratiche

Guida alle decisioni per scegliere Pandas vs Polars Matrice decisionale: percorsi divergenti per la prototipazione su piccola scala rispetto alle pipeline di produzione su larga scala

Pandas è giustificato se:

  • Dataset fino a 1M righe e non si esegue GroupBy su centinaia di gruppi — la differenza tra Pandas 2.2 e Polars è spesso trascurabile (1,5-2x)
  • Hai bisogno di pandas-ta o altre librerie con API Pandas — riscrivere 130 indicatori non è pratico per uno studio occasionale
  • Prototipazione — l'API Pandas è più familiare alla maggior parte, e la velocità non è critica per i test rapidi delle ipotesi
  • Integrazione con codice legacy — una pipeline Pandas esistente che funziona e non necessita di ottimizzazione

Polars è necessario se:

  • Dataset da 10M righe — decine e centinaia di milioni di righe di dati tick, cache multi-timeframe
  • Rolling per gruppo — 100+ strumenti, indicatori per ciascuno: speedup 100-3500x
  • Pipeline ETL — caricamento, pulizia, trasformazione di grandi volumi di dati
  • RAM limitata — lazy evaluation e il motore streaming consentono l'elaborazione di dati che non entrano in memoria
  • Stack Parquet/QuestDB — Arrow nativo = zero-copy, predicate pushdown, projection pushdown

Cosa Non Aspettarsi

Il dato di marketing "30x più veloce" è lo speedup massimo su operazioni specifiche. Lo speedup realistico su operazioni tipiche della pipeline: 2-10x. Su rolling per gruppo — significativamente di più. Su dataset piccoli — a volte Polars è persino più lento a causa dell'overhead.

La Nostra Esperienza su marketmaker.cc

Dashboard delle performance di produzione Metriche di produzione: speedup della pipeline di 6-8x e 8x più iterazioni di ottimizzazione all'ora

Su marketmaker.cc, utilizziamo un'architettura ibrida Polars + Numba per il motore di backtest. L'intera pipeline di dati — caricamento dalla cache Parquet, calcolo degli indicatori, filtraggio, feature engineering — gira su Polars. La simulazione del portafoglio gira su Numba.

Il passaggio da Pandas a Polars nella pipeline di dati ha dato uno speedup di 6-8x sui nostri dataset tipici (50-100M righe, 200+ strumenti). Il calcolo degli indicatori rolling per gruppo è passato da minuti a centinaia di millisecondi. Questo ci ha permesso di aumentare il numero di iterazioni di ottimizzazione da ~500 a ~4000 all'ora senza cambiare hardware.

Un punto chiave: non abbiamo migrato tutto il codice in un solo giorno. Prima abbiamo spostato l'I/O (lettura Parquet), poi il calcolo degli indicatori, poi il filtraggio e il feature engineering. Pandas è rimasto solo nell'interfaccia con i componenti legacy che si aspettano un pd.DataFrame. La conversione df.to_pandas() / pl.from_pandas() richiede millisecondi e non è un collo di bottiglia.

Le metriche calcolate durante la fase di backtest — incluso il PnL per Tempo Attivo — sono già calcolate su DataFrame Polars, il che semplifica la pipeline ed elimina le conversioni intermedie.

Conclusione

Convergenza dell'architettura Tre flussi tecnologici convergenti: Polars, Numba e Arrow che si uniscono in un'unica pipeline ottimizzata

Polars non è un sostituto di Pandas in ogni scenario. È uno strumento di una classe diversa che eccelle alle scale tipiche dell'algotrading serio: milioni e centinaia di milioni di righe, decine e centinaia di strumenti, ottimizzazione continua dei parametri.

Numeri chiave:

  • Operazioni di base: speedup 2-10x su attività tipiche della pipeline
  • Rolling per gruppo: 10-3500x — la principale killer feature per le pipeline di trading
  • I/O CSV: fino a 25x — critico per il caricamento iniziale dei dati
  • Memoria: risparmio 2-6x grazie ad Arrow e alla lazy evaluation
  • Streaming: elaborazione di dati che non entrano in RAM

Architettura raccomandata per un motore di backtest di produzione:

  1. Polars — l'intera pipeline di dati: caricamento, indicatori, filtraggio, feature
  2. Numba/Rust — simulazione del portafoglio: logica path-dependent di ordini e posizioni
  3. Arrow — il formato dei dati a tutti i giunti: Parquet, QuestDB, Polars, NumPy

Nessuno strato Pandas intermedio. I dati fluiscono dall'archiviazione attraverso Polars in array NumPy e poi nel motore Numba — senza copie non necessarie, senza GIL, senza colli di bottiglia single-threaded.


Link Utili

  1. Polars — Guida Utente
  2. Polars vs Pandas — benchmark ufficiale
  3. Benchmark PDS-H — confronto tra librerie DataFrame
  4. Apache Arrow — specifica del formato columnar
  5. Numba — compilatore JIT per Python
  6. vectorbt — framework di backtesting
  7. pandas-ta — Indicatori di Analisi Tecnica
  8. Ritchie Vink — Ho scritto una delle librerie DataFrame più veloci (origine di Polars)
  9. Towards Data Science — Polars vs Pandas: benchmark nel mondo reale
  10. Ernest Chan — Quantitative Trading

Citazione

@article{soloviov2026polarsvspandas,
  author = {Soloviov, Eugen},
  title = {Polars vs Pandas for Algotrading: Benchmarks on Real Data},
  year = {2026},
  url = {https://marketmaker.cc/it/blog/post/polars-vs-pandas-algotrading},
  description = {Confronto dettagliato tra Polars e Pandas su attività di algotrading: benchmark per filtraggio, aggregazione, calcoli di segnali rolling, I/O e consumo di memoria. Architettura ibrida Polars + Numba per la massima performance nei backtest.}
}
Disclaimer: le informazioni fornite in questo articolo hanno solo scopo didattico e informativo e non costituiscono consulenza finanziaria, di investimento o di trading. Il trading di criptovalute comporta un rischio significativo di perdita.

Autori

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

Resta un Passo Avanti al Mercato

Iscriviti alla nostra newsletter per approfondimenti esclusivi sul trading con IA, analisi di mercato e aggiornamenti sulla piattaforma.

Rispettiamo la tua privacy. Annulla l'iscrizione in qualsiasi momento.