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

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

Observability: متریک، لاگ، Grafana و Prometheus

متریک‌های لازم برای سیستم بازارسازی، انواع متریک Prometheus و نام‌گذاری‌شان، PromQL و قواعد هشدار، لاگ ساخت‌یافته با شناسه پیگیری، و داشبورد Grafana.

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

۱. سیستمی که نمی‌بینی، کنترل نمی‌کنی

در درس ۲۷ Breakerهایی ساختیم که در چند میلی‌ثانیه و بدون دخالت انسان واکنش نشان می‌دهند. ولی بعد از هر توقف، یک انسان باید بفهمد چه اتفاقی افتاده. و بدتر، بعضی مشکلات هیچ Breakerی را فعال نمی‌کنند: تأخیر صرافی آرام‌آرام بالا می‌رود، نرخ پر شدن هفته به هفته کم می‌شود، یا Markout (درس ۸) بی‌صدا منفی‌تر می‌شود.

Observability یعنی بتوانی از بیرون سیستم بفهمی درونش چه می‌گذرد. سه ابزار اصلی دارد:

  • متریک (Metric): عددهایی که در طول زمان جمع می‌شوند. ارزان، سریع، مناسب داشبورد و هشدار. جواب می‌دهد «چه چیزی غیرعادی است؟»
  • لاگ (Log): ثبت رویدادهای تکی با جزئیات کامل. جواب می‌دهد «دقیقاً برای این سفارش چه اتفاقی افتاد؟»
  • Trace: دنبال کردن یک درخواست در میان چند سرویس. برای سیستمی که در یک پردازه اجرا می‌شود، لاگ با شناسه پیگیری (بخش ۸) بیشتر کار Trace را انجام می‌دهد.

در این درس از ابزارهای متن‌باز رایج استفاده می‌کنیم: Prometheus برای جمع‌آوری و ذخیره متریک‌ها، و Grafana برای داشبورد.

۲. چه چیزی را اندازه بگیریم؟

یک قاعده ساده: برای هر سؤالی که ساعت سه صبح هنگام یک مشکل از خودت می‌پرسی، باید یک متریک داشته باشی. برای سیستم بازارسازی این مسیر درس‌هاست:

متریکنوعبه چه سؤالی جواب می‌دهد
mm_orders_sent_totalCounterچند سفارش در ثانیه می‌فرستیم؟
mm_order_rejects_totalCounterچند درصد رد می‌شوند و به چه دلیل؟
mm_fills_totalCounterنرخ پر شدن در هر سمت چقدر است؟
mm_order_ack_latency_secondsHistogramصرافی چقدر سریع پاسخ می‌دهد؟
mm_tick_to_order_secondsHistogramاز رسیدن داده تا ارسال سفارش، خود ما چقدر کندیم؟
mm_event_loop_lag_secondsHistogramآیا Event Loop مسدود می‌شود؟ (درس ۲۵)
mm_queue_depthGaugeآیا صفی در حال انباشته شدن است؟
mm_inventory_btcGaugeموجودی چقدر به حد نزدیک است؟
mm_pnl_usdtGaugePnL تحقق‌یافته و تحقق‌نیافته امروز (درس ۷)
mm_market_data_last_update_timestamp_secondsGaugeداده بازار چقدر کهنه است؟
mm_kill_switch_trippedGaugeآیا سیستم متوقف است؟
mm_kill_drill_time_to_safe_secondsGaugeدر آخرین تمرین، لغو همه سفارش‌ها چقدر طول کشید؟ (درس ۲۷)
mm_errors_totalCounterخطاها در کدام جزء و از چه نوعی‌اند؟

۳. سه نوع متریک در Prometheus

Prometheus مدل Pull دارد: برنامه تو یک آدرس HTTP (معمولاً /metrics) باز می‌کند و Prometheus هر چند ثانیه یک بار آن را می‌خواند. هر متریک یکی از این نوع‌هاست:

Counter: عددی که فقط زیاد می‌شود (یا با Restart برنامه به صفر برمی‌گردد). تعداد سفارش، تعداد Fill، تعداد خطا. خود عدد Counter معمولاً معنای زیادی ندارد؛ چیزی که مهم است نرخ تغییرش است که با rate() یا increase() حساب می‌شود. این توابع Restart را هم تشخیص می‌دهند و درست حساب می‌کنند.

Gauge: عددی که بالا و پایین می‌رود. موجودی، PnL، طول صف، زمان آخرین به‌روزرسانی. Gauge را مستقیم نمایش می‌دهی و هرگز rate() رویش نمی‌گیری.

Histogram: توزیع یک کمیت، معمولاً تأخیر. برنامه برای هر مقدار مشاهده‌شده، شمارنده سطل (Bucket) مربوطش را زیاد می‌کند. در خروجی، هر سطل با برچسب le (کوچک‌تر یا مساوی) شمارش تجمعی را نشان می‌دهد، به‌علاوه _sum و _count. از روی این سطل‌ها می‌شود صدک‌ها (Percentile) را تخمین زد.

یک نوع چهارم هم هست به اسم Summary که صدک‌ها را در خود برنامه حساب می‌کند. مشکلش این است که صدک‌ها را نمی‌شود بین چند نمونه برنامه جمع زد؛ میانگین p99 دو سرور، p99 کل نیست. برای تأخیر، Histogram انتخاب پیش‌فرض است.

۴. نام‌گذاری و برچسب

قواعد نام‌گذاری Prometheus قراردادی‌اند ولی رعایتشان باعث می‌شود همه چیز قابل پیش‌بینی باشد:

  • snake_case با پیشوند برنامه: همه متریک‌های ما با mm_ شروع می‌شوند.
  • واحد پایه در نام: زمان همیشه به ثانیه (_seconds)، نه میلی‌ثانیه. Grafana خودش نمایش را به میلی‌ثانیه تبدیل می‌کند.
  • Counter با _total تمام می‌شود.
  • زمان یونیکس با _timestamp_seconds.
  • ابعاد در برچسب (Label)، نه در نام: mm_order_rejects_total{reason="post_only"} درست است، نه mm_order_rejects_post_only_total.
درستنادرستچرا
mm_order_ack_latency_secondsmm_order_ack_latency_msواحد پایه زمان ثانیه است
mm_fills_totalmm_fillsCounter پسوند _total می‌گیرد
mm_orders_sent_total{side="buy"}mm_buy_orders_sent_totalسمت معامله یک بُعد است، نه بخشی از نام
شناسه سفارش در لاگ{client_id="mm-1-000042"}برچسب با مقادیر بی‌شمار

مورد آخر مهم‌ترین است. هر ترکیب یکتای برچسب‌ها یک سری زمانی جدا در Prometheus می‌سازد. اگر شناسه سفارش را برچسب کنی، با ۱,۰۰۰ سفارش در دقیقه، روزانه ۱,۴۴۰,۰۰۰ سری جدید ساخته می‌شود و Prometheus زیر بار حافظه از کار می‌افتد. به این مشکل Cardinality بالا می‌گویند. برچسب فقط برای مقادیری است که فهرست کوتاه و محدودی دارند: سمت، دلیل رد، نماد، نام جزء.

۵. خروجی متریک‌ها از نزدیک

در کار واقعی از کتابخانه رسمی prometheus_client استفاده می‌کنی که کلاس‌های Counter، Gauge و Histogram دارد. اینجا برای اینکه ببینی زیر کاپوت چه می‌گذرد، نسخه کوچکی را فقط با کتابخانه استاندارد می‌سازیم و یک دقیقه شلوغ را شبیه‌سازی می‌کنیم: ۱,۰۰۰ سفارش، ۲٪ رد، و تأخیر تأیید با توزیع Lognormal با میانه حدود ۱۸ میلی‌ثانیه (اعداد فرضی):

import bisect
import random

class Metric:
    def __init__(self, name, kind, help_text):
        self.name, self.kind, self.help = name, kind, help_text
        self.values = {}                              # label tuple -> value

    def _key(self, labels):
        return tuple(sorted(labels.items()))

    def inc(self, amount=1.0, **labels):              # counter: only goes up
        assert amount >= 0, "counters never decrease"
        k = self._key(labels)
        self.values[k] = self.values.get(k, 0.0) + amount

    def set(self, value, **labels):                   # gauge: any value
        self.values[self._key(labels)] = value

    def render(self):
        out = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} {self.kind}"]
        for k, v in sorted(self.values.items()):
            lbl = ",".join(f'{n}="{val}"' for n, val in k)
            out.append(f"{self.name}{'{' + lbl + '}' if lbl else ''} {v:g}")
        return out

class Histogram:
    def __init__(self, name, help_text, buckets):
        self.name, self.help, self.bounds = name, help_text, list(buckets)
        self.counts = [0] * (len(self.bounds) + 1)    # last slot is +Inf
        self.sum = 0.0

    def observe(self, x):
        self.counts[bisect.bisect_left(self.bounds, x)] += 1   # le means "<="
        self.sum += x

    def render(self):
        out = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} histogram"]
        cum = 0
        for le, c in zip(self.bounds + ["+Inf"], self.counts):
            cum += c                                  # buckets are cumulative
            out.append(f'{self.name}_bucket{{le="{le}"}} {cum}')
        out += [f"{self.name}_sum {self.sum:.4f}", f"{self.name}_count {cum}"]
        return out

sent = Metric("mm_orders_sent_total", "counter", "Orders sent to the exchange.")
rejects = Metric("mm_order_rejects_total", "counter", "Orders rejected by the exchange.")
inventory = Metric("mm_inventory_btc", "gauge", "Current inventory in BTC.")
ack = Histogram("mm_order_ack_latency_seconds", "Time from send to exchange ack.",
                [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0])

rng = random.Random(7)
latencies = []
for _ in range(1000):                                 # one simulated busy minute
    side = rng.choice(["buy", "sell"])
    sent.inc(side=side)
    if rng.random() < 0.02:
        rejects.inc(reason=rng.choice(["post_only", "rate_limit"]))
        continue
    lat = rng.lognormvariate(-4.0, 0.6)               # median about 18 ms
    latencies.append(lat)
    ack.observe(lat)
inventory.set(0.3)

for m in (sent, rejects, inventory, ack):
    print("\n".join(m.render()))

خروجی، دقیقاً همان متنی است که Prometheus از /metrics می‌خواند:

# HELP mm_orders_sent_total Orders sent to the exchange.
# TYPE mm_orders_sent_total counter
mm_orders_sent_total{side="buy"} 513
mm_orders_sent_total{side="sell"} 487
# HELP mm_order_rejects_total Orders rejected by the exchange.
# TYPE mm_order_rejects_total counter
mm_order_rejects_total{reason="post_only"} 7
mm_order_rejects_total{reason="rate_limit"} 12
# HELP mm_inventory_btc Current inventory in BTC.
# TYPE mm_inventory_btc gauge
mm_inventory_btc 0.3
# HELP mm_order_ack_latency_seconds Time from send to exchange ack.
# TYPE mm_order_ack_latency_seconds histogram
mm_order_ack_latency_seconds_bucket{le="0.005"} 18
mm_order_ack_latency_seconds_bucket{le="0.01"} 146
mm_order_ack_latency_seconds_bucket{le="0.025"} 684
mm_order_ack_latency_seconds_bucket{le="0.05"} 936
mm_order_ack_latency_seconds_bucket{le="0.1"} 980
mm_order_ack_latency_seconds_bucket{le="0.25"} 981
mm_order_ack_latency_seconds_bucket{le="0.5"} 981
mm_order_ack_latency_seconds_bucket{le="1.0"} 981
mm_order_ack_latency_seconds_bucket{le="+Inf"} 981
mm_order_ack_latency_seconds_sum 21.2311
mm_order_ack_latency_seconds_count 981

به سطل‌ها دقت کن: عدد هر سطل شامل همه سطل‌های قبلی است. 684 یعنی ۶۸۴ تأیید در ۲۵ میلی‌ثانیه یا کمتر رسیده‌اند. سطل +Inf همیشه با _count برابر است. این خروجی را با promtool check metrics هم می‌توانی بررسی کنی.

۶. صدک از روی سطل و انتخاب سطل‌ها

Prometheus مقادیر تکی را نگه نمی‌دارد، فقط شمارش سطل‌ها را. پس صدک را با درون‌یابی خطی داخل سطل تخمین می‌زند. همین را در پایتون بازسازی می‌کنیم و با مقدار دقیق مقایسه می‌کنیم (ادامه کد قبل):

def histogram_quantile(q, bounds, counts):
    """Same linear interpolation Prometheus uses inside the bucket."""
    cum = [sum(counts[: i + 1]) for i in range(len(counts))]
    rank = q * cum[-1]
    i = next(j for j, c in enumerate(cum) if c >= rank)
    if i == len(bounds):                  # landed in +Inf: return the last finite bound
        return bounds[-1]
    lo = bounds[i - 1] if i > 0 else 0.0
    below = cum[i - 1] if i > 0 else 0
    return lo + (bounds[i] - lo) * (rank - below) / (cum[i] - below)

exact = sorted(latencies)
for q in (0.5, 0.99):
    est = histogram_quantile(q, ack.bounds, ack.counts)
    true = exact[int(q * len(exact))]
    print(f"p{q * 100:.0f}: from buckets {est * 1000:.1f} ms, exact {true * 1000:.1f} ms")
p50: from buckets 19.6 ms, exact 17.9 ms
p99: from buckets 90.0 ms, exact 66.0 ms

میانه با حدود ۱۰٪ خطا تخمین زده شد ولی p99 حدود ۳۶٪ بیشتر از مقدار واقعی است. دلیلش این است که p99 در سطل ۵۰ تا ۱۰۰ میلی‌ثانیه افتاده و تابع فرض می‌کند مقادیر داخل سطل یکنواخت پخش شده‌اند. محاسبه دستی:

rank = 0.99 × 981 = 971.19
p99  = 0.05 + (0.1 − 0.05) × (971.19 − 936) / (980 − 936) = 0.0900 s

درس عملی: سطل‌ها را جایی متراکم کن که برایت مهم است. اگر آستانه هشدار تو ۲۵۰ میلی‌ثانیه است، باید سطلی دقیقاً روی ۰.۲۵ داشته باشی تا بپرسی «چند درصد بالای ۲۵۰ میلی‌ثانیه‌اند؟» و جواب دقیق بگیری. و اگر دم توزیع برایت مهم است، بین ۵۰ و ۱۰۰ میلی‌ثانیه سطل اضافه کن.

۷. PromQL: پرس‌وجو از متریک‌ها

PromQL زبان پرس‌وجوی Prometheus است. پرکاربردترین پرس‌وجوهای این سیستم:

# Orders per second, per side, over the last minute
sum by (side) (rate(mm_orders_sent_total[1m]))

# Reject ratio over 5 minutes, and the breakdown by reason
sum(rate(mm_order_rejects_total[5m])) / sum(rate(mm_orders_sent_total[5m]))
sum by (reason) (rate(mm_order_rejects_total[5m]))

# p99 ack latency: rate per bucket first, then sum keeping `le`, then the quantile
histogram_quantile(0.99, sum by (le) (rate(mm_order_ack_latency_seconds_bucket[5m])))

# Fills in the last hour, and fill ratio (fills per order sent)
sum by (side) (increase(mm_fills_total[1h]))
sum(rate(mm_fills_total[15m])) / sum(rate(mm_orders_sent_total[15m]))

# Market data age in seconds
time() - mm_market_data_last_update_timestamp_seconds

# Total PnL (realized + unrealized)
sum(mm_pnl_usdt)

دو اشتباه رایج:

  • ترتیب sum و rate: همیشه اول rate و بعد sum، یعنی sum(rate(x[5m])). rate باید روی Counter خام هر نمونه برنامه اجرا شود تا بتواند Restart آن را تشخیص دهد. اگر Counterها را اول جمع کنی (مثلاً با یک Recording Rule) و بعد از مجموع نرخ بگیری، Restart یک نمونه مثل افت مجموع به نظر می‌رسد و نرخ غلط می‌شود.
  • حذف le در Histogram: در sum by (le) اگر le را نگه نداری، سطل‌ها با هم قاطی می‌شوند و histogram_quantile هیچ معنایی ندارد.

۸. قواعد هشدار

متریک بدون هشدار یعنی کسی باید مدام به داشبورد خیره شود. قواعد هشدار Prometheus در فایل YAML تعریف می‌شوند. فایل زیر با promtool check rules بررسی شده است؛ آستانه‌ها فرضی‌اند:

groups:
  - name: market-maker
    rules:
      - alert: MarketMakerDown
        expr: up{job="market-maker"} == 0
        for: 1m
        labels:
          severity: page
        annotations:
          summary: "Prometheus cannot scrape the market maker"

      - alert: MarketDataStale
        expr: time() - mm_market_data_last_update_timestamp_seconds > 10
        for: 30s
        labels:
          severity: page
        annotations:
          summary: "No market data for {{ $labels.symbol }} in over 10s"

      - alert: KillSwitchTripped
        expr: mm_kill_switch_tripped == 1
        labels:
          severity: page
        annotations:
          summary: "Kill switch tripped; the first reason is in the logs"

      - alert: HighRejectRatio
        expr: |
          sum(rate(mm_order_rejects_total[5m]))
            / sum(rate(mm_orders_sent_total[5m])) > 0.05
        for: 5m
        labels:
          severity: ticket
        annotations:
          summary: "More than 5% of orders rejected over 5 minutes"

      - alert: AckLatencyP99High
        expr: |
          histogram_quantile(0.99,
            sum by (le) (rate(mm_order_ack_latency_seconds_bucket[5m]))) > 0.25
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "p99 order ack latency above 250ms for 10 minutes"

      - alert: InventoryNearLimit
        expr: abs(mm_inventory_btc) > 0.4
        for: 2m
        labels:
          severity: ticket
        annotations:
          summary: "Inventory above 80% of the 0.5 BTC limit"

چند اصل در این فایل:

  • for جلوی هشدار نویزی را می‌گیرد. شرط باید در تمام این مدت برقرار بماند تا هشدار فعال شود. یک جهش ۱۰ ثانیه‌ای تأخیر کسی را بیدار نمی‌کند.
  • دو سطح شدت: page یعنی همین الان یک انسان باید کاری کند؛ ticket یعنی در ساعت کاری بررسی شود. اگر هر هشداری کسی را بیدار کند، بعد از یک هفته همه هشدارها نادیده گرفته می‌شوند.
  • وقتی برنامه مرده، متریک‌هایش هم نیستند. اگر پردازه از کار بیفتد، mm_market_data_last_update_timestamp_seconds دیگر وجود ندارد و هشدار داده کهنه هرگز فعال نمی‌شود. به همین دلیل MarketMakerDown روی متریک up لازم است که خود Prometheus برای هر هدف می‌سازد. برای متریکی که نباید ناپدید شود، absent(...) هم ابزار مفیدی است.
  • هشدار جای Breaker را نمی‌گیرد. فاصله خواندن (مثلاً ۱۵ ثانیه) به‌علاوه for یعنی هشدار دست‌کم ده‌ها ثانیه بعد می‌رسد. Breakerهای درس ۲۷ در میلی‌ثانیه عمل می‌کنند و هشدار به انسان خبر می‌دهد که عمل کرده‌اند.

۹. لاگ ساخت‌یافته و شناسه پیگیری

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

  1. ساخت‌یافته (Structured): هر خط یک شیء JSON با فیلدهای ثابت، نه جمله آزاد. می‌شود رویش جست‌وجو و فیلتر کرد.
  2. شناسه پیگیری (Correlation ID): یک شناسه که از تصمیم Strategy تا ارسال، تأیید و Fill در همه خطوط تکرار شود. برای سیستم ما بهترین انتخاب همان Client Order ID درس ۲۶ است که از قبل در همه پیام‌های صرافی هست.

در asyncio، ماژول contextvars این کار را تمیز انجام می‌دهد: هر Task نسخه خودش از متغیر را دارد، پس شناسه یک سفارش به لاگ سفارش دیگر نشت نمی‌کند:

import asyncio
import contextvars
import json
import logging
import sys
import time

client_id = contextvars.ContextVar("client_id", default=None)

class JsonFormatter(logging.Formatter):
    def format(self, record):
        entry = {
            "ts": round(record.created, 3),
            "level": record.levelname,
            "component": record.name,
            "msg": record.getMessage(),
            "client_id": client_id.get(),      # correlation id, set once per order
        }
        entry.update(getattr(record, "fields", {}))
        return json.dumps(entry)

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
strategy_log = logging.getLogger("strategy")
gateway_log = logging.getLogger("gateway")

async def send_to_exchange(side, price, lots):
    gateway_log.info("order sent", extra={"fields": {"side": side, "price": price, "lots": lots}})
    start = time.perf_counter()
    await asyncio.sleep(0.02)                  # simulated round trip
    ms = round((time.perf_counter() - start) * 1000, 1)
    gateway_log.info("order acked", extra={"fields": {"latency_ms": ms}})

async def place_quote(seq, side, price, lots):
    client_id.set(f"mm-1-{seq:06d}")           # every log line below carries it
    strategy_log.info("quote decided", extra={"fields": {"fair_value": 100_000}})
    await send_to_exchange(side, price, lots)

async def main():
    async with asyncio.TaskGroup() as tg:      # each task gets its own context copy
        tg.create_task(place_quote(42, "buy", 99_990, 100))
        tg.create_task(place_quote(43, "sell", 100_010, 100))

asyncio.run(main())

خروجی یک اجرا (ts در اجرای تو فرق می‌کند):

{"ts": 1791809122.787, "level": "INFO", "component": "strategy", "msg": "quote decided", "client_id": "mm-1-000042", "fair_value": 100000}
{"ts": 1791809122.787, "level": "INFO", "component": "gateway", "msg": "order sent", "client_id": "mm-1-000042", "side": "buy", "price": 99990, "lots": 100}
{"ts": 1791809122.787, "level": "INFO", "component": "strategy", "msg": "quote decided", "client_id": "mm-1-000043", "fair_value": 100000}
{"ts": 1791809122.787, "level": "INFO", "component": "gateway", "msg": "order sent", "client_id": "mm-1-000043", "side": "sell", "price": 100010, "lots": 100}
{"ts": 1791809122.808, "level": "INFO", "component": "gateway", "msg": "order acked", "client_id": "mm-1-000042", "latency_ms": 21.1}
{"ts": 1791809122.809, "level": "INFO", "component": "gateway", "msg": "order acked", "client_id": "mm-1-000043", "latency_ms": 21.1}

خطوط دو سفارش در هم آمده‌اند، چون دو Task هم‌زمان اجرا می‌شوند. ولی با فیلتر روی client_id کل داستان یک سفارش از تصمیم تا تأیید جدا می‌شود؛ بدون اینکه تابع send_to_exchange اصلاً از وجود شناسه خبر داشته باشد.

چه چیزی را لاگ کنیم؟ هر رویداد چرخه عمر سفارش: تصمیم، ارسال، تأیید، رد (با پیام کامل صرافی)، Fill، لغو، انتقال غیرمجاز، Trip شدن Breaker. هر Tick داده بازار را نه: صدها خط در ثانیه هم دیسک را پر می‌کند و هم Event Loop را کند. داده بازار جای خودش را دارد (درس ۱۶). نوشتن لاگ هم نباید Event Loop را مسدود کند؛ در محیط واقعی QueueHandler از ماژول logging.handlers نوشتن را به ترد جدا منتقل می‌کند.

۱۰. چیدمان داشبورد Grafana

داشبورد خوب از بالا به پایین، از «آیا همه چیز خوب است؟» به «چرا خوب نیست؟» می‌رود:

ردیفپنل‌هاسؤال
۱. وضعیتKill Switch، سن داده بازار، PnL امروز، موجودی نسبت به حد، upدر یک نگاه: سالم است یا نه؟
۲. معاملهPnL در طول زمان، موجودی با خط حد، نرخ Fill هر سمت، نسبت Fill به سفارشاستراتژی چه می‌کند؟
۳. اجراp50 و p99 تأخیر تأیید، Heatmap تأخیر، تأخیر Tick تا سفارش، نرخ رد به تفکیک دلیلارتباط با صرافی سالم است؟
۴. سیستمLoop Lag، طول صف‌ها، تعداد اتصال دوباره WebSocket، CPU و حافظهخود برنامه سالم است؟

چند توصیه عملی:

  • ردیف اول فقط عدد بزرگ (Stat) و رنگ. سبز، زرد، قرمز با همان آستانه‌های هشدار.
  • خط حد را روی نمودار موجودی بکش. 0.3 BTC بی‌معناست تا وقتی ببینی حد 0.5 است.
  • محور زمان همه پنل‌ها یکی باشد تا وقتی جهش تأخیر را می‌بینی، بلافاصله ببینی هم‌زمان PnL و نرخ رد چه کرده‌اند.
  • داشبورد را مثل کد نگه دار: خروجی JSON داشبورد را در مخزن کد ذخیره کن تا تغییراتش بازبینی و قابل بازگشت باشد.

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

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

سؤال ۱: برای نمایش تعداد کل Fillها، موجودی فعلی و تأخیر تأیید سفارش، به ترتیب کدام نوع متریک مناسب است؟
سؤال ۲: کدام نام متریک با قواعد Prometheus سازگار است؟
سؤال ۳: Histogram تأخیر ۱,۰۰۰ مشاهده دارد. سطل le=0.05 شمارش تجمعی 950 و سطل le=0.1 شمارش تجمعی 1000 دارد. histogram_quantile برای p99 چه عددی می‌دهد؟
سؤال ۴: پردازه بازارساز از کار افتاده است. چرا هشدار time() - mm_market_data_last_update_timestamp_seconds > 10 فعال نمی‌شود؟

درس بعدی

بخش هفتم اینجا تمام می‌شود: حالا سیستمی داریم که معماری درست، چرخه عمر سفارش قابل اعتماد، محافظ ریسک و مانیتورینگ دارد. در درس ۲۹ بخش آخر، یعنی پروژه عملی، را شروع می‌کنیم: همه قطعات مسیر را کنار هم می‌گذاریم و یک Market Maker برای یک جفت‌ارز طراحی می‌کنیم؛ از انتخاب بازار و مدل قیمت‌گذاری تا حدهای ریسک و معیارهایی که با آن‌ها موفقیتش را می‌سنجیم.

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