مسیر Quant Developer · بخش ۷: Production و کنترل ریسک · درس ۲۶
Order Lifecycle و مدیریت خطا
وضعیتهای یک سفارش از ساخت تا پر شدن یا لغو، فرق تأیید صرافی با پر شدن، و تطبیق وضعیت خودمان با صرافی بعد از Timeout یا قطع ارتباط.
۱. سفارش یک شیء با عمر است
در مثال درس ۲۵، Gateway هر از گاهی یک Fill بینام در صف میگذاشت و Strategy موجودی را بهروز میکرد. در دنیای واقعی کار به این سادگی نیست. بین لحظهای که تصمیم میگیری سفارش بدهی و لحظهای که مطمئن میشوی چه بر سرش آمده، دهها چیز ممکن است خراب شود: درخواست به صرافی نرسد، برسد ولی پاسخش گم شود، پاسخ تأیید بعد از Fill برسد، یا وسط کار اتصال قطع شود.
بیشتر زیانهای عملیاتی در سیستمهای معاملاتی از مدل یا پیشبینی بد نمیآیند؛ از جایی میآیند که سیستم فکر میکند چیزی را میداند ولی نمیداند. سیستمی که خیال میکند سفارشی لغو شده در حالی که هنوز در دفتر است، یا خیال میکند موجودی صفر است در حالی که 0.3 BTC خریده. این درس درباره این است که این «دانستن» را چطور دقیق و قابل اعتماد کنیم.
۲. ماشین حالت سفارش
راه استاندارد مدل کردن عمر سفارش، ماشین حالت (State Machine) است: فهرستی محدود از وضعیتها و فهرستی صریح از انتقالهای مجاز بین آنها. مسیر عادی یک سفارش Limit این است:
- NEW سفارش در سیستم خودمان ساخته و ثبت شده، هنوز ارسال نشده
- PENDING_NEW ارسال شده؛ منتظر پاسخ صرافی هستیم
- OPEN صرافی پذیرفته و سفارش در دفتر نشسته است
- PARTIALLY_FILLED بخشی پر شده و بقیه هنوز در دفتر است
- FILLED کل حجم پر شده؛ وضعیت پایانی
و وضعیتهای فرعی:
| وضعیت | معنا | پایانی؟ |
|---|---|---|
| PENDING_CANCEL | درخواست لغو فرستادهایم ولی هنوز تأیید نشده | خیر |
| CANCELLED | صرافی لغو را تأیید کرده (به درخواست ما یا به دلیل خودش، مثل IOC یا Post-Only در درس ۳) | بله |
| REJECTED | صرافی سفارش را نپذیرفته؛ مثلاً موجودی کافی نبوده یا قیمت خارج از محدوده مجاز بوده | بله |
دو اصل این مدل را مفید میکنند:
- انتقالها صریحاند. هر رویدادی که با وضعیت فعلی جور درنیاید (مثلاً Fill برای سفارشی که رد شده) یک خطای آشکار است، نه چیزی که بیصدا نادیده گرفته شود.
- وضعیت پایانی پایانی است. از 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 موجودی را تغییر میدهد.
اما برای ریسک، سفارشی که هنوز پر نشده هم مهم است، چون هر لحظه میتواند پر شود و تو کنترلی روی آن لحظه نداری. پس محاسبه ریسک باید بدترین حالت را در نظر بگیرد:
«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 و وضعیت نامعلوم
سفارشی فرستادهای و بعد از ۲ ثانیه (یک عدد فرضی) هیچ پاسخی نیامده. سه احتمال هست و از بیرون نمیشود فرقشان را فهمید:
- درخواست هرگز به صرافی نرسیده.
- رسیده، پذیرفته شده، و شاید حتی پر شده، ولی پاسخ گم شده.
- رسیده و رد شده، ولی پاسخ گم شده.
اشتباه رایج این است که Timeout را مساوی شکست بگیری. Timeout یعنی «نمیدانم»، و سیستم باید این ندانستن را صادقانه نمایش دهد:
- سفارش را در PENDING_NEW نگه دار و در محاسبه ریسک زنده حساب کن.
- با همان Client Order ID وضعیت را از صرافی استعلام کن.
- تا جواب استعلام نیامده، سفارش جایگزین با شناسه جدید نفرست.
- اگر استعلام هم جواب نداد، مشکل از اتصال است، نه از یک سفارش: سفارشگذاری را متوقف کن. این یکی از ورودیهای Circuit Breaker درس ۲۷ است.
اگر صرافی بگوید چنین شناسهای را نمیشناسد، معمولاً یعنی احتمال اول؛ ولی فقط وقتی به این نتیجه اعتماد کن که مطمئن باشی پیام دیررسی در راه نیست. بعضی سیستمها چند ثانیه صبر میکنند و دوباره استعلام میکنند، و بعضی برای اطمینان یک درخواست لغو با همان شناسه هم میفرستند.
۸. وصل شدن دوباره و Reconciliation
وقتی WebSocket قطع میشود، هر رویدادی که در آن فاصله رخ داده از دست رفته است. ممکن است سفارشهایت پر شده باشند، صرافی آنها را لغو کرده باشد (بعضی صرافیها گزینه لغو خودکار هنگام قطع اتصال دارند)، یا سفارشی که فرستاده بودی هرگز نرسیده باشد. تطبیق (Reconciliation) یعنی مقایسه وضعیت خودمان با منبع حقیقت، یعنی صرافی.
ترتیب درست، همان الگوی Snapshot و Diff درس ۱۶ است:
- سفارشگذاری را متوقف کن. تا تطبیق تمام نشده، Strategy حق ارسال ندارد.
- اول به جریان رویدادهای خصوصی (سفارشها و Fillها) وصل شو و پیامها را بافر کن.
- Snapshot بگیر: سفارشهای باز، Fillهای بعد از آخرین Trade ID که از آن مطمئنی، و موجودی حساب.
- مقایسه کن و اختلافها را برطرف کن.
- رویدادهای بافرشده را اعمال کن؛ تکراریها با Trade ID حذف میشوند.
- موجودی را چک کن و فقط اگر همه چیز جور بود، سفارشگذاری را دوباره فعال کن.
قلب مرحله ۴:
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) از جلسه قبل را هیچ بخشی از سیستم مدیریت نمیکند. سیاست امن، لغو آن است. شناسههای جلسهدار دقیقاً برای تشخیص همین حالت به کار میآیند.
۹. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
در درس ۲۷ از «سیستم درست کار کند» به «وقتی خراب شد، کم خراب شود» میرسیم: Kill Switch و Circuit Breaker. بررسیهای پیش از ارسال سفارش، حد زیان، طراحی دکمه توقف اضطراری، و توقف خودکار وقتی داده کهنه میشود یا خطاها ناگهان زیاد میشوند.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.