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

مسیر Quant Developer · بخش ۸: پروژه عملی و آمادگی استخدام · درس ۲۹

طراحی Market Maker برای یک جفت‌ارز

سند طراحی یک بازارساز ساده برای BTC/USDT؛ از انتخاب Fair Value و قاعده Spread تا Skew، اندازه سفارش، سقف موجودی، کنترل ریسک و لاگ، همراه با یک تابع Python که همه این تصمیم‌ها را کنار هم می‌گذارد.

سطح: پیشرفته۱۴ دقیقه مطالعه

۱. از دانستن تا ساختن

۲۸ درس پشت سر گذاشته‌ایم. حالا می‌دانی Fair Value چیست (درس ۵)، Skew چرا لازم است (درس ۶)، PnL از چه تکه‌هایی ساخته می‌شود (درس ۷)، Markout چه چیزی را لو می‌دهد (درس ۸) و یک سیستم واقعی با Kill Switch و مانیتورینگ چه شکلی دارد (درس‌های ۲۵ تا ۲۸). ولی دانستن تکه‌ها با کنار هم گذاشتنشان فرق دارد.

سه درس آخر این مسیر یک پروژه واحدند:

  • درس ۲۹ (همین درس): سند طراحی و تابع قیمت‌دهی.
  • درس ۳۰: یک Backtester رویدادمحور که همین تابع را با کارمزد، تأخیر و مدل صف اجرا می‌کند.
  • درس ۳۱: تحلیل نتیجه‌ها؛ تفکیک PnL، Markout، بهینه‌سازی پارامتر و خطر Overfitting.

خروجی این سه درس همان چیزی است که در درس ۳۲ به‌عنوان نمونه‌کار (Portfolio) در مصاحبه ارائه می‌دهی. پس از همین اول مثل یک مهندس کار می‌کنیم: قبل از کد، سند طراحی (Design Doc) می‌نویسیم.

چرا سند؟ چون هر عدد در یک بازارساز یک تصمیم است. اگر بعداً PnL منفی شد، باید بدانی کدام تصمیم را با چه فرضی گرفته بودی تا بتوانی آن را آزمایش کنی. سندی که فرض‌ها را صریح بنویسد، نصف کار تحلیل درس ۳۱ را جلوجلو انجام داده است.

۲. دامنه پروژه

اول مشخص می‌کنیم چه چیزی داخل پروژه است و چه چیزی بیرون:

موضوعدر این پروژهبیرون از دامنه (فعلاً)
بازاریک جفت‌ارز Spot، مثلاً BTC/USDTچند بازار هم‌زمان، Perpetual (درس ۲۲)
صرافییک صرافی فرضی با Price-Time PriorityCross-Venue (درس ۲۳)
پوشش ریسکفقط با Skew و سقف موجودیHedging با ابزار دیگر (درس ۲۱)
مدل قیمت‌دهیقاعده ساده و قابل‌توضیحفرمول کامل Avellaneda–Stoikov یا GLFT (درس‌های ۱۱ و ۱۲)
اندازهحجم کوچک و ثابتتخصیص سرمایه بین استراتژی‌ها (درس ۲۴)

این جدول ضعف نیست، تصمیم است. پروژه‌ای که همه‌چیز را بخواهد، هیچ‌وقت تمام نمی‌شود. نسخه اول باید آن‌قدر ساده باشد که بتوانی هر عدد خروجی‌اش را توضیح بدهی.

مثال همیشگی را نگه می‌داریم: دفتر سفارش درس ۱ با Best Bid برابر 99,990 (حجم 0.4) و Best Ask برابر 100,010 (حجم 0.3). همه پارامترها و نرخ‌ها در این پروژه فرضی‌اند.

۳. نمای کلی سیستم

هر بار که داده بازار تازه‌ای می‌رسد، این زنجیره اجرا می‌شود:

  1. Market Data بهترین قیمت‌ها و حجم‌ها، و معاملات (درس ۱۶)
  2. Fair Value + Volatility قیمت منصفانه و تخمین نوسان کوتاه‌مدت
  3. Quote Engine Spread، Skew و اندازه هر طرف
  4. Risk Checks سقف زیان، داده کهنه، محدوده قیمت
  5. Order Manager ثبت، لغو و جایگزینی سفارش (درس ۲۶)
  6. Exchange سفارش‌ها در صف می‌نشینند و پر می‌شوند

و موازی با همه این‌ها، لاگ هر تصمیم و هر رویداد ثبت می‌شود (بخش ۱۰). در ادامه هر جعبه را به یک تصمیم مشخص تبدیل می‌کنیم.

۴. Fair Value: میکروپرایس

در درس ۵ دیدیم آخرین قیمت معامله و حتی Mid Price همیشه بهترین تخمین ارزش نیستند. برای نسخه اول Microprice را انتخاب می‌کنیم؛ میانگین Bid و Ask که با حجم طرف مقابل وزن داده شده است:

Microprice = (Bid × AskSize + Ask × BidSize) / (BidSize + AskSize)

با دفتر سفارش درس ۱:

(99,990 × 0.3 + 100,010 × 0.4) / 0.7 = 100,001.43

سمت خرید حجم بیشتری دارد، پس Fair Value کمی بالاتر از Mid (100,000) می‌نشیند. شهودش این است که صف کوچک‌تر سمت فروش زودتر تمام می‌شود و قیمت بعدی احتمالاً بالاتر است.

تصمیم و فرض: فرض می‌کنیم عدم‌تعادل حجم بالای دفتر کمی اطلاعات درباره حرکت بعدی دارد. این فرض قابل آزمایش است؛ اگر در داده واقعی برقرار نباشد، میکروپرایس فقط نویز به Mid اضافه می‌کند. در سند می‌نویسیم که در درس ۳۱ باید این را بسنجیم، و در کد هم یک پارامتر (use_micro) می‌گذاریم تا بتوانیم Fair Value را با یک کلید به Mid ساده برگردانیم. هر تصمیمی که قرار است آزمایش شود، باید قابل خاموش کردن باشد.

۵. قاعده Spread

نیم‌اسپرد (Half-Spread)، یعنی فاصله هر سفارش از قیمت مرجع، را این‌طور تعیین می‌کنیم:

half = max(min_half, vol_mult × σ)

در این قاعده σ انحراف معیار تغییر Mid در یک ثانیه است که با میانگین متحرک نمایی (EWMA، درس ۹) تخمین زده می‌شود. منطقش:

  • کف ثابت (min_half، فرضی 6 دلار): باید از کارمزد هر طرف، یک Tick و زیان معمول Adverse Selection (درس ۸) بیشتر باشد. زیر این کف، هر پر شدن به‌طور متوسط ضرر است.
  • جزء نوسان: وقتی بازار پرنوسان است، قیمت سریع‌تر از سفارش‌های ما عبور می‌کند و پر شدن‌ها سمی‌تر می‌شوند. پس Spread باید با σ بزرگ شود.

این قاعده هم‌روح مدل Avellaneda–Stoikov است (درس ۱۱)، نه خود آن. در آن مدل مجموع فاصله دو طرف این است:

δa + δb = γσ²(T − t) + (2/γ) ln(1 + γ/k)

که در آن γ ضریب ریسک‌گریزی، k شدت کاهش احتمال پر شدن با فاصله از Mid، و T − t زمان باقی‌مانده است. مدل ما هم با σ بزرگ می‌شود، اما به‌جای تخمین γ و k که در عمل سخت و ناپایدارند، دو پارامتر قابل‌فهم دارد. این یک معامله آگاهانه است: سادگی و توضیح‌پذیری در برابر بهینگی نظری.

۶. Skew: قیمت رزرو

در درس ۶ دیدیم بازارسازی که موجودی دارد باید هر دو قیمتش را جابه‌جا کند تا موجودی به صفر برگردد. قیمت رزرو (Reservation Price) را این‌طور تعریف می‌کنیم:

r = fair − skew × half × (q / max_pos)

که q موجودی فعلی (BTC) و max_pos سقف موجودی است. وقتی q به سقف برسد، قیمت رزرو به اندازه skew برابر نیم‌اسپرد پایین می‌آید. با skew = 1 و موجودی پر، سفارش فروش دقیقاً روی Fair Value می‌نشیند؛ یعنی حاضری برای خلاص شدن از موجودی از کل حاشیه‌ات بگذری.

مقایسه با Avellaneda–Stoikov:

r = s − q γ σ² (T − t)

ساختار یکی است: جابه‌جایی خطی با موجودی. تفاوت در ضریب است. ضریب ما (skew × half / max_pos) مستقیم به سقف موجودی گره خورده و تفسیرش ساده است. در درس ۱۲ دیدیم مدل GLFT هم سقف موجودی را صریحاً وارد مسئله می‌کند؛ ما همین ایده را با یک قاعده خطی ساده تقلید می‌کنیم.

قیمت نهایی هر طرف:

bid = floor((r − half) / tick) × tick
ask = ceil((r + half) / tick) × tick

گرد کردن همیشه به سمت دور از Fair Value است تا گرد کردن هیچ‌وقت لبه ما را کم نکند. بعد یک شرط Post-Only (درس ۳) اعمال می‌شود: Bid ما هرگز به Best Ask بازار نمی‌رسد و Ask ما هرگز به Best Bid.

۷. اندازه سفارش و سقف موجودی

قاعده اندازه ساده است: هر طرف حجم پایه base_size (فرضی 0.01 BTC) دارد، ولی هرگز بیشتر از فضای باقی‌مانده تا سقف نیست:

bid_size = min(base_size, max_pos − q)
ask_size = min(base_size, max_pos + q)

با max_pos = 0.05، وقتی موجودی به 0.05 BTC برسد، حجم خرید صفر می‌شود و فقط سمت فروش فعال است. این سقف سخت است. Skew نقش سقف نرم را دارد: خیلی قبل از رسیدن به سقف سخت، قیمت‌ها را طوری جابه‌جا می‌کند که احتمال برگشت موجودی بیشتر شود. تفاوت این دو و نحوه تعیین عدد سقف از روی سرمایه را در درس ۲۴ دیدیم.

۸. کنترل‌های ریسک

Quote Engine می‌گوید «چه می‌خواهم»؛ لایه ریسک می‌گوید «چه اجازه داری». این دو را عمداً جدا نگه می‌داریم تا یک باگ در منطق قیمت‌دهی نتواند از کنترل‌ها عبور کند:

کنترلآستانه (فرضی)واکنش
سقف زیان (Kill Switch، درس ۲۷)زیان بیش از 50 دلارلغو همه سفارش‌ها
داده کهنهآخرین داده قدیمی‌تر از 500 میلی‌ثانیهلغو همه سفارش‌ها
محدوده قیمتفاصله قیمت از Mid بیش از 50 دلارحذف همان طرف
سقف موجودی0.05 BTCصفر شدن حجم سمتی که ریسک را زیاد می‌کند
حداکثر حجم هر سفارش0.01 BTCاز راه base_size

کنترل محدوده قیمت شاید عجیب به نظر برسد، چون قاعده ما هیچ‌وقت چنین قیمتی تولید نمی‌کند. دقیقاً به همین دلیل لازم است: اگر روزی σ به خاطر یک داده خراب ۱۰۰ برابر شد، این کنترل جلوی سفارش بی‌معنی را می‌گیرد. دقت کن که این کنترل فاصله را از Mid می‌سنجد، نه از Fair Value مدل. لایه ریسک نباید به خروجی همان مدلی تکیه کند که قرار است جلوی خطایش را بگیرد.

۹. کد: همه تصمیم‌ها در یک فایل

حالا تصمیم‌های بخش‌های ۴ تا ۸ را در یک فایل کوچک می‌نویسیم. اسمش را mm_quote.py بگذار، چون در درس ۳۰ آن را import می‌کنیم:

import math
from dataclasses import dataclass

@dataclass(frozen=True)
class Params:
    tick: float = 1.0        # price step, USDT
    base_size: float = 0.01  # BTC per quote
    max_pos: float = 0.05    # hard inventory limit, BTC
    min_half: float = 6.0    # floor for the half-spread, USDT
    vol_mult: float = 1.0    # half-spread = max(min_half, vol_mult * sigma)
    skew: float = 1.0        # reservation-price shift at max_pos, in half-spreads
    use_micro: bool = True   # fair value: microprice (lesson 5) or plain mid

@dataclass
class Quote:
    bid_px: float
    bid_sz: float
    ask_px: float
    ask_sz: float

def microprice(bid, ask, bid_sz, ask_sz):
    """Fair value from the top of the book (lesson 5)."""
    return (bid * ask_sz + ask * bid_sz) / (bid_sz + ask_sz)

def make_quote(bid, ask, bid_sz, ask_sz, q, sigma, p: Params) -> Quote:
    fair = microprice(bid, ask, bid_sz, ask_sz) if p.use_micro else (bid + ask) / 2
    half = max(p.min_half, p.vol_mult * sigma)          # spread rule
    r = fair - p.skew * half * (q / p.max_pos)          # inventory skew (lesson 6)
    bid_px = math.floor((r - half) / p.tick) * p.tick   # round away from fair
    ask_px = math.ceil((r + half) / p.tick) * p.tick
    bid_px = min(bid_px, ask - p.tick)                  # post-only: never cross
    ask_px = max(ask_px, bid + p.tick)
    room_buy = round(max(0.0, p.max_pos - q), 8)        # size shrinks near the limit
    room_sell = round(max(0.0, p.max_pos + q), 8)
    return Quote(bid_px, min(p.base_size, room_buy), ask_px, min(p.base_size, room_sell))

@dataclass(frozen=True)
class Limits:
    max_loss: float = 50.0      # USDT, kill switch (lesson 27)
    max_md_age_ms: int = 500    # stale market data -> pull quotes
    band: float = 50.0          # USDT, max distance of a quote from the mid

def risk_check(qt: Quote, mid, pnl, md_age_ms, lim: Limits) -> Quote:
    """Last gate before sending: returns a quote with sizes zeroed where needed."""
    if pnl < -lim.max_loss or md_age_ms > lim.max_md_age_ms:
        return Quote(qt.bid_px, 0.0, qt.ask_px, 0.0)
    if mid - qt.bid_px > lim.band:
        qt.bid_sz = 0.0
    if qt.ask_px - mid > lim.band:
        qt.ask_sz = 0.0
    return qt

if __name__ == "__main__":
    p = Params()
    print(round(microprice(99_990, 100_010, 0.4, 0.3), 2))
    for q in (0.0, 0.02, 0.05, -0.05):
        print(q, make_quote(99_990, 100_010, 0.4, 0.3, q, sigma=9.0, p=p))

چند نکته درباره خود کد:

  • make_quote یک تابع خالص است: ورودی می‌گیرد و خروجی می‌دهد، بدون شبکه، ساعت یا حالت پنهان. این یعنی همان تابع بدون هیچ تغییری هم در Backtester درس ۳۰ اجرا می‌شود و هم در سیستم Production درس ۲۵. هر چیزی که به زمان یا شبکه وابسته است، بیرون از این تابع می‌ماند.
  • Params تغییرناپذیر (frozen) است. در درس ۳۱ ده‌ها ترکیب پارامتر را آزمایش می‌کنیم و نمی‌خواهیم یک اجرا پارامترهای اجرای دیگر را دست بزند.
  • round(..., 8) جلوی خطای اعشاری را می‌گیرد. همان مشکلی که در درس ۳ دیدیم: بدون آن، 0.05 منهای 0.04 دقیقاً 0.01 نمی‌شود.
  • در این نسخه ساده، Kill Switch قفل نمی‌شود: اگر زیان دوباره از 50 دلار کمتر شود، سفارش‌دهی ادامه پیدا می‌کند. در درس ۲۷ دیدیم Kill Switch واقعی باید تا دخالت انسان خاموش بماند. این را در سند به‌عنوان محدودیت ثبت می‌کنیم.

خروجی روی دفتر سفارش درس ۱

با σ برابر 9 دلار (نزدیک همان نوسانی که در داده مصنوعی درس ۳۰ می‌بینیم)، نیم‌اسپرد برابر max(6, 9) = 9 دلار است:

100001.43
0.0 Quote(bid_px=99992.0, bid_sz=0.01, ask_px=100011.0, ask_sz=0.01)
0.02 Quote(bid_px=99988.0, bid_sz=0.01, ask_px=100007.0, ask_sz=0.01)
0.05 Quote(bid_px=99983.0, bid_sz=0.0, ask_px=100002.0, ask_sz=0.01)
-0.05 Quote(bid_px=100001.0, bid_sz=0.01, ask_px=100020.0, ask_sz=0.0)
موجودی qقیمت رزرو rBid (حجم)Ask (حجم)
صفر100,001.4399,992 (0.01)100,011 (0.01)
0.02 BTC خرید99,997.8399,988 (0.01)100,007 (0.01)
0.05 BTC خرید (سقف)99,992.4399,983 (صفر)100,002 (0.01)
0.05 BTC فروش (سقف)100,010.43100,001 (0.01)100,020 (صفر)

ردیف دوم را با دست چک کنیم. q برابر 0.02 یعنی ۴۰٪ سقف، پس جابه‌جایی برابر 1 × 9 × 0.4 = 3.6 دلار است:

r = 100,001.43 − 3.6 = 99,997.83
bid = floor(99,997.83 − 9) = floor(99,988.83) = 99,988
ask = ceil(99,997.83 + 9) = ceil(100,006.83) = 100,007

در ردیف سوم Ask ما (100,002) از Best Ask بازار (100,010) هم ارزان‌تر است؛ یعنی با موجودی پر، تهاجمی‌ترین فروشنده بازاریم. در ردیف چهارم برعکس: Bid ما (100,001) بالاتر از Mid است. این همان رفتاری است که از Skew می‌خواستیم.

۱۰. چه چیزی باید لاگ شود؟

هر تحلیلی که در درس ۳۱ انجام می‌دهیم، به داده‌ای وابسته است که امروز تصمیم می‌گیری ثبت کنی. داده‌ای که ثبت نشده، بعداً قابل بازسازی نیست:

رویدادفیلدهای لازمبرای چه
تصمیم قیمت‌دهیزمان، Bid و Ask بازار، Fair، σ، q، قیمت و حجم هر طرفبازتولید هر تصمیم
ارسال و تأیید سفارششناسه، زمان تصمیم، زمان ارسال، زمان تأییداندازه‌گیری تأخیر (درس ۱۹)
پر شدنزمان، سمت، قیمت، حجم، کارمزد، Mid در لحظه پر شدن، نوع پر شدنتفکیک PnL و Markout (درس‌های ۷ و ۸)
رد شدن یا خطاشناسه، دلیلچرخه عمر سفارش (درس ۲۶)
وضعیت دوره‌ایهر ثانیه: PnL، q، σDrawdown و داشبورد (درس‌های ۲۰ و ۲۸)
فعال شدن کنترل ریسککدام کنترل، مقدار، آستانهبازبینی بعد از حادثه

قاعده طلایی: ورودی‌های تصمیم را لاگ کن، نه فقط خروجی‌اش را. اگر فقط بدانی سفارش در 99,988 ثبت شد، نمی‌دانی چرا. اگر Fair، σ و q را هم داشته باشی، می‌توانی همان تصمیم را دوباره با make_quote بسازی و ببینی کد همان کاری را کرده که سند می‌گوید.

۱۱. محدودیت‌های این طراحی

سند خوب جای ضعف‌هایش را هم نشان می‌دهد:

  • Microprice فرض است، نه واقعیت. ممکن است در یک بازار خاص هیچ قدرت پیش‌بینی نداشته باشد.
  • σ از گذشته تخمین زده می‌شود. در یک جهش ناگهانی، EWMA چند ثانیه عقب است و درست در بدترین لحظه Spread ما هنوز تنگ است.
  • هیچ فیلتری برای جریان سمی نداریم. سیستم نمی‌فهمد کدام معامله‌گر آگاه‌تر است؛ فقط با Spread کلی از خودش دفاع می‌کند.
  • صف را نمی‌بیند. تابع نمی‌داند اگر قیمتش را عوض کند، جایگاهش در صف (درس ۱۸) از دست می‌رود.
  • یک بازار، بدون Hedge. تنها ابزار کنترل موجودی، Skew و سقف است.

هیچ‌کدام از این‌ها دلیل نساختن نیست. دلیل اندازه‌گیری است. در درس ۳۰ این طراحی را در یک بازار مصنوعی اجرا می‌کنیم و در درس ۳۱ می‌بینیم کدام ضعف واقعاً هزینه دارد.

۱۲. تمرین این درس

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

سؤال ۱: در دفتر سفارش درس ۱ اگر حجم Best Bid برابر 0.3 و حجم Best Ask برابر 0.4 بود (برعکس مثال درس)، Microprice چقدر می‌شد؟
سؤال ۲: σ برابر 4 دلار و موجودی صفر است. نیم‌اسپرد چقدر است؟
سؤال ۳: موجودی 0.05 BTC خرید (برابر سقف) است. کدام جمله درست است؟
سؤال ۴: چرا make_quote را تابعی خالص و بدون دسترسی به ساعت یا شبکه نوشتیم؟

درس بعدی

در درس سی‌ام برای همین طراحی یک Backtester رویدادمحور می‌سازیم: یک بازار مصنوعی BTC/USDT با معامله‌گران آگاه و ناآگاه، صفی که سفارش ما در آن منتظر می‌ماند، تأخیر بین تصمیم و رسیدن سفارش به صرافی، و کارمزد. بعد کل پروژه را از ابتدا تا انتها اجرا می‌کنیم و اولین عدد واقعی PnL این طراحی را می‌بینیم.

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