مسیر Quant Developer · بخش ۷: Production و کنترل ریسک · درس ۲۸
Observability: متریک، لاگ، Grafana و Prometheus
متریکهای لازم برای سیستم بازارسازی، انواع متریک Prometheus و نامگذاریشان، PromQL و قواعد هشدار، لاگ ساختیافته با شناسه پیگیری، و داشبورد Grafana.
۱. سیستمی که نمیبینی، کنترل نمیکنی
در درس ۲۷ Breakerهایی ساختیم که در چند میلیثانیه و بدون دخالت انسان واکنش نشان میدهند. ولی بعد از هر توقف، یک انسان باید بفهمد چه اتفاقی افتاده. و بدتر، بعضی مشکلات هیچ Breakerی را فعال نمیکنند: تأخیر صرافی آرامآرام بالا میرود، نرخ پر شدن هفته به هفته کم میشود، یا Markout (درس ۸) بیصدا منفیتر میشود.
Observability یعنی بتوانی از بیرون سیستم بفهمی درونش چه میگذرد. سه ابزار اصلی دارد:
- متریک (Metric): عددهایی که در طول زمان جمع میشوند. ارزان، سریع، مناسب داشبورد و هشدار. جواب میدهد «چه چیزی غیرعادی است؟»
- لاگ (Log): ثبت رویدادهای تکی با جزئیات کامل. جواب میدهد «دقیقاً برای این سفارش چه اتفاقی افتاد؟»
- Trace: دنبال کردن یک درخواست در میان چند سرویس. برای سیستمی که در یک پردازه اجرا میشود، لاگ با شناسه پیگیری (بخش ۸) بیشتر کار Trace را انجام میدهد.
در این درس از ابزارهای متنباز رایج استفاده میکنیم: Prometheus برای جمعآوری و ذخیره متریکها، و Grafana برای داشبورد.
۲. چه چیزی را اندازه بگیریم؟
یک قاعده ساده: برای هر سؤالی که ساعت سه صبح هنگام یک مشکل از خودت میپرسی، باید یک متریک داشته باشی. برای سیستم بازارسازی این مسیر درسهاست:
| متریک | نوع | به چه سؤالی جواب میدهد |
|---|---|---|
mm_orders_sent_total | Counter | چند سفارش در ثانیه میفرستیم؟ |
mm_order_rejects_total | Counter | چند درصد رد میشوند و به چه دلیل؟ |
mm_fills_total | Counter | نرخ پر شدن در هر سمت چقدر است؟ |
mm_order_ack_latency_seconds | Histogram | صرافی چقدر سریع پاسخ میدهد؟ |
mm_tick_to_order_seconds | Histogram | از رسیدن داده تا ارسال سفارش، خود ما چقدر کندیم؟ |
mm_event_loop_lag_seconds | Histogram | آیا Event Loop مسدود میشود؟ (درس ۲۵) |
mm_queue_depth | Gauge | آیا صفی در حال انباشته شدن است؟ |
mm_inventory_btc | Gauge | موجودی چقدر به حد نزدیک است؟ |
mm_pnl_usdt | Gauge | PnL تحققیافته و تحققنیافته امروز (درس ۷) |
mm_market_data_last_update_timestamp_seconds | Gauge | داده بازار چقدر کهنه است؟ |
mm_kill_switch_tripped | Gauge | آیا سیستم متوقف است؟ |
mm_kill_drill_time_to_safe_seconds | Gauge | در آخرین تمرین، لغو همه سفارشها چقدر طول کشید؟ (درس ۲۷) |
mm_errors_total | Counter | خطاها در کدام جزء و از چه نوعیاند؟ |
۳. سه نوع متریک در 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_seconds | mm_order_ack_latency_ms | واحد پایه زمان ثانیه است |
mm_fills_total | mm_fills | Counter پسوند _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های درس ۲۷ در میلیثانیه عمل میکنند و هشدار به انسان خبر میدهد که عمل کردهاند.
۹. لاگ ساختیافته و شناسه پیگیری
متریک میگوید «نرخ رد بالا رفته». لاگ باید بگوید «کدام سفارشها، با چه قیمتی، و صرافی دقیقاً چه گفت». دو قاعده لاگ را مفید میکند:
- ساختیافته (Structured): هر خط یک شیء JSON با فیلدهای ثابت، نه جمله آزاد. میشود رویش جستوجو و فیلتر کرد.
- شناسه پیگیری (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 داشبورد را در مخزن کد ذخیره کن تا تغییراتش بازبینی و قابل بازگشت باشد.
۱۱. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
بخش هفتم اینجا تمام میشود: حالا سیستمی داریم که معماری درست، چرخه عمر سفارش قابل اعتماد، محافظ ریسک و مانیتورینگ دارد. در درس ۲۹ بخش آخر، یعنی پروژه عملی، را شروع میکنیم: همه قطعات مسیر را کنار هم میگذاریم و یک Market Maker برای یک جفتارز طراحی میکنیم؛ از انتخاب بازار و مدل قیمتگذاری تا حدهای ریسک و معیارهایی که با آنها موفقیتش را میسنجیم.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.