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

مسیر Quant Developer · بخش ۴: داده و زیرساخت تحلیلی · درس ۱۳

Python برای Quant

عادت‌هایی که کد تحقیقاتی را قابل اعتماد می‌کنند: ساختار پروژه، برداری‌سازی با NumPy، سری زمانی در pandas بدون نگاه به آینده، مدیریت خطا و تست با pytest.

سطح: متوسط۱۴ دقیقه مطالعه

۱. کد تحقیقاتی، یک نوع باگ بدتر دارد

بخش سوم با مدل‌ها تمام شد. از این درس وارد بخش چهارم می‌شویم: ابزارهایی که برای آزمودن آن مدل‌ها روی داده لازم داری. این درس درباره یاد گرفتن Python نیست؛ فرض می‌کنیم برنامه‌نویسی بلدی. موضوع، عادت‌هایی است که کد تحقیقاتی Quant را از کد معمولی جدا می‌کند.

در کد بک‌اند، باگ معمولاً خودش را نشان می‌دهد: Exception، خطای 500، تستی که قرمز می‌شود. در کد تحقیقاتی، بدترین باگ‌ها هیچ خطایی نمی‌دهند؛ فقط نتیجه را زیادی خوب نشان می‌دهند. یک shift جاافتاده، یک داده که از آینده آمده، یا یک عدد تصادفی که هر بار فرق می‌کند، می‌تواند استراتژی بی‌ارزشی را سودده جلوه دهد. و چون نتیجه خوب است، کسی دنبال باگ نمی‌گردد.

پس سه اصل این درس:

  • تکرارپذیری (Reproducibility): هر نتیجه باید با همان کد، همان داده و همان پارامترها دوباره دقیقاً همان عدد را بدهد.
  • بدون نگاه به آینده (No Look-ahead): هر تصمیم فقط از داده‌ای استفاده کند که در آن لحظه واقعاً در دسترس بود.
  • شکست بلند (Fail Loudly): داده یا ورودی نامعتبر باید برنامه را متوقف کند، نه اینکه بی‌صدا به عدد غلط تبدیل شود.

۲. ساختار پروژه

Notebook برای کاوش عالی است، ولی جای منطق اصلی نیست. کدی که در Notebook می‌ماند تست نمی‌شود، بین تحلیل‌ها کپی می‌شود و هر نسخه‌اش کمی با بقیه فرق دارد. یک ساختار ساده که جواب می‌دهد:

mm-research/
├── pyproject.toml        # pinned dependencies + pytest config
├── configs/
│   └── base.yaml         # seed, hypothetical fees, date ranges
├── data/
│   ├── raw/              # exactly as received; never edited
│   └── processed/        # derived; can always be rebuilt from raw
├── src/mmr/
│   ├── engine.py         # matching engine from lesson 3
│   ├── pnl.py
│   └── features.py
├── notebooks/            # exploration only; imports from src/mmr
├── tests/
│   ├── test_engine.py
│   └── test_pnl.py
└── results/              # one folder per run: config + git commit + outputs

سه قاعده پشت این ساختار است:

  • داده خام دست‌نخورده می‌ماند. هر پاک‌سازی یک مرحله کد است که از raw می‌خواند و در processed می‌نویسد. اگر باگی در پاک‌سازی پیدا شد، فقط همان مرحله را دوباره اجرا می‌کنی. این ایده را در درس ۱۶ برای داده بازار کامل می‌کنیم.
  • Notebook فقط import می‌کند. وقتی تابعی در Notebook دو بار لازم شد، جایش در src/mmr است، کنار تستش.
  • هر اجرا ردپا می‌گذارد. در پوشه هر اجرا، فایل پیکربندی، شناسه Commit در Git و نسخه داده را ذخیره کن. سه ماه بعد که عددی در یک گزارش دیدی، باید بتوانی دقیقاً همان را بازتولید کنی.

۳. NumPy: چرا حلقه نمی‌نویسیم

داده بازار حجیم است؛ یک روز داده Tick یک جفت‌ارز پرمعامله می‌تواند میلیون‌ها ردیف باشد. حلقه Python روی هر ردیف، برای هر عنصر یک شیء Python می‌سازد و هر عملیات را جداگانه تفسیر می‌کند. برداری‌سازی (Vectorization) یعنی یک عملیات را یک‌جا روی کل آرایه بخواهی تا حلقه در کد کامپایل‌شده NumPy اجرا شود.

بازده لگاریتمی یک میلیون قیمت میانی، با حلقه و بدون حلقه:

import time
import numpy as np

rng = np.random.default_rng(42)
n = 1_000_000
mid = 100_000 * np.exp(np.cumsum(rng.normal(0, 1e-4, n)))  # synthetic mid prices

def log_returns_loop(p):
    out = np.empty(len(p) - 1)
    for i in range(1, len(p)):
        out[i - 1] = np.log(p[i] / p[i - 1])
    return out

def log_returns_vec(p):
    return np.diff(np.log(p))

t0 = time.perf_counter()
r_loop = log_returns_loop(mid)
t1 = time.perf_counter()
r_vec = log_returns_vec(mid)
t2 = time.perf_counter()

print(f"loop: {t1 - t0:.2f}s, vectorized: {t2 - t1:.4f}s, same: {np.allclose(r_loop, r_vec)}")

روی سیستم ما خروجی چیزی شبیه این بود:

حلقه Python
≈ 0.6 s
NumPy برداری
≈ 0.005 s
نسبت سرعت (تقریبی)
> 100×
نتیجه یکسان؟
True

عدد دقیق به سخت‌افزار و نسخه‌ها بستگی دارد؛ ولی اختلاف دو مرتبه بزرگی (بیش از ۱۰۰ برابر) معمول است. این اختلاف تعیین می‌کند یک آزمایش ۵ ثانیه طول بکشد یا ۱۰ دقیقه، و در نتیجه چند ایده را در یک روز می‌توانی امتحان کنی.

جایی که برداری‌سازی می‌شکند

همه چیز برداری نمی‌شود. محاسبه‌ای که هر قدمش به وضعیت قدم قبل بستگی دارد (Path-dependent)، با یک فرمول آرایه‌ای درست درنمی‌آید. مثال کلاسیک بازارساز: سقف موجودی (ریسک موجودی را در درس ۶ دیدیم و محدودیت‌هایش را در درس ۲۴ کامل بررسی می‌کنیم). اگر معامله‌ای موجودی را از سقف رد کند، انجام نمی‌شود؛ و همین حذف، همه قدم‌های بعدی را عوض می‌کند:

import numpy as np

def capped_position(signals: np.ndarray, max_pos: int) -> np.ndarray:
    """Each signal wants to buy (+1) or sell (-1) one lot; trades that breach the limit are skipped."""
    out = np.empty(len(signals), dtype=np.int64)
    pos = 0
    for i, s in enumerate(signals):
        if abs(pos + s) <= max_pos:
            pos += s
        out[i] = pos
    return out

signals = np.array([1, 1, 1, -1])
print(capped_position(signals, max_pos=2))   # [1 2 2 1]
print(np.clip(np.cumsum(signals), -2, 2))    # [1 2 2 2]  <- wrong

میان‌بر «برداری» یعنی np.clip(np.cumsum(...)) خوانا و سریع است، ولی غلط است: خرید سوم انجام نشد، پس فروش بعدی باید موجودی را به 1 برگرداند. این نوع باگ هیچ خطایی نمی‌دهد. برای این محاسبات حلقه بنویس، و اگر کند بود، همان حلقه را با ابزارهایی مثل Numba کامپایل کن؛ منطق را برای سرعت قربانی نکن.

۴. pandas برای سری زمانی، و باگ نگاه به آینده

pandas برای سری زمانی دو ابزار کلیدی دارد: ایندکس زمانی (DatetimeIndex) که داده را بر اساس زمان مرتب و قابل برش می‌کند، و shift که داده را در زمان جابه‌جا می‌کند. همین shift منشأ رایج‌ترین باگ تحقیقاتی است.

آزمایش زیر را ببین. قیمت را یک گام تصادفی خالص (Random Walk) می‌سازیم؛ یعنی طبق ساختار داده، هیچ استراتژی‌ای نمی‌تواند لبه واقعی داشته باشد. بعد یک سیگنال ساده Momentum می‌سازیم: اگر جمع بازده ۵ کندل اخیر مثبت بود خرید، وگرنه فروش.

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
idx = pd.date_range("2027-01-01", periods=10_000, freq="1min", tz="UTC")
# A pure random walk: by construction, no strategy can have an edge here
mid = pd.Series(100_000 * np.exp(np.cumsum(rng.normal(0, 5e-4, len(idx)))), index=idx)

ret = np.log(mid).diff()                  # return of bar t (known at the END of bar t)
signal = np.sign(ret.rolling(5).sum())    # 5-bar momentum, also known at the end of bar t

wrong = (signal * ret).sum()              # uses bar t's return to trade bar t: look-ahead
right = (signal.shift(1) * ret).sum()     # decide at end of t, earn the return of t+1

print(f"wrong: {wrong:+.3f}   right: {right:+.3f}")
wrong: +1.815   right: +0.011

جمع بازده لگاریتمی 1.815 یعنی سرمایه حدود ۶ برابر شده؛ روی داده‌ای که اصلاً قابل پیش‌بینی نیست. دلیلش: سیگنال کندل t خودش از بازده کندل t ساخته شده، و نسخه غلط با همان سیگنال، همان بازده را معامله می‌کند. یعنی تصمیم بعد از دیدن نتیجه گرفته شده. نسخه درست تصمیم پایان کندل t را روی بازده کندل t+1 اعمال می‌کند و حاصلش 0.011 است؛ عددی که با انحراف معیار حدود 0.05 برای جمع ۱۰,۰۰۰ بازده، فقط نویز است. (با Seed دیگر عدد دیگری می‌گیری؛ اینکه کدام عدد «معنادار» است، موضوع آزمون فرضیه در درس ۱۰ بود.)

درس ۱۴ همین موضوع را در سطح داده Tick دنبال می‌کند، جایی که برچسب زمانی کندل‌ها و Join کردن معامله‌ها با Quoteها تله‌های مشابهی دارند.

۵. نوع‌ها، dataclass و شکست بلند

در کد تحقیقاتی وسوسه می‌شوی همه چیز را tuple یا dict بگذاری. چند هفته بعد معلوم نیست x[2] حجم بود یا قیمت. Type Hint و dataclass هزینه کمی دارند و هم خواننده را راهنمایی می‌کنند، هم ابزارهایی مثل mypy را. در درس ۳ موتور معاملات را با dataclass نوشتیم؛ اینجا یک تابع PnL ساده با همان قراردادها می‌سازیم: قیمت عدد صحیح بر حسب Tick، حجم عدد صحیح بر حسب Lot.

from dataclasses import dataclass

LOTS_PER_BTC = 1_000  # 1 lot = 0.001 BTC, as in lesson 3

@dataclass(frozen=True)
class Fill:
    side: str   # "buy" or "sell"
    price: int  # USDT, integer ticks (tick = 1 USDT here)
    qty: int    # lots

    def __post_init__(self):
        if self.side not in ("buy", "sell"):
            raise ValueError(f"unknown side: {self.side!r}")
        if self.qty <= 0:
            raise ValueError(f"qty must be positive, got {self.qty}")

def mark_to_market_pnl(fills: list[Fill], mark: int) -> float:
    """Cash from all fills plus the open position valued at `mark`, in USDT."""
    cash = 0       # USDT * lots, kept as an exact integer
    position = 0   # lots
    for f in fills:
        sign = 1 if f.side == "buy" else -1
        position += sign * f.qty
        cash -= sign * f.price * f.qty
    return (cash + position * mark) / LOTS_PER_BTC

چند انتخاب عمدی در این کد هست:

  • frozen=True: یک Fill بعد از ساخته شدن عوض نمی‌شود. داده تاریخی که وسط تحلیل تغییر کند، منشأ باگ‌هایی است که بازتولیدشان تقریباً ناممکن است.
  • اعتبارسنجی در __post_init__: حجم صفر یا منفی، یا "Buy" به‌جای "buy"، همان لحظه خطا می‌دهد. بدون این بررسی، "Buy" بی‌صدا مثل فروش حساب می‌شد.
  • حساب با عدد صحیح، تقسیم در آخر: جمع‌ها دقیق می‌مانند و فقط خروجی نهایی به دلار تبدیل می‌شود.

درباره مدیریت خطا در کد تحقیقاتی، یک قاعده کافی است: except خالی ننویس. الگوی try: ... except: pass روی یک فایل داده خراب یعنی آن روز بی‌صدا از نتایج حذف می‌شود؛ و اگر روزهای حذف‌شده همان روزهای پرنوسان باشند، نتیجه سیستماتیک خوش‌بینانه می‌شود. اگر واقعاً می‌خواهی از خطا بگذری، فقط نوع مشخص خطا را بگیر، دلیلش را Log کن و تعداد موارد حذف‌شده را در گزارش اجرا بیاور. مدیریت خطا در سیستم زنده داستان دیگری است و در درس ۲۶ به آن می‌رسیم.

۶. تست با pytest

تست در کد تحقیقاتی کمتر از کد بک‌اند جدی گرفته می‌شود، در حالی که دلیلش قوی‌تر است: باگ اینجا خطا نمی‌دهد، فقط عدد را جابه‌جا می‌کند. pytest رایج‌ترین ابزار تست در Python است: هر تابعی که نامش با test_ شروع شود یک تست است و assert ساده کافی است.

تست تابع PnL

بهترین تست‌ها از مثال‌هایی می‌آیند که جوابشان را با دست حساب کرده‌ای. مثال درس ۱: خرید 0.1 BTC در 99,990 و فروش در 100,010 باید ۲ دلار سود ناخالص بدهد.

import pytest
from mmr.pnl import Fill, mark_to_market_pnl

def test_round_trip_earns_the_spread():
    # Lesson 1: buy 0.1 BTC at 99,990, sell at 100,010 -> 2 USDT before fees
    fills = [Fill("buy", 99_990, 100), Fill("sell", 100_010, 100)]
    assert mark_to_market_pnl(fills, mark=100_000) == 2.0

def test_open_position_is_marked():
    fills = [Fill("buy", 99_990, 100)]
    assert mark_to_market_pnl(fills, mark=100_000) == 1.0

def test_rejects_bad_input():
    with pytest.raises(ValueError):
        Fill("buy", 100_000, 0)

تست موتور معاملات درس ۳

برای موتور معاملات دو نوع تست می‌نویسیم. اولی یک مثال دقیق: همان سفارش خرید Limit درس ۳ با قیمت 100,020 و حجم 1 BTC (یعنی ۱,۰۰۰ Lot) که باید 0.3 و 0.5 BTC را پر کند و 0.2 BTC باقی‌مانده را به‌عنوان Best Bid جدید در دفتر بگذارد. دومی یک تست ناوردایی (Invariant): ویژگی‌ای که بعد از هر سفارشی باید برقرار بماند، مهم نیست سفارش چه باشد. اینجا: دفتر سفارش هیچ‌وقت نباید Crossed شود، یعنی بالاترین Bid هرگز به پایین‌ترین Ask نرسد؛ چون موتور درست، چنین سفارش‌هایی را فوراً معامله می‌کند.

import random
import pytest
from mmr.engine import Order, OrderBook

@pytest.fixture
def book():
    """Lesson 1 book in lots (1 lot = 0.001 BTC)."""
    b = OrderBook()
    for i, (price, qty) in enumerate([(100_010, 300), (100_020, 500), (100_030, 900)]):
        b.submit(Order(f"a{i}", "sell", price, qty))
    for i, (price, qty) in enumerate([(99_990, 400), (99_980, 700), (99_970, 1_200)]):
        b.submit(Order(f"b{i}", "buy", price, qty))
    return b

def test_limit_buy_partial_fill(book):
    fills = book.submit(Order("t1", "buy", 100_020, 1_000))
    assert fills == [("a0", 100_010, 300), ("a1", 100_020, 500)]
    assert max(book.bids) == 100_020            # remainder is the new best bid
    assert book.bids[100_020][0].qty == 200

def test_book_never_crossed():
    rng = random.Random(0)                     # fixed seed: a failure is reproducible
    b = OrderBook()
    for i in range(2_000):
        side = rng.choice(["buy", "sell"])
        b.submit(Order(str(i), side, rng.randint(99_950, 100_050), rng.randint(1, 500)))
        if b.bids and b.asks:
            assert max(b.bids) < min(b.asks)

برای اینکه from mmr... در ساختار بخش ۲ کار کند، در pyproject.toml این را اضافه کن (از pytest نسخه ۷ به بعد پشتیبانی می‌شود):

[tool.pytest.ini_options]
pythonpath = ["src"]

با اجرای pytest -q در ریشه پروژه، هر پنج تست باید سبز شوند. چند نکته:

  • Fixture (تابع book) دفتر سفارش را برای هر تست از نو می‌سازد؛ پس تست‌ها روی وضعیت هم اثر نمی‌گذارند.
  • تست تصادفی با Seed ثابت: ۲,۰۰۰ سفارش تصادفی حالت‌هایی را می‌سازد که به ذهن خودت نمی‌رسید؛ Seed ثابت تضمین می‌کند اگر روزی شکست خورد، دقیقاً همان دنباله را دوباره بسازی و Debug کنی. کتابخانه‌هایی مثل Hypothesis این ایده (Property-based Testing) را کامل‌تر پیاده می‌کنند.
  • تست‌ها کد را نگه می‌دارند: وقتی در درس ۳۰ موتور را برای سرعت بازنویسی کنیم (مثلاً جایگزین کردن min روی همه سطوح با ساختار مرتب)، همین تست‌ها می‌گویند رفتار عوض نشده است.

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

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

سؤال ۱: ret بازده کندل t و signal سیگنالی است که در پایان کندل t معلوم می‌شود. کدام عبارت PnL را بدون نگاه به آینده حساب می‌کند؟
سؤال ۲: سقف موجودی ۲ لات است و از موجودی صفر شروع می‌کنی. سیگنال‌ها به ترتیب: خرید، خرید، خرید، فروش، فروش (هر کدام یک لات). موجودی نهایی چند لات است؟
سؤال ۳: کدام محاسبه را نمی‌توان با یک عملیات برداری ساده NumPy درست انجام داد؟
سؤال ۴: چرا در تست تصادفی موتور معاملات از Seed ثابت استفاده کردیم؟

درس بعدی

در درس چهاردهم سراغ داده Tick-level می‌رویم: میلیون‌ها معامله و Quote در روز. می‌بینیم چطور با pandas و Polars آن‌ها را به کندل تبدیل کنیم، معامله‌ها را با Quote همان لحظه با Asof Join هم‌تراز کنیم، نوع داده مناسب حافظه را انتخاب کنیم، و کی Polars ارزش جابه‌جایی دارد.

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