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

مسیر Quant Developer · بخش ۷: Production و کنترل ریسک · درس ۲۶

Order Lifecycle و مدیریت خطا

وضعیت‌های یک سفارش از ساخت تا پر شدن یا لغو، فرق تأیید صرافی با پر شدن، و تطبیق وضعیت خودمان با صرافی بعد از Timeout یا قطع ارتباط.

سطح: متوسط تا پیشرفته۱۵ دقیقه مطالعه

۱. سفارش یک شیء با عمر است

در مثال درس ۲۵، Gateway هر از گاهی یک Fill بی‌نام در صف می‌گذاشت و Strategy موجودی را به‌روز می‌کرد. در دنیای واقعی کار به این سادگی نیست. بین لحظه‌ای که تصمیم می‌گیری سفارش بدهی و لحظه‌ای که مطمئن می‌شوی چه بر سرش آمده، ده‌ها چیز ممکن است خراب شود: درخواست به صرافی نرسد، برسد ولی پاسخش گم شود، پاسخ تأیید بعد از Fill برسد، یا وسط کار اتصال قطع شود.

بیشتر زیان‌های عملیاتی در سیستم‌های معاملاتی از مدل یا پیش‌بینی بد نمی‌آیند؛ از جایی می‌آیند که سیستم فکر می‌کند چیزی را می‌داند ولی نمی‌داند. سیستمی که خیال می‌کند سفارشی لغو شده در حالی که هنوز در دفتر است، یا خیال می‌کند موجودی صفر است در حالی که 0.3 BTC خریده. این درس درباره این است که این «دانستن» را چطور دقیق و قابل اعتماد کنیم.

۲. ماشین حالت سفارش

راه استاندارد مدل کردن عمر سفارش، ماشین حالت (State Machine) است: فهرستی محدود از وضعیت‌ها و فهرستی صریح از انتقال‌های مجاز بین آن‌ها. مسیر عادی یک سفارش Limit این است:

  1. NEW سفارش در سیستم خودمان ساخته و ثبت شده، هنوز ارسال نشده
  2. PENDING_NEW ارسال شده؛ منتظر پاسخ صرافی هستیم
  3. OPEN صرافی پذیرفته و سفارش در دفتر نشسته است
  4. PARTIALLY_FILLED بخشی پر شده و بقیه هنوز در دفتر است
  5. FILLED کل حجم پر شده؛ وضعیت پایانی

و وضعیت‌های فرعی:

وضعیتمعناپایانی؟
PENDING_CANCELدرخواست لغو فرستاده‌ایم ولی هنوز تأیید نشدهخیر
CANCELLEDصرافی لغو را تأیید کرده (به درخواست ما یا به دلیل خودش، مثل IOC یا Post-Only در درس ۳)بله
REJECTEDصرافی سفارش را نپذیرفته؛ مثلاً موجودی کافی نبوده یا قیمت خارج از محدوده مجاز بودهبله

دو اصل این مدل را مفید می‌کنند:

  1. انتقال‌ها صریح‌اند. هر رویدادی که با وضعیت فعلی جور درنیاید (مثلاً Fill برای سفارشی که رد شده) یک خطای آشکار است، نه چیزی که بی‌صدا نادیده گرفته شود.
  2. وضعیت پایانی پایانی است. از FILLED، CANCELLED و REJECTED هیچ انتقالی وجود ندارد.

۳. Client Order ID و Idempotency

هر سفارش دو شناسه دارد: شناسه‌ای که صرافی بعد از پذیرش می‌دهد، و Client Order ID که خودمان پیش از ارسال انتخاب می‌کنیم. دومی کلید همه چیز است، چون تنها شناسه‌ای است که حتی وقتی هیچ پاسخی از صرافی نگرفته‌ای هم آن را داری.

قواعد عملی:

  • یکتا در همه اجراها. شمارنده‌ای که با هر بار راه‌اندازی از صفر شروع شود کافی نیست؛ شماره جلسه یا زمان شروع را هم در شناسه بگذار. در مثال این درس قالب mm-1-000042 یعنی استراتژی mm، جلسه ۱، سفارش شماره ۴۲.
  • اول ثبت، بعد ارسال. سفارش را با وضعیت NEW در حافظه (و اگر لازم است روی دیسک) ثبت کن و بعد بفرست. اگر برنامه درست بعد از ارسال از کار بیفتد، بعد از راه‌اندازی دوباره می‌دانی دنبال کدام شناسه بگردی.
  • تکرار با همان شناسه. اگر ارسال را تکرار می‌کنی، با همان Client Order ID تکرار کن. اگر صرافی شناسه تکراری را رد کند، تکرار بی‌خطر است. به این ویژگی Idempotency می‌گویند: انجام دوباره یک عمل، نتیجه را عوض نمی‌کند.

Idempotency برای پیام‌های ورودی هم لازم است. بعد از قطع و وصل WebSocket، صرافی ممکن است چند رویداد را دوباره بفرستد. اگر هر Fill را با شناسه معامله‌اش (Trade ID) ثبت کنی، Fill تکراری را تشخیص می‌دهی و موجودی دو بار حساب نمی‌شود.

۴. Ack با Fill فرق دارد

Ack (تأیید) یعنی صرافی سفارش را پذیرفته و در دفتر گذاشته. Fill یعنی معامله واقعاً انجام شده. فقط Fill موجودی را تغییر می‌دهد.

اما برای ریسک، سفارشی که هنوز پر نشده هم مهم است، چون هر لحظه می‌تواند پر شود و تو کنترلی روی آن لحظه نداری. پس محاسبه ریسک باید بدترین حالت را در نظر بگیرد:

Worst-case Long = Position + Σ (remaining qty of live buy orders)

«Live» یعنی هر سفارشی که هنوز در وضعیت پایانی نیست؛ از جمله PENDING_NEW و PENDING_CANCEL. فرض کن موجودی 0.2 BTC است، یک سفارش خرید 0.1 BTC باز داری و یک سفارش خرید 0.1 BTC دیگر هم همین الان فرستاده‌ای که هنوز Ack نگرفته. بدترین حالت سمت خرید 0.4 BTC است، نه 0.3. اگر حد موجودی درس ۲۴ برابر 0.35 BTC باشد، همین سفارش آخر نباید ارسال می‌شد. Risk Gate درس ۲۷ دقیقاً همین محاسبه را پیش از هر سفارش انجام می‌دهد.

۵. رقابت لغو و اصلاح

بازارساز مدام قیمت‌هایش را جابه‌جا می‌کند. فرض کن سفارش خرید 0.1 BTC در 99,990 داری و Fair Value بالا رفته؛ می‌خواهی سفارش را به 99,995 ببری. دو راه داری:

۱. اول لغو، بعد سفارش جدید (Cancel then New). اگر سفارش جدید را قبل از رسیدن تأیید لغو بفرستی، برای مدتی دو سفارش خرید زنده داری. در یک حرکت تند بازار هر دو پر می‌شوند و موجودی‌ات به‌جای 0.1 به 0.2 BTC می‌رسد. اگر صبر کنی تا لغو تأیید شود، امن است ولی در آن فاصله در سمت خرید هیچ سفارشی نداری.

۲. اصلاح (Amend یا Replace). بعضی صرافی‌ها اجازه می‌دهند قیمت یا حجم سفارش را در یک درخواست عوض کنی. این کار پنجره دو سفارش هم‌زمان را می‌بندد، ولی معمولاً تغییر قیمت یا افزایش حجم یعنی از دست دادن جایگاه در صف (درس ۳ و ۱۸).

در هر دو حالت یک رقابت (Race) همیشه وجود دارد: درخواست لغو در راه است و سفارش پر می‌شود. صرافی بعد از Fill درخواست لغو را با خطایی مثل «سفارش پیدا نشد» یا «دیر است» رد می‌کند. این خطا نیست؛ یک اتفاق عادی است و سیستم باید بدون هشدار قرمز از کنارش بگذرد. چیزی که مهم است این است که Fill ثبت شود و موجودی درست باشد.

۶. ماشین حالت به زبان کد

نسخه زیر همه این قواعد را پیاده می‌کند. حجم‌ها مثل درس ۳ بر حسب Lot و عدد صحیح‌اند (هر Lot برابر 0.001 BTC):

from dataclasses import dataclass, field
from enum import Enum

class S(Enum):
    NEW = "new"                    # created locally, not sent yet
    PENDING_NEW = "pending_new"    # sent, waiting for the exchange
    OPEN = "open"                  # acknowledged, resting in the book
    PARTIAL = "partially_filled"
    PENDING_CANCEL = "pending_cancel"
    FILLED = "filled"
    CANCELLED = "cancelled"
    REJECTED = "rejected"

TERMINAL = {S.FILLED, S.CANCELLED, S.REJECTED}

ALLOWED = {
    S.NEW: {S.PENDING_NEW},
    # a fill can arrive before the ack when they travel on different channels
    S.PENDING_NEW: {S.OPEN, S.PARTIAL, S.FILLED, S.REJECTED, S.CANCELLED,
                    S.PENDING_CANCEL},
    S.OPEN: {S.PARTIAL, S.FILLED, S.PENDING_CANCEL, S.CANCELLED},
    S.PARTIAL: {S.PARTIAL, S.FILLED, S.PENDING_CANCEL, S.CANCELLED},
    # cancel in flight: it may still get filled, or the cancel may be rejected
    S.PENDING_CANCEL: {S.PENDING_CANCEL, S.FILLED, S.CANCELLED, S.OPEN, S.PARTIAL},
}

class IllegalTransition(Exception):
    pass

@dataclass
class Order:
    client_id: str                 # our id, chosen before sending
    side: str
    price: int
    qty: int                       # in lots
    state: S = S.NEW
    filled: int = 0
    trade_ids: set = field(default_factory=set)

    @property
    def live(self) -> bool:
        """Counts toward risk until the exchange confirms it is done."""
        return self.state not in TERMINAL

    def _go(self, new: S):
        if new not in ALLOWED.get(self.state, set()):
            raise IllegalTransition(f"{self.client_id}: {self.state.value} -> {new.value}")
        self.state = new

    def on_sent(self):
        self._go(S.PENDING_NEW)

    def on_ack(self):
        if self.state in (S.OPEN, S.PARTIAL, S.FILLED, S.PENDING_CANCEL):
            return                               # duplicate, or a later event got here first
        self._go(S.OPEN)

    def on_reject(self):
        self._go(S.REJECTED)

    def on_fill(self, trade_id: str, qty: int):
        if trade_id in self.trade_ids:           # duplicate message: ignore (idempotent)
            return
        total = self.filled + qty
        if total > self.qty:
            raise IllegalTransition(f"{self.client_id}: overfill {total} > {self.qty}")
        if total == self.qty:
            target = S.FILLED
        elif self.state == S.PENDING_CANCEL:     # keep the pending cancel visible
            target = S.PENDING_CANCEL
        else:
            target = S.PARTIAL
        self._go(target)                         # check first, then mutate
        self.filled = total
        self.trade_ids.add(trade_id)

    def request_cancel(self):
        self._go(S.PENDING_CANCEL)

    def on_cancel_ack(self):
        self._go(S.CANCELLED)

    def on_cancel_reject(self):
        if self.state in TERMINAL:               # "too late to cancel": a normal race
            return
        self._go(S.PARTIAL if self.filled else S.OPEN)

چند تصمیم طراحی در این کد:

  • Fill قبل از Ack مجاز است. اگر Ack و Fill از دو کانال جدا بیایند (مثلاً پاسخ HTTP و جریان WebSocket)، ترتیب رسیدنشان تضمین‌شده نیست. Ack دیررس سفارش پرشده را دوباره باز نمی‌کند.
  • Fill حین لغو وضعیت را PENDING_CANCEL نگه می‌دارد مگر اینکه سفارش کامل پر شود. رد شدن لغو برای سفارشی که تمام شده، بی‌صدا پذیرفته می‌شود.
  • اول بررسی، بعد تغییر. on_fill قبل از اینکه filled را عوض کند انتقال را بررسی می‌کند؛ رویداد غیرمجاز هیچ اثری روی وضعیت نمی‌گذارد.

حالا مهم‌ترین بخش: تست. این کد را در فایل order_sm.py ذخیره کن و تست‌ها را در کنارش:

import unittest
from order_sm import Order, S, IllegalTransition

def new_order():
    o = Order("mm-1-000042", "buy", 99990, 100)   # 100 lots = 0.1 BTC
    o.on_sent()
    return o

class TestOrder(unittest.TestCase):
    def test_happy_path(self):
        o = new_order()
        o.on_ack()
        o.on_fill("t1", 40)
        self.assertEqual(o.state, S.PARTIAL)
        o.on_fill("t2", 60)
        self.assertEqual(o.state, S.FILLED)

    def test_duplicate_fill_is_ignored(self):
        o = new_order()
        o.on_ack()
        o.on_fill("t1", 40)
        o.on_fill("t1", 40)                  # same trade id delivered twice
        self.assertEqual(o.filled, 40)

    def test_fill_before_ack(self):
        o = new_order()
        o.on_fill("t1", 100)                 # fill overtakes the ack
        o.on_ack()                           # late ack must not reopen it
        self.assertEqual(o.state, S.FILLED)

    def test_fill_races_cancel(self):
        o = new_order()
        o.on_ack()
        o.request_cancel()
        o.on_fill("t1", 100)                 # filled while cancel was in flight
        o.on_cancel_reject()                 # exchange: "too late to cancel"
        self.assertEqual(o.state, S.FILLED)
        with self.assertRaises(IllegalTransition):
            o.on_cancel_ack()                # a filled order cannot also be cancelled

    def test_cancel_reject_returns_to_partial(self):
        o = new_order()
        o.on_ack()
        o.on_fill("t1", 30)
        o.request_cancel()
        o.on_cancel_reject()
        self.assertEqual(o.state, S.PARTIAL)

    def test_illegal_transitions(self):
        o = Order("mm-1-000043", "sell", 100010, 100)
        with self.assertRaises(IllegalTransition):
            o.on_ack()                       # ack for an order we never sent
        o.on_sent()
        o.on_reject()
        for event in (o.on_ack, o.request_cancel, lambda: o.on_fill("t9", 10)):
            with self.assertRaises(IllegalTransition):
                event()                      # nothing may leave a terminal state
        self.assertEqual(o.filled, 0)        # a rejected event changes nothing

    def test_overfill(self):
        o = new_order()
        o.on_ack()
        with self.assertRaises(IllegalTransition):
            o.on_fill("t1", 120)

if __name__ == "__main__":
    unittest.main(verbosity=2)
Ran 7 tests in 0.000s

OK

تست‌های این ماشین حالت باید بیشتر روی مسیرهای عجیب باشند تا مسیر عادی: پیام تکراری، ترتیب به‌هم‌ریخته، رقابت لغو و Fill، و رویداد بعد از وضعیت پایانی. مسیر عادی را هر سیستمی درست انجام می‌دهد؛ تفاوت سیستم قابل اتکا در همین گوشه‌هاست.

۷. Timeout و وضعیت نامعلوم

سفارشی فرستاده‌ای و بعد از ۲ ثانیه (یک عدد فرضی) هیچ پاسخی نیامده. سه احتمال هست و از بیرون نمی‌شود فرقشان را فهمید:

  1. درخواست هرگز به صرافی نرسیده.
  2. رسیده، پذیرفته شده، و شاید حتی پر شده، ولی پاسخ گم شده.
  3. رسیده و رد شده، ولی پاسخ گم شده.

اشتباه رایج این است که Timeout را مساوی شکست بگیری. Timeout یعنی «نمی‌دانم»، و سیستم باید این ندانستن را صادقانه نمایش دهد:

  • سفارش را در PENDING_NEW نگه دار و در محاسبه ریسک زنده حساب کن.
  • با همان Client Order ID وضعیت را از صرافی استعلام کن.
  • تا جواب استعلام نیامده، سفارش جایگزین با شناسه جدید نفرست.
  • اگر استعلام هم جواب نداد، مشکل از اتصال است، نه از یک سفارش: سفارش‌گذاری را متوقف کن. این یکی از ورودی‌های Circuit Breaker درس ۲۷ است.

اگر صرافی بگوید چنین شناسه‌ای را نمی‌شناسد، معمولاً یعنی احتمال اول؛ ولی فقط وقتی به این نتیجه اعتماد کن که مطمئن باشی پیام دیررسی در راه نیست. بعضی سیستم‌ها چند ثانیه صبر می‌کنند و دوباره استعلام می‌کنند، و بعضی برای اطمینان یک درخواست لغو با همان شناسه هم می‌فرستند.

۸. وصل شدن دوباره و Reconciliation

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

ترتیب درست، همان الگوی Snapshot و Diff درس ۱۶ است:

  1. سفارش‌گذاری را متوقف کن. تا تطبیق تمام نشده، Strategy حق ارسال ندارد.
  2. اول به جریان رویدادهای خصوصی (سفارش‌ها و Fillها) وصل شو و پیام‌ها را بافر کن.
  3. Snapshot بگیر: سفارش‌های باز، Fillهای بعد از آخرین Trade ID که از آن مطمئنی، و موجودی حساب.
  4. مقایسه کن و اختلاف‌ها را برطرف کن.
  5. رویدادهای بافرشده را اعمال کن؛ تکراری‌ها با Trade ID حذف می‌شوند.
  6. موجودی را چک کن و فقط اگر همه چیز جور بود، سفارش‌گذاری را دوباره فعال کن.

قلب مرحله ۴:

def reconcile(local, exch_open, exch_fills):
    """local: client_id -> Order; exch_open: client_id -> remaining lots on the exchange;
    exch_fills: (client_id, trade_id, lots) since the last trade we are sure about."""
    actions = []
    for cid, tid, lots in exch_fills:            # 1) replay missed fills first
        if cid in local:
            local[cid].on_fill(tid, lots)        # duplicates are ignored by trade id
        else:
            actions.append(f"ALERT  fill for unknown order {cid}")
    for cid, o in local.items():                 # 2) compare every live local order
        if not o.live:
            continue
        if cid not in exch_open:
            actions.append(f"QUERY  {cid} ({o.state.value}): not open on exchange")
        elif exch_open[cid] != o.qty - o.filled:
            actions.append(f"ALERT  {cid}: remaining differs")
    for cid in exch_open:                        # 3) open on exchange, unknown to us
        if cid not in local:
            actions.append(f"CANCEL {cid}: orphan order")
    return actions

و یک سناریو: سه سفارش داشتیم که اتصال قطع شد. در این فاصله سفارش اول کامل پر شده، از دومی 30 Lot پر شده، و سومی که درست قبل از قطع فرستاده بودیم هیچ ردی در صرافی ندارد. یک سفارش قدیمی هم از اجرای قبلی (جلسه صفر) در دفتر جا مانده:

# Uses Order from order_sm.py and reconcile() from above
# Orders we believed were live when the connection dropped
a = Order("mm-1-000050", "buy", 99990, 100); a.on_sent(); a.on_ack()
b = Order("mm-1-000051", "sell", 100010, 100); b.on_sent(); b.on_ack()
c = Order("mm-1-000052", "buy", 99980, 100); c.on_sent()        # no reply yet
local = {o.client_id: o for o in (a, b, c)}

# What the exchange reports after we reconnect
exch_open = {"mm-1-000051": 70, "mm-0-000917": 200}  # the second is from an old session
exch_fills = [("mm-1-000050", "t7", 100), ("mm-1-000051", "t8", 30)]

for line in reconcile(local, exch_open, exch_fills):
    print(line)
print({o.client_id: (o.state.value, o.filled) for o in local.values()})
QUERY  mm-1-000052 (pending_new): not open on exchange
CANCEL mm-0-000917: orphan order
{'mm-1-000050': ('filled', 100), 'mm-1-000051': ('partially_filled', 30), 'mm-1-000052': ('pending_new', 0)}

هر سطر خروجی یک تصمیم است:

  • سفارش ۵۰ و ۵۱ فقط با اعمال Fillهای از دست رفته درست شدند. سفارش ۵۱ هنوز 70 Lot باز دارد و صرافی هم همین را می‌گوید.
  • سفارش ۵۲ نه باز است و نه Fill دارد. احتمالاً هرگز نرسیده، ولی سیستم حدس نمی‌زند؛ استعلام می‌کند.
  • سفارش یتیم (Orphan) از جلسه قبل را هیچ بخشی از سیستم مدیریت نمی‌کند. سیاست امن، لغو آن است. شناسه‌های جلسه‌دار دقیقاً برای تشخیص همین حالت به کار می‌آیند.

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

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

سؤال ۱: سفارشی فرستاده‌ای و بعد از Timeout هیچ پاسخی نیامده. کدام کار درست است؟
سؤال ۲: موجودی 0.1 BTC است. یک سفارش خرید باز با 0.15 BTC باقی‌مانده و یک سفارش خرید 0.1 BTC در وضعیت PENDING_CANCEL داری. بدترین حالت موجودی خرید چقدر است؟
سؤال ۳: درخواست لغو فرستاده‌ای و صرافی پاسخ می‌دهد «سفارش پیدا نشد». هم‌زمان یک Fill کامل برای همان سفارش رسیده است. این وضعیت چیست؟
سؤال ۴: بعد از وصل شدن دوباره، صرافی سفارشی را باز نشان می‌دهد که در حافظه سیستم هیچ اثری از آن نیست. سیاست امن چیست؟

درس بعدی

در درس ۲۷ از «سیستم درست کار کند» به «وقتی خراب شد، کم خراب شود» می‌رسیم: Kill Switch و Circuit Breaker. بررسی‌های پیش از ارسال سفارش، حد زیان، طراحی دکمه توقف اضطراری، و توقف خودکار وقتی داده کهنه می‌شود یا خطاها ناگهان زیاد می‌شوند.

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