← กลับไปยังบทความ
March 13, 2026
อ่าน 5 นาที

Polars vs Pandas สำหรับ Algotrading: ผลการทดสอบด้วยข้อมูลจริง

Polars vs Pandas สำหรับ Algotrading: ผลการทดสอบด้วยข้อมูลจริง
#algotrading
#Polars
#Pandas
#benchmarks
#performance
#data engineering

ซีรีส์ "Backtests Without Illusions", บทความที่ 9

การทดสอบกลยุทธ์ย้อนหลังไม่ได้เกี่ยวกับแค่ตรรกะสัญญาณและการจำลองการประมวลผลคำสั่งเท่านั้น แต่ยังรวมถึง data pipeline: การโหลดแท่งเทียนหลายล้านแท่ง การรีแซมเปิลกรอบเวลา การคำนวณตัวชี้วัด การกรองตามเงื่อนไข และการจัดกลุ่มตามตราสาร เมื่อ pipeline ใช้เวลา 30 วินาทีแทนที่จะเป็น 3 วินาที ผลกระทบไม่ใช่แค่ความไม่สะดวก แต่หมายถึงการทดลองที่น้อยกว่า 10 เท่าต่อชั่วโมง การวนซ้ำที่ช้าลง 10 เท่า และเส้นทางจากไอเดียสู่การผลิตที่ยาวนานขึ้น 10 เท่า

Pandas คือมาตรฐาน de facto สำหรับข้อมูลแบบตารางใน Python แต่ Pandas ถูกออกแบบในปี 2008 เมื่อ CPU มีความเร็วน้อยกว่าและชุดข้อมูลมีขนาดเล็กกว่า Pandas ทำงานแบบ single-threaded ใช้หน่วยความจำมาก และไม่มี query optimizer Polars คือไลบรารีรุ่นใหม่ที่เขียนด้วย Rust มีการประมวลผลแบบขนาน ใช้ Apache Arrow เป็นแกนหลัก และมี lazy query planner

คำถามคือ: Polars เร็วกว่าแค่ไหน ในงาน algotrading จริงๆ? ไม่ใช่การทดสอบสังเคราะห์จาก README แต่เป็นการกรอง tick การคำนวณตัวชี้วัด rolling การจัดกลุ่มตามตราสาร และการโหลดจาก Parquet/QuestDB?

บทความนี้นำเสนอการทดสอบอย่างเป็นระบบพร้อมตัวเลข โค้ด และคำแนะนำเชิงปฏิบัติ

วิธีการทดสอบ

Benchmark methodology setup ห้องปฏิบัติการวัดแบบอนาคต: สภาพแวดล้อมการทดสอบแม่นยำที่มีพารามิเตอร์ควบคุม

ก่อนเปรียบเทียบ มากำหนดกฎเกณฑ์ให้ผลลัพธ์สามารถทำซ้ำได้และยุติธรรม

สภาพแวดล้อม

  • Python 3.11, Pandas 2.2, Polars 1.x (เวอร์ชันเสถียรล่าสุด)
  • เครื่อง: 8 คอร์, 32 GB RAM, NVMe SSD
  • ทดสอบแต่ละรายการ 100 ครั้ง; ใช้ค่ามัธยฐาน
  • Warmup: 5 รอบก่อนการวัด
  • ปิด GC ระหว่างการวัด (gc.disable())

ข้อมูล

สามระดับของขนาด:

  • เล็ก: 10K แถว (ตราสารหนึ่ง, หนึ่งวัน, แท่งเทียนรายนาที)
  • กลาง: 1M แถว (ตราสารหนึ่ง, ~2 ปี, แท่งเทียนรายนาที)
  • ใหญ่: 10M+ แถว (100 ตราสาร, 2 ปี, แท่งเทียนรายนาที)

นอกจากนี้: ชุดข้อมูล NYC Taxi จริง (12.7M แถว) สำหรับการทดสอบ ETL — การทดสอบมาตรฐานอุตสาหกรรม

สิ่งที่เราวัด

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,
    }

ผลการทดสอบการดำเนินการ: ตาราง

Operation benchmarks comparison การเปรียบเทียบประสิทธิภาพข้ามการดำเนินการ: filter, groupby, join และ select ในขนาดข้อมูลต่างกัน

ชุดข้อมูลขนาดเล็ก (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 ข้อได้เปรียบเห็นได้ชัดแล้ว: hash table ใน Rust ของ Polars เร็วกว่า 13 เท่า

ชุดข้อมูลขนาดกลาง (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.6 เท่าอย่างสม่ำเสมอในการกรองและจัดกลุ่ม สำหรับ select (การเลือกชุดย่อยของคอลัมน์) — 10.9 เท่า เนื่องจากรูปแบบคอลัมน์ Arrow ช่วยให้ตัดชิ้นส่วนแบบ zero-copy ได้

ชุดข้อมูลขนาดใหญ่ (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 คอร์และ query optimizer สร้างผลสะสม GroupBy เร็วกว่า 8.6 เท่า — ความแตกต่างระหว่าง "รอหนึ่งวินาที" และ "รอ 100 มิลลิวินาที"

ETL กับข้อมูลจริง (NYC Taxi, 12.7M แถว)

การดำเนินการ 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

I/O สำหรับ CSV คือผลลัพธ์ที่น่าตื่นตาที่สุด: Polars อ่าน CSV แบบขนานบน Rust engine เร็วกว่า 25 เท่า สิ่งนี้สำคัญมากสำหรับการโหลดข้อมูลประวัติศาสตร์ครั้งแรก

การทดสอบ PDS-H อย่างเป็นทางการ (พฤษภาคม 2025)

PDS-H benchmark leaderboard การแข่งขันประสิทธิภาพของไลบรารี DataFrame: Polars และ DuckDB นำหน้าขณะที่ Pandas ล้าหลังหลายลำดับขนาด

PDS-H (Performance Data Science — Holistic) คือการทดสอบมาตรฐานสำหรับไลบรารี DataFrame คล้ายกับ TPC-H สำหรับฐานข้อมูล ผลจากพฤษภาคม 2025:

  • Pandas มีส่วนร่วมเฉพาะที่ scale SF-10 — single-threaded ไม่มี query optimizer ช้ากว่าผู้นำสองลำดับขนาด
  • Polars และ DuckDB อยู่ในลีกของตัวเองที่ SF-10 และ SF-100
  • streaming engine ใหม่ใน Polars ให้ speedup เพิ่มเติม 3-7 เท่าเมื่อเทียบกับโหมด in-memory — ช่วยให้ประมวลผลข้อมูลที่ไม่พอดีกับ RAM ได้

สำหรับ algotrading หมายความว่า: หาก pipeline ของคุณถูกจำกัดด้วยหน่วยความจำเมื่อโหลดข้อมูล tick 100M+ แถว — streaming engine ของ Polars ช่วยให้ประมวลผลได้โดยไม่ต้องเพิ่ม RAM

การคำนวณ Rolling สำหรับสัญญาณซื้อขาย: ฟีเจอร์ที่โดดเด่น

Polars vs Pandas rolling speedup comparison

นี่คือการทดสอบที่สำคัญที่สุดสำหรับ algotrading งานทั่วไป: คุณมี 100 ตราสาร และสำหรับแต่ละตราสารต้องคำนวณค่าเฉลี่ย rolling ค่าเบี่ยงเบนมาตรฐาน rolling 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 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

Speedup 10x ถึง 3500x ในการคำนวณ rolling ตามกลุ่ม นี่ไม่ใช่การพิมพ์ผิด Pandas groupby().transform(lambda x: x.rolling().mean()) สร้าง Python loop สำหรับแต่ละกลุ่ม โดยทุกการเรียกมี interpreter overhead Polars ประมวลผลทุกอย่างใน Rust แบบขนานข้ามกลุ่ม โดยไม่มีอ็อบเจกต์ Python กลาง

สำหรับ pipeline ที่ต้องคำนวณ 10 ตัวชี้วัดใน 100 ตราสาร — นี่คือความแตกต่างระหว่าง 2 นาทีกับ 0.3 วินาที

ตัวชี้วัดทางเทคนิค: Bollinger Bands, Keltner Channels, TTM Squeeze

Technical indicators visualization 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 คือวิธีการระบุการเปลี่ยนผ่านของตลาดจากสถานะ 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

Speedup ที่สม่ำเสมอ ~7 เท่า สำหรับตราสารเดียว เมื่อคำนวณตามกลุ่ม (100 ตราสาร) speedup เพิ่มขึ้นเป็นหลายร้อยเท่าเนื่องจาก overhead ของ groupby ใน Pandas

หมายเหตุเกี่ยวกับแพ็คเกจตัวชี้วัดสำเร็จรูป

สำหรับ Pandas มี pandas-ta — ไลบรารีที่มีตัวชี้วัดกว่า 130 รายการ สำหรับ Polars ยังไม่มีแพ็คเกจเทียบเท่า ซึ่งหมายความว่าเมื่อใช้ Polars คุณจะต้องใช้งานตัวชี้วัดเอง อย่างไรก็ตาม building blocks พื้นฐาน (rolling_mean, rolling_std, ewm_mean, shift, การคำนวณคอลัมน์) ครอบคลุมตัวชี้วัดมาตรฐานส่วนใหญ่ และการใช้งาน Polars มักสั้นกว่าที่คิด

การทดสอบ I/O: CSV, Parquet, ฐานข้อมูล

Data I/O pipeline visualization กระแสข้อมูลจากแหล่ง CSV, Parquet และฐานข้อมูล: I/O แบบขนานของ Rust เทียบกับ Python แบบ single-threaded

data pipeline เริ่มต้นด้วยการโหลดข้อมูล รูปแบบการจัดเก็บและวิธีการอ่านกำหนดความเร็วพื้นฐานของ 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 เร็วกว่าถึง 25 เท่า — การ parse แบบขนานใน Rust
  2. Parquet read: Polars เร็วกว่า 2.6 เท่าสำหรับการอ่านเต็มรูปแบบ และ 4.5 เท่าด้วย projection pushdown (อ่านเฉพาะคอลัมน์ที่ต้องการ)
  3. Parquet write: เกือบเท่ากัน — ทั้งคู่ใช้ PyArrow/Arrow backend
  4. Lazy scan: Polars สามารถใช้ filter ที่ระดับ row group ของไฟล์ Parquet โดยไม่ต้องโหลดข้อมูลเข้าหน่วยความจำ สิ่งนี้เป็นไปไม่ได้กับ Pandas โดยไม่ใช้ PyArrow ด้วยตนเอง

สำหรับ Parquet cache — รูปแบบหลักสำหรับจัดเก็บ timeframes และตัวชี้วัดที่คำนวณล่วงหน้า — Polars กับ lazy evaluation ให้การผสานรวมในอุดมคติ: โหลดเฉพาะคอลัมน์และช่วงเวลาที่ต้องการโดยไม่ต้องอ่านไฟล์ทั้งหมดเข้าหน่วยความจำ

การใช้หน่วยความจำและ Lazy Evaluation

Memory consumption and lazy evaluation รูปแบบหน่วยความจำ eager vs lazy: สำเนาซ้ำซ้อนสีส้มเทียบกับเลย์เอาต์คอลัมน์ Arrow ที่เหมาะสมสีฟ้าอมเขียว

Eager vs Lazy

Pandas ทำงานเฉพาะในโหมด eager: ทุกการดำเนินการจะดำเนินการทันที และผลลัพธ์กลางจะถูกนำมาสร้างในหน่วยความจำ

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 รองรับ lazy evaluation — query ถูกสร้างเป็น graph ปรับให้เหมาะสม และดำเนินการในรอบเดียว:

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 optimizer ทำงานโดยอัตโนมัติ:

  • Projection pushdown: อ่านเพียง 3 คอลัมน์แทนที่จะเป็นทั้งหมด
  • Predicate pushdown: ใช้ filter volume > 1000 ระหว่างการอ่านโดยไม่โหลดแถวที่ไม่จำเป็น
  • Common subexpression elimination: หลีกเลี่ยงการคำนวณสิ่งเดียวกันสองครั้ง

การใช้หน่วยความจำ (10M แถว, 6 คอลัมน์ float64)

สถานการณ์ 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 สร้างสำเนากลาง; inplace=True ช่วยได้บางส่วน แต่ไม่ใช่สำหรับทุกการดำเนินการ

Polars ใช้รูปแบบคอลัมน์ Arrow แบบ native: ข้อมูลถูกจัดเก็บตามคอลัมน์ ไม่ซ้ำแถว และใช้การดำเนินการแบบ zero-copy ทุกที่ที่เป็นไปได้ สำหรับ pipeline ที่มีการแปลงหลายรายการ Polars ใช้หน่วยความจำน้อยกว่า 2-6 เท่า

Streaming Engine: ข้อมูลที่ใหญ่กว่า RAM

สำหรับชุดข้อมูลที่ไม่พอดีกับ 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 โหมด streaming เร็วกว่า in-memory 3-7 เท่าสำหรับขนาดใหญ่ — ต้องขอบคุณ cache locality ที่ดีกว่าและการไม่มีแรงกดดัน virtual memory

สถาปัตยกรรมแบบไฮบริด: Polars + Numba

Hybrid Polars + Numba architecture data flow

การ backtest ประกอบด้วยสองส่วนที่แตกต่างกันโดยพื้นฐาน:

  1. Data pipeline — การโหลด การแปลง ตัวชี้วัด การกรอง เป็นแบบขนานจำนวนมาก เน้นคอลัมน์ และเหมาะกับ Polars อย่างยิ่ง

  2. Portfolio simulation — การเติมคำสั่ง การคำนวณ PnL การจัดการตำแหน่ง เป็นแบบ path-dependent: แต่ละขั้นตอนขึ้นอยู่กับสถานะก่อนหน้า สิ่งนี้ต้องการการผ่านแบบ element-wise ผ่าน time series

Pandas เหมาะไม่ดีสำหรับทั้งสองส่วน Polars เก่งในส่วนแรก แต่ไม่ใช่ส่วนที่สอง สำหรับ logic แบบ path-dependent เครื่องมือที่เหมาะสมคือ Numba (JIT compiler สำหรับ Python) หรือ Rust/C++ แบบ native

สถาปัตยกรรม

┌─────────────────────────────────────────────────────┐
│                   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                                │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

ตัวอย่าง: 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 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)

ทำไมไม่ใช้ vectorbt?

vectorbt คือ framework สำหรับ backtesting ยอดนิยมที่ประมวลผล 1M คำสั่งใน 70-100ms มันสร้างบน Pandas + NumPy + Numba ปัญหาคือ Pandas เป็น bottleneck ใน data pipeline — ช้า single-threaded ใช้หน่วยความจำมาก vectorbt ต้องหลีกเลี่ยงข้อจำกัดของ Pandas ผ่าน Numba สำหรับส่วนที่สำคัญ แต่การโหลดข้อมูลและการคำนวณตัวชี้วัดยังคงผ่าน Pandas

สถาปัตยกรรมแบบไฮบริด Polars + Numba นำสิ่งที่ดีที่สุดจากทั้งสองโลก:

  • Polars สำหรับ data pipeline — เร็วกว่า Pandas 5-350 เท่าในการดำเนินการเดียวกัน
  • Numba สำหรับ portfolio simulation — ความเร็วเดียวกับใน vectorbt
  • ไม่มีชั้น Pandas กลาง — ข้อมูลไหลจาก Arrow ไปยัง NumPy โดยตรงผ่าน zero-copy

การย้ายระบบ: รูปแบบสำคัญจาก Pandas สู่ Polars

Migration from Pandas to Polars สะพานระหว่าง legacy และโค้ดสมัยใหม่: การแปลรูปแบบ Pandas เป็น Polars expressions

หาก pipeline ของคุณเขียนด้วย Pandas การย้ายระบบไม่จำเป็นต้องเขียนใหม่ตั้งแต่ต้น รูปแบบหลักแปลผ่าน template

การอ่านข้อมูล

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

การกรอง

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 แบบ native — รูปแบบเดียวกับที่ QuestDB ใช้สำหรับการถ่ายโอนข้อมูล ซึ่งหมายถึง zero-copy เมื่อรับผลลัพธ์ 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()  # copy + type conversion

สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการทำงานกับ QuestDB สำหรับจัดเก็บและวิเคราะห์ข้อมูลซื้อขาย ดูซีรีส์บทความเกี่ยวกับสถาปัตยกรรมข้อมูลของเรา

การผสานรวมกับ Parquet Cache

Parquet cache architecture Columnar Parquet cache พร้อม predicate pushdown และ projection pushdown สำหรับการโหลดข้อมูลแบบเลือกสรร

ในบทความ Aggregated Parquet Cache เราได้อธิบายวิธีคำนวณ timeframes และตัวชี้วัดล่วงหน้าครั้งเดียวและบันทึกเป็นไฟล์ 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,
)

ระหว่างการ optimization จำนวนมาก — เมื่อต้องรันพารามิเตอร์หลายพันชุด — การอ่านจาก Parquet cache ผ่าน Polars scan_parquet กับ predicate pushdown ช่วยให้โหลดเฉพาะช่วงเวลาและคอลัมน์ที่ต้องการโดยไม่ต้องอ่านไฟล์ทั้งหมด

การผสานรวมกับ Adaptive drill-down: Polars lazy evaluation เหมาะอย่างยิ่งสำหรับการโหลดสองระดับ — ข้อมูลหยาบสำหรับ pass หลัก ข้อมูลละเอียด (วินาที มิลลิวินาที) เฉพาะสำหรับโซนที่มีความไม่แน่นอนในการเติมคำสั่ง

เมื่อใดควรใช้อะไร: คำแนะนำเชิงปฏิบัติ

Decision guide for choosing Pandas vs Polars Decision matrix: เส้นทางที่แตกต่างสำหรับ prototyping ขนาดเล็กกับ production pipeline ขนาดใหญ่

Pandas เหมาะสมถ้า:

  • ชุดข้อมูลถึง 1M แถว และคุณไม่ได้ทำ GroupBy ข้ามหลายร้อยกลุ่ม — ความแตกต่างระหว่าง Pandas 2.2 และ Polars มักไม่มีนัยสำคัญ (1.5-2 เท่า)
  • คุณต้องการ pandas-ta หรือไลบรารีอื่นที่มี Pandas API — การเขียนตัวชี้วัด 130 รายการใหม่ไม่คุ้มสำหรับการศึกษาครั้งเดียว
  • การ prototyping — Pandas API คุ้นเคยกว่าสำหรับคนส่วนใหญ่ และความเร็วไม่สำคัญสำหรับการทดสอบสมมติฐานอย่างรวดเร็ว
  • การผสานรวมกับ legacy code — Pandas pipeline ที่มีอยู่ซึ่งทำงานได้และไม่ต้องการการ optimize

Polars จำเป็นถ้า:

  • ชุดข้อมูลตั้งแต่ 10M แถวขึ้นไป — tick data หลายสิบถึงหลายร้อยล้านแถว multi-timeframe cache
  • Rolling ตามกลุ่ม — ตราสาร 100+ รายการ ตัวชี้วัดสำหรับแต่ละรายการ: speedup 100-3500 เท่า
  • ETL pipeline — การโหลด ทำความสะอาด แปลงข้อมูลปริมาณมาก
  • RAM จำกัด — lazy evaluation และ streaming engine ช่วยให้ประมวลผลข้อมูลที่ไม่พอดีกับหน่วยความจำ
  • Parquet/QuestDB stack — native Arrow = zero-copy, predicate pushdown, projection pushdown

สิ่งที่ไม่ควรคาดหวัง

ตัวเลขการตลาด "เร็วกว่า 30 เท่า" คือ speedup สูงสุดสำหรับการดำเนินการเฉพาะ Speedup ที่เป็นจริงสำหรับการดำเนินการ pipeline ทั่วไป: 2-10 เท่า สำหรับ rolling ตามกลุ่ม — มากกว่านั้นอย่างมีนัยสำคัญ สำหรับชุดข้อมูลขนาดเล็ก — บางครั้ง Polars ช้ากว่าด้วยซ้ำเนื่องจาก overhead

ประสบการณ์ของเราที่ marketmaker.cc

Production performance dashboard Production metrics: pipeline speedup 6-8 เท่าและการวนซ้ำ optimization มากขึ้น 8 เท่าต่อชั่วโมง

ที่ marketmaker.cc เราใช้สถาปัตยกรรมแบบไฮบริด Polars + Numba สำหรับ backtest engine ทั้ง data pipeline ทั้งหมด — การโหลดจาก Parquet cache การคำนวณตัวชี้วัด การกรอง feature engineering — ทำงานบน Polars Portfolio simulation ทำงานบน Numba

การเปลี่ยนจาก Pandas เป็น Polars ใน data pipeline ให้ speedup 6-8 เท่าสำหรับชุดข้อมูลทั่วไปของเรา (50-100M แถว ตราสาร 200+ รายการ) การคำนวณตัวชี้วัด rolling ตามกลุ่มเปลี่ยนจากนาทีเป็นหลายร้อยมิลลิวินาที สิ่งนี้ทำให้เราเพิ่มจำนวนการวนซ้ำ optimization จาก ~500 เป็น ~4000 ต่อชั่วโมงโดยไม่ต้องเปลี่ยนฮาร์ดแวร์

ประเด็นสำคัญ: เราไม่ได้ย้ายโค้ดทั้งหมดในวันเดียว ก่อนอื่นเราย้าย I/O (การอ่าน Parquet) จากนั้นการคำนวณตัวชี้วัด จากนั้นการกรองและ feature engineering Pandas ยังคงอยู่เฉพาะในอินเทอร์เฟซกับ legacy components ที่คาดหวัง pd.DataFrame การแปลง df.to_pandas() / pl.from_pandas() ใช้เวลามิลลิวินาทีและไม่ใช่ bottleneck

Metrics ที่คำนวณระหว่างขั้นตอน backtest — รวมถึง PnL by Active Time — ถูกคำนวณบน Polars DataFrames แล้ว ซึ่งทำให้ pipeline เรียบง่ายขึ้นและลดการแปลงกลาง

บทสรุป

Architecture convergence กระแสเทคโนโลยีสามสายรวมเข้าด้วยกัน: Polars, Numba และ Arrow รวมตัวเป็น pipeline ที่ optimize เดียว

Polars ไม่ใช่ตัวแทน Pandas ในทุกสถานการณ์ มันคือเครื่องมือคลาสที่แตกต่างซึ่งโดดเด่นในขนาดที่เป็นลักษณะของ algotrading จริงจัง: หลายล้านถึงหลายร้อยล้านแถว หลายสิบถึงหลายร้อยตราสาร การ optimize พารามิเตอร์อย่างต่อเนื่อง

ตัวเลขสำคัญ:

  • การดำเนินการพื้นฐาน: speedup 2-10 เท่าสำหรับงาน pipeline ทั่วไป
  • Rolling ตามกลุ่ม: 10-3500 เท่า — ฟีเจอร์ killer หลักสำหรับ trading pipeline
  • CSV I/O: ถึง 25 เท่า — สำคัญสำหรับการโหลดข้อมูลครั้งแรก
  • หน่วยความจำ: ประหยัด 2-6 เท่า ต้องขอบคุณ Arrow และ lazy evaluation
  • Streaming: ประมวลผลข้อมูลที่ไม่พอดีกับ RAM

สถาปัตยกรรมที่แนะนำสำหรับ production backtest engine:

  1. Polars — data pipeline ทั้งหมด: การโหลด ตัวชี้วัด การกรอง features
  2. Numba/Rust — portfolio simulation: logic คำสั่งและตำแหน่งแบบ path-dependent
  3. Arrow — รูปแบบข้อมูลที่จุดเชื่อมต่อทั้งหมด: Parquet, QuestDB, Polars, NumPy

ไม่มีชั้น Pandas กลาง ข้อมูลไหลจากที่จัดเก็บผ่าน Polars เข้า NumPy arrays แล้วเข้า Numba engine — โดยไม่มีสำเนาที่ไม่จำเป็น โดยไม่มี GIL โดยไม่มี bottleneck แบบ single-threaded


ลิงก์ที่เป็นประโยชน์

  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 for Algotrading: Benchmarks on Real Data},
  year = {2026},
  url = {https://marketmaker.cc/th/blog/post/polars-vs-pandas-algotrading},
  description = {การเปรียบเทียบ Polars และ Pandas อย่างละเอียดในงาน algotrading: การทดสอบประสิทธิภาพสำหรับการกรอง การรวมข้อมูล การคำนวณสัญญาณ rolling, I/O และการใช้หน่วยความจำ สถาปัตยกรรม Polars + Numba แบบไฮบริดเพื่อประสิทธิภาพสูงสุดในการ backtest}
}
ข้อจำกัดความรับผิดชอบ: ข้อมูลที่ให้ไว้ในบทความนี้มีไว้เพื่อการศึกษาและให้ข้อมูลเท่านั้น และไม่ถือเป็นคำแนะนำทางการเงิน การลงทุน หรือการเทรด การเทรดสกุลเงินดิจิทัลมีความเสี่ยงสูงที่จะขาดทุน

ผู้เขียน

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

ก้าวนำหน้าตลาด

สมัครรับจดหมายข่าวของเราเพื่อรับข้อมูลเชิงลึกการเทรดด้วย AI เฉพาะ การวิเคราะห์ตลาด และการอัปเดตแพลตฟอร์ม

เราเคารพความเป็นส่วนตัวของคุณ ยกเลิกการสมัครได้ทุกเมื่อ