مسیر 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 Priority | Cross-Venue (درس ۲۳) |
| پوشش ریسک | فقط با Skew و سقف موجودی | Hedging با ابزار دیگر (درس ۲۱) |
| مدل قیمتدهی | قاعده ساده و قابلتوضیح | فرمول کامل Avellaneda–Stoikov یا GLFT (درسهای ۱۱ و ۱۲) |
| اندازه | حجم کوچک و ثابت | تخصیص سرمایه بین استراتژیها (درس ۲۴) |
این جدول ضعف نیست، تصمیم است. پروژهای که همهچیز را بخواهد، هیچوقت تمام نمیشود. نسخه اول باید آنقدر ساده باشد که بتوانی هر عدد خروجیاش را توضیح بدهی.
مثال همیشگی را نگه میداریم: دفتر سفارش درس ۱ با Best Bid برابر 99,990 (حجم 0.4) و Best Ask برابر 100,010 (حجم 0.3). همه پارامترها و نرخها در این پروژه فرضیاند.
۳. نمای کلی سیستم
هر بار که داده بازار تازهای میرسد، این زنجیره اجرا میشود:
- Market Data بهترین قیمتها و حجمها، و معاملات (درس ۱۶)
- Fair Value + Volatility قیمت منصفانه و تخمین نوسان کوتاهمدت
- Quote Engine Spread، Skew و اندازه هر طرف
- Risk Checks سقف زیان، داده کهنه، محدوده قیمت
- Order Manager ثبت، لغو و جایگزینی سفارش (درس ۲۶)
- Exchange سفارشها در صف مینشینند و پر میشوند
و موازی با همه اینها، لاگ هر تصمیم و هر رویداد ثبت میشود (بخش ۱۰). در ادامه هر جعبه را به یک تصمیم مشخص تبدیل میکنیم.
۴. Fair Value: میکروپرایس
در درس ۵ دیدیم آخرین قیمت معامله و حتی Mid Price همیشه بهترین تخمین ارزش نیستند. برای نسخه اول Microprice را انتخاب میکنیم؛ میانگین Bid و Ask که با حجم طرف مقابل وزن داده شده است:
با دفتر سفارش درس ۱:
سمت خرید حجم بیشتری دارد، پس Fair Value کمی بالاتر از Mid (100,000) مینشیند. شهودش این است که صف کوچکتر سمت فروش زودتر تمام میشود و قیمت بعدی احتمالاً بالاتر است.
تصمیم و فرض: فرض میکنیم عدمتعادل حجم بالای دفتر کمی اطلاعات درباره حرکت بعدی دارد. این فرض قابل آزمایش است؛ اگر در داده واقعی برقرار نباشد، میکروپرایس فقط نویز به Mid اضافه میکند. در سند مینویسیم که در درس ۳۱ باید این را بسنجیم، و در کد هم یک پارامتر (use_micro) میگذاریم تا بتوانیم Fair Value را با یک کلید به Mid ساده برگردانیم. هر تصمیمی که قرار است آزمایش شود، باید قابل خاموش کردن باشد.
۵. قاعده Spread
نیماسپرد (Half-Spread)، یعنی فاصله هر سفارش از قیمت مرجع، را اینطور تعیین میکنیم:
در این قاعده σ انحراف معیار تغییر Mid در یک ثانیه است که با میانگین متحرک نمایی (EWMA، درس ۹) تخمین زده میشود. منطقش:
- کف ثابت (
min_half، فرضی 6 دلار): باید از کارمزد هر طرف، یک Tick و زیان معمول Adverse Selection (درس ۸) بیشتر باشد. زیر این کف، هر پر شدن بهطور متوسط ضرر است. - جزء نوسان: وقتی بازار پرنوسان است، قیمت سریعتر از سفارشهای ما عبور میکند و پر شدنها سمیتر میشوند. پس Spread باید با σ بزرگ شود.
این قاعده همروح مدل Avellaneda–Stoikov است (درس ۱۱)، نه خود آن. در آن مدل مجموع فاصله دو طرف این است:
که در آن γ ضریب ریسکگریزی، k شدت کاهش احتمال پر شدن با فاصله از Mid، و T − t زمان باقیمانده است. مدل ما هم با σ بزرگ میشود، اما بهجای تخمین γ و k که در عمل سخت و ناپایدارند، دو پارامتر قابلفهم دارد. این یک معامله آگاهانه است: سادگی و توضیحپذیری در برابر بهینگی نظری.
۶. Skew: قیمت رزرو
در درس ۶ دیدیم بازارسازی که موجودی دارد باید هر دو قیمتش را جابهجا کند تا موجودی به صفر برگردد. قیمت رزرو (Reservation Price) را اینطور تعریف میکنیم:
که q موجودی فعلی (BTC) و max_pos سقف موجودی است. وقتی q به سقف برسد، قیمت رزرو به اندازه skew برابر نیماسپرد پایین میآید. با skew = 1 و موجودی پر، سفارش فروش دقیقاً روی Fair Value مینشیند؛ یعنی حاضری برای خلاص شدن از موجودی از کل حاشیهات بگذری.
مقایسه با Avellaneda–Stoikov:
ساختار یکی است: جابهجایی خطی با موجودی. تفاوت در ضریب است. ضریب ما (skew × half / max_pos) مستقیم به سقف موجودی گره خورده و تفسیرش ساده است. در درس ۱۲ دیدیم مدل GLFT هم سقف موجودی را صریحاً وارد مسئله میکند؛ ما همین ایده را با یک قاعده خطی ساده تقلید میکنیم.
قیمت نهایی هر طرف:
گرد کردن همیشه به سمت دور از Fair Value است تا گرد کردن هیچوقت لبه ما را کم نکند. بعد یک شرط Post-Only (درس ۳) اعمال میشود: Bid ما هرگز به Best Ask بازار نمیرسد و Ask ما هرگز به Best Bid.
۷. اندازه سفارش و سقف موجودی
قاعده اندازه ساده است: هر طرف حجم پایه base_size (فرضی 0.01 BTC) دارد، ولی هرگز بیشتر از فضای باقیمانده تا سقف نیست:
با 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 | قیمت رزرو r | Bid (حجم) | Ask (حجم) |
|---|---|---|---|
| صفر | 100,001.43 | 99,992 (0.01) | 100,011 (0.01) |
| 0.02 BTC خرید | 99,997.83 | 99,988 (0.01) | 100,007 (0.01) |
| 0.05 BTC خرید (سقف) | 99,992.43 | 99,983 (صفر) | 100,002 (0.01) |
| 0.05 BTC فروش (سقف) | 100,010.43 | 100,001 (0.01) | 100,020 (صفر) |
ردیف دوم را با دست چک کنیم. q برابر 0.02 یعنی ۴۰٪ سقف، پس جابهجایی برابر 1 × 9 × 0.4 = 3.6 دلار است:
در ردیف سوم 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 هستند.
درس بعدی
در درس سیام برای همین طراحی یک Backtester رویدادمحور میسازیم: یک بازار مصنوعی BTC/USDT با معاملهگران آگاه و ناآگاه، صفی که سفارش ما در آن منتظر میماند، تأخیر بین تصمیم و رسیدن سفارش به صرافی، و کارمزد. بعد کل پروژه را از ابتدا تا انتها اجرا میکنیم و اولین عدد واقعی PnL این طراحی را میبینیم.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.