مسیر Quant Developer · بخش ۵: Backtesting و شبیهسازی · درس ۲۰
ارزیابی عملکرد استراتژی
خواندن خروجی Backtest با Net PnL، Drawdown، Sharpe Ratio و دامهای سالانهسازی، نرخ پر شدن و PnL به ازای حجم؛ و تشخیص نتیجه پایدار از شانس.
۱. یک Backtest، یک عدد؟
Backtest تمام شد و یک عدد روی صفحه است: «۳۰ روز، ۷,۶۹۷ دلار سود». این عدد خوب است یا بد؟ جواب به سؤالهایی بستگی دارد که خود عدد جوابشان را نمیدهد:
- در این مسیر، حساب تا کجا پایین رفت؟
- سود از چند روز خوب آمد یا هر روز کمی؟
- اگر کارمزد کمی عوض شود چه میشود؟
- اگر یک ماه دیگر را آزمایش کنی، باز هم سود میدهد؟
این درس معیارهایی را معرفی میکند که به این سؤالها جواب میدهند، و مهمتر از خود فرمولها، میگوید هر کدام کجا گمراه میکند. درسهای ۱۷ تا ۱۹ کوشیدند خود Backtest را واقعگرایانه کنند؛ این درس درباره خواندن خروجی آن است. تجزیه دقیق PnL به اجزایش و مقایسه Backtest با اجرای واقعی را در درس ۳۱ انجام میدهیم.
۲. داده این درس
برای اینکه همه معیارها را روی یک داده ببینیم، خروجی یک Backtest مصنوعی میسازیم: PnL دقیقهبهدقیقه یک بازارساز در ۳۰ روز. همه اعداد فرضیاند و ساختارش عمداً چند ویژگی واقعی را دارد:
- در هر دقیقه حدود ۴۰ سفارش فرستاده میشود و هر کدام با احتمال ۵٪ پر میشود؛ هر پر شدن 0.1 BTC است.
- Markout هر BTC پیش از کارمزد منفی است: در روزهای آرام ۸ دلار زیان، در ۴ روز پرنوسان ۱۶ دلار زیان. استراتژی فقط به لطف ریبیت ۱ bp سطح ۴ جدول درس ۱۹ (۱۰ دلار برای هر BTC) سودده است؛ همان وضعیتی که درس ۱۹ دربارهاش هشدار داد.
- PnL موجودی خودهمبسته است: نتیجه هر دقیقه کمی به دقیقه قبل شبیه است، چون موجودی چند دقیقه میماند.
import numpy as np
import pandas as pd
def simulate(seed):
"""Synthetic one-minute output of a market-making backtest: 30 days."""
rng = np.random.default_rng(seed)
idx = pd.date_range("2027-01-01", periods=30 * 1440, freq="min")
n = len(idx)
stress = (idx.day >= 18) & (idx.day <= 21) # 4 volatile days
orders = rng.poisson(40, n) # orders sent per minute
fills = rng.binomial(orders, 0.05) # each order fills with prob 5%
volume = fills * 0.1 # BTC traded, 0.1 BTC per fill
edge = np.where(stress, -16.0, -8.0) # markout per BTC, before fees
shock = rng.normal(0, 25, n) * np.where(stress, 1.5, 1.0)
inventory_pnl = np.zeros(n) # autocorrelated inventory PnL
for i in range(1, n):
inventory_pnl[i] = 0.3 * inventory_pnl[i - 1] + shock[i]
fees = volume * 100_000 * (-0.0001) # negative fee: 1 bp rebate
return pd.DataFrame({"orders": orders, "fills": fills, "volume": volume, "fees": fees,
"pnl": volume * edge + inventory_pnl - fees, "stress": stress},
index=idx)
def sharpe(pnl, periods_per_year):
return pnl.mean() / pnl.std() * np.sqrt(periods_per_year)
و همه معیارهای این درس در چند خط:
df = simulate(seed=20)
equity = df["pnl"].cumsum()
drawdown = equity - equity.cummax()
daily = df.resample("D").agg({"pnl": "sum", "volume": "sum", "stress": "first"})
per_btc = df["pnl"].sum() / df["volume"].sum()
print(f"net PnL {df['pnl'].sum():8.0f} USDT (rebate received {-df['fees'].sum():.0f})")
print(f"max drawdown {drawdown.min():8.0f} USDT (deepest on {drawdown.idxmin():%m-%d})")
print(f"Sharpe, daily {sharpe(daily['pnl'], 365):8.2f}")
print(f"Sharpe, minute {sharpe(df['pnl'], 365 * 1440):8.2f} <- naive annualization")
print(f"lag-1 autocorr {df['pnl'].autocorr(1):8.2f} (minute PnL)")
print(f"fill rate {df['fills'].sum() / df['orders'].sum():8.1%}")
print(f"PnL per BTC {per_btc:8.2f} USDT = {per_btc / 100_000 * 1e4:.2f} bp of notional")
for is_stress, d in daily.groupby("stress"):
print(f"{'stress' if is_stress else 'calm':6} days={len(d):2d} pnl/day={d['pnl'].mean():6.0f} "
f"per BTC={d['pnl'].sum() / d['volume'].sum():5.2f}")
net PnL 7697 USDT (rebate received 86518)
max drawdown -7873 USDT (deepest on 01-20)
Sharpe, daily 3.25
Sharpe, minute 4.55 <- naive annualization
lag-1 autocorr 0.29 (minute PnL)
fill rate 5.0%
PnL per BTC 0.89 USDT = 0.09 bp of notional
calm days=26 pnl/day= 385 per BTC= 1.33
stress days= 4 pnl/day= -580 per BTC=-2.02
- Net PnL — سود خالص ۳۰ روز
- 7,697 USDT
- Max Drawdown — بیشترین افت از اوج
- 7,873 USDT
- Sharpe سالانه از PnL روزانه
- 3.25
- PnL به ازای حجم
- 0.09 bp
حالا هر خط را جداگانه میخوانیم.
۳. Net PnL: نقطه شروع، نه پایان
Net PnL سود و زیان بعد از همه هزینههاست: کارمزد و ریبیت، هزینه هج، و ارزشگذاری موجودی باقیمانده با قیمت پایان دوره (درس ۷). اینجا ۷,۶۹۷ دلار است.
ولی به عدد داخل پرانتز نگاه کن: ریبیت دریافتی ۸۶,۵۱۸ دلار. یعنی استراتژی پیش از ریبیت حدود ۷۸,۸۲۱ دلار زیان داده و کل سودش، و بیشتر، از ساختار کارمزد آمده است. Net PnL بهتنهایی این را نشان نمیدهد. همیشه کنارش اجزایش را گزارش کن: سهم Spread و Markout، کارمزد، موجودی. روش دقیق این تجزیه موضوع درس ۳۱ است.
۴. Drawdown: دردی که باید تحمل کنی
Drawdown در هر لحظه فاصله منحنی سرمایه (Equity Curve، یعنی PnL تجمعی) از بالاترین نقطهای است که تا آن لحظه داشته:
Drawdown همیشه صفر یا منفی است. Max Drawdown عمیقترین آن است: بدترین افتی که اگر در بدترین لحظه شروع کرده بودی، تجربه میکردی. در کد بالا این دو خط کار را انجام میدهند: cummax بالاترین نقطه تا هر لحظه را میدهد و تفاضل، Drawdown است.
اینجا Max Drawdown حدود ۷,۸۷۳ دلار است و عمیقترین نقطهاش ۲۰ ژانویه، وسط روزهای پرنوسان. یعنی در یک مقطع، حساب تقریباً به اندازه کل سود ماه پایین رفت. چند نکته:
- نسبت سود به افت. Net PnL تقسیم بر Max Drawdown اینجا حدود ۱ است. اگر بخواهی با این استراتژی ۷,۶۹۷ دلار سود کنی، باید آماده باشی در مسیر حدود ۷,۸۷۳ دلار افت را ببینی و استراتژی را خاموش نکنی.
- مدت هم مهم است. افت ۸ هزار دلاری که دو ساعته جبران شود با افتی که دو هفته طول بکشد فرق دارد. مدت زیر آب ماندن (از اوج قبلی تا رسیدن دوباره به آن) را هم گزارش کن.
- Max Drawdown گذشته، سقف آینده نیست. این عدد فقط بدترین افتی است که در این ۳۰ روز رخ داد. در داده بیشتر، تقریباً همیشه افت عمیقتری پیدا میشود.
حد افت از اوج یکی از ورودیهای کنترل ریسک زنده هم هست؛ در درس ۲۷ (Kill Switch) دوباره به آن میرسیم.
۵. Sharpe Ratio و دام سالانهسازی
Sharpe Ratio میانگین PnL را بر نوسانش تقسیم میکند: به ازای هر واحد نوسان، چقدر سود. برای اینکه بتوان استراتژیها را مقایسه کرد، آن را سالانه میکنند. با قاعده جذر زمان درس ۹، میانگین با تعداد دورهها رشد میکند و انحراف معیار با جذر آن:
(برای رمزارز که هر روز سال معامله میشود ۳۶۵ و برای بازارهایی که آخر هفته تعطیلاند معمولاً ۲۵۲. نرخ بدون ریسک در افقهای کوتاه بازارسازی معمولاً کنار گذاشته میشود.) اینجا Sharpe روزانه سالانهشده ۳.۲۵ است. سه دام:
دام ۱: داده پرتکرار و خودهمبستگی
داده دقیقهای داری؛ چرا Sharpe را از PnL دقیقهای حساب نکنی و در √(365 × 1440) ضرب نکنی؟ خروجی کد جواب میدهد: ۴.۵۵ بهجای ۳.۲۵.
دلیلش این است که قاعده جذر زمان فقط وقتی درست است که دورهها مستقل باشند. اینجا خودهمبستگی PnL دقیقهای حدود ۰.۲۹ است: دقیقه خوب معمولاً دنبال دقیقه خوب میآید، پس نوسان روزانه بیشتر از چیزی است که جذر زمان پیشبینی میکند. Lo (2002) نشان داد سالانهسازی ساده با خودهمبستگی مثبت Sharpe را بیش از واقع نشان میدهد. برای خودهمبستگی درجه اول ρ و تعداد زیاد دوره، نسبت خطا تقریباً این است:
با ρ برابر ۰.۳، این نسبت حدود ۱.۳۶ است؛ در داده ما ۴.۵۵ تقسیم بر ۳.۲۵ میشود ۱.۴. در PnL بازارساز خودهمبستگی منفی هم رایج است (مثلاً وقتی موجودی را با آخرین قیمت معامله ارزشگذاری میکنی که بین Bid و Ask رفتوبرگشت میکند)؛ آن وقت سالانهسازی ساده Sharpe را کمتر از واقع نشان میدهد. راه ساده: PnL را به دورهای جمع کن که تقریباً مستقل باشد، معمولاً روزانه، و Sharpe را از همان حساب کن.
دام ۲: نمونه کوچک
۳۰ روز یعنی ۳۰ عدد. در درس ۹ دیدیم هر تخمینی خطای استاندارد دارد. برای Sharpe، با فرض PnL روزانه مستقل و نرمال، Lo (2002) این تقریب را میدهد (SR اینجا Sharpe روزانه است، نه سالانه):
Sharpe روزانه ما ۳.۲۵ تقسیم بر √365، یعنی حدود ۰.۱۷ است. با n برابر ۳۰، خطای استاندارد حدود ۰.۱۸ میشود و سالانهاش حدود ۳.۵. یعنی Sharpe ما «۳.۲۵ بهاضافه یا منهای ۳.۵» است؛ تقریباً هیچ اطلاعاتی درباره اینکه Sharpe واقعی ۱ است یا ۶.
این را میشود مستقیم دید. همان استراتژی، با همان پارامترها، فقط با Seed متفاوت:
ratios = [sharpe(simulate(s).resample("D")["pnl"].sum(), 365) for s in range(1, 21)]
print(f"daily Sharpe over 20 seeds: min {min(ratios):.2f}, max {max(ratios):.2f}, "
f"mean {np.mean(ratios):.2f}")
daily Sharpe over 20 seeds: min 0.43, max 8.23, mean 3.51
بیست ماه با فرایند تولید یکسان، Sharpe از ۰.۴۳ تا ۸.۲۳. اگر فقط یکی از این ماهها را دیده بودی، ممکن بود استراتژی را کنار بگذاری یا رویش همه چیز را بگذاری؛ برای یک چیز یکسان.
دام ۳: Sharpe همه ریسک نیست
Sharpe فقط میانگین و انحراف معیار را میبیند. استراتژیای که هر روز کمی سود میدهد و سالی یک بار با یک پرش قیمت (درس ۹، دمهای پهن) نیمی از سرمایه را از دست میدهد، تا قبل از آن روز Sharpe عالی دارد. به همین دلیل Sharpe را همیشه کنار Max Drawdown و بدترین روز گزارش کن.
۶. نرخ پر شدن و PnL به ازای حجم
دو معیار مخصوص بازارسازی:
نرخ پر شدن (Fill Rate) نسبت سفارشهای پرشده به سفارشهای فرستادهشده است؛ اینجا ۵٪. گاهی بر حسب حجم هم حساب میشود (حجم پرشده تقسیم بر حجمی که در دفتر گذاشتی). عکس همین عدد، یعنی سفارش به ازای هر معامله (اینجا ۲۰)، برای صرافیها هم مهم است و بعضی برایش سقف میگذارند. در درس ۸ دیدیم نرخ پر شدن بالا لزوماً خبر خوبی نیست؛ ممکن است فقط یعنی قیمتهایت بد است. نرخ پر شدن را همیشه کنار Markout بخوان.
PnL به ازای حجم سود خالص تقسیم بر حجم معاملهشده است. اینجا ۰.۸۹ دلار برای هر BTC، یعنی ۰.۰۹ bp از ارزش معامله. این شاید مهمترین عدد کل گزارش باشد، چون حاشیه امنیت را نشان میدهد:
ریبیت بهتنهایی ۱۰ دلار برای هر BTC است. اگر ریبیت فقط ۰.۱ bp کم شود، یعنی ۱ دلار برای هر BTC، روی حدود 8,652 BTC حجم این ماه، ۸,۶۵۲ دلار از سود کم میشود و نتیجه به حدود ۹۵۵ دلار زیان میرسد. حاشیهای که از خطای مدل پر شدن (درس ۱۸) یا تأخیر (درس ۱۹) کوچکتر است، حاشیه نیست.
۷. پایداری: نتیجه واقعی است یا شانس؟
تا اینجا همه معیارها یک عدد از یک اجرا بودند. پایداری (Robustness) یعنی بپرسی آن عدد با تغییرهای معقول چقدر جابهجا میشود.
رژیمهای بازار
دو خط آخر خروجی همان معیارها را جدا برای روزهای آرام و پرنوسان نشان میدهند:
| رژیم | تعداد روز | PnL روزانه | PnL هر BTC |
|---|---|---|---|
| آرام | ۲۶ | ۳۸۵ دلار سود | ۱.۳۳ دلار سود |
| پرنوسان | ۴ | ۵۸۰ دلار زیان | ۲.۰۲ دلار زیان |
میانگین ماهانه این تفاوت را پنهان میکرد. ۴ روز پرنوسان حدود ۲,۳۲۰ دلار زیان دادند. اگر ماه بعد ۱۰ روز پرنوسان داشته باشد، نتیجه ماه بعد احتمالاً منفی است. پس Backtest را در رژیمهای مختلف جداگانه گزارش کن: آرام و پرنوسان، روند و بیروند، حجم بالا و پایین. (اعداد هر رژیم هم نویز دارند: در طراحی داده، سود هر BTC روزهای آرام ۲ دلار و زیان روزهای پرنوسان ۶ دلار بود؛ ۴ روز برای دیدن عدد دقیق کافی نیست.)
حساسیت به پارامترها
یک پارامتر را کمی تغییر بده و ببین نتیجه چه میشود. مثال فرضی: اگر نیماسپرد ۹، ۱۰ و ۱۱ دلار Sharpeهای ۳.۱، ۳.۳ و ۲.۹ بدهند، با یک فلات روبهرویی و انتخاب دقیق پارامتر اهمیت کمی دارد. اگر بدهند ۰.۵، ۳.۳ و ۰.۲، با یک قله تیز روبهرویی که به احتمال زیاد نویز همین داده است. استراتژی خوب در همسایگی پارامترهایش هم خوب است.
داده خارج از نمونه
در درس ۱۰ گفتیم داده آزمون را بر اساس زمان جدا کن. در Backtest شکل کاملترش Walk-forward است: پارامترها را روی دو هفته تنظیم کن، روی هفته بعد آزمایش کن، پنجره را یک هفته جلو ببر و تکرار کن. فقط نتیجه هفتههای آزمون را گزارش کن.
تعداد آزمایشها را بشمار
اگر صد ترکیب پارامتر را امتحان کنی، بهترینش حتی بدون هیچ برتری واقعی، Sharpe بالایی خواهد داشت؛ بیست Seed بخش ۵ نشان داد پراکندگی Sharpe در یک ماه چقدر زیاد است. Bailey و López de Prado (2014) برای همین Deflated Sharpe Ratio را پیشنهاد کردند که Sharpe را با توجه به تعداد آزمایشها تعدیل میکند. حداقل کار این است که تعداد ترکیبهایی را که امتحان کردی یادداشت و گزارش کنی.
۸. تمرین این درس
گزینه مناسب هر سؤال را انتخاب کن و در پایان نتیجه را ببین.
درس بعدی
با این درس بخش Backtesting تمام میشود و در درس بیستویکم بخش ششم را شروع میکنیم: Hedging و انتخاب ابزار هج. میبینیم ریسک موجودی را چطور با یک موقعیت مخالف در بازار دیگر پوشش دهیم، با چه ابزاری و به چه نسبتی، و چه وقت هزینه هج، که در درس ۱۹ اندازهاش را دیدیم، از ریسکی که کم میکند بیشتر میشود.
این مطلب صرفاً آموزشی است و توصیه خرید، فروش یا سرمایهگذاری نیست.