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

مسیر 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، حجمی که جلوی توست. بعد هر تغییر سطح را تفسیر می‌کنی:

  1. ورود: وقتی سفارشت به صرافی می‌رسد، ته صف می‌نشینی. پس ahead برابر حجم کل همان سطح در لحظه رسیدن سفارش است، نه لحظه فرستادنش (فاصله این دو را در درس ۱۹ می‌بینیم).
  2. معامله در قیمت تو: معامله از جلوی صف مصرف می‌کند، پس ahead به اندازه حجم معامله کم می‌شود. اگر حجم معامله از ahead بیشتر باشد، مازادش به سفارش تو می‌رسد.
  3. افزایش حجم سطح: سفارش تازه آمده و طبق اولویت زمان، پشت تو نشسته. ahead تغییر نمی‌کند.
  4. کاهش حجم سطح بدون معامله: کسی سفارشش را لغو کرده. اینجا مشکل شروع می‌شود: آن سفارش جلوی تو بود یا پشت تو؟ L2 جواب نمی‌دهد.

سه فرض برای لغوها

فرض کن حجم کل دیگران در سطح level است، ahead جلوی تو و بقیه، یعنی level − ahead، پشت تو. حجم c لغو می‌شود:

  • بدبینانه (Pessimistic): لغوها اول از پشت تو هستند. ahead فقط وقتی کم می‌شود که لغو از کل حجم پشت تو بیشتر باشد.
  • خوش‌بینانه (Optimistic): لغوها اول از جلوی تو هستند. هر لغو مستقیم تو را جلو می‌برد.
  • تناسبی (Proportional): هر واحد حجم در صف، شانس برابر برای لغو شدن دارد. پس سهم جلوی تو از لغو، متناسب با سهمش از کل صف است:
ahead_new = ahead − c × ahead / level

فرض تناسبی معمولاً نقطه شروع معقولی است، ولی رفتار واقعی بازار را بازتاب نمی‌دهد. در عمل، سفارش‌هایی که تازه آمده‌اند (پشت صف) معمولاً زودتر لغو می‌شوند، چون اغلب از الگوریتم‌هایی هستند که مدام قیمتشان را به‌روز می‌کنند. اگر داده L3 داری، می‌توانی این را برای بازار خودت اندازه بگیری.

یک مثال کامل

همان صف درس ۳: 0.4 BTC جلوی تو در 99,990، و سفارش 0.1 BTC تو ته صف. این رویدادها پشت سر هم در داده L2 دیده می‌شوند (ستون L2، حجم کل دیگران در سطح است):

رویدادL2ahead بدبینانهahead تناسبیahead خوش‌بینانه
ورود تو0.40.40.40.4
فروش Market، 0.150.250.250.250.25
سفارش تازه، 0.250.50.250.250.25
لغو 0.20.30.250.150.05
فروش Market، 0.20.10.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
touch4,813.540,3698.39
خوش‌بینانه1,344.05,2763.93
تناسبی1,153.13,4102.96
بدبینانه982.81,8481.88
through778.4۸۷ زیان۰.۱۱ زیان

سه نکته از این جدول:

۱. touch در لیگ دیگری است. حدود ۳.۶ برابر فرض خوش‌بینانه پر می‌شود و PnL آن حدود ۷.۷ برابر است. هر Backtest بازارسازی که با قاعده لمس قیمت پر می‌کند، تقریباً بی‌ارزش است.

۲. حتی بین فرض‌های معقول، اختلاف بزرگ است. PnL فرض خوش‌بینانه حدود ۲.۹ برابر فرض بدبینانه است، در حالی که حجم پرشده فقط حدود ۱.۴ برابر است. یعنی اختلاف فقط در تعداد پر شدن‌ها نیست؛ در کیفیت آن‌هاست.

۳. هر چه دیرتر در صف، Markout بدتر. ستون آخر را ببین: از ۸.۳۹ دلار برای هر BTC تا ۰.۱۱ دلار زیان. سفارش ته صف معمولاً وقتی پر می‌شود که فشار فروش آن‌قدر زیاد است که کل صف جلویش را خورده، و همین فشار قیمت را پایین می‌برد. این همان هشدار Note درس ۳ است، حالا با عدد: جایگاه در صف هم احتمال پر شدن را تعیین می‌کند و هم کیفیت پر شدن را.

۴. احتمال پر شدن و فاصله از Mid

تا اینجا سفارش همیشه روی Best Bid بود. اما بازارساز می‌تواند دورتر از Mid قیمت بدهد: کمتر پر می‌شود، ولی هر پر شدن Spread بیشتری دارد. در درس ۱۱ مدل Avellaneda–Stoikov این رابطه را با یک فرض نمایی بیان کرد و گفت شکلش باید با داده سنجیده شود:

λ(δ) = A × e^(−k × δ)

λ نرخ پر شدن (تعداد در ثانیه) برای سفارشی به فاصله δ از Mid است. برای تخمین A و k از داده، یک روش ساده این است: برای هر لحظه و هر فاصله δ، بررسی کن آیا قیمت در T ثانیه بعد از سفارش فرضی تو عبور کرده است یا نه. نسبت دفعاتی که عبور رخ داده، احتمال پر شدن در افق T است. اگر پر شدن‌ها را فرایند Poisson فرض کنیم، نرخ از احتمال به دست می‌آید:

λ = −ln(1 − P) / T

همین کار را روی 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. در درس ۳ دیدیم بعضی بازارها بر اساس اندازه سفارش تقسیم می‌کنند، نه زمان. آنجا کل این بخش جای خودش را به مدل دیگری می‌دهد.

۶. تمرین این درس

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

سؤال ۱: سفارش خرید تو در 99,990 است و 0.3 BTC جلویت و 0.1 BTC پشتت قرار دارد. حجم سطح بدون معامله 0.2 کم می‌شود. با فرض تناسبی، حجم جلوی تو چقدر می‌شود؟
سؤال ۲: حجم سطح قیمت تو در داده L2 از 0.5 به 0.7 می‌رسد. با قاعده Price-Time Priority، حجم جلوی تو چه تغییری می‌کند؟
سؤال ۳: در شبیه‌سازی بخش ۳، چرا PnL هر BTC از قاعده touch تا قاعده through مدام کمتر می‌شود؟
سؤال ۴: اگر k برابر 0.061 به ازای هر دلار باشد، با 20 دلار دورتر شدن از Mid، نرخ پر شدن تقریباً چند برابر می‌شود؟

درس بعدی

در درس نوزدهم سه هزینه‌ای را اضافه می‌کنیم که تا اینجا صفر فرض کردیم: Latency، کارمزد و اسلیپیج. می‌بینیم تأخیر داده و تأخیر سفارش چطور با هم جمع می‌شوند، چرا قیمت کهنه یعنی شکار شدن توسط معامله‌گرهای سریع‌تر، سطح کارمزد چطور مرز سود و زیان را جابه‌جا می‌کند، و اسلیپیج سفارش‌های Taker هج را در Backtest چطور مدل کنیم.

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