مسیر Quant Developer · بخش ۷: Production و کنترل ریسک · درس ۲۷
Kill Switch و Circuit Breaker
بررسیهای پیش از ارسال سفارش، حد زیان، دکمه توقف اضطراری و توقف خودکار در برابر داده کهنه، خطای زیاد و پر شدن غیرعادی؛ و تست این مسیرها.
۱. سیستم خراب خواهد شد
هر سیستم معاملاتی دیر یا زود خراب میشود. سؤال این نیست که «اگر»، بلکه این است که وقتی خراب شد، چقدر خسارت میزند. چند نمونه از خرابیهایی که در هیچ Backtestی دیده نمیشوند:
- یک باگ علامت در محاسبه Skew باعث میشود سیستم بهجای کم کردن موجودی، مدام آن را زیاد کند.
- WebSocket داده بازار بیصدا قطع شده ولی اتصال باز مانده؛ Strategy با قیمت ۳۰ ثانیه پیش قیمتدهی میکند.
- صرافی کند شده و نیمی از سفارشها Timeout میخورند.
- اپراتور یک صفر اضافه در فایل تنظیمات میگذارد و اندازه سفارش ده برابر میشود.
در همه این موارد، مدل قیمتگذاری درسهای ۱۱ و ۱۲ هیچ کمکی نمیکند، چون مشکل در مدل نیست. چیزی که نجاتت میدهد لایههایی است که مستقل از Strategy جلوی ضرر را میگیرند:
- Pre-trade Checks بررسی تکتک سفارشها پیش از ارسال؛ همزمان و بدون استثنا
- Circuit Breakers نگاه به جریان رویدادها: داده کهنه، خطای زیاد، پر شدن غیرعادی، زیان
- Kill Switch توقف کامل: نه سفارش جدید، لغو همه سفارشها؛ فقط با دست انسان دوباره فعال میشود
- محافظهای بیرونی ناظر در پردازه جدا، لغو خودکار سمت صرافی هنگام قطع اتصال، حدهای خود حساب
هر لایه برای وقتی است که لایه قبلی کار نکرده باشد.
۲. بررسیهای پیش از ارسال
در درس ۲۵ گفتیم هیچ سفارشی نباید بدون عبور از Risk Gate به صرافی برسد. حالا محتوای این دروازه را میسازیم. همه اعداد فرضیاند و برای مثال BTC/USDT با Mid حدود 100,000 انتخاب شدهاند:
| بررسی | جلوی چه چیزی را میگیرد | مقدار نمونه (فرضی) |
|---|---|---|
| حداکثر اندازه سفارش | صفر اضافه، باگ واحد (BTC بهجای Lot) | 0.2 BTC |
| باند قیمت | قیمت اشتباه (Fat Finger)، Fair Value خراب | حداکثر ۰.۵٪ فاصله از Mid، یعنی ۵۰۰ دلار |
| حد موجودی در بدترین حالت | انباشت موجودی با سفارشهای زنده (درس ۲۴ و ۲۶) | 0.5 BTC |
| نرخ ارسال | حلقه بیپایان، مسدود شدن حساب توسط صرافی | ۱۰ سفارش در ثانیه، حداکثر ۲۰ پشت سر هم |
| تازگی داده | قیمتدهی روی داده کهنه | حداکثر ۲ ثانیه |
چند نکته مهندسی:
- حد موجودی را با بدترین حالت بسنج، همان فرمول درس ۲۶: موجودی فعلی بهعلاوه همه سفارشهای زنده همجهت، بهعلاوه سفارش جدید. سفارشی که موجودی را کم میکند باید مجاز بماند، وگرنه در لحظه بحران حتی نمیتوانی موقعیتت را کوچک کنی.
- حد نرخ ارسال را زیر حد صرافی بگذار. اگر صرافی (بهطور فرضی) ۵۰ درخواست در ثانیه اجازه میدهد و تو تا سقف استفاده کنی، دقیقاً وقتی بیشترین نیاز را به لغو سفارش داری، درخواست لغوت با خطای «نرخ زیاد» رد میشود. یک راه رایج این است که بخشی از ظرفیت را برای لغو کنار بگذاری.
- این بررسیها باید سریع و همگام باشند. فقط با حافظه کار کنند، بدون شبکه و بدون پایگاه داده. اگر بررسی ریسک خودش به یک سرویس بیرونی وابسته باشد، قطع آن سرویس یا کل معاملهگری را متوقف میکند یا بدتر، باعث میشود کسی بررسی را «موقتاً» غیرفعال کند.
۳. حد زیان
حد زیان روزانه سادهترین و مهمترین Circuit Breaker است. دو جزئیات که اغلب اشتباه میشوند:
- زیان تحققنیافته را هم حساب کن. اگر 0.5 BTC موجودی داری و قیمت ۲٪ (۲,۰۰۰ دلار) پایین رفته، هنوز هیچ معاملهای با زیان بسته نشده ولی ۱,۰۰۰ دلار از دست دادهای. PnL را همانطور که در درس ۷ دیدیم، با ارزشگذاری موجودی به قیمت فعلی بسنج.
- حد افت از اوج (Drawdown) هم داشته باش. اگر صبح ۸۰۰ دلار سود کردهای و حالا به صفر رسیدهای، حد زیان روزانه هنوز فعال نشده، ولی ۸۰۰ دلار افت در چند ساعت نشانه این است که چیزی عوض شده (درس ۲۰).
۴. طراحی Kill Switch
Kill Switch یعنی «همه چیز را متوقف کن». ولی «همه چیز» دقیقاً چیست؟ سه سطح وجود دارد:
| سطح | چه میکند | خطرش |
|---|---|---|
| توقف قیمتدهی | هیچ سفارش جدیدی ارسال نمیشود | سفارشهای باز هنوز در دفترند و ممکن است پر شوند |
| لغو همه | توقف قیمتدهی و لغو همه سفارشهای باز | موجودی فعلی باقی میماند و در معرض حرکت قیمت است |
| صاف کردن موجودی (Flatten) | لغو همه و بستن موجودی با سفارش تهاجمی | پرداخت Spread و اسلیپیج در بدترین لحظه بازار |
پیشفرض معقول برای بیشتر بازارسازها لغو همه است و Flatten را خودکار نکن. دلیلش ساده است: بسیاری از دلایل فعال شدن Kill Switch این است که سیستم دیگر به دادههایش اعتماد ندارد. اگر علت، اختلاف موجودی با صرافی باشد، صاف کردن موجودی بر اساس عددی که میدانی غلط است، میتواند موقعیت را بزرگتر کند. و فروش Market در بازاری که همین الان ۲٪ ریخته، اغلب یعنی فروش در کف (درس ۳ و ۴). تصمیم Flatten را یک انسان با دیدن داده درست میگیرد.
ویژگیهای یک Kill Switch خوب:
- قفلشونده (Latched). وقتی فعال شد، فعال میماند حتی اگر شرط اولیه برطرف شود. فقط انسان آن را دوباره مسلح میکند. اگر خودکار برگردد، سیستم میتواند بین خراب و سالم نوسان کند و هر بار کمی بیشتر ضرر بزند.
- اولین دلیل را نگه دار. وقتی یک خرابی رخ میدهد، معمولاً ده هشدار پشت سرش میآید. علت ریشهای اولین است.
- Idempotent. صد بار فعال کردنش همان اثر یک بار را دارد.
- مسیر مستقل. دستور لغو همه نباید پشت صف قیمتهای Strategy گیر کند. اگر Event Loop مسدود شده باشد (درس ۲۵)، Kill Switch داخل همان پردازه هم کار نمیکند؛ به همین دلیل یک ناظر در پردازه جدا یا قابلیت سمت صرافی لازم است.
- قابل دسترس از بیرون. اپراتور باید بتواند بدون ورود به کد و بدون Restart آن را فعال کند: یک دستور مدیریتی، یک Endpoint داخلی، یا حتی وجود یک فایل.
۵. Circuit Breaker روی ناهنجاریها
بررسیهای پیش از ارسال به تک سفارش نگاه میکنند. Circuit Breakerها به الگوی رویدادها نگاه میکنند. همه به یک اندازه جدی نیستند، پس دو نوع واکنش داریم:
- توقف موقت (Pause): سفارشها لغو میشوند و وقتی شرط برطرف شد، سیستم خودش ادامه میدهد.
- Kill: توقف قفلشونده که انسان باید بررسی و آزادش کند.
| ناهنجاری | شرط نمونه (فرضی) | واکنش |
|---|---|---|
| داده کهنه | بیش از ۲ ثانیه بدون بهروزرسانی دفتر سفارش | Pause |
| جهش قیمت | حرکت Mid بیش از ۱٪ در ۱ ثانیه | Pause، شاید با Spread بزرگتر برگردد |
| Loop Lag (درس ۲۵) | بیش از ۵۰۰ میلیثانیه | Pause |
| نرخ رد سفارش | بیش از ۲۰٪ از حداقل ۲۰ سفارش در ۶۰ ثانیه | Kill |
| پر شدن غیرعادی | بیش از ۸ Fill در ۱۰ ثانیه | Kill |
| انتقال غیرمجاز یا اختلاف موجودی (درس ۲۶) | هر بار | Kill |
| حد زیان روزانه | ۵۰۰ دلار زیان | Kill |
پر شدن غیرعادی شاید عجیب به نظر برسد؛ مگر پر شدن بد است؟ برای بازارساز، موجی از Fill در یک سمت تقریباً همیشه یعنی قیمتهایت از بازار عقب افتادهاند و کسی سریعتر دارد سفارشهایت را برمیدارد؛ همان Adverse Selection درس ۸، این بار با سرعت زیاد.
برای نرخ رد، حداقل نمونه مهم است. اگر از ۲ سفارش ۱ تا رد شده باشد، نرخ ۵۰٪ است ولی معنای آماری ندارد. بدون حداقل نمونه، Breaker با هر اتفاق تصادفی فعال میشود و تیم یاد میگیرد نادیدهاش بگیرد.
۶. به زبان کد
کد زیر یک Risk Gate با همه بررسیهای بخش ۲، یک Kill Switch قفلشونده، و دو Breaker (پر شدن غیرعادی و حد زیان) را پیاده میکند. حجمها مثل درس ۲۶ بر حسب Lot هستند (هر Lot برابر 0.001 BTC):
from collections import deque
from dataclasses import dataclass
@dataclass
class Limits: # every value here is hypothetical
max_order_lots: int = 200 # 0.2 BTC per order (1 lot = 0.001 BTC)
max_position_lots: int = 500 # 0.5 BTC worst case, live orders included
band_bps: int = 50 # price must be within 0.5% of reference mid
orders_per_sec: float = 10.0 # stay well under the exchange's own limit
burst: int = 20
max_data_age: float = 2.0 # seconds
max_daily_loss: float = 500.0 # USDT, realized + unrealized
max_fills_10s: int = 8
class KillSwitch:
"""Latched: once tripped it stays tripped until a human resets it."""
def __init__(self):
self.reason = None
def trip(self, reason: str):
if self.reason is None: # keep the first cause; later ones are symptoms
self.reason = reason
# real system: block new orders, cancel all open orders, page a human
def reset(self, operator: str):
print(f" reset by {operator} (was: {self.reason})")
self.reason = None
class TokenBucket:
def __init__(self, rate: float, burst: int):
self.rate, self.burst = rate, burst
self.tokens, self.last = float(burst), 0.0
def take(self, now: float) -> bool:
self.tokens = min(self.burst, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens < 1:
return False
self.tokens -= 1
return True
class RiskGate:
def __init__(self, lim: Limits, kill: KillSwitch):
self.lim, self.kill = lim, kill
self.bucket = TokenBucket(lim.orders_per_sec, lim.burst)
self.fill_times = deque()
def check(self, now, side, price, lots, s) -> str | None:
"""Return None if the order may go out, otherwise the reason it may not."""
lim = self.lim
if self.kill.reason:
return f"kill switch: {self.kill.reason}"
if now - s["mid_time"] > lim.max_data_age:
return "stale market data"
if lots > lim.max_order_lots:
return f"size {lots} > {lim.max_order_lots} lots"
if abs(price - s["mid"]) * 10_000 > s["mid"] * lim.band_bps:
return f"price {price} outside band"
live = s["live_buy"] if side == "buy" else s["live_sell"]
worst = abs(s["position"] + (lots + live) * (1 if side == "buy" else -1))
if worst > lim.max_position_lots:
return f"worst-case position {worst} > {lim.max_position_lots} lots"
if not self.bucket.take(now): # last, so rejected orders cost no tokens
return "rate limit"
return None
def on_fill(self, now, s):
"""Circuit breakers that look at what already happened."""
self.fill_times.append(now)
while self.fill_times[0] < now - 10:
self.fill_times.popleft()
if len(self.fill_times) > self.lim.max_fills_10s:
self.kill.trip(f"{len(self.fill_times)} fills in 10s")
if s["pnl"] <= -self.lim.max_daily_loss:
self.kill.trip(f"daily loss {s['pnl']:.0f} USDT")
به یک تصمیم طراحی دقت کن: زمان (now) ورودی تابع است، نه time.time() داخل آن. این کار باعث میشود بتوانی سناریوهای زمانی را دقیق و قطعی تست کنی، بدون اینکه واقعاً ۱۰ ثانیه صبر کنی. حالا یک سناریو. موجودی 300 Lot خرید است، 100 Lot سفارش خرید و 100 Lot سفارش فروش زنده داریم، و PnL روز ۱۲۰ دلار زیان است:
kill = KillSwitch()
gate = RiskGate(Limits(), kill)
s = {"mid": 100_000, "mid_time": 0.0, "position": 300,
"live_buy": 100, "live_sell": 100, "pnl": -120.0}
def order(now, side, price, lots):
reason = gate.check(now, side, price, lots, s)
print(f"t={now:<4} {side:4} {lots:>3} @ {price:,}: {reason or 'OK'}")
order(0.0, "buy", 99_990, 50) # normal quote
order(0.0, "buy", 99_990, 300) # too big
order(0.0, "sell", 99_000, 50) # fat finger: 1% below mid
order(0.0, "buy", 99_990, 150) # 300 + 100 + 150 = 550 lots worst case
order(0.0, "sell", 100_010, 150) # selling reduces risk: allowed
sent = sum(gate.check(0.1, "sell", 100_010, 1, s) is None for _ in range(30))
print(f"t=0.1 30 orders in one burst: {sent} sent, {30 - sent} blocked by rate limit")
order(3.0, "buy", 99_990, 50) # no market data for 3 seconds
s["mid_time"] = 3.0
for i in range(9): # nine fills within 4 seconds
gate.on_fill(3.0 + i * 0.5, s)
order(7.5, "sell", 100_010, 50)
kill.reset("operator-on-duty")
s["mid_time"] = 8.0
order(8.0, "sell", 100_010, 50)
s["mid_time"], s["pnl"] = 20.0, -520.0 # a bad move marks the inventory down
gate.on_fill(20.0, s)
order(20.0, "buy", 99_990, 50)
t=0.0 buy 50 @ 99,990: OK
t=0.0 buy 300 @ 99,990: size 300 > 200 lots
t=0.0 sell 50 @ 99,000: price 99000 outside band
t=0.0 buy 150 @ 99,990: worst-case position 550 > 500 lots
t=0.0 sell 150 @ 100,010: OK
t=0.1 30 orders in one burst: 19 sent, 11 blocked by rate limit
t=3.0 buy 50 @ 99,990: stale market data
t=7.5 sell 50 @ 100,010: kill switch: 9 fills in 10s
reset by operator-on-duty (was: 9 fills in 10s)
t=8.0 sell 50 @ 100,010: OK
t=20.0 buy 50 @ 99,990: kill switch: daily loss -520 USDT
خط به خط:
- سفارش ۳۰۰ Lot از حداکثر ۲۰۰ بزرگتر است.
- فروش در 99,000 هزار دلار با Mid فاصله دارد، در حالی که باند ۵۰ واحد پایه یعنی ۵۰۰ دلار است. اگر این سفارش میرفت، فوراً به سفارشهای خرید دفتر میخورد و زیر قیمت میفروخت.
- خرید 150 Lot موجودی بدترین حالت را به 550 Lot میرساند (
300 + 100 + 150). ولی فروش 150 Lot پذیرفته میشود، چون بدترین حالتش 50 Lot است (300 − 100 − 150). - نرخ ارسال: سطل ۲۰ توکن داشت، دو سفارش پذیرفتهشده در لحظه صفر دو توکن مصرف کردند و در ۰.۱ ثانیه یک توکن اضافه شد. پس از ۳۰ سفارش، ۱۹ تا رفت.
- داده کهنه: ۳ ثانیه بدون بهروزرسانی؛ سفارش رد میشود ولی Kill فعال نمیشود. این یک Pause است.
- ۹ Fill در ۴ ثانیه Kill Switch را فعال کرد و بعد از آن هر سفارشی، حتی فروشی که ریسک را کم میکند، رد میشود؛ تا وقتی اپراتور آن را آزاد کند.
- زیان ۵۲۰ دلاری با اولین Fill بعدی Kill را دوباره فعال کرد.
۷. دخالت دستی
انسان باید بتواند سیستم را کنترل کند، ولی هر نوع دخالت یکجور نیست:
- توقف همیشه آزاد است. هر کسی که مسئول است باید بتواند بدون مانع، بدون تأیید دوم و بدون پرسیدن سؤال، Kill Switch را بزند. هزینه یک توقف بیدلیل چند دقیقه درآمد است؛ هزینه یک توقف دیرهنگام میتواند کل سرمایه باشد.
- ادامه دادن چکلیست دارد. قبل از Reset: علت اولین Trip پیدا شده؟ تطبیق با صرافی (درس ۲۶) انجام شده؟ موجودی و سفارشهای باز درستاند؟ نام کسی که Reset کرده و دلیلش ثبت میشود.
- دور زدن یک بررسی هرگز در کد نیست. اگر لازم است حدی بالا برود، از راه تغییر تنظیمات با بازبینی و ثبت انجام شود؛ ترجیحاً با تاریخ انقضا. پرچمی مثل
skip_risk_checks=Trueدیر یا زود در جای اشتباه روشن میماند.
۸. تست مسیرهای خرابی
Kill Switchی که هرگز تست نشده، وجود ندارد. سه لایه تست:
- تست واحد برای هر بررسی با زمان تزریقشده، دقیقاً مثل سناریوی بالا. هر مرز را تست کن: سفارش دقیقاً ۲۰۰ Lot پذیرفته شود و ۲۰۱ رد شود.
- تست سناریو با خطای تزریقی: داده ذخیرهشده یک روز را (درس ۱۶) دوباره پخش کن و در میانهاش خرابی بساز: فید را ۵ ثانیه متوقف کن، ۳۰٪ سفارشها را رد کن، Fill تکراری بفرست، اتصال را قطع کن. انتظار داری کدام Breaker فعال شود و چقدر طول بکشد؟
- تمرین آتشنشانی (Fire Drill): در محیط آزمایشی صرافی یا با حجم بسیار کوچک، Kill Switch واقعی را بهطور منظم بزن و اندازه بگیر از لحظه فشردن تا تأیید لغو آخرین سفارش چقدر طول میکشد. این عدد، زمان تا امن شدن، یکی از متریکهایی است که در درس ۲۸ روی داشبورد میآوریم.
۹. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
همه Breakerهای این درس به عددهایی تکیه دارند: سن داده، نرخ رد، تعداد Fill، PnL. در درس ۲۸، Observability، همین عددها را برای انسانها قابل دیدن میکنیم: کدام متریکها را بفرستیم، انواع متریک در Prometheus و نامگذاری درستشان، لاگ ساختیافته با شناسه پیگیری، و چیدمان یک داشبورد Grafana برای سیستم بازارسازی.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.