مسیر Quant Developer · بخش ۸: پروژه عملی و آمادگی استخدام · درس ۳۰
ساخت Backtester با کارمزد، تأخیر و مدل پر شدن
یک Backtester رویدادمحور برای Market Maker درس ۲۹ میسازیم؛ با بازار مصنوعی، صف سفارش، تأخیر شبکه و کارمزد، و آن را از ابتدا تا انتها اجرا میکنیم.
۱. چرا رویدادمحور؟
در درس ۱۷ دو سبک Backtest را مقایسه کردیم. سبک برداری (Vectorized) کل سری قیمت را یکجا پردازش میکند و برای استراتژیهایی که روی کندل روزانه تصمیم میگیرند کافی است. اما Market Maker سه ویژگی دارد که سبک برداری نمیتواند شبیهسازی کند:
- وضعیت سفارش: سفارش ما در صف مینشیند، بخشی از آن پر میشود و باقیاش منتظر میماند. نتیجه هر لحظه به تاریخچه سفارش بستگی دارد.
- تأخیر: بین تصمیم ما و رسیدن سفارش به صرافی چند ده میلیثانیه میگذرد. در این فاصله سفارش قدیمی هنوز در دفتر است و ممکن است پر شود.
- بازخورد: هر پر شدن موجودی را عوض میکند و موجودی قیمتهای بعدی را (از راه Skew درس ۲۹).
پس به یک Backtester رویدادمحور (Event-Driven) نیاز داریم: صفی از رویدادها که به ترتیب زمان پردازش میشوند و هر رویداد میتواند رویداد تازهای در آینده بسازد. این همان معماری درس ۲۵ است، فقط ساعتش بهجای ساعت واقعی، زمان شبیهسازی است.
۲. نقشه کد
Backtester ما سه نوع رویداد دارد:
| رویداد | از کجا میآید | چه میکند |
|---|---|---|
md | بازار مصنوعی، هر 100 میلیثانیه | بهروزرسانی دفتر، چک عبور قیمت، قیمتدهی تازه |
trade | بازار مصنوعی، تصادفی | سفارش Market که ممکن است به سفارش ما برسد |
arrive | خود ما، با تأخیر | سفارش جدید یا لغو، به صرافی میرسد |
- make_market رویدادهای بازار مصنوعی با Seed ثابت
- Event Heap همه رویدادها به ترتیب زمان
- make_quote + risk_check همان کد درس ۲۹، بدون تغییر
- Simulated Exchange صف، Post-Only، عبور قیمت، پر شدن
- Fills + Equity خروجی برای تحلیل درس ۳۱
کد کامل حدود ۱۳۰ خط است و در پنج تکه میآید. همه تکهها را به ترتیب در یک فایل به اسم backtest.py کنار mm_quote.py درس ۲۹ بگذار. فقط به Python 3 و numpy و pandas نیاز داری.
۳. تکه اول: یک بازار مصنوعی
داده واقعی Tick-level (درس ۱۶) بهترین ورودی است، ولی برای یادگیری یک مزیت بزرگ ندارد: حقیقت پنهانش را نمیدانی. در بازار مصنوعی میدانیم چند درصد معاملهگرها آگاهاند و میتوانیم ببینیم Backtester همان را کشف میکند یا نه.
import heapq
import itertools
import math
import numpy as np
import pandas as pd
from mm_quote import Params, Limits, make_quote, risk_check
STEP_MS = 100 # one market-data snapshot every 100 ms
def make_market(seed=7, n_steps=36_000, informed=0.4, horizon=20):
"""Synthetic BTC/USDT: a hidden 'true' price, a noisy top of book, and market orders."""
rng = np.random.default_rng(seed)
moves = rng.normal(0, 2.5, n_steps) # 100-ms price changes, USDT
jumps = rng.random(n_steps) < 0.002 # rare large moves
moves[jumps] += rng.normal(0, 30, jumps.sum())
true = 100_000 + np.cumsum(moves)
mid = np.round(true)
future = np.append(true[horizon:], np.full(horizon, true[-1])) - true # next 2 s
events = []
for i in range(n_steps):
t = i * STEP_MS
half = rng.choice([3.0, 5.0, 10.0]) # competitors keep the spread 6-20
imb = np.clip(0.15 * np.tanh(future[i] / 15) + rng.uniform(-0.6, 0.6), -0.9, 0.9)
bid_sz, ask_sz = round(0.8 * (1 + imb), 3), round(0.8 * (1 - imb), 3)
events.append((t, 0, "md", (mid[i] - half, mid[i] + half, bid_sz, ask_sz)))
for _ in range(rng.poisson(0.3)): # market orders in this step
if rng.random() < informed and future[i] != 0:
side = "buy" if future[i] > 0 else "sell" # informed flow knows the next move
else:
side = "buy" if rng.random() < 0.5 else "sell"
size = round(rng.exponential(0.05), 3) + 0.001
events.append((t + int(rng.integers(1, STEP_MS)), 1, "trade", (side, size)))
return events, mid
دنیایی که این تابع میسازد، با قوانین زیر کار میکند:
- قیمت پنهان: یک گام تصادفی با انحراف معیار 2.5 دلار در هر 100 میلیثانیه، بهاضافه جهشهای نادر (احتمال ۰.۲٪ در هر گام، با انحراف معیار 30 دلار). نوسان یکثانیهای Mid حدود 9.2 دلار میشود؛ همان σ که در مثال درس ۲۹ استفاده کردیم.
- رقبا: Spread بازار در هر لحظه بهتصادف 6، 10 یا 20 دلار است. یعنی بازارسازهای دیگری هم هستند که گاهی Spread را از ما تنگتر نگه میدارند.
- دفتر کمی اطلاعات دارد: عدمتعادل حجم (
imb) کمی با حرکت دو ثانیه بعد همجهت است، ولی بیشترش نویز است. پس Microprice درس ۲۹ در اصل چیزی برای گفتن دارد؛ اینکه در عمل به سود خالص کمک میکند یا نه، یکی از سؤالهای درس ۳۱ است. - جریان سفارش: بهطور متوسط ۳ سفارش Market در ثانیه با حجم میانگین حدود 0.05 BTC. ۴۰٪ آنها آگاهاند: جهت قیمت دو ثانیه بعد را میدانند. این منبع Adverse Selection درس ۸ است.
یک ساعت (36,000 گام) حدود 10,700 سفارش Market تولید میکند. با Seed برابر 7، قیمت از 100,000 شروع میشود و در 99,320 تمام میشود.
۴. تکه دوم: مدل صف و پر شدن
مهمترین و خطرناکترین بخش هر Backtester بازارسازی، قاعدهای است که تصمیم میگیرد سفارش ما کی پر میشود. در درس ۱۸ دیدیم که خوشبینی در همین نقطه، Backtest سودده را به اجرای زیانده تبدیل میکند.
class Order:
def __init__(self, side, px, sz, queue):
self.side, self.px, self.sz, self.queue = side, px, sz, queue
def queue_on_arrival(side, px, book):
"""Volume ahead of us when our order lands. We only see the top of the book, so at or
behind the best price we assume the whole best-level size is ahead of us."""
bid, ask, bid_sz, ask_sz = book
if side == "buy":
return 0.0 if px > bid else bid_sz # better than best -> first in line
return 0.0 if px < ask else ask_sz
def touches(o, book):
"""True if a market order on the opposite side can reach our resting order."""
bid, ask, _, _ = book
return o.px >= bid if o.side == "buy" else o.px <= ask
قاعدههای پر شدن، صریح و یکجا:
- صف هنگام رسیدن: اگر قیمت ما از بهترین قیمت بازار بهتر باشد، نفر اول صفیم. اگر برابر یا بدتر باشد، فرض میکنیم کل حجم سطح اول جلوی ماست. چون فقط بالای دفتر را میبینیم، برای سطحهای عقبتر هم همین تخمین را به کار میبریم.
- پر شدن با معامله: یک سفارش Market فقط به بهترین سطح میرسد. اگر سفارش ما در آن سطح یا بهتر باشد، معامله اول صف جلوی ما را میخورد و فقط باقیماندهاش به ما میرسد.
- پر شدن با عبور قیمت: اگر در یک بهروزرسانی، Best Ask بازار به Bid ما یا پایینتر برسد، یعنی قیمت از روی سفارش ما رد شده است. فرض میکنیم کل سفارش با قیمت خودمان پر شده است. این پر شدنها را با برچسب
crossجدا ثبت میکنیم، چون تقریباً همیشه سمیاند.
چیزهایی که این مدل نمیبیند: معاملهای که چند سطح را میخورد، سفارشهای پنهان، و اثر سفارش ما روی رفتار رقبا. فرض اول (صف) را در درس ۳۱ با یک نسخه بدبینانه عوض میکنیم تا ببینیم نتیجه چقدر به آن حساس است.
۵. تکه سوم: حالت و حسابداری
def run(p=Params(), lim=Limits(), seed=7, latency_ms=50, fee_bps=0.2, n_steps=36_000):
events, mid_path = make_market(seed, n_steps)
heap = [(t, prio, i, kind, data) for i, (t, prio, kind, data) in enumerate(events)]
heapq.heapify(heap)
seq = itertools.count(len(heap))
live = {"buy": None, "sell": None} # orders resting at the exchange
inflight = {"buy": False, "sell": False} # one message in flight per side
book, last_mid, var = None, None, 2.5**2 # EWMA variance of 100-ms mid changes
q = cash = fees = 0.0
fills, equity = [], []
def fill(o, qty, t, how):
nonlocal q, cash, fees
sign = 1 if o.side == "buy" else -1
fee = fee_bps / 1e4 * o.px * qty
q, cash, fees = q + sign * qty, cash - sign * o.px * qty, fees + fee
o.sz = round(o.sz - qty, 8)
if o.sz == 0:
live[o.side] = None
fills.append((t, o.side, o.px, qty, (book[0] + book[1]) / 2, fee, how))
چند تصمیم در همین چند خط:
- ترتیب رویدادها: هر ورودی Heap یک چندتایی
(time, prio, seq, kind, data)است. اگر دو رویداد همزمان باشند، اول داده بازار (prio=0)، بعد معامله (1) و آخر رسیدن سفارش ما (2) پردازش میشود. شمارهseqتضمین میکند ترتیب همیشه یکسان و قطعی باشد. Backtester غیرقطعی را نمیشود دیباگ کرد. - یک پیام در راه برای هر طرف: تا وقتی پیام قبلی به صرافی نرسیده، پیام تازه نمیفرستیم. این همان قاعدهای است که در درس ۲۶ برای جلوگیری از Race Condition در چرخه عمر سفارش دیدیم.
- حسابداری درس ۷:
cashپول نقد ورودی و خروجی است،qموجودی وfeesکارمزد پرداختی. PnL در هر لحظه:
- کارمزد فرضی:
fee_bps=0.2یعنی ۰.۰۰۲٪ از ارزش هر معامله، حدود 2 دلار برای هر BTC در قیمت ۱۰۰ هزار. این عدد برای هیچ صرافی مشخصی نیست. - لاگ پر شدن: برای هر پر شدن، Mid همان لحظه را هم ذخیره میکنیم. همان قاعده درس ۲۹: ورودیهای تحلیل را همین الان ثبت کن.
۶. تکه چهارم: حلقه رویدادها
قلب Backtester. این تکه ادامه همان تابع run است:
while heap:
t, _, _, kind, data = heapq.heappop(heap)
if kind == "md":
book = data
bid, ask, bid_sz, ask_sz = book
mid = (bid + ask) / 2
for o in list(live.values()): # market moved through our price
if o and ((o.side == "buy" and ask <= o.px) or (o.side == "sell" and bid >= o.px)):
fill(o, o.sz, t, "cross")
if last_mid is not None:
var = 0.99 * var + 0.01 * (mid - last_mid) ** 2
last_mid = mid
sigma_1s = math.sqrt(var * 1000 / STEP_MS)
pnl = cash + q * mid - fees
if t % 1000 == 0:
equity.append((t, pnl, q))
qt = risk_check(make_quote(bid, ask, bid_sz, ask_sz, q, sigma_1s, p), mid, pnl, 0, lim)
for side, px, sz in (("buy", qt.bid_px, qt.bid_sz), ("sell", qt.ask_px, qt.ask_sz)):
o = live[side]
stale = (o is None and sz > 0) or (o is not None and (o.px != px or o.sz > sz))
if stale and not inflight[side]:
inflight[side] = True
heapq.heappush(heap, (t + latency_ms, 2, next(seq), "arrive", (side, px, sz)))
elif kind == "arrive": # cancel/replace reaches the exchange
side, px, sz = data
inflight[side] = False
bid, ask, _, _ = book
crosses = px >= ask if side == "buy" else px <= bid
if sz == 0 or crosses:
live[side] = None # a pure cancel, or rejected as post-only
else:
live[side] = Order(side, px, sz, queue_on_arrival(side, px, book))
elif kind == "trade":
side, size = data
o = live["buy" if side == "sell" else "sell"] # a market sell hits our bid
if o and touches(o, book):
eaten = min(size, o.queue) # orders ahead of us trade first
o.queue -= eaten
size -= eaten
if size > 0:
fill(o, min(size, o.sz), t, "trade")
هر شاخه را مرور کنیم:
md، داده تازه بازار. اول چک میکنیم قیمت از روی سفارشهای ما رد شده یا نه (قاعده ۳). بعد σ را با EWMA بهروز میکنیم. ضریب 0.99 یعنی حافظهای حدود ۱۰۰ گام یا ۱۰ ثانیه. واریانس یک گام 100 میلیثانیهای را در 10 ضرب میکنیم تا واریانس یک ثانیه به دست بیاید (با فرض استقلال گامها، درس ۹). بعد همان تابع درس ۲۹ قیمت میدهد و از risk_check عبور میکند. محدوده قیمت نسبت به Mid سنجیده میشود و سن داده را صفر میدهیم، چون شبیهساز ما قطعی داده ندارد. در آخر، فقط اگر قیمت یا حجم مطلوب با سفارش فعلی فرق داشته باشد، پیام جایگزینی میفرستیم. این پیام latency_ms بعد میرسد.
arrive، رسیدن پیام به صرافی. سفارش جدید جای قبلی را میگیرد و جایگاهش در صف از نو حساب میشود؛ هر جایگزینی یعنی رفتن به ته صف. اگر در این فاصله بازار جابهجا شده باشد و سفارش ما حالا طرف مقابل را قطع کند، صرافی آن را بهعنوان Post-Only رد میکند (درس ۳).
trade، سفارش Market. فروش Market به Bid ما میخورد و خرید Market به Ask ما. اول صف جلوی ما مصرف میشود، بعد نوبت ماست.
۷. تکه پنجم: خروجی و اجرا
df = pd.DataFrame(fills, columns=["t", "side", "px", "qty", "mid", "fee", "how"])
eq = pd.DataFrame(equity, columns=["t", "pnl", "q"])
pnl = cash + q * mid_path[-1] - fees # mark to the last mid
return df, eq, mid_path, dict(q=q, cash=cash, fees=fees, pnl=pnl)
def summary(df, eq, res):
sign = np.where(df.side == "buy", 1, -1)
spread_capture = (sign * (df.mid - df.px) * df.qty).sum()
gross = res["pnl"] + res["fees"]
dd = (eq.pnl - eq.pnl.cummax()).min()
print(f"fills: {len(df)} volume: {df.qty.sum():.3f} BTC final q: {res['q']:+.3f} BTC")
print(f"spread capture: {spread_capture:8.2f} inventory: {gross - spread_capture:8.2f}")
print(f"fees: {-res['fees']:8.2f} net PnL: {res['pnl']:8.2f} (USDT)")
max_q = (sign * df.qty).cumsum().abs().max()
print(f"max drawdown: {dd:8.2f} max |q|: {max_q:8.3f} BTC")
if __name__ == "__main__":
df, eq, mid_path, res = run()
summary(df, eq, res)
تابع summary سود ناخالص را به دو تکه درس ۷ تقسیم میکند:
- Spread Capture: برای هر پر شدن، فاصله قیمت ما از Mid همان لحظه، ضرب در حجم. یعنی اگر بلافاصله با Mid تسویه میکردیم چقدر میبردیم.
- Inventory: باقی سود ناخالص؛ اثر حرکت قیمت روی موجودیای که نگه داشتیم.
حالا اجرا کن: python3 backtest.py. روی یک لپتاپ معمولی حدود یک ثانیه طول میکشد:
fills: 1332 volume: 12.405 BTC final q: +0.011 BTC
spread capture: 46.81 inventory: -13.00
fees: -24.70 net PnL: 9.12 (USDT)
max drawdown: -4.17 max |q|: 0.039 BTC
- Spread Capture
- 46.81 USDT
- Inventory
- −13.00 USDT
- کارمزد
- −24.70 USDT
- Net PnL در یک ساعت
- 9.12 USDT
۸. این اعداد چه میگویند؟
اول، حسابداری را چک کن. 46.81 منهای 13.00 منهای 24.70 میشود 9.11؛ با اختلاف گرد کردن، همان 9.12. اگر این اتحاد برقرار نبود، قبل از هر تحلیلی باید باگ را پیدا میکردی.
دوم، اندازه سود را با حجم بسنج. 12.405 BTC معامله با قیمت میانگین حدود 99,540، یعنی حدود ۱.۲۳ میلیون دلار حجم. سود خالص 9.12 دلار است: حدود ۰.۰۷ واحد پایه (bp) از حجم. یک تغییر کوچک در هر فرض میتواند این عدد را منفی کند.
سوم، کارمزد از سود بزرگتر است. 24.70 دلار کارمزد، بیش از نیمی از Spread Capture را برده است. در درس ۱ با یک معامله دیدیم کارمزد چطور اسپرد را میخورد؛ اینجا همان را در مقیاس ۱,۳۳۲ پر شدن میبینیم.
چهارم، موجودی ضرر داده، ولی کم. قیمت در این یک ساعت 680 دلار افت کرد. اگر کل این مدت با موجودی کامل 0.05 BTC خرید مانده بودیم، این افت ۳۴ دلار هزینه داشت؛ زیان واقعی موجودی ۱۳ دلار بود. حدس ما این است که بخش بزرگ این زیان از روند نیست، از Adverse Selection است: معاملهگرهای آگاه درست قبل از حرکت قیمت با ما معامله میکنند. در درس ۳۱ این حدس را با Markout میسنجیم.
پنجم، سوراخ درس ۲۹ این بار باز نشد. بیشینه موجودی 0.039 BTC بود، زیر سقف 0.05. ولی این یعنی «در این یک ساعت اتفاق نیفتاد»، نه «اتفاق نمیافتد». تست واحدی که سقف را با سفارشهای در راه چک کند، هنوز لازم است.
ششم، Drawdown کوچک گولزننده است. بیشترین افت از قله 4.17 دلار بود و منحنی سرمایه تقریباً صاف بالا رفته است. دلیلش این است که در دنیای ما هیچ رژیم بدی وجود ندارد: نه قطعی صرافی، نه یک ساعت روند شدید، نه رقیبی که ناگهان سریعتر شود. Sharpe بالای یک Backtest کوتاهمدت بیشتر درباره شبیهساز حرف میزند تا استراتژی (درس ۲۰).
۹. Backtester را هم باید تست کرد
Backtester خودش نرمافزار است و باگ دارد. چند تست ساده که قبل از اعتماد به هر عددی اجرا میکنیم (همه را با ابزارهای درس ۱۳ میشود به pytest تبدیل کرد):
- قطعیت: دو اجرا با Seed یکسان باید دقیقاً یک خروجی بدهند.
- اتحاد حسابداری: Spread Capture بهاضافه Inventory منهای Fees باید با PnL نهایی برابر باشد.
- بدون کارمزد: با
fee_bps=0سود خالص باید دقیقاً به اندازه کارمزد بالا برود و تعداد پر شدنها تغییر نکند. - دنیای بیاطلاعات: اگر هر دو منبع اطلاعات را خاموش کنی (
informed=0و ضریب 0.15 در خطimb)، میانگین Inventory در چند Seed باید نزدیک صفر باشد. در آزمایش ما با ۸ Seed، میانگین حدود ۱.۳ دلار زیان با خطای استاندارد ۱.۶ دلار بود؛ یعنی از صفر قابل تشخیص نیست. اگر فقطinformedرا صفر کنی، Inventory بهطور متوسط حدود ۲۳ دلار سود میدهد؛ این سهم Microprice است که ما را سمت درست حرکت بعدی نگه میدارد. تستی که نتیجهاش را از قبل نمیدانستی، همینطور چیزی یادت میدهد. - سقف موجودی: هیچوقت نباید از
max_posبهاضافه یک سفارش در راه بیشتر شود.
تست سوم را در درس ۳۱ اجرا میکنیم: با کارمزد صفر، سود خالص 33.82 دلار میشود، دقیقاً 9.12 بهاضافه 24.70.
۱۰. از داده مصنوعی به داده واقعی
تنها نقطهای که این Backtester به بازار مصنوعی وابسته است، تابع make_market است. خروجیاش فهرستی از رویدادهای md و trade با زمان است. اگر Pipeline درس ۱۶ همین قالب را از داده واقعی بسازد، بقیه کد بدون تغییر کار میکند. سه چیز را آن وقت باید جدی بگیری:
- قیمت پنهان دیگر وجود ندارد. Mid واقعی نویز دارد و Markout را باید با آن حساب کنی.
- داده هم تأخیر دارد. چیزی که در داده ذخیرهشده میبینی، با چیزی که سیستم در لحظه دیده بود فرق دارد (درس ۱۹).
- صف واقعی دیده نمیشود. مدل صف همیشه یک فرض باقی میماند، حتی با داده کامل دفتر سفارش.
۱۱. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
در درس سیویکم خروجی همین Backtester را کالبدشکافی میکنیم: PnL را به Spread Capture، موجودی و کارمزد میشکنیم، منحنی Markout را میکشیم، پارامترها را جاروب میکنیم و میبینیم بهترین پارامتر روی یک ساعت داده، روی ساعتهای دیگر چه میکند. و مهمتر از همه: چرا نتیجه اجرای واقعی تقریباً همیشه از Backtest بدتر است و این شکاف را چطور اندازه بگیریم.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.