एल्गोट्रेडिंग के लिए Polars बनाम Pandas: वास्तविक डेटा पर बेंचमार्क
सीरीज़ "भ्रम रहित बैकटेस्ट", लेख 9
रणनीति बैकटेस्टिंग केवल सिग्नल लॉजिक और एक्ज़ीक्यूशन सिमुलेशन के बारे में नहीं है। यह एक डेटा पाइपलाइन भी है: लाखों कैंडल्स लोड करना, टाइमफ्रेम रीसैंपल करना, इंडिकेटर्स की गणना करना, शर्तों के अनुसार फ़िल्टर करना, इंस्ट्रूमेंट्स के अनुसार समूहित करना। जब पाइपलाइन 3 सेकंड के बजाय 30 सेकंड लेती है, तो यह केवल एक असुविधा नहीं है। इसका मतलब है प्रति घंटे 10 गुना कम प्रयोग, 10 गुना धीमी इटरेशन, और आइडिया से प्रोडक्शन तक 10 गुना लंबा रास्ता।
Python में टैबुलर डेटा के लिए Pandas वास्तविक मानक है। लेकिन Pandas 2008 में डिज़ाइन किया गया था, जब CPU कोर धीमे थे और डेटासेट छोटे थे। Pandas सिंगल-थ्रेडेड है, मेमोरी की अधिक खपत करता है, और इसमें क्वेरी ऑप्टिमाइज़र नहीं है। Polars अगली पीढ़ी की लाइब्रेरी है जो Rust में लिखी गई है, जिसमें समानांतर एक्ज़ीक्यूशन, कोर में Apache Arrow, और एक लेज़ी क्वेरी प्लानर है।
सवाल यह है: वास्तविक एल्गोट्रेडिंग कार्यों पर Polars कितना तेज़ है? किसी README से लिए गए सिंथेटिक बेंचमार्क पर नहीं, बल्कि टिक फ़िल्टरिंग, रोलिंग इंडिकेटर गणना, इंस्ट्रूमेंट्स के अनुसार ग्रुपिंग, और Parquet/QuestDB से लोडिंग पर?
यह लेख संख्याओं, कोड और व्यावहारिक सिफारिशों के साथ व्यवस्थित बेंचमार्क प्रदान करता है।
बेंचमार्क पद्धति
Futuristic measurement laboratory: precision benchmarking environment with controlled parameters
तुलना करने से पहले, आइए नियम परिभाषित करें ताकि परिणाम पुनरुत्पादनीय और निष्पक्ष हों।
वातावरण
- Python 3.11, Pandas 2.2, Polars 1.x (नवीनतम स्थिर संस्करण)
- मशीन: 8 कोर, 32 GB RAM, NVMe SSD
- प्रत्येक बेंचमार्क 100 बार चलाया जाता है; मध्यमान (median) लिया जाता है
- वार्मअप: मापन से पहले 5 इटरेशन
- मापन के दौरान GC अक्षम (
gc.disable())
डेटा
तीन स्केल स्तर:
- छोटा: 10K पंक्तियाँ (एक इंस्ट्रूमेंट, एक दिन, मिनट कैंडल्स)
- मध्यम: 1M पंक्तियाँ (एक इंस्ट्रूमेंट, ~2 साल, मिनट कैंडल्स)
- बड़ा: 10M+ पंक्तियाँ (100 इंस्ट्रूमेंट, 2 साल, मिनट कैंडल्स)
इसके अतिरिक्त: ETL बेंचमार्क के लिए वास्तविक NYC टैक्सी डेटासेट (12.7M पंक्तियाँ) — एक मानक इंडस्ट्री बेंचमार्क।
हम क्या मापते हैं
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,
}
ऑपरेशन बेंचमार्क: टेबल
Performance comparison across operations: filter, groupby, join, and select at different data scales
छोटे डेटासेट (10K पंक्तियाँ)
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Filter | 0.18 | 0.32 | 0.56x |
| GroupBy | 1.2 | 0.75 | 1.6x |
| Join | 5.5 | 0.4 | 13.75x |
| Select | 0.5 | 0.2 | 2.5x |
10K पंक्तियों पर, सरल फ़िल्टर पर Pandas कभी-कभी तेज़ होता है — PyO3 के माध्यम से Polars फ़ंक्शन कॉल करने का ओवरहेड ऑपरेशन के समय के बराबर होता है। लेकिन जॉइन पर, फायदा पहले से ही दिखाई देता है: Rust में Polars हैश टेबल 13 गुना तेज़ है।
मध्यम डेटासेट (1M पंक्तियाँ)
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Filter | 12.4 | 7.8 | 1.6x |
| GroupBy | 45.2 | 28.6 | 1.6x |
| Join | 89.0 | 14.3 | 6.2x |
| Select | 21.8 | 2.0 | 10.9x |
दस लाख पंक्तियों पर, फ़िल्टरिंग और ग्रुपिंग में Polars लगातार 1.6 गुना तेज़ है। सिलेक्ट (कॉलम का सबसेट चुनना) पर — 10.9 गुना, क्योंकि Arrow कॉलमनार फॉर्मेट ज़ीरो-कॉपी स्लाइसिंग की अनुमति देता है।
बड़े डेटासेट (10M+ पंक्तियाँ)
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Filter | 185 | 50 | 3.7x |
| GroupBy | 860 | 100 | 8.6x |
| Join | 1450 | 120 | 12.1x |
| Select | 240 | 40 | 6.0x |
बड़े डेटा पर, Polars का फायदा गैर-रैखिक रूप से बढ़ता है: 8 कोर पर समानांतर एक्ज़ीक्यूशन और क्वेरी ऑप्टिमाइज़र संचयी प्रभाव पैदा करते हैं। GroupBy 8.6 गुना तेज़ है — "एक सेकंड इंतजार करने" और "100 मिलीसेकंड इंतजार करने" के बीच का अंतर।
वास्तविक डेटा पर ETL (NYC टैक्सी, 12.7M पंक्तियाँ)
| Operation | Pandas (s) | Polars (s) | Speedup |
|---|---|---|---|
| CSV Load | 28.5 | 1.14 | 25.0x |
| Filter + GroupBy + Agg | 3.8 | 0.42 | 9.0x |
| Multi-column transform | 2.1 | 0.7 | 3.0x |
| Full ETL pipeline | 34.4 | 2.26 | 15.2x |
CSV I/O सबसे नाटकीय परिणाम है: Polars अपने Rust इंजन पर समानांतर रूप से CSV पढ़ता है, 25 गुना तेज़। यह ऐतिहासिक डेटा के प्रारंभिक लोडिंग के लिए महत्वपूर्ण है।
आधिकारिक PDS-H बेंचमार्क (मई 2025)
DataFrame library performance race: Polars and DuckDB lead while Pandas trails by orders of magnitude
PDS-H (Performance Data Science — Holistic) DataFrame लाइब्रेरीज़ के लिए एक मानक बेंचमार्क है, जो डेटाबेस के लिए TPC-H के समान है। मई 2025 के परिणाम:
- Pandas केवल SF-10 स्केल पर भाग लेता है — सिंगल-थ्रेडेड, बिना क्वेरी ऑप्टिमाइज़र के, लीडर्स से दो ऑर्डर ऑफ मैग्निट्यूड धीमा
- Polars और DuckDB SF-10 और SF-100 पर अपनी ही लीग में हैं
- Polars में नया स्ट्रीमिंग इंजन इन-मेमोरी मोड की तुलना में अतिरिक्त 3-7x स्पीडअप देता है — जो उस डेटा को प्रोसेस करने में सक्षम बनाता है जो RAM में फिट नहीं होता
एल्गोट्रेडिंग के लिए, इसका मतलब है: यदि आपकी पाइपलाइन 100M+ पंक्तियों के टिक डेटा को लोड करते समय मेमोरी-बाउंड है — Polars स्ट्रीमिंग इंजन आपको RAM बढ़ाए बिना उन्हें प्रोसेस करने देता है।
ट्रेडिंग सिग्नल के लिए रोलिंग गणना: किलर फीचर

यह एल्गोट्रेडिंग के लिए सबसे महत्वपूर्ण बेंचमार्क है। एक विशिष्ट कार्य: आपके पास 100 इंस्ट्रूमेंट हैं, और प्रत्येक के लिए आपको रोलिंग मीन, रोलिंग स्टैंडर्ड डेविएशन, z-स्कोर की गणना करनी है, और उनके आधार पर एक सिग्नल जनरेट करना है। 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")
)
परिणाम
| Operation | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Rolling mean, 100 groups x 100K rows | 4200 | 12 | 350x |
| Rolling std, 100 groups x 100K rows | 5100 | 15 | 340x |
| Z-score (mean + std + arithmetic) | 12500 | 35 | 357x |
| Rolling mean, 1000 groups x 10K rows | 38000 | 11 | 3454x |
समूह के अनुसार रोलिंग गणना पर 10x से 3500x तक स्पीडअप। यह कोई टाइपो नहीं है। Pandas का groupby().transform(lambda x: x.rolling().mean()) प्रत्येक समूह पर एक Python लूप बनाता है, जिसमें हर कॉल में इंटरप्रेटर ओवरहेड लगता है। Polars सब कुछ Rust में, समूहों के बीच समानांतर रूप से, बिना किसी मध्यवर्ती Python ऑब्जेक्ट के एक्ज़ीक्यूट करता है।
100 इंस्ट्रूमेंट्स पर 10 इंडिकेटर्स की गणना करने वाली पाइपलाइन के लिए — यह 2 मिनट और 0.3 सेकंड के बीच का अंतर है।
तकनीकी इंडिकेटर: Bollinger Bands, Keltner Channels, TTM Squeeze
Bollinger Bands and Keltner Channels enveloping a price series, with TTM Squeeze zones highlighted
आइए ट्रेडिंग रणनीतियों में उपयोग किए जाने वाले वास्तविक तकनीकी इंडिकेटर्स की गणना की जांच करें।
Bollinger Bands
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
जहाँ ATR (Average True Range):
TTM Squeeze
TTM Squeeze बाज़ार के स्क्वीज़ स्टेट (कम अस्थिरता) से एक्सपैंशन स्टेट में बदलाव की पहचान करने की एक विधि है। सिग्नल तब उत्पन्न होता है जब Bollinger Bands, Keltner Channels के अंदर होते हैं:
तकनीकी इंडिकेटर बेंचमार्क (1M पंक्तियाँ, एकल टिकर)
| Indicator | Pandas (ms) | Polars (ms) | Speedup |
|---|---|---|---|
| Bollinger Bands (20, 2) | 8.4 | 1.2 | 7.0x |
| Keltner Channels (20, 1.5) | 14.2 | 2.1 | 6.8x |
| TTM Squeeze (full) | 28.6 | 4.1 | 7.0x |
| RSI (14) | 6.8 | 1.1 | 6.2x |
| MACD (12, 26, 9) | 5.2 | 0.8 | 6.5x |
एकल टिकर पर एक सुसंगत ~7x स्पीडअप। समूह के अनुसार गणना करने पर (100 टिकर), Pandas के groupby ओवरहेड के कारण स्पीडअप सैकड़ों गुना तक बढ़ जाता है।
रेडी-मेड इंडिकेटर पैकेज पर एक नोट
Pandas के लिए, pandas-ta है — 130+ इंडिकेटर्स वाली एक लाइब्रेरी। Polars के लिए, अभी तक कोई समकक्ष पैकेज नहीं है। इसका मतलब है कि Polars का उपयोग करते समय, आपको इंडिकेटर्स स्वयं इम्प्लीमेंट करने होंगे। हालाँकि, बुनियादी बिल्डिंग ब्लॉक्स (rolling_mean, rolling_std, ewm_mean, shift, कॉलम अरिथमेटिक) अधिकांश मानक इंडिकेटर्स को कवर करते हैं, और Polars इम्प्लीमेंटेशन आमतौर पर जितना दिखता है उससे छोटा होता है।
I/O बेंचमार्क: CSV, Parquet, डेटाबेस
Data streams from CSV, Parquet, and database sources: parallel Rust I/O versus single-threaded Python
डेटा पाइपलाइन डेटा लोड करने से शुरू होती है। स्टोरेज फॉर्मेट और रीडिंग मेथड पूरी पाइपलाइन की बेसलाइन स्पीड निर्धारित करते हैं।
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 कॉलम)
| Operation | Pandas (s) | Polars (s) | Speedup |
|---|---|---|---|
| CSV read | 28.5 | 1.14 | 25.0x |
| CSV write | 42.0 | 2.8 | 15.0x |
| Parquet read (all columns) | 0.82 | 0.31 | 2.6x |
| Parquet read (3 of 6 columns) | 0.54 | 0.12 | 4.5x |
| Parquet write | 0.95 | 0.91 | 1.04x |
| Parquet lazy (filter + select) | N/A | 0.08 | predicate pushdown |
मुख्य निष्कर्ष:
- CSV: Polars 25 गुना तक तेज़ — Rust में समानांतर पार्सिंग
- Parquet रीड: पूरा पढ़ने पर Polars 2.6 गुना तेज़ है और प्रोजेक्शन पुशडाउन के साथ 4.5 गुना (केवल आवश्यक कॉलम पढ़ना)
- Parquet राइट: लगभग समान — दोनों PyArrow/Arrow बैकएंड का उपयोग करते हैं
- Lazy Scan: Polars डेटा को मेमोरी में लोड किए बिना Parquet फ़ाइल के row group लेवल पर फ़िल्टर लागू कर सकता है। PyArrow का मैन्युअल उपयोग किए बिना Pandas के साथ यह असंभव है
Parquet कैश के लिए — प्रीकंप्यूटेड टाइमफ्रेम्स और इंडिकेटर्स को स्टोर करने का हमारा प्राथमिक फॉर्मेट — Polars लेज़ी एवैल्यूएशन के साथ आदर्श इंटीग्रेशन प्रदान करता है: पूरी फ़ाइल को मेमोरी में पढ़े बिना केवल आवश्यक कॉलम और पीरियड्स को लोड करना।
मेमोरी उपभोग और लेज़ी एवैल्यूएशन
Eager vs lazy memory patterns: redundant copies in orange versus optimized Arrow columnar layout in cyan
Eager बनाम 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 लेज़ी एवैल्यूएशन को सपोर्ट करता है — क्वेरीज़ को ग्राफ के रूप में बनाया जाता है, ऑप्टिमाइज़ किया जाता है, और एक ही पास में एक्ज़ीक्यूट किया जाता है:
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 ऑप्टिमाइज़र स्वचालित रूप से करता है:
- प्रोजेक्शन पुशडाउन: सभी की बजाय केवल 3 कॉलम पढ़ता है
- प्रेडिकेट पुशडाउन: पढ़ते समय
volume > 1000फ़िल्टर लागू करता है, बिना अनावश्यक पंक्तियाँ लोड किए - कॉमन सबएक्सप्रेशन एलिमिनेशन: एक ही चीज़ को दो बार गणना करने से बचाता है
मेमोरी उपभोग (10M पंक्तियाँ, 6 float64 कॉलम)
| Scenario | Pandas (GB) | Polars eager (GB) | Polars lazy (GB) |
|---|---|---|---|
| CSV Load | 0.92 | 0.46 | 0.46 |
| Filter + Select 3 columns | 1.38* | 0.22 | 0.22 |
| Pipeline of 5 transformations | 2.76* | 0.48 | 0.48 |
| Parquet Load (3 of 6 cols) | 0.46 | 0.23 | 0.23 |
* Pandas मध्यवर्ती कॉपी बनाता है; inplace=True आंशिक रूप से मदद करता है, लेकिन सभी ऑपरेशंस के लिए नहीं।
Polars नेटिवली Arrow कॉलमनार फॉर्मेट का उपयोग करता है: डेटा कॉलम के अनुसार स्टोर होता है, पंक्तियाँ डुप्लिकेट नहीं होतीं, और जहाँ भी संभव हो ज़ीरो-कॉपी ऑपरेशंस का उपयोग किया जाता है। कई ट्रांसफॉर्मेशन वाली पाइपलाइनों के लिए, Polars 2-6 गुना कम मेमोरी की खपत करता है।
स्ट्रीमिंग इंजन: RAM से बड़ा डेटा
RAM में फिट न होने वाले डेटासेट के लिए, Polars एक स्ट्रीमिंग इंजन प्रदान करता है:
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")
)
स्ट्रीमिंग इंजन पूरे डेटासेट को मेमोरी में लोड किए बिना चंक्स में डेटा प्रोसेस करता है। PDS-H बेंचमार्क डेटा के अनुसार, बड़े स्केल पर स्ट्रीमिंग मोड इन-मेमोरी मोड की तुलना में 3-7 गुना तेज़ है — बेहतर कैश लोकैलिटी और वर्चुअल मेमोरी प्रेशर की अनुपस्थिति के कारण।
हाइब्रिड आर्किटेक्चर: Polars + Numba

एक बैकटेस्ट में दो मौलिक रूप से भिन्न भाग होते हैं:
-
डेटा पाइपलाइन — लोडिंग, ट्रांसफॉर्मेशन, इंडिकेटर्स, फ़िल्टरिंग। यह बड़े पैमाने पर समानांतर, कॉलम-ओरिएंटेड है, और Polars के लिए पूरी तरह उपयुक्त है।
-
पोर्टफोलियो सिमुलेशन — ऑर्डर फिलिंग, PnL गणना, पोजीशन मैनेजमेंट। यह पथ-निर्भर (path-dependent) है: प्रत्येक चरण पिछली स्थिति पर निर्भर करता है। इसके लिए टाइम सीरीज़ पर एक एलिमेंट-वाइज़ पास की आवश्यकता होती है।
Pandas दोनों भागों के लिए खराब रूप से उपयुक्त है। Polars पहले भाग में उत्कृष्ट है लेकिन दूसरे में नहीं। पथ-निर्भर लॉजिक के लिए, इष्टतम टूल Numba (Python के लिए एक JIT कंपाइलर) या नेटिव Rust/C++ है।
आर्किटेक्चर
┌─────────────────────────────────────────────────────┐
│ 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 │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
उदाहरण: पूरी पाइपलाइन
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 एक लोकप्रिय बैकटेस्टिंग फ्रेमवर्क है जो 1M ऑर्डर को 70-100ms में प्रोसेस करता है। यह Pandas + NumPy + Numba पर आधारित है। समस्या: Pandas डेटा पाइपलाइन में बॉटलनेक है — धीमा, सिंगल-थ्रेडेड, मेमोरी की अधिक खपत करने वाला। vectorbt को क्रिटिकल भागों के लिए Numba के माध्यम से Pandas की सीमाओं से बचना पड़ता है, लेकिन डेटा लोडिंग और इंडिकेटर गणना अभी भी Pandas से होकर गुजरती है।
हाइब्रिड Polars + Numba आर्किटेक्चर दोनों दुनियाओं का सर्वश्रेष्ठ लेता है:
- Polars डेटा पाइपलाइन के लिए — समान ऑपरेशंस पर Pandas से 5-350 गुना तेज़
- Numba पोर्टफोलियो सिमुलेशन के लिए — vectorbt के समान स्पीड
- कोई मध्यवर्ती Pandas लेयर नहीं — डेटा Arrow से सीधे ज़ीरो-कॉपी के माध्यम से NumPy में प्रवाहित होता है
माइग्रेशन: Pandas से Polars तक के मुख्य पैटर्न
Bridge between legacy and modern code: translating Pandas patterns to Polars expressions
यदि आपकी पाइपलाइन 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") # 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 + एग्रीगेशन
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"),
])
समूह के अनुसार रोलिंग
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 डेटा ट्रांसफर के लिए उपयोग करता है। इसका मतलब है क्वेरी परिणाम प्राप्त करते समय ज़ीरो-कॉपी:
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 कैश के साथ इंटीग्रेशन
Columnar Parquet cache with predicate pushdown and projection pushdown for selective data loading
एग्रीगेटेड 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,
)
मास ऑप्टिमाइज़ेशन के दौरान — जब आपको हज़ारों पैरामीटर कॉम्बिनेशन चलाने की आवश्यकता होती है — Polars scan_parquet के माध्यम से Parquet कैश से प्रेडिकेट पुशडाउन के साथ पढ़ना, पूरी फ़ाइल को पढ़े बिना केवल आवश्यक पीरियड्स और कॉलम को लोड करने की अनुमति देता है।
एडैप्टिव ड्रिल-डाउन के साथ इंटीग्रेशन: Polars लेज़ी एवैल्यूएशन दो-स्तरीय लोडिंग के लिए पूरी तरह उपयुक्त है — मुख्य पास के लिए मोटा डेटा, केवल फिल-एम्बिग्युटी ज़ोन के लिए विस्तृत डेटा (सेकंड, मिलीसेकंड)।
कब क्या उपयोग करें: व्यावहारिक सिफारिशें
Decision matrix: diverging paths for small-scale prototyping versus large-scale production pipelines
Pandas उचित है यदि:
- 1M पंक्तियों तक का डेटासेट और आप सैकड़ों समूहों पर GroupBy नहीं कर रहे हैं — Pandas 2.2 और Polars के बीच का अंतर अक्सर नगण्य होता है (1.5-2x)
- आपको
pandas-taया Pandas API वाली अन्य लाइब्रेरीज़ की आवश्यकता है — एकबारगी अध्ययन के लिए 130 इंडिकेटर्स को फिर से लिखना अव्यावहारिक है - प्रोटोटाइपिंग — Pandas API अधिकांश लोगों के लिए अधिक परिचित है, और त्वरित परिकल्पना परीक्षण के लिए स्पीड महत्वपूर्ण नहीं है
- लेगेसी कोड के साथ इंटीग्रेशन — एक मौजूदा Pandas पाइपलाइन जो काम करती है और उसे ऑप्टिमाइज़ करने की आवश्यकता नहीं है
Polars आवश्यक है यदि:
- 10M पंक्तियों से डेटासेट — टिक डेटा की दसियों और सैकड़ों मिलियन पंक्तियाँ, मल्टी-टाइमफ्रेम कैश
- समूह के अनुसार रोलिंग — 100+ इंस्ट्रूमेंट, प्रत्येक के लिए इंडिकेटर्स: 100-3500x स्पीडअप
- ETL पाइपलाइन — बड़ी मात्रा में डेटा को लोड करना, साफ करना, ट्रांसफॉर्म करना
- सीमित RAM — लेज़ी एवैल्यूएशन और स्ट्रीमिंग इंजन उस डेटा को प्रोसेस करने की अनुमति देते हैं जो मेमोरी में फिट नहीं होता
- Parquet/QuestDB स्टैक — नेटिव Arrow = ज़ीरो-कॉपी, प्रेडिकेट पुशडाउन, प्रोजेक्शन पुशडाउन
क्या उम्मीद न करें
मार्केटिंग का आंकड़ा "30 गुना तेज़" विशिष्ट ऑपरेशंस पर पीक स्पीडअप है। सामान्य पाइपलाइन ऑपरेशंस पर वास्तविक स्पीडअप: 2-10x। समूह के अनुसार रोलिंग पर — काफी अधिक। छोटे डेटासेट पर — कभी-कभी ओवरहेड के कारण Polars और भी धीमा होता है।
marketmaker.cc पर हमारा अनुभव
Production metrics: 6-8x pipeline speedup and 8x more optimization iterations per hour
marketmaker.cc पर, हम बैकटेस्ट इंजन के लिए हाइब्रिड Polars + Numba आर्किटेक्चर का उपयोग करते हैं। पूरी डेटा पाइपलाइन — Parquet कैश से लोडिंग, इंडिकेटर गणना, फ़िल्टरिंग, फीचर इंजीनियरिंग — Polars पर चलती है। पोर्टफोलियो सिमुलेशन Numba पर चलता है।
डेटा पाइपलाइन में Pandas से Polars में स्विच करने से हमारे विशिष्ट डेटासेट (50-100M पंक्तियाँ, 200+ इंस्ट्रूमेंट) पर 6-8x स्पीडअप मिला। समूह के अनुसार रोलिंग इंडिकेटर गणना मिनटों से घटकर सैकड़ों मिलीसेकंड रह गई। इससे हमें हार्डवेयर बदले बिना ऑप्टिमाइज़ेशन इटरेशन की संख्या को ~500 से ~4000 प्रति घंटे तक बढ़ाने में मदद मिली।
एक मुख्य बिंदु: हमने सारा कोड एक ही दिन में माइग्रेट नहीं किया। पहले हमने I/O (Parquet पढ़ना) को स्थानांतरित किया, फिर इंडिकेटर गणना, फिर फ़िल्टरिंग और फीचर इंजीनियरिंग। Pandas केवल लेगेसी कंपोनेंट्स के साथ इंटरफेस में रहा जो pd.DataFrame की अपेक्षा करते हैं। कन्वर्ज़न df.to_pandas() / pl.from_pandas() मिलीसेकंड लेता है और यह कोई बॉटलनेक नहीं है।
बैकटेस्ट चरण के दौरान गणना की गई मेट्रिक्स — एक्टिव टाइम के अनुसार PnL सहित — पहले से ही Polars DataFrames पर गणना की जाती हैं, जो पाइपलाइन को सरल बनाती है और मध्यवर्ती कन्वर्ज़न को समाप्त करती है।
निष्कर्ष
Three technology streams converging: Polars, Numba, and Arrow uniting into a single optimized pipeline
Polars हर परिदृश्य में Pandas का विकल्प नहीं है। यह एक अलग श्रेणी का टूल है जो गंभीर एल्गोट्रेडिंग के लिए विशिष्ट स्केल पर चमकता है: लाखों और सैकड़ों मिलियन पंक्तियाँ, दसियों और सैकड़ों इंस्ट्रूमेंट, निरंतर पैरामीटर ऑप्टिमाइज़ेशन।
मुख्य आंकड़े:
- बुनियादी ऑपरेशंस: विशिष्ट पाइपलाइन कार्यों पर 2-10x स्पीडअप
- समूह के अनुसार रोलिंग: 10-3500x — ट्रेडिंग पाइपलाइनों के लिए मुख्य किलर फीचर
- CSV I/O: 25x तक — प्रारंभिक डेटा लोडिंग के लिए महत्वपूर्ण
- मेमोरी: Arrow और लेज़ी एवैल्यूएशन की बदौलत 2-6x बचत
- स्ट्रीमिंग: उस डेटा को प्रोसेस करना जो RAM में फिट नहीं होता
प्रोडक्शन बैकटेस्ट इंजन के लिए अनुशंसित आर्किटेक्चर:
- Polars — पूरी डेटा पाइपलाइन: लोडिंग, इंडिकेटर्स, फ़िल्टरिंग, फीचर्स
- Numba/Rust — पोर्टफोलियो सिमुलेशन: पथ-निर्भर ऑर्डर और पोजीशन लॉजिक
- Arrow — सभी जंक्शनों पर डेटा फॉर्मेट: Parquet, QuestDB, Polars, NumPy
कोई मध्यवर्ती Pandas लेयर नहीं। डेटा स्टोरेज से Polars के माध्यम से NumPy एरे में और फिर Numba इंजन में प्रवाहित होता है — बिना अनावश्यक कॉपी के, बिना GIL के, बिना सिंगल-थ्रेडेड बॉटलनेक के।
उपयोगी लिंक
- Polars — User Guide
- Polars vs Pandas — official benchmark
- PDS-H Benchmark — DataFrame libraries comparison
- Apache Arrow — columnar format specification
- Numba — JIT compiler for Python
- vectorbt — backtesting framework
- pandas-ta — Technical Analysis Indicators
- Ritchie Vink — I wrote one of the fastest DataFrame libraries (Polars origin)
- Towards Data Science — Polars vs Pandas: real-world benchmarks
- 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/ru/blog/post/polars-vs-pandas-algotrading},
description = {Detailed comparison of Polars and Pandas on algotrading tasks: benchmarks for filtering, aggregation, rolling signal computations, I/O, and memory consumption. Hybrid Polars + Numba architecture for maximum backtest performance.}
}
Authors
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.