مسیر Quant Developer · بخش ۵: Backtesting و شبیهسازی · درس ۱۷
طراحی Backtest واقعگرایانه
چرا Backtest با کندل برای Market Making جواب نمیدهد، Backtest رویدادمحور چطور کار میکند، کدام خطاها نتیجه را خوشبینانه میکنند، و اسکلت آن در Python.
۱. Backtest چه سؤالی را جواب میدهد؟
Backtest یعنی اجرای یک استراتژی روی داده گذشته، تا ببینی اگر آن موقع اجرایش میکردی چه اتفاقی میافتاد. سؤال درست Backtest این نیست که «این استراتژی چقدر سود میدهد؟»؛ این است که «اگر همین کد، با همین تأخیر و همین کارمزد، آن روز در بازار بود، کدام سفارشهایش پر میشد و نتیجه چه میشد؟».
در بخش ۴ داده را آماده کردیم: در درس ۱۶ دیدیم چطور جریان پیامهای بازار را دریافت، پاکسازی و ذخیره کنیم و دفتر سفارش را از روی آن بازسازی کنیم. بخش ۵ درباره استفاده درست از همین داده است. این درس اسکلت را میسازد، درس ۱۸ مدل پر شدن سفارش را دقیق میکند، درس ۱۹ تأخیر و کارمزد و اسلیپیج را اضافه میکند، و درس ۲۰ میگوید نتیجه را با چه معیارهایی بسنجیم.
برای Market Making، Backtest سختتر از اغلب استراتژیهاست. استراتژیای که روزی یک بار بر اساس قیمت بسته شدن میخرد یا میفروشد، تقریباً همیشه با قیمتی نزدیک به همان قیمت معامله میشود. اما بازارساز با سفارش Limit کار میکند و تمام سود و زیانش در این است که کدام سفارشها، کی و در چه شرایطی پر میشوند. اگر Backtest این را اشتباه بگوید، هیچ عدد دیگری در گزارش معنا ندارد.
۲. دو معماری: Vectorized و Event-driven
دو روش کلی برای نوشتن Backtest وجود دارد:
- Vectorized: کل داده را به شکل آرایه یا DataFrame بارگذاری میکنی و سیگنال و سود و زیان را با عملیات ستونی NumPy و pandas (درس ۱۳ و ۱۴) برای همه ردیفها یکجا حساب میکنی.
- Event-driven (رویدادمحور): داده را به شکل جریانی از رویدادها به ترتیب زمان پخش میکنی: یک تغییر در دفتر سفارش، یک معامله، رسیدن سفارش تو به صرافی، گزارش پر شدن. استراتژی هر رویداد را جداگانه میبیند و به آن واکنش نشان میدهد، دقیقاً مثل اجرای زنده.
| Vectorized | Event-driven | |
|---|---|---|
| ورودی | کل داده یکجا | رویدادها یکییکی، به ترتیب زمان |
| سرعت | بسیار سریع | کندتر؛ هر رویداد جداگانه پردازش میشود |
| وضعیت (سفارش باز، جای صف، موجودی) | دشوار و پرخطا | طبیعی؛ هر رویداد وضعیت را بهروز میکند |
| تأخیر | تقریبی (جابهجا کردن ستونها) | دقیق؛ هر پیام زمان رسیدن خودش را دارد |
| خطر Look-ahead | بالا؛ یک shift جاافتاده کافی است | کمتر؛ آینده هنوز از صف بیرون نیامده |
| کاربرد مناسب | غربال سریع ایدهها، سیگنالهای کند | Market Making و اجرای سفارش |
روش Vectorized بد نیست. برای اینکه بفهمی یک سیگنال پیشبینیکننده (مثلاً عدمتوازن دفتر سفارش در درس ۱۰) اصلاً ارزش دنبال کردن دارد، سریعترین ابزار است. اما وقتی نتیجه به وضعیت بستگی دارد، یعنی اینکه الان چه سفارشی در دفتر داری، کجای صف است و پیام لغوت کی میرسد، Vectorized یا خیلی سادهسازی میکند یا به کدی پر از حالتهای خاص تبدیل میشود. Market Making دقیقاً همین حالت است.
۳. چرا Backtest با کندل برای Market Making شکست میخورد
سادهترین Backtest با کندل یکدقیقهای (OHLC) این قاعده را دارد: «اگر Low کندل به قیمت Bid من رسید، سفارش خریدم پر شده است». فرض کن کندل یک دقیقه این باشد:
| Open | High | Low | Close |
|---|---|---|---|
| 100,010 | 100,030 | 99,990 | 100,010 |
و تو در تمام این دقیقه یک سفارش خرید 0.1 BTC در 99,990 داشتی؛ همان سفارش درس ۳ که 0.4 BTC جلویش در صف بود. Low کندل 99,990 است، پس Backtest کندلی میگوید پر شدی. اما کندل چند چیز را نمیگوید:
- لمس کردن یعنی پر شدن نیست. Low برابر 99,990 فقط یعنی دستکم یک معامله در این قیمت انجام شد. اگر در این دقیقه فقط 0.15 BTC فروش در 99,990 معامله شده باشد، هنوز 0.25 BTC جلوی تو بوده و سفارشت اصلاً پر نشده است. در درس ۳ دیدیم دستکم 0.5 BTC فروش در این قیمت لازم است تا 0.1 BTC تو کامل پر شود.
- فقط عبور از قیمت، پر شدن را تضمین میکند. اگر Low به 99,980 رسیده بود، یعنی کل سطح 99,990 مصرف شده و قیمت از آن گذشته؛ سفارشی که از قبل در آن سطح نشسته بود حتماً پر شده است. ولی همین عبور، خبر بدی هم هست: پر شدی چون قیمت داشت پایین میرفت. این همان Adverse Selection درس ۸ است.
- ترتیب درون کندل معلوم نیست. اگر سفارش فروشی هم در 100,030 داشتی، کندل نمیگوید اول High آمد یا اول Low. ممکن است هر دو پر شده باشند (یک رفتوبرگشت با ۴۰ دلار Spread) یا فقط یکی و موجودیات با قیمت بعدی جابهجا شده باشد.
- استراتژی در طول دقیقه ثابت نمیماند. بازارساز در یک دقیقه ممکن است دهها بار قیمتش را بهروز کند. کندل فقط یک عکس از کل دقیقه است و جای هیچکدام از این تصمیمها را ندارد.
نتیجه: Backtest کندلی برای بازارساز سیستماتیک خوشبین است. پر شدنهایی را میشمارد که اتفاق نیفتادهاند، و از پر شدنهایی که واقعاً رخ دادهاند، بیشتر آنهایی را میبیند که بعدشان قیمت برخلاف تو رفته است. کمی جلوتر همین را با کد میبینیم.
۴. سه خطایی که نتیجه را زیبا میکنند
در درس ۱۰ دیدیم Look-ahead و تقسیم نادرست داده چطور نتیجه آماری را خراب میکنند. در Backtest این خطاها شکلهای تازهای پیدا میکنند.
Look-ahead Bias: دانستن آینده
یعنی تصمیمی که در لحظه t گرفته شده، از اطلاعاتی استفاده کند که در لحظه t هنوز به تو نرسیده بود. در Market Making رایجترین شکلهایش اینهاست:
- یکی گرفتن زمان صرافی و زمان دریافت. در درس ۱۶ دیدیم هر پیام دو زمان دارد:
exchange_ts، وقتی رویداد در صرافی رخ داد، وrecv_ts، وقتی به سیستم تو رسید. اگر Backtest باexchange_tsتصمیم بگیرد، فرض کرده تأخیر صفر است و استراتژی همیشه زودتر از واقعیت واکنش نشان میدهد. - تصمیم با Close، اجرا با همان Close. قیمت بسته شدن کندل فقط در پایان دقیقه معلوم است؛ معامله کردن با همان قیمت یعنی استفاده از عددی که هنوز نبود.
- معاملهای که هم سفارشت را پر میکند و هم به تو خبر میدهد. چنین معاملهای نباید پیش از پر شدن، Fair Value تو را هم بهروز کرده باشد و فرصت لغو به تو بدهد؛ در واقعیت اول پر میشوی، بعد خبرش را میشنوی.
Survivorship Bias: فقط بازماندگان
یعنی Backtest فقط روی چیزهایی اجرا شود که امروز هنوز وجود دارند. اگر فهرست جفتارزها را از صرافی امروز بگیری، توکنهایی که سقوط کردند و از فهرست حذف شدند در دادهات نیستند؛ همانهایی که بازارساز رویشان بیشترین زیان موجودی را میداد. شکل ظریفترش در Market Making: انتخاب جفتارزهایی برای گزارش که استراتژی رویشان خوب کار کرد.
Overfitting: حفظ کردن گذشته
یعنی پارامترها آنقدر روی داده گذشته تنظیم شوند که بهجای الگوی واقعی، نویز همان دوره را یاد بگیرند. استراتژی بازارسازی پارامتر زیاد دارد: پهنای Spread، شدت Skew، حجم سفارش، فاصله بهروزرسانی، سقف موجودی. اگر صد ترکیب را روی یک ماه داده امتحان کنی و بهترینش را گزارش کنی، بخشی از برتریاش فقط شانس است. در درس ۲۰ میبینیم چطور با داده خارج از نمونه و بررسی حساسیت پارامترها این را بسنجیم.
۵. طراحی حلقه رویداد
یک Backtest رویدادمحور از چند جزء ساخته میشود که هر کدام نقش یک بخش از دنیای واقعی را بازی میکند:
- Data Replay پخش داده ذخیرهشده درس ۱۶ به شکل رویداد، با زمان صرافی
- Event Queue صف اولویت بر اساس زمان؛ همه رویدادها از اینجا عبور میکنند
- Strategy داده را با تأخیر میبیند و سفارش یا لغو میفرستد
- Simulated Exchange سفارشها را با تأخیر دریافت و درباره پر شدن تصمیم میگیرد
- Portfolio & Metrics موجودی، پول نقد، PnL و معیارهای درس ۲۰
دو انتخاب طراحی، بیشترین اثر را دارند:
۱. همه چیز رویداد است، حتی کارهای خودت. داده بازار رویداد است، ولی فرستادن سفارش هم یک رویداد آینده میسازد: «این سفارش در زمان t + تأخیر به صرافی میرسد». گزارش پر شدن هم همینطور: «در زمان t + تأخیر به تو میرسد». با این کار تأخیر در خود ساختار Backtest جا میگیرد، نه به شکل یک وصله بعدی.
۲. یک صف، به ترتیب زمان. همه رویدادها در یک صف اولویت (Priority Queue) بر اساس زمان قرار میگیرند و حلقه اصلی همیشه زودترین را برمیدارد. اگر دو رویداد زمان یکسان داشته باشند، ترتیب ورودشان حفظ میشود تا Backtest قطعی (Deterministic) بماند: هر اجرا با همان داده، دقیقاً همان نتیجه را بدهد.
یک قاعده مهم هم درباره مرز اجزا: Strategy هرگز مستقیم به Simulated Exchange دسترسی ندارد. فقط پیام میفرستد و پیام دریافت میکند. همین مرز باعث میشود همان کد Strategy بعداً بدون تغییر در اجرای زنده هم کار کند (درس ۲۵).
۶. اسکلت در Python
کد زیر کوچکترین نسخه این طراحی است. یک صف رویداد، یک صرافی شبیهسازیشده و یک استراتژی که همیشه روی Best Bid و Best Ask قیمت میدهد، با سقف موجودی ساده. داده بازار مصنوعی است: Mid با گامهای ۱۰ دلاری حرکت میکند و هر معامله روی Bid یا Ask همان لحظه انجام میشود. تأخیر داده و تأخیر سفارش هر کدام ۵ میلیثانیه فرضی است.
صرافی شبیهسازیشده دو قاعده پر شدن دارد تا بتوانیم بخش ۳ را با عدد ببینیم: touch (هر معامله در قیمت تو سفارشت را پر میکند) و through (فقط وقتی قیمت از سفارش تو عبور کند).
import heapq, itertools
import numpy as np
class EventLoop:
"""Every input is an event with a timestamp; process them in time order."""
def __init__(self):
self.queue, self.counter, self.now = [], itertools.count(), 0.0
def schedule(self, time, kind, data):
# the counter breaks ties, so equal-time events keep insertion order
heapq.heappush(self.queue, (time, next(self.counter), kind, data))
def run(self, handlers):
while self.queue:
self.now, _, kind, data = heapq.heappop(self.queue)
handlers[kind](data)
class SimExchange:
"""Holds our resting orders and decides when they are filled."""
def __init__(self, loop, latency, rule):
self.loop, self.latency, self.rule, self.orders = loop, latency, rule, {}
def on_new_order(self, o): self.orders[o["id"]] = o
def on_cancel(self, oid): self.orders.pop(oid, None)
def on_trade(self, t):
for oid, o in list(self.orders.items()):
gap = o["price"] - t["price"] if o["side"] == "buy" else t["price"] - o["price"]
# "touch": trade at our price is enough; "through": price must pass us
if gap > 0 or (self.rule == "touch" and gap == 0):
del self.orders[oid]
self.loop.schedule(self.loop.now + self.latency, "fill", o)
class Strategy:
"""Quotes best bid and best ask; sees data late and its orders arrive late."""
def __init__(self, loop, latency, size=0.01, max_inventory=0.05):
self.loop, self.latency, self.size = loop, latency, size
self.max_inventory = max_inventory
self.ids, self.live = itertools.count(), {}
self.cash, self.inventory, self.fills = 0.0, 0.0, 0
def on_book(self, b):
for side, price in (("buy", b["bid"]), ("sell", b["ask"])):
sign = 1 if side == "buy" else -1
if sign * self.inventory >= self.max_inventory:
price = None # limit reached: stop quoting this side
old = self.live.get(side)
if old and old["price"] == price:
continue
if old:
self.loop.schedule(self.loop.now + self.latency, "cancel", old["id"])
del self.live[side]
if price is None:
continue
new = {"id": next(self.ids), "side": side, "price": price, "qty": self.size}
self.live[side] = new
self.loop.schedule(self.loop.now + self.latency, "new_order", new)
def on_fill(self, o):
sign = 1 if o["side"] == "buy" else -1
self.inventory += sign * o["qty"]
self.cash -= sign * o["qty"] * o["price"]
self.fills += 1
if self.live.get(o["side"]) is o:
del self.live[o["side"]]
def backtest(rule, md_latency=0.005, order_latency=0.005, seed=42):
rng = np.random.default_rng(seed)
loop = EventLoop()
exchange = SimExchange(loop, order_latency, rule)
strategy = Strategy(loop, order_latency)
mid, t = 100_000.0, 0.0
for _ in range(5_000): # synthetic market data
t += rng.exponential(0.2)
mid += 10 * rng.choice([-1, 0, 0, 1])
side = rng.choice(["buy", "sell"])
price = mid + 10 if side == "buy" else mid - 10
loop.schedule(t, "trade", {"price": price}) # at the exchange
loop.schedule(t + md_latency, "book", {"bid": mid - 10, "ask": mid + 10}) # at us
loop.run({"trade": exchange.on_trade, "new_order": exchange.on_new_order,
"cancel": exchange.on_cancel, "book": strategy.on_book,
"fill": strategy.on_fill})
pnl = strategy.cash + strategy.inventory * mid # mark inventory to the last mid
print(f"{rule:8} fills={strategy.fills:5d} inventory={strategy.inventory:+.2f} BTC pnl={pnl:+8.1f} USDT")
backtest("touch")
backtest("through")
خروجی (حدود ۱۷ دقیقه بازار مصنوعی، بدون کارمزد):
touch fills= 2737 inventory=+0.05 BTC pnl= +168.9 USDT
through fills= 1089 inventory=+0.03 BTC pnl= -1.4 USDT
یک استراتژی، یک داده، دو قاعده پر شدن. با touch استراتژی حدود ۲.۵ برابر بیشتر پر میشود و حدود ۱۶۹ دلار سود نشان میدهد. با through تقریباً صفر است. دلیلش در ساختار همین داده است: در این بازار مصنوعی، قیمت فقط وقتی از سفارش تو عبور میکند که Mid یک گام برخلاف تو رفته باشد، پس هر پر شدن «عبوری» درست در لحظهای رخ میدهد که سهم Spread تو با حرکت قیمت خنثی شده است. در چند Seed دیگری که امتحان کردیم، touch هر بار حدود ۱۷۰ تا ۲۰۰ دلار سود نشان داد و through حول صفر ماند.
جواب درست هیچکدام نیست. touch فرض میکند همیشه اول صفی، و through فرض میکند همیشه آخر صفی و فقط وقتی پر میشوی که کل سطح مصرف شده باشد. واقعیت بین این دو است و جایش به جایگاه تو در صف بستگی دارد. درس بعد دقیقاً همین است.
چند نکته که در این کد کوتاه پنهان است:
- پر شدن بعد از لغو. وقتی Strategy پیام لغو میفرستد، آن پیام ۵ میلیثانیه بعد به صرافی میرسد. اگر در این فاصله معاملهای سفارش را پر کند، پر شدن معتبر است و گزارشش بعداً میرسد. خط
if self.live.get(o["side"]) is oهمین حالت را مدیریت میکند؛ حالتی که در Backtest کندلی اصلاً وجود ندارد و در اجرای زنده مدام رخ میدهد (درس ۲۶). - صرافی حجم معامله را نادیده میگیرد. هر معاملهای که شرط را داشته باشد، کل سفارش را پر میکند. این سادهسازی را در درس ۱۸ با مدل صف برطرف میکنیم.
- موجودی با آخرین Mid ارزشگذاری شده. این همان PnL تحققنیافته درس ۷ است.
- کارمزد صفر است. با ریبیت فرضی ۰.۰۱٪ درس ۷، هر پر شدن 0.01 BTC حدود ۰.۱ دلار ریبیت میگیرد؛ هماندازه کل سهم Spread همان پر شدن. اثر کارمزد را در درس ۱۹ میبینیم.
در درس ۳۰ همین اسکلت را به یک Backtester کامل تبدیل میکنیم: با داده واقعی دفتر سفارش، مدل صف، کارمزد و تأخیر متغیر.
۷. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
در درس هجدهم سراغ مهمترین فرض هر Backtest بازارسازی میرویم: Queue Position و احتمال پر شدن. میبینیم با داده L2، که فقط حجم کل هر سطح را نشان میدهد، چطور جایگاه سفارش در صف را تخمین بزنیم، لغو سفارشهای دیگران را با فرض بدبینانه، خوشبینانه یا تناسبی چطور حساب کنیم، و این فرضها چقدر PnL را جابهجا میکنند.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.