رفتن به محتوای اصلی
سرفصل مسیر Quant Developer

مسیر 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) هم برقرار است.

برای ستون‌های دیگر:

ستونانتخاب پیشنهادیدلیل
tsdatetime64 با دقت نانوثانیه، به وقت UTCمقایسه و Join سریع؛ بدون ابهام منطقه زمانی
pricefloat64، یا int64 بر حسب Tickfloat32 برای قیمت‌های بزرگ دقت کافی ندارد
qtyfloat64، یا int64 بر حسب Lotجمع‌های دقیق در حالت عدد صحیح
side، symbolcategoryچند مقدار تکراری؛ صرفه‌جویی زیاد در حافظه

تله 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 (میانگین قیمت وزنی با حجم) هم حساب می‌کنیم؛ قیمتی که هر واحد حجم در آن بازه به‌طور میانگین با آن معامله شده:

VWAP = Σ(price × qty) / Σ qty
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 هم دارد که داده را تکه‌تکه پردازش می‌کند.

۷. کدام را انتخاب کنم؟

موقعیتانتخاب معقول
داده در حافظه جا می‌شود، کاوش در Notebookpandas؛ اکوسیستم بزرگ‌تر و آشنایی بیشتر
کتابخانه‌های آماری و رسم نمودارpandas؛ بیشتر کتابخانه‌ها ورودی pandas می‌گیرند
چند گیگابایت Parquet، فیلتر و تجمیع سنگینPolars Lazy
Pipeline تکراری که سرعتش مهم استPolars؛ موازی و قابل بهینه‌سازی
منطق وابسته به مسیر (موجودی، صف سفارش)هیچ‌کدام؛ حلقه یا کد کامپایل‌شده (درس ۱۳)

این دو رقیب انحصاری نیستند. الگوی رایج: کار سنگین کاهش داده (فیلتر، Join، کندل‌سازی) با Polars، و خروجی کوچک‌شده با to_pandas() برای تحلیل آماری و نمودار. نقطه ضعف واقعی Polars این است که API آن هنوز سریع‌تر از pandas تغییر می‌کند؛ نسخه را در پروژه ثابت کن (درس ۱۳) و موقع ارتقا تست‌ها را اجرا کن.

۸. تمرین این درس

گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.

سؤال ۱: یک کندل یک‌دقیقه‌ای در pandas با برچسب 10:00 (برچسب ابتدای بازه) ساخته شده است. Close این کندل از چه لحظه‌ای قابل استفاده است؟
سؤال ۲: در یک دقیقه دو معامله انجام شده: 0.2 BTC در 100,000 و 0.3 BTC در 100,010. VWAP این دقیقه چقدر است؟
سؤال ۳: چرا ذخیره قیمت BTC (حدود 100,000) با نوع float32 خطرناک است؟
سؤال ۴: فید، معامله و Quote بعد از معامله را با Timestamp یکسان منتشر می‌کند. اگر با merge_asof و تنظیمات پیش‌فرض Join کنی، چه می‌شود؟

درس بعدی

در درس پانزدهم همین مسئله‌ها را به پایگاه داده می‌بریم: Window Functionها در SQL (مثل LAG و جمع تجمعی)، ساخت کندل OHLCV و VWAP با SQL، تفاوت پایگاه داده سطری و ستونی، و مفاهیم اصلی ClickHouse و TimescaleDB برای داده سری زمانی بازار.

این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایه‌گذاری نیست.