مسیر Quant Developer · بخش ۵: Backtesting و شبیهسازی · درس ۱۸
Queue Position و Fill Probability
تخمین جایگاه سفارش در صف با داده L2، افت احتمال پر شدن با فاصله از Mid، و اینکه فرض پر شدن چقدر نتیجه Backtest را جابهجا میکند.
۱. از صف درس ۳ تا داده واقعی
در درس ۳ صف سطح 99,990 را از نزدیک دیدیم: سه سفارش A و B و C با جمع 0.4 BTC، و سفارش 0.1 BTC تو که چهارم صف بود. آنجا همه چیز معلوم بود: اندازه هر سفارش، ترتیبشان، و اینکه هر فروش Market دقیقاً کدام را پر کرد.
در درس قبل دیدیم Backtest با قاعده touch (هر معامله در قیمت تو سفارشت را پر میکند) و قاعده through (فقط عبور قیمت از سفارشت آن را پر میکند) نتیجههای کاملاً متفاوتی میدهد، و جواب واقعی بین این دو است. جای دقیقش به یک عدد بستگی دارد: چه حجمی جلوی تو در صف است (Queue Position).
مشکل این است که بیشتر دادههایی که در دسترس داری این عدد را مستقیم نمیدهند. داده دفتر سفارش معمولاً در یکی از این دو سطح منتشر میشود:
- L3 (Order-by-Order): هر سفارش جداگانه با شناسهاش؛ ورود، لغو و پر شدنش. با L3 میتوانی صف درس ۳ را دقیقاً بازسازی کنی. بسیاری از بازارها چنین دادهای منتشر نمیکنند.
- L2 (Market by Price): برای هر سطح قیمت فقط حجم کل. بهجای «A و B و C» فقط میبینی «0.4». دادهای که در درس ۱۶ ذخیره کردیم معمولاً از همین نوع است.
با L2 تو جایگاهت در صف را نمیبینی؛ باید تخمینش بزنی.
۲. تخمین جایگاه در صف از داده L2
ایده اصلی ساده است. یک متغیر نگه میداری: ahead، حجمی که جلوی توست. بعد هر تغییر سطح را تفسیر میکنی:
- ورود: وقتی سفارشت به صرافی میرسد، ته صف مینشینی. پس
aheadبرابر حجم کل همان سطح در لحظه رسیدن سفارش است، نه لحظه فرستادنش (فاصله این دو را در درس ۱۹ میبینیم). - معامله در قیمت تو: معامله از جلوی صف مصرف میکند، پس
aheadبه اندازه حجم معامله کم میشود. اگر حجم معامله ازaheadبیشتر باشد، مازادش به سفارش تو میرسد. - افزایش حجم سطح: سفارش تازه آمده و طبق اولویت زمان، پشت تو نشسته.
aheadتغییر نمیکند. - کاهش حجم سطح بدون معامله: کسی سفارشش را لغو کرده. اینجا مشکل شروع میشود: آن سفارش جلوی تو بود یا پشت تو؟ L2 جواب نمیدهد.
سه فرض برای لغوها
فرض کن حجم کل دیگران در سطح level است، ahead جلوی تو و بقیه، یعنی level − ahead، پشت تو. حجم c لغو میشود:
- بدبینانه (Pessimistic): لغوها اول از پشت تو هستند.
aheadفقط وقتی کم میشود که لغو از کل حجم پشت تو بیشتر باشد. - خوشبینانه (Optimistic): لغوها اول از جلوی تو هستند. هر لغو مستقیم تو را جلو میبرد.
- تناسبی (Proportional): هر واحد حجم در صف، شانس برابر برای لغو شدن دارد. پس سهم جلوی تو از لغو، متناسب با سهمش از کل صف است:
فرض تناسبی معمولاً نقطه شروع معقولی است، ولی رفتار واقعی بازار را بازتاب نمیدهد. در عمل، سفارشهایی که تازه آمدهاند (پشت صف) معمولاً زودتر لغو میشوند، چون اغلب از الگوریتمهایی هستند که مدام قیمتشان را بهروز میکنند. اگر داده L3 داری، میتوانی این را برای بازار خودت اندازه بگیری.
یک مثال کامل
همان صف درس ۳: 0.4 BTC جلوی تو در 99,990، و سفارش 0.1 BTC تو ته صف. این رویدادها پشت سر هم در داده L2 دیده میشوند (ستون L2، حجم کل دیگران در سطح است):
| رویداد | L2 | ahead بدبینانه | ahead تناسبی | ahead خوشبینانه |
|---|---|---|---|---|
| ورود تو | 0.4 | 0.4 | 0.4 | 0.4 |
| فروش Market، 0.15 | 0.25 | 0.25 | 0.25 | 0.25 |
| سفارش تازه، 0.25 | 0.5 | 0.25 | 0.25 | 0.25 |
| لغو 0.2 | 0.3 | 0.25 | 0.15 | 0.05 |
| فروش Market، 0.2 | 0.1 | 0.05 | صفر؛ 0.05 پر شد | صفر؛ 0.1 پر شد |
سه رویداد اول برای هر سه فرض یکساناند: معامله 0.15 از جلوی صف کم کرد (A کامل و 0.05 از B پر شد)، و سفارش تازه پشت تو نشست. اختلاف از لغو شروع میشود. در فرض تناسبی، جلوی تو 0.25 و پشت تو 0.25 بود، پس نصف لغو، یعنی 0.1، از جلوی تو حساب میشود.
بعد فروش 0.2 میآید. در فرض بدبینانه هنوز 0.05 جلوی توست و پر نمیشوی. در فرض تناسبی 0.15 جلوی تو مصرف میشود و 0.05 از سفارش تو پر میشود: پر شدن جزئی (درس ۳). در فرض خوشبینانه کل سفارشت پر میشود. یک داده، سه نتیجه.
۳. شبیهسازی: فرض پر شدن چقدر PnL را جابهجا میکند؟
حالا همین منطق را روی یک بازار مصنوعی طولانی اجرا میکنیم. اول بازار را میسازیم: فقط بهترین سطح خرید و فروش، با گامهای یکثانیهای. در هر ثانیه برای هر طرف ممکن است سفارش تازه بیاید، بخشی از سفارشها لغو شوند و یک سفارش Market برسد. اگر سفارش Market کل یک سطح را مصرف کند، قیمت ۱۰ دلار جابهجا میشود. همه پارامترها فرضیاند و فقط برای ساختن یک بازار شبیه واقعیت انتخاب شدهاند. فهرست events همان چیزی است که داده L2 از سطح Best Bid نشان میدهد:
import numpy as np
# Synthetic market: only the best bid and best ask levels, 1 step = 1 second
rng = np.random.default_rng(18)
T, SIZE, H = 100_000, 0.1, 10 # steps, our order size (BTC), markout horizon (s)
bid, queue = 99_990, {"bid": 0.4, "ask": 0.3}
events, mids = [], np.empty(T) # what L2 data shows at the best bid; mid per step
for t in range(T):
step = []
for side in rng.permutation(["bid", "ask"]):
if rng.random() < 0.7: # a new limit order joins the back
x = rng.exponential(0.1); queue[side] += x
if side == "bid": step.append(("add", x))
if rng.random() < 0.7: # some resting size is cancelled
x = min(rng.exponential(0.12), 0.9 * queue[side]); queue[side] -= x
if side == "bid": step.append(("cancel", x))
if rng.random() < 0.5: # a market order hits this level
v = rng.exponential(0.15)
if v < queue[side]:
queue[side] -= v
if side == "bid": step.append(("trade", v))
else: # level wiped out: price moves one tick
bid += -10 if side == "bid" else 10
queue = {"bid": rng.uniform(0.2, 1.2), "ask": rng.uniform(0.2, 1.2)}
step.append(("down" if side == "bid" else "up", queue["bid"]))
break
events.append(step)
mids[t] = bid + 10
استراتژی ساده است: همیشه یک سفارش خرید 0.1 BTC روی Best Bid. اگر پر شد، دوباره ته صف مینشیند. اگر قیمت بالا رفت، سفارش را به Best Bid جدید منتقل میکند و ته صف آن قرار میگیرد. ارزش هر پر شدن را با Markout دهثانیهای میسنجیم (درس ۸): اختلاف Mid ده ثانیه بعد با قیمت خرید. چون سفارش ۱۰ دلار زیر Mid است، اگر قیمت تکان نخورد هر BTC پرشده ۱۰ دلار میارزد.
همان داده را با پنج قاعده پر شدن بازپخش میکنیم: touch و through از درس قبل، و سه فرض صف از بخش ۲:
def replay(rule):
"""Keep one buy order at the best bid; return (BTC filled, markout PnL)."""
price, ahead, level, left = 99_990, 0.4, 0.4, SIZE
filled = pnl = 0.0
for t, step in enumerate(events):
for kind, x in step:
got = 0.0 # our fill from this event
if kind == "add":
level += x # newcomers join behind us
elif kind == "cancel":
behind = level - ahead
if rule == "optimistic": from_ahead = min(x, ahead)
elif rule == "pessimistic": from_ahead = max(0.0, x - behind)
else: from_ahead = x * ahead / level # proportional
ahead -= from_ahead; level -= x
elif kind == "trade":
if rule == "touch":
got = left # any print at our price fills us
elif rule != "through":
got = min(left, max(0.0, x - ahead))
ahead = max(0.0, ahead - x)
level -= x
elif kind == "down":
got = left # price traded through our bid
pnl += got * (mids[min(t + H, T - 1)] - price) # markout vs mid H s later
filled += got; left -= got
if kind in ("down", "up"): # new best bid: rejoin at its back
price, ahead, level, left = mids[t] - 10, x, x, SIZE
elif left < 1e-12: # fully filled: rejoin at the back
ahead, left = level, SIZE
return filled, pnl
for rule in ["touch", "optimistic", "proportional", "pessimistic", "through"]:
filled, pnl = replay(rule)
print(f"{rule:12} filled={filled:7.1f} BTC pnl={pnl:7.0f} USDT per BTC={pnl / filled:5.2f}")
touch filled= 4813.5 BTC pnl= 40369 USDT per BTC= 8.39
optimistic filled= 1344.0 BTC pnl= 5276 USDT per BTC= 3.93
proportional filled= 1153.1 BTC pnl= 3410 USDT per BTC= 2.96
pessimistic filled= 982.8 BTC pnl= 1848 USDT per BTC= 1.88
through filled= 778.4 BTC pnl= -87 USDT per BTC=-0.11
همان استراتژی، همان حدود ۲۸ ساعت داده، بدون کارمزد:
| قاعده پر شدن | حجم پرشده (BTC) | PnL با Markout (USDT) | PnL هر BTC |
|---|---|---|---|
| touch | 4,813.5 | 40,369 | 8.39 |
| خوشبینانه | 1,344.0 | 5,276 | 3.93 |
| تناسبی | 1,153.1 | 3,410 | 2.96 |
| بدبینانه | 982.8 | 1,848 | 1.88 |
| through | 778.4 | ۸۷ زیان | ۰.۱۱ زیان |
سه نکته از این جدول:
۱. touch در لیگ دیگری است. حدود ۳.۶ برابر فرض خوشبینانه پر میشود و PnL آن حدود ۷.۷ برابر است. هر Backtest بازارسازی که با قاعده لمس قیمت پر میکند، تقریباً بیارزش است.
۲. حتی بین فرضهای معقول، اختلاف بزرگ است. PnL فرض خوشبینانه حدود ۲.۹ برابر فرض بدبینانه است، در حالی که حجم پرشده فقط حدود ۱.۴ برابر است. یعنی اختلاف فقط در تعداد پر شدنها نیست؛ در کیفیت آنهاست.
۳. هر چه دیرتر در صف، Markout بدتر. ستون آخر را ببین: از ۸.۳۹ دلار برای هر BTC تا ۰.۱۱ دلار زیان. سفارش ته صف معمولاً وقتی پر میشود که فشار فروش آنقدر زیاد است که کل صف جلویش را خورده، و همین فشار قیمت را پایین میبرد. این همان هشدار Note درس ۳ است، حالا با عدد: جایگاه در صف هم احتمال پر شدن را تعیین میکند و هم کیفیت پر شدن را.
۴. احتمال پر شدن و فاصله از Mid
تا اینجا سفارش همیشه روی Best Bid بود. اما بازارساز میتواند دورتر از Mid قیمت بدهد: کمتر پر میشود، ولی هر پر شدن Spread بیشتری دارد. در درس ۱۱ مدل Avellaneda–Stoikov این رابطه را با یک فرض نمایی بیان کرد و گفت شکلش باید با داده سنجیده شود:
λ نرخ پر شدن (تعداد در ثانیه) برای سفارشی به فاصله δ از Mid است. برای تخمین A و k از داده، یک روش ساده این است: برای هر لحظه و هر فاصله δ، بررسی کن آیا قیمت در T ثانیه بعد از سفارش فرضی تو عبور کرده است یا نه. نسبت دفعاتی که عبور رخ داده، احتمال پر شدن در افق T است. اگر پر شدنها را فرایند Poisson فرض کنیم، نرخ از احتمال به دست میآید:
همین کار را روی Mid بازار مصنوعی بخش ۳ انجام میدهیم (این کد ادامه کد بخش ۳ است):
from numpy.lib.stride_tricks import sliding_window_view
W = 60 # horizon in seconds
future_min = sliding_window_view(mids[1:], W).min(axis=1)
now = mids[:len(future_min)]
dist = np.array([10, 20, 30, 40, 50]) # our bid at mid - d
p = np.array([np.mean(future_min - 10 < now - d) for d in dist]) # bid fell below us
lam = -np.log(1 - p) / W # implied fill rate per second
slope, intercept = np.polyfit(dist, np.log(lam), 1) # log(lambda) = log(A) - k * d
for d, pi, li in zip(dist, p, lam):
print(f"d={d} P(fill in {W}s)={pi:.2f} lambda={li:.4f}/s")
print(f"fit: k={-slope:.3f} per USDT, A={np.exp(intercept):.3f} per second")
d=10 P(fill in 60s)=0.75 lambda=0.0233/s
d=20 P(fill in 60s)=0.53 lambda=0.0126/s
d=30 P(fill in 60s)=0.35 lambda=0.0071/s
d=40 P(fill in 60s)=0.21 lambda=0.0040/s
d=50 P(fill in 60s)=0.11 lambda=0.0020/s
fit: k=0.061 per USDT, A=0.043 per second
در این بازار، رابطه بهخوبی نمایی است: با هر ۱۰ دلار دور شدن از Mid، نرخ پر شدن در e^(−0.61)، یعنی حدود ۰.۵۴، ضرب میشود؛ تقریباً نصف. این همان k است که در فرمول Spread بهینه درس ۱۱ مینشیند.
چند هشدار درباره این تخمین:
- محافظهکارانه است. معیار ما «عبور قیمت» بود، یعنی قاعده
through. پر شدن واقعی کمی بیشتر است. از طرف دیگر، سفارشی که از قبل در سطح دورتر نشسته، وقتی قیمت به آن سطح میرسد احتمالاً جلوی صف است، چون زودتر از بقیه آمده. این مزیت پنهان سفارشهای عمیق است و در تخمین بالا نیامده. - A و k ثابت نیستند. در ساعتهای پرنوسان، k کوچکتر میشود (قیمت راحتتر به سطوح دور میرسد). یک A و k برای کل روز، میانگینی از رژیمهای متفاوت است.
- پنجرهها همپوشانی دارند. پنجرههای ۶۰ ثانیهای پشت سر هم مستقل نیستند؛ تخمین نقطهای مشکلی ندارد، ولی خطای آماری را نباید با فرض نمونههای مستقل حساب کرد (درس ۹).
۵. مدل صف کجا میشکند؟
مدل بخش ۲ سادهترین نسخه است. در داده واقعی چند چیز کار را سختتر میکند:
- Snapshot بهجای تغییر تکتک. اگر داده L2 فقط هر ۱۰۰ میلیثانیه یک عکس بدهد، ورود و لغو و معامله در همان فاصله با هم جمع میشوند. کاهش 0.2 ممکن است لغو 0.5 و ورود 0.3 باشد.
- ترتیب پیام معامله و پیام دفتر. در بعضی بازارها پیام معامله زودتر از تغییر دفتر سفارش میرسد و در بعضی دیرتر. اگر هر دو را حساب کنی، یک معامله دو بار از
aheadکم میشود. - سفارشهای پنهان. سفارش Iceberg فقط بخشی از حجمش را نشان میدهد. معاملهای بزرگتر از حجم دیدهشده ممکن است سطح را خالی نکند.
- اصلاح سفارش. در بسیاری از بازارها، افزایش حجم یا تغییر قیمت سفارش، جایگاهش در صف را از بین میبرد و آن را ته صف میفرستد. استراتژیای که مدام سفارشش را اصلاح میکند، همیشه ته صف است.
- بازارهای Pro-Rata. در درس ۳ دیدیم بعضی بازارها بر اساس اندازه سفارش تقسیم میکنند، نه زمان. آنجا کل این بخش جای خودش را به مدل دیگری میدهد.
۶. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
در درس نوزدهم سه هزینهای را اضافه میکنیم که تا اینجا صفر فرض کردیم: Latency، کارمزد و اسلیپیج. میبینیم تأخیر داده و تأخیر سفارش چطور با هم جمع میشوند، چرا قیمت کهنه یعنی شکار شدن توسط معاملهگرهای سریعتر، سطح کارمزد چطور مرز سود و زیان را جابهجا میکند، و اسلیپیج سفارشهای Taker هج را در Backtest چطور مدل کنیم.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.