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

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

Kill Switch و Circuit Breaker

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

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

۱. سیستم خراب خواهد شد

هر سیستم معاملاتی دیر یا زود خراب می‌شود. سؤال این نیست که «اگر»، بلکه این است که وقتی خراب شد، چقدر خسارت می‌زند. چند نمونه از خرابی‌هایی که در هیچ Backtestی دیده نمی‌شوند:

  • یک باگ علامت در محاسبه Skew باعث می‌شود سیستم به‌جای کم کردن موجودی، مدام آن را زیاد کند.
  • WebSocket داده بازار بی‌صدا قطع شده ولی اتصال باز مانده؛ Strategy با قیمت ۳۰ ثانیه پیش قیمت‌دهی می‌کند.
  • صرافی کند شده و نیمی از سفارش‌ها Timeout می‌خورند.
  • اپراتور یک صفر اضافه در فایل تنظیمات می‌گذارد و اندازه سفارش ده برابر می‌شود.

در همه این موارد، مدل قیمت‌گذاری درس‌های ۱۱ و ۱۲ هیچ کمکی نمی‌کند، چون مشکل در مدل نیست. چیزی که نجاتت می‌دهد لایه‌هایی است که مستقل از Strategy جلوی ضرر را می‌گیرند:

  1. Pre-trade Checks بررسی تک‌تک سفارش‌ها پیش از ارسال؛ هم‌زمان و بدون استثنا
  2. Circuit Breakers نگاه به جریان رویدادها: داده کهنه، خطای زیاد، پر شدن غیرعادی، زیان
  3. Kill Switch توقف کامل: نه سفارش جدید، لغو همه سفارش‌ها؛ فقط با دست انسان دوباره فعال می‌شود
  4. محافظ‌های بیرونی ناظر در پردازه جدا، لغو خودکار سمت صرافی هنگام قطع اتصال، حدهای خود حساب

هر لایه برای وقتی است که لایه قبلی کار نکرده باشد.

۲. بررسی‌های پیش از ارسال

در درس ۲۵ گفتیم هیچ سفارشی نباید بدون عبور از 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) هم داشته باش. اگر صبح ۸۰۰ دلار سود کرده‌ای و حالا به صفر رسیده‌ای، حد زیان روزانه هنوز فعال نشده، ولی ۸۰۰ دلار افت در چند ساعت نشانه این است که چیزی عوض شده (درس ۲۰).
Daily PnL = Realized Gross PnL + Inventory × (Mid − Average Cost) − Fees

۴. طراحی 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ی که هرگز تست نشده، وجود ندارد. سه لایه تست:

  1. تست واحد برای هر بررسی با زمان تزریق‌شده، دقیقاً مثل سناریوی بالا. هر مرز را تست کن: سفارش دقیقاً ۲۰۰ Lot پذیرفته شود و ۲۰۱ رد شود.
  2. تست سناریو با خطای تزریقی: داده ذخیره‌شده یک روز را (درس ۱۶) دوباره پخش کن و در میانه‌اش خرابی بساز: فید را ۵ ثانیه متوقف کن، ۳۰٪ سفارش‌ها را رد کن، Fill تکراری بفرست، اتصال را قطع کن. انتظار داری کدام Breaker فعال شود و چقدر طول بکشد؟
  3. تمرین آتش‌نشانی (Fire Drill): در محیط آزمایشی صرافی یا با حجم بسیار کوچک، Kill Switch واقعی را به‌طور منظم بزن و اندازه بگیر از لحظه فشردن تا تأیید لغو آخرین سفارش چقدر طول می‌کشد. این عدد، زمان تا امن شدن، یکی از متریک‌هایی است که در درس ۲۸ روی داشبورد می‌آوریم.

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

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

سؤال ۱: موجودی 0.3 BTC خرید است و 0.1 BTC سفارش خرید زنده داری. حد موجودی بدترین حالت 0.5 BTC است. یک سفارش خرید جدید حداکثر چقدر می‌تواند باشد؟
سؤال ۲: Mid برابر 100,000 و باند قیمت ۳۰ واحد پایه (۰.۳٪) است. کدام سفارش فروش رد می‌شود؟
سؤال ۳: Kill Switch به دلیل اختلاف موجودی سیستم با صرافی فعال شده است. چرا Flatten خودکار در این حالت خطرناک است؟
سؤال ۴: چرا Kill Switch باید قفل‌شونده باشد و بعد از برطرف شدن شرط، خودکار برنگردد؟

درس بعدی

همه Breakerهای این درس به عددهایی تکیه دارند: سن داده، نرخ رد، تعداد Fill، PnL. در درس ۲۸، Observability، همین عددها را برای انسان‌ها قابل دیدن می‌کنیم: کدام متریک‌ها را بفرستیم، انواع متریک در Prometheus و نام‌گذاری درستشان، لاگ ساخت‌یافته با شناسه پیگیری، و چیدمان یک داشبورد Grafana برای سیستم بازارسازی.

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