مسیر Quant Developer · بخش ۴: داده و زیرساخت تحلیلی · درس ۱۴
Pandas و Polars برای داده Tick-level
پردازش معاملهها و Quoteهای بازار: نوع داده، ساخت کندل و VWAP، همتراز کردن معامله با Quote با Asof Join، اجرای Lazy در Polars و انتخاب بین Pandas و Polars.
۱. داده Tick-level چیست؟
کندلهای یکدقیقهای که در نمودارها میبینی، خلاصهای از دادهای بسیار ریزتر هستند. برای کار بازارساز، خلاصه کافی نیست؛ باید بدانی هر معامله در چه لحظهای و نسبت به کدام Quote انجام شد. به این سطح از داده Tick-level میگویند و معمولاً دو جریان جدا دارد:
| جریان | هر ردیف یعنی | ستونهای اصلی |
|---|---|---|
| Trades (معاملهها) | یک معامله انجامشده | ts، price، qty، side |
| Quotes (پیشنهادها) | تغییر بهترین قیمت خرید یا فروش | ts، bid، ask (و معمولاً حجم هر کدام) |
Quote اینجا یعنی وضعیت بالای دفتر سفارش: Best Bid و Best Ask که در درس ۲ دیدیم. هر بار یکی از آنها عوض شود، یک ردیف تازه میآید. به همین دلیل Quoteها معمولاً چند برابر معاملهها هستند.
حجم این داده برای یک جفتارز پرمعامله میتواند به دهها میلیون ردیف در روز برسد. در این درس با یک ساعت داده مصنوعی کار میکنیم؛ همه چیز روی لپتاپ اجرا میشود، ولی هر تصمیمی که اینجا میگیریم (نوع داده، نحوه Join، اجرای Lazy) در مقیاس واقعی اثرش چند صد برابر میشود.
۲. داده نمونه
کد زیر یک ساعت داده مصنوعی BTC/USDT حول 100,000 دلار میسازد: حدود ۵۰ تغییر Quote در ثانیه و ۵۰,۰۰۰ معامله. Tick Size را فرضاً 0.1 دلار گرفتهایم و قیمتها، مثل درس ۳، اول بهصورت عدد صحیح بر حسب Tick ساخته میشوند.
یک جزئیات عمدی در این داده هست: هر معامله با Quote قبلی انجام میشود، و Quote جدیدی که اثر همان معامله را نشان میدهد (دفتر یک Tick به سمت معامله جابهجا شده) دقیقاً همان Timestamp معامله را دارد. در بسیاری از فیدها معامله و بهروزرسانی دفتری که از آن نتیجه شده، با یک برچسب زمانی منتشر میشوند. این جزئیات در بخش ۵ مهم میشود.
import numpy as np
import pandas as pd
rng = np.random.default_rng(14)
TICK = 0.1 # hypothetical tick size, USDT
T0 = pd.Timestamp("2027-01-04", tz="UTC")
# Quote updates: Poisson arrivals (~50 per second), prices in integer ticks
n_q = 200_000
q_time = T0 + pd.to_timedelta(np.cumsum(rng.exponential(0.02, n_q)), unit="s")
step = rng.integers(-1, 2, n_q) # best bid moves -1, 0 or +1 tick
# 50,000 trades. Each trade happens AT a quote update: it executes against the previous
# quote, and the update at the same timestamp is the post-trade book (moved 1 tick its way).
k = np.sort(rng.choice(np.arange(1, n_q), 50_000, replace=False))
side = rng.choice(np.array([1, -1]), len(k)) # hidden truth: +1 buy, -1 sell
step[k] = side
bid_t = 1_000_000 + np.cumsum(step) # 1,000,000 ticks = 100,000 USDT
ask_t = bid_t + rng.integers(1, 4, n_q) # spread of 1-3 ticks
quotes = pd.DataFrame({"ts": q_time, "bid": bid_t * TICK, "ask": ask_t * TICK})
trades = pd.DataFrame({
"ts": q_time[k],
"price": np.where(side == 1, ask_t[k - 1], bid_t[k - 1]) * TICK,
"qty": np.round(rng.exponential(0.05, len(k)), 3) + 0.001, # BTC
"side": np.where(side == 1, "buy", "sell"),
})
ستون side را نگه داشتهایم تا بعداً بتوانیم درستی روشهایمان را بسنجیم. خیلی از فیدهای معامله این ستون را دارند، ولی همه ندارند؛ و آنجا باید جهت معامله را از روی Quote حدس بزنی.
۳. نوع داده و حافظه
قبل از هر تحلیلی ببین دادهات چقدر جا میگیرد:
def mb(df):
return df.memory_usage(deep=True).sum() / 1e6
print(f"as generated: {mb(trades):.2f} MB") # 3.83 MB
print(f"side as category: {mb(trades.astype({'side': 'category'})):.2f} MB") # 1.25 MB
فقط با عوض کردن نوع یک ستون، حافظه از 3.83 به 1.25 مگابایت رسید؛ یعنی حدود یکسوم. دلیلش این است که ستون متنی side در حالت پیشفرض برای هر ردیف یک شیء رشتهای Python نگه میدارد، در حالی که نوع Category فقط یک کد عددی یکبایتی و یک جدول کوچک از مقادیر یکتا ذخیره میکند. همین قاعده برای ستون نماد (symbol) هم برقرار است.
برای ستونهای دیگر:
| ستون | انتخاب پیشنهادی | دلیل |
|---|---|---|
| ts | datetime64 با دقت نانوثانیه، به وقت UTC | مقایسه و Join سریع؛ بدون ابهام منطقه زمانی |
| price | float64، یا int64 بر حسب Tick | float32 برای قیمتهای بزرگ دقت کافی ندارد |
| qty | float64، یا int64 بر حسب Lot | جمعهای دقیق در حالت عدد صحیح |
| side، symbol | category | چند مقدار تکراری؛ صرفهجویی زیاد در حافظه |
تله float32 را جدی بگیر. این نوع فقط حدود ۷ رقم معنادار دارد و قیمت 100,000.1 همهاش را مصرف میکند:
print(float(np.float32(100_000.1))) # 100000.1015625
print(np.spacing(np.float32(100_000))) # 0.0078125: float32 step size near 100k
نزدیک 100,000، فاصله دو عدد متوالی float32 حدود 0.0078 دلار است. برای Tick برابر 0.1 شاید قابل تحمل به نظر برسد، ولی با Tickهای ریزتر یا جمع زدن میلیونها مبلغ معامله، خطا جمع میشود و پیدا کردنش سخت است. نصف کردن حافظه به این قیمت نمیارزد.
۴. از Tick به کندل
ساختن کندل (Bar) یعنی گروهبندی معاملهها در بازههای زمانی ثابت و خلاصه کردن هر گروه. در کنار OHLCV معمول، VWAP (میانگین قیمت وزنی با حجم) هم حساب میکنیم؛ قیمتی که هر واحد حجم در آن بازه بهطور میانگین با آن معامله شده:
trades["notional"] = trades["price"] * trades["qty"]
bars = trades.set_index("ts").resample("1min").agg(
open=("price", "first"),
high=("price", "max"),
low=("price", "min"),
close=("price", "last"),
volume=("qty", "sum"),
notional=("notional", "sum"),
n_trades=("price", "count"),
)
bars["vwap"] = bars["notional"] / bars["volume"]
نتیجه ۶۷ کندل است. سه نکته که در این چند خط پنهان است:
- برچسب کندل، لحظه دسترسی نیست. در pandas کندل یکدقیقهای بهطور پیشفرض با ابتدای بازه برچسب میخورد: کندلی که برچسب 00:00 دارد، معاملههای 00:00 تا قبل از 00:01 را شامل میشود و Close آن تازه در 00:01 معلوم است. اگر این کندل را با سیگنالی که در 00:00 ساخته شده Join کنی، همان باگ نگاه به آینده درس ۱۳ را ساختهای. یا با
label="right"برچسب را پایان بازه بگذار، یا صریحاً ستونی مثلavailable_atبساز. - کندل آخر ناقص است. داده ما وسط دقیقه ۶۷ تمام میشود. کندل آخر فقط ۳۹۸ معامله دارد، در حالی که بقیه بین ۶۷۰ تا ۸۲۱ معامله دارند. حجم کم آن واقعی نیست؛ اگر کندلهای ناقص ابتدا و انتهای داده را حذف نکنی، روی ویژگیهایی مثل حجم اثر میگذارند.
- کندل خالی. در بازار کممعامله ممکن است در یک دقیقه هیچ معاملهای نباشد. pandas آن ردیف را با
NaNمیسازد. قرارداد رایج: Close را از کندل قبل باffillپر کن، Open و High و Low را برابر همان Close بگذار و حجم را صفر. این کار جای خالی را پر میکند ولی پنهانش نمیکند؛ ستونn_tradesبرابر صفر همچنان نشان میدهد آن دقیقه معاملهای نبوده است.
۵. Asof Join: هر معامله کنار Quote همان لحظه
سؤال رایج تحلیل اجرا: «این معامله نسبت به Mid Price همان لحظه کجا بود؟» برای جواب باید هر معامله را با آخرین Quote قبل از آن کنار هم بگذاری. Join معمولی به برابری دقیق کلید نیاز دارد و زمان معامله و Quote تقریباً هیچوقت دقیقاً برابر نیستند. Asof Join برای هر ردیف چپ، نزدیکترین ردیف راست را در گذشته پیدا میکند.
یک کاربرد مستقیم: اگر فید جهت معامله را نداشته باشد، میتوانی آن را با قاعده Quote حدس بزنی؛ معامله بالاتر از Mid احتمالاً خرید Taker بوده و پایینتر از Mid احتمالاً فروش. (این قاعده بخشی از روش شناختهشده Lee–Ready است.) چون در داده مصنوعی جهت واقعی را داریم، میتوانیم دقت آن را بسنجیم:
def classify(trades, quotes, allow_exact):
m = pd.merge_asof(trades, quotes, on="ts", direction="backward",
allow_exact_matches=allow_exact)
d = m["price"] - (m["bid"] + m["ask"]) / 2 # distance from mid
eps = TICK / 100 # float tolerance
m["guess"] = np.select([d > eps, d < -eps], ["buy", "sell"], "unknown")
return m
for allow_exact in (True, False):
m = classify(trades, quotes, allow_exact)
acc = (m["guess"] == m["side"]).mean()
unk = (m["guess"] == "unknown").mean()
print(f"allow_exact_matches={allow_exact}: correct {acc:.1%}, unknown {unk:.1%}")
- allow_exact_matches=True (پیشفرض pandas) — درست
- 38.7%
- allow_exact_matches=True — نامعلوم (روی Mid)
- 22.4%
- allow_exact_matches=False — درست
- 100%
- allow_exact_matches=False — نامعلوم
- 0%
تنها تفاوت دو اجرا یک پارامتر است. در حالت پیشفرض، Quote با Timestamp برابر هم پذیرفته میشود؛ و در داده ما آن Quote، دفترِ بعد از معامله است که یک Tick به سمت معامله رفته. معامله با قیمت میانیای مقایسه میشود که خودش ساخته است، و دقت از حدس تصادفی (۵۰٪) هم بدتر میشود. با allow_exact_matches=False فقط Quoteهای اکیداً قبل از معامله پذیرفته میشوند و همه معاملهها درست دستهبندی میشوند.
چند پارامتر دیگر merge_asof که در عمل لازم میشوند:
by="symbol": وقتی چند نماد در یک جدول داری، Join فقط بین ردیفهای همنماد انجام میشود.tolerance=pd.Timedelta("1s"): اگر آخرین Quote قدیمیتر از این باشد، Join خالی برمیگرداند. Quote دهدقیقهای که بعد از قطع اتصال باقی مانده، Mid معتبری نیست.- مرتب بودن: هر دو جدول باید بر اساس
tsمرتب باشند؛ وگرنه pandas خطا میدهد.
۶. Polars و اجرای Lazy
Polars کتابخانه DataFrame جدیدتری است که با Rust نوشته شده و دو تفاوت اصلی با pandas دارد: بهطور پیشفرض از همه هستههای CPU استفاده میکند، و حالت Lazy دارد. در حالت Lazy هر دستور اجرا نمیشود؛ Polars فقط طرح کوئری (Query Plan) را میسازد و وقتی collect() را صدا بزنی، کل طرح را بهینه میکند و یکجا اجرا میکند.
همان دو کار بخش ۴ و ۵، این بار با Polars و مستقیم از فایل Parquet:
import polars as pl
# Files written earlier with trades.to_parquet(...) and quotes.to_parquet(...)
trades_lf = pl.scan_parquet("trades.parquet") # lazy: nothing is read yet
quotes_lf = pl.scan_parquet("quotes.parquet")
bars = (
trades_lf
.sort("ts")
.group_by_dynamic("ts", every="1m", closed="left", label="left")
.agg(
open=pl.col("price").first(),
high=pl.col("price").max(),
low=pl.col("price").min(),
close=pl.col("price").last(),
volume=pl.col("qty").sum(),
vwap=(pl.col("price") * pl.col("qty")).sum() / pl.col("qty").sum(),
)
)
classified = (
trades_lf
.join_asof(quotes_lf, on="ts", strategy="backward", allow_exact_matches=False)
.with_columns(d=pl.col("price") - (pl.col("bid") + pl.col("ask")) / 2)
.with_columns(
guess=pl.when(pl.col("d") > TICK / 100).then(pl.lit("buy"))
.when(pl.col("d") < -TICK / 100).then(pl.lit("sell"))
.otherwise(pl.lit("unknown"))
)
)
print(bars.collect().head(3))
acc = classified.select((pl.col("guess") == pl.col("side")).mean()).collect().item()
ما این کد را با Polars 1.x و 2.0 اجرا کردیم و هر شش ستون کندلها با نسخه pandas یکی بود؛ دقت دستهبندی هم همان ۱۰۰٪ شد. چند تفاوت با pandas:
- ایندکس وجود ندارد. زمان یک ستون معمولی است و هر عملیات صریحاً میگوید روی کدام ستون کار میکند.
- Expressionها: بهجای ساختن ستونهای میانی (مثل
notionalدر pandas)، محاسبه داخلaggنوشته میشود و Polars آن را موازی اجرا میکند. join_asofهم به مرتب بودن هر دو طرف بر اساس کلید نیاز دارد. پارامترallow_exact_matchesدر نسخههای قدیمیتر Polars وجود نداشت؛ اگر نسخهات قدیمی است، مستندات همان نسخه را ببین.
چرا Lazy مهم است؟
با bars.explain() میتوانی طرح بهینهشده را ببینی. در خروجی آن این خط هست:
Parquet SCAN [trades.parquet]
PROJECT 3/4 COLUMNS
یعنی Polars فهمیده برای کندلها فقط ۳ ستون از ۴ ستون فایل لازم است و ستون side را اصلاً از دیسک نمیخواند (Projection Pushdown). اگر فیلتری مثل pl.col("side") == "buy" اضافه کنی، شرط هم به مرحله خواندن فایل منتقل میشود (Predicate Pushdown) و ردیفهای غیرلازم از همان ابتدا کنار گذاشته میشوند. روی چند گیگابایت داده Parquet، این یعنی تفاوت بین «جا میشود» و «حافظه تمام شد». برای دادهای بزرگتر از حافظه، Polars موتور Streaming هم دارد که داده را تکهتکه پردازش میکند.
۷. کدام را انتخاب کنم؟
| موقعیت | انتخاب معقول |
|---|---|
| داده در حافظه جا میشود، کاوش در Notebook | pandas؛ اکوسیستم بزرگتر و آشنایی بیشتر |
| کتابخانههای آماری و رسم نمودار | pandas؛ بیشتر کتابخانهها ورودی pandas میگیرند |
| چند گیگابایت Parquet، فیلتر و تجمیع سنگین | Polars Lazy |
| Pipeline تکراری که سرعتش مهم است | Polars؛ موازی و قابل بهینهسازی |
| منطق وابسته به مسیر (موجودی، صف سفارش) | هیچکدام؛ حلقه یا کد کامپایلشده (درس ۱۳) |
این دو رقیب انحصاری نیستند. الگوی رایج: کار سنگین کاهش داده (فیلتر، Join، کندلسازی) با Polars، و خروجی کوچکشده با to_pandas() برای تحلیل آماری و نمودار. نقطه ضعف واقعی Polars این است که API آن هنوز سریعتر از pandas تغییر میکند؛ نسخه را در پروژه ثابت کن (درس ۱۳) و موقع ارتقا تستها را اجرا کن.
۸. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
در درس پانزدهم همین مسئلهها را به پایگاه داده میبریم: Window Functionها در SQL (مثل LAG و جمع تجمعی)، ساخت کندل OHLCV و VWAP با SQL، تفاوت پایگاه داده سطری و ستونی، و مفاهیم اصلی ClickHouse و TimescaleDB برای داده سری زمانی بازار.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.