بکتست یک استراتژی ساده با pandas و روشا
بکتست یعنی اجرای یک قاعدهٔ معاملاتی روی دادهٔ گذشته برای دیدن رفتار آن. در این نوشته یک بکتست تقاطع میانگین متحرک را با pandas و دادهٔ روزانهٔ تعدیلشدهٔ روشا در حدود ۶۰ خط مینویسیم.
این یک آموزش برنامهنویسی است، نه توصیهٔ سرمایهگذاری. استراتژی این نوشته عمداً ساده است و هزینه، نقدشوندگی و قواعد بازار را نادیده میگیرد. خروجی آن دلیلی برای خرید یا فروش هیچ نمادی نیست. بخش «چرا این عددها واقعی نیستند؟» را حتماً بخوانید.
چرا باید از قیمت تعدیلشده استفاده کنم؟
بکتست روی قیمت خام غلط است، چون قیمت خام در افزایش سرمایه و سود نقدی بدون معامله افت میکند. بکتست این افت را زیان حساب میکند و میانگینهای متحرک هم سیگنال نادرست میدهند. با adjusted=true این پرشها برداشته میشود. توضیح روش در قیمت تعدیلشده چیست؟ آمده است.
قاعدهٔ استراتژی
- دو میانگین متحرک ساده روی قیمت پایانی تعدیلشده میسازیم: کوتاه ۲۰ روزه و بلند ۶۰ روزه.
- هر روزی که میانگین کوتاه بالاتر از میانگین بلند بسته شود، روز معاملاتی بعد در سهم هستیم.
- در غیر این صورت، روز بعد نقد هستیم.
عددهای ۲۰ و ۶۰ دلخواه هستند و هیچ برتریای ندارند.
آمادهسازی
یک کلید رایگان کافی است، چون فقط دادهٔ پایان روز لازم داریم.
pip install requests pandasexport ROSHA_KEY="rosha_xxxxxxxx_your-secret"کد کامل
import osimport time
import pandas as pdimport requests
BASE = "https://poolick.ir"HEADERS = {"Authorization": f"Bearer {os.environ['ROSHA_KEY']}"}REF = "46348559193224090" # فولادFAST, SLOW = 20, 60
def get(path, **kwargs): """GET with one retry on 429, waiting as long as Retry-After says.""" for _ in range(2): r = requests.get(BASE + path, headers=HEADERS, timeout=30, **kwargs) if r.status_code != 429: break time.sleep(int(r.headers.get("Retry-After", "1"))) r.raise_for_status() return r.json()
data = get( f"/v1/instruments/{REF}/daily", params={"adjusted": "true", "start": "2016-03-20", "limit": 5000},)df = pd.DataFrame(data["rows"]).set_index("d")df.index = pd.to_datetime(df.index)
# 1. keep only days with real trades (halted days have no open/high/low)df = df[df["volume"] > 0].copy()
# 2. indicators on the adjusted closedf["fast"] = df["close"].rolling(FAST).mean()df["slow"] = df["close"].rolling(SLOW).mean()
# 3. today's signal decides tomorrow's position: shift(1) avoids look-aheaddf["signal"] = (df["fast"] > df["slow"]).astype(int)df["position"] = df["signal"].shift(1).fillna(0)
# 4. daily returns, close to closedf["ret"] = df["close"].pct_change().fillna(0)df["strategy_ret"] = df["position"] * df["ret"]
df = df.iloc[SLOW:] # drop the warm-up period of the slow averageequity = (1 + df["strategy_ret"]).cumprod()hold = (1 + df["ret"]).cumprod()drawdown = equity / equity.cummax() - 1
print("period:", df.index[0].date(), "to", df.index[-1].date(), f"({len(df)} trading days)")print("position changes:", int(df["position"].diff().abs().sum()))print("days in the market:", f"{df['position'].mean():.0%}")print("strategy, growth of 1:", round(equity.iloc[-1], 2))print("buy and hold, growth of 1:", round(hold.iloc[-1], 2))print("strategy, max drawdown:", f"{drawdown.min():.0%}")کد چه میکند؟
گام ۱: حذف روزهای بدون معامله. در روزهایی که نماد متوقف است، API ردیف را با volume صفر و open تهی برمیگرداند. این ردیفها بازده واقعی ندارند و میانگین متحرک را هم بیدلیل صاف میکنند.
گام ۲: میانگینها. rolling(20).mean() میانگین ۲۰ ردیف آخر است. چون روزهای توقف حذف شدهاند، این ۲۰ روز معاملاتی واقعی است.
گام ۳: جلوگیری از نگاه به آینده. سیگنال با قیمت پایانی امروز ساخته میشود. پس زودترین زمان عمل به آن، فردا است. shift(1) همین را اعمال میکند. بدون این خط، بکتست از قیمتی استفاده میکند که هنگام تصمیم هنوز معلوم نبوده و نتیجه بهطور ساختگی بهتر میشود.
گام ۴: بازده. بازده روزانه، تغییر قیمت پایانی تعدیلشده از روز قبل است. بازده استراتژی، همان بازده در روزهایی است که position برابر ۱ است.
شش عدد پایانی فقط رفتار این قاعده را در این بازه توصیف میکنند. با تغییر تاریخ شروع، طول میانگینها یا نماد، عددها کاملاً عوض میشوند.
چرا این عددها واقعی نیستند؟
خروجی این کد را نمیتوان در بازار واقعی به دست آورد. دلیلها:
- کارمزد و مالیات نیست. هر خرید و فروش در بورس تهران کارمزد دارد و فروش مالیات هم دارد. استراتژیای که زیاد جابهجا میشود، بخش بزرگی از بازده کاغذی خود را به هزینه میدهد.
- نقدشوندگی نیست. کد فرض میکند هر مقدار سهم را با قیمت پایانی میتوان خرید یا فروخت. در بازار واقعی، سفارش بزرگ قیمت را جابهجا میکند.
- دامنهٔ نوسان و صف. قیمت هر نماد در یک روز فقط در دامنهٔ مجاز حرکت میکند. وقتی نماد در صف خرید یا فروش قفل است، سفارش شما ممکن است اصلاً اجرا نشود. بکتست این روزها را هم معاملهشده حساب میکند. نمونهٔ واقعی: فولاد در ۲۰ آبان ۱۴۰۵ با
open،high،lowوcloseبرابر ۴٬۹۵۰ ریال ثبت شده است؛ یعنی کل روز در یک قیمت. - قیمت پایانی قیمت معامله نیست.
closeمیانگین وزنی معاملات روز است و با حجم مبنا هم تغییر میکند. هیچکس نمیتواند دقیقاً با قیمت پایانی معامله کند. قیمت آخرین معامله در فیلدlastاست. - توقف نماد. نمادها برای مجمع و افشای اطلاعات متوقف میشوند، گاهی برای هفتهها. در این مدت نمیتوان از سهم خارج شد و بازگشایی ممکن است بدون محدودیت دامنه باشد.
- سود نقدی دوباره سرمایهگذاری نمیشود. قیمت تعدیلشده فرض میکند سود نقدی در همان روز مجمع به سهم برگشته است. در واقعیت، سود ماهها بعد پرداخت میشود.
- سوگیری انتخاب. ما یک نماد را انتخاب کردهایم که امروز هنوز معامله میشود. نمادهایی که حذف شدهاند در این آزمون نیستند.
- بیشبرازش. اگر ۲۰ و ۶۰ را آنقدر عوض کنید تا نتیجه خوب شود، فقط گذشته را حفظ کردهاید.
- تورم. بازده اسمی ریالی در یک دورهٔ دهساله بدون مقایسه با تورم معنای کمی دارد.
رفتار گذشتهٔ هیچ قاعدهای، رفتار آیندهٔ آن را تضمین نمیکند.
برای تمرین برنامهنویسی، بعد چه کنم؟
- هزینه اضافه کنید. در هر روزی که
positionتغییر میکند، یک درصد هزینهٔ فرضی از بازده کم کنید و اثر آن را ببینید. - با شاخص مقایسه کنید. سابقهٔ شاخص کل را از
/v1/indices/{ref}/dailyبگیرید و کنار دو منحنی بگذارید. - روزهای قفل را حذف کنید. روزهایی را که
highوlowبرابرند، غیرقابلمعامله فرض کنید. - چند نماد. همین کد را در حلقه اجرا کنید. روش گرفتن چند نماد در دریافت دیتای بورس با پایتون است.
- داده را محلی نگه دارید. برای آزمونهای پرتکرار، داده را یک بار بگیرید و ذخیره کنید: همگامسازی روزانه.
روشا (ROSHA)، پلتفرم دادهٔ بازار سرمایه، در این سرویس فقط داده ارائه میکند و مشاورهٔ شخصی نمیدهد. متن کامل در سلب مسئولیت است.