ساخت ربات معامله گر رمزارز با میانگین متحرک ساده در پایتون — بخش دوم
در بخش اول مطالب گذشته مجله فرادرس به ساخت ربات معامله گر رمزارز با استفاده از استراتژی میانگین متحرک ساده در پایتون پرداختیم و آن را روی مجموعه داده آموزش (Train Dataset) ارزیابی کردیم. در این مطلب، قصد داریم با ایجاد تغییراتی در کدهای نوشتهشده، قابلیت ارزیابی ربات معاملهگر روی مجموعه داده آزمایش (Test Dataset) را نیز اضافه کنیم.
بهبود ربات معامله گر رمزارز
حال وارد محیط برنامهنویسی پایتون میشویم و کد نوشته شده مربوط به کلاس smaLR را وارد میکنیم:
1import numpy as np
2import pandas as pd
3import pyswarm as ps
4import pandas_datareader as pdt
5import matplotlib.pyplot as plt
6
7class smaLR:
8 def __init__(self):
9 self.TrainLog = []
10 def GetData(self, Ticker:str, Start:str, End:str):
11 self.Ticker = Ticker
12 self.Start = Start
13 self.End = End
14 self.DF = pdt.DataReader(Ticker,
15 data_source='yahoo',
16 start=Start,
17 end=End)
18 self.DF.drop(labels=['High', 'Low', 'Volume', 'Adj Close'],
19 axis=1,
20 inplace=True)
21 def ProcessData(self, L:int):
22 self.L = L
23 self.DF[f'SMA({L})'] = self.DF['Close'].rolling(L).mean()
24 self.DF[f'Log-Ratio({L})'] = np.log(self.DF['Close'] / self.DF[f'SMA({L})'])
25 self.DF['FP'] = self.DF['Open'].shift(-1)
26 self.DF.dropna(inplace=True)
27 self.nD = len(self.DF)
28 def Trade(self, Parameters:np.ndarray):
29 Moneys = np.zeros(self.nD)
30 Shares = np.zeros(self.nD)
31 Values = np.zeros(self.nD)
32 Signals = np.zeros(self.nD)
33 Buys = {'Time':[], 'Price':[]}
34 Sells = {'Time':[], 'Price':[]}
35 money = 1
36 share = 0
37 for i in range(self.nD):
38 fp = self.DF['FP'][i]
39 lr = self.DF[f'Log-Ratio({self.L})'][i]
40 signal = Parameters[0] + Parameters[1] * lr
41 if signal > 0 and share == 0:
42 share = money / fp
43 money = 0
44 Buys['Time'].append(i)
45 Buys['Price'].append(fp)
46 elif signal < 0 and share > 0:
47 money = share * fp
48 share = 0
49 Sells['Time'].append(i)
50 Sells['Price'].append(fp)
51 Moneys[i] = money
52 Shares[i] = share
53 Values[i] = money + share * fp
54 Signals[i] = signal
55 Return = 100 * ((Values[-1] / Values[0])**(1 / (self.nD - 1)) - 1)
56 return Moneys, Shares, Values, Signals, Buys, Sells, Return
57 def Loss(self, P:np.ndarray):
58 Return = self.Trade(P)[-1]
59 self.TrainLog.append(Return)
60 return -Return
61 def Train(self, MaxIteration:int, SwarmSize:int):
62 lb = -1 * np.ones(2)
63 ub = +1 * np.ones(2)
64 self.P, BestLoss = ps.pso(self.Loss,
65 lb,
66 ub,
67 swarmsize=SwarmSize,
68 maxiter=MaxIteration)
69 BestReturn = -BestLoss
70 print('_'*50)
71 print('Optimization Result:')
72 print(f'\tBest Parameters: {self.P}')
73 print(f'\tBest Return: {BestReturn} %')
74 print('_'*50)
حال نیاز داریم متد سازنده را اصلاح کنیم. برای جدا کردن مجموعه داده به دو بخش آموزش و آزمایش که نسبت اندازه مجموعه داده آموزش به کل عدد تعیینکننده در جداسازی مجموعه داده خواهد بود. این عدد در ورودی متد سازنده دریافت خواهد شد:
1 def __init__(self, sTrain:float=0.8):
حال، کد قبلی را حفظ کرده و در انتها این نسبت را در شی ذخیره میکنیم:
1 def __init__(self, sTrain:float=0.8):
2 self.TrainLog = []
3 self.sTrain = sTrain
متد بعدی با اسم GetData نیاز به تغییرات ندارد، زیرا قبل از فرایند تقسیم داده و پردازش است.
برای یادگیری برنامهنویسی با زبان پایتون، پیشنهاد میکنیم به مجموعه آموزشهای مقدماتی تا پیشرفته پایتون فرادرس مراجعه کنید که لینک آن در ادامه آورده شده است.
- برای مشاهده مجموعه آموزشهای برنامه نویسی پایتون (Python) — مقدماتی تا پیشرفته + اینجا کلیک کنید.
متد بعدی با اسم ProcessData نیاز است تا تغییر کند و دو دیتافریم برای مجموعه داده آموزش و آزمایش ایجاد شود. در انتهای کد قبلی، دیتافریم کامل ایجاد میشود و تنها نیاز است سایز هر مجموعه را محاسبه کنیم:
1 def ProcessData(self, L:int):
2 self.L = L
3 self.DF[f'SMA({L})'] = self.DF['Close'].rolling(L).mean()
4 self.DF[f'Log-Ratio({L})'] = np.log(self.DF['Close'] / self.DF[f'SMA({L})'])
5 self.DF['FP'] = self.DF['Open'].shift(-1)
6 self.DF.dropna(inplace=True)
7 self.nD = len(self.DF)
8 self.nDtr = round(self.sTrain * self.nD)
9 self.nDte = self.nD - self.nDtr
حال میتوانیم nDtr سطر اول دیتافریم را برای آموزش و بقیه را برای آزمایش جدا میکنیم:
1 def ProcessData(self, L:int):
2 self.L = L
3 self.DF[f'SMA({L})'] = self.DF['Close'].rolling(L).mean()
4 self.DF[f'Log-Ratio({L})'] = np.log(self.DF['Close'] / self.DF[f'SMA({L})'])
5 self.DF['FP'] = self.DF['Open'].shift(-1)
6 self.DF.dropna(inplace=True)
7 self.nD = len(self.DF)
8 self.nDtr = round(self.sTrain * self.nD)
9 self.nDte = self.nD - self.nDtr
10 self.trDF = self.DF.iloc[:self.nDtr, :]
11 self.teDF = self.DF.iloc[self.nDtr:, :]
به این ترتیب، دو مجموعه داده از یکدیگر جدا و در شی ذخیره میشوند.
حال میتوانیم تابع مربوط به Trade را نیز اصلاح کنیم. با توجه به اینکه دو مجموعه داده وجود دارد، باید در ورودی تابع، مجموعه داده مورد استفاده را نیز تعریف کنیم. به این ترتیب، خواهیم داشت:
1 def Trade(self, Parameters:np.ndarray, DF:pd.core.frame.DataFrame):
حال، باید اندازه دیتافریم را محاسبه کنیم:
1 def Trade(self, Parameters:np.ndarray, DF:pd.core.frame.DataFrame):
2 nD = len(DF)
حال آرایه (Array) و دیکشنریهای (Dictionary) مربوط به تاریخچه را ایجاد میکنیم:
1 def Trade(self, Parameters:np.ndarray, DF:pd.core.frame.DataFrame):
2 nD = len(DF)
3 Moneys = np.zeros(nD)
4 Shares = np.zeros(nD)
5 Values = np.zeros(nD)
6 Signals = np.zeros(nD)
7 Buys = {'Time':[], 'Price':[]}
8 Sells = {'Time':[], 'Price':[]}
حال همانند روندی که در برنامه گذشته وجود داشت، سرمایه اولیه و سهام اولیه را تعریف و حلقه اصلی مربوط به انجام معامله را ایجاد میکنیم:
1 def Trade(self, Parameters:np.ndarray, DF:pd.core.frame.DataFrame):
2 nD = len(DF)
3 Moneys = np.zeros(nD)
4 Shares = np.zeros(nD)
5 Values = np.zeros(nD)
6 Signals = np.zeros(nD)
7 Buys = {'Time':[], 'Price':[]}
8 Sells = {'Time':[], 'Price':[]}
9 money = 1
10 share = 0
11 for i in range(nD):
حال دادههای مربوط به روز را استخراج و سیگنال را محاسبه میکنیم:
1 def Trade(self, Parameters:np.ndarray, DF:pd.core.frame.DataFrame):
2 nD = len(DF)
3 Moneys = np.zeros(nD)
4 Shares = np.zeros(nD)
5 Values = np.zeros(nD)
6 Signals = np.zeros(nD)
7 Buys = {'Time':[], 'Price':[]}
8 Sells = {'Time':[], 'Price':[]}
9 money = 1
10 share = 0
11 for i in range(nD):
12 fp = DF['FP'][i]
13 lr = DF[f'Log-Ratio({self.L})'][i]
14 signal = Parameters[0] + Parameters[1] * lr
حال در مورد روز موردنظر تصمیمگیری کرده و در انتها موارد موردنظر را در تاریخچه ذخیره میکنیم:
1 def Trade(self, Parameters:np.ndarray, DF:pd.core.frame.DataFrame):
2 nD = len(DF)
3 Moneys = np.zeros(nD)
4 Shares = np.zeros(nD)
5 Values = np.zeros(nD)
6 Signals = np.zeros(nD)
7 Buys = {'Time':[], 'Price':[]}
8 Sells = {'Time':[], 'Price':[]}
9 money = 1
10 share = 0
11 for i in range(nD):
12 fp = DF['FP'][i]
13 lr = DF[f'Log-Ratio({self.L})'][i]
14 signal = Parameters[0] + Parameters[1] * lr
15 if signal > 0 and share == 0:
16 share = money / fp
17 money = 0
18 Buys['Time'].append(i)
19 Buys['Price'].append(fp)
20 elif signal < 0 and share > 0:
21 money = share * fp
22 share = 0
23 Sells['Time'].append(i)
24 Sells['Price'].append(fp)
25 Moneys[i] = money
26 Shares[i] = share
27 Values[i] = money + share * fp
28 Signals[i] = signal
در نهایت نیز میانگین درصد سود روزانه را محاسبه کرده و به همراه تاریخچه برمیگردانیم:
1 def Trade(self, Parameters:np.ndarray, DF:pd.core.frame.DataFrame):
2 nD = len(DF)
3 Moneys = np.zeros(nD)
4 Shares = np.zeros(nD)
5 Values = np.zeros(nD)
6 Signals = np.zeros(nD)
7 Buys = {'Time':[], 'Price':[]}
8 Sells = {'Time':[], 'Price':[]}
9 money = 1
10 share = 0
11 for i in range(nD):
12 fp = DF['FP'][i]
13 lr = DF[f'Log-Ratio({self.L})'][i]
14 signal = Parameters[0] + Parameters[1] * lr
15 if signal > 0 and share == 0:
16 share = money / fp
17 money = 0
18 Buys['Time'].append(i)
19 Buys['Price'].append(fp)
20 elif signal < 0 and share > 0:
21 money = share * fp
22 share = 0
23 Sells['Time'].append(i)
24 Sells['Price'].append(fp)
25 Moneys[i] = money
26 Shares[i] = share
27 Values[i] = money + share * fp
28 Signals[i] = signal
29 Return = 100 * ((Values[-1] / Values[0])**(1 / (nD - 1)) - 1)
30 return Moneys, Shares, Values, Signals, Buys, Sells, Return
به این ترتیب، متد را به گونهای تغییر میدهیم که به جای self.DF از دیتافریم ورودی استفاده کند. متد بعدی که باید اصلاح شود، مربوط به تابع هزینه است که با اسم Loss تعریف شده. این متد نیز باید در ورودی علاوه بر پارامترها، دیتافریم را نیز دریافت کند:
1 def Loss(self, P:np.ndarray, DF:pd.core.frame.DataFrame):
حال میتوانیم فراخوانی متد Trade را نیز اصلاح کنیم و ادامه تابع را بدون تغییر داشته باشیم:
1 def Loss(self, P:np.ndarray, DF:pd.core.frame.DataFrame):
2 Return = self.Trade(P, DF)[-1]
3 self.TrainLog.append(Return)
4 return -Return
به این ترتیب، این متد اصلاح میشود. تنها متد باقیمانده، مربوط به Train است. این تابع در ورودی نیازی به تغییر ندارد و تنها باید برای الگوریتم بهینهساز، ورودی دیگری تعریف کنیم که دیتافریم آموزش را به تابع هزینه پاس دهد:
1 def Train(self, MaxIteration:int, SwarmSize:int):
2 lb = -1 * np.ones(2)
3 ub = +1 * np.ones(2)
4 self.P, BestLoss = ps.pso(self.Loss,
5 lb,
6 ub,
7 swarmsize=SwarmSize,
8 maxiter=MaxIteration,
9 args=(self.trDF, ))
10 BestReturn = -BestLoss
11 print('_'*50)
12 print('Optimization Result:')
13 print(f'\tBest Parameters: {self.P}')
14 print(f'\tBest Return: {BestReturn} %')
15 print('_'*50)
به این ترتیب، همه متدهای مربوط به کلاس اصلاح میشود. حال تنظیمات زیر را اعمال میکنیم:
1np.random.seed(0)
2plt.style.use('ggplot')
حال از کلاس اصلاح شده یک شی ایجاد میکنیم:
1Trader = smaLR()
حال مجموعه داده را دریافت، پردازشهای مورد نیاز را اعمال و در نهایت مدل را آموزش میدهیم:
1Trader.GetData('BTC-USD', '2017-01-01', '2022-04-01')
2
3Trader.ProcessData(40)
4
5Trader.Train(30, 40)
در کد فوق، سه مرحله زیر انجام میشود:
- مجموعه داده مربوط به قیمت بیتکوین (Bitcoin) بین دو تاریخ 2017/01/01 تا 2022-04-01 دریافت میشود.
- میانگین متحرک ساده (Simple Moving Average | SMA) بر روی قیمت اعمال و لگاریتم نسبت قیمت به میانگین متحرک ساده محاسبه میشود.
- سپس، مجموعه داده آموزش و آزمایش از یکدیگر جدا میشود.
الگوریتم بهینهسازی ازدحام ذرات (Particle Swarm Optimization | PSO) با 80 ذره به تعداد 60 مرحله کار میکند و پارامترهای ربات بهینه میشود.
پس از اجرا، نتایج زیر حاصل میشود:
1Optimization Result:
2 Best Parameters: [-0.00424805 0.34820413]
3 Best Return: 0.30380404578569387 %
به این ترتیب، میانگین درصد سود روزانه 0/3038 % حاصل شده است. حال میتوانیم همانند مطلب گذشته، نمودار سود در طول آموزش مدل را رسم کنیم:
1plt.plot(Trader.TrainLog, ls='-', lw=0.8, c='crimson')
2plt.title('Model Return Over Function Evaluations')
3plt.xlabel('Function Evaluation')
4plt.ylabel('Average Daily Return (%)')
5plt.show()
که پس از اجرا، شکل زیر را خواهیم داشت.
به این ترتیب، روند بهبود رفتار ربات کاملاً مشهود است. حال میتوانیم ربات را روی مجموعه داده آموزش و آزمایش اجرا کرده و تاریخچه را دریافت کنیم:
1_, _, trValues, trSignals, trBuys, trSells, trReturn = Trader.Trade(Trader.P, Trader.trDF)
2_, _, teValues, teSignals, teBuys, teSells, teReturn = Trader.Trade(Trader.P, Trader.teDF)
حال میتوانیم نمودار قیمت، میانگین متحرک و ارزش پرتفوی را همانند قبل رسم کنیم:
1plt.subplot(1, 2, 1)
2plt.semilogy(Trader.trDF['Close'].to_numpy(), ls='-', lw=0.8, c='k', label='Close')
3plt.semilogy(Trader.trDF[f'SMA({Trader.L})'].to_numpy(), ls='-', lw=1, c='b', label=f'SMA({Trader.L})')
4plt.title('Price Over Time (Train)')
5plt.xlabel('Time (Day)')
6plt.ylabel('Price ($)')
7plt.legend()
8
9plt.subplot(1, 2, 2)
10trMeanValue = (1 + trReturn / 100)**np.arange(start=0, stop=trValues.size, step=1)
11plt.semilogy(trValues, ls='-', lw=0.8, c='crimson', label='Real Values')
12plt.semilogy(trMeanValue, ls='--', lw=1, c='k', label=f'Mean Values (Return: {round(trReturn, 4)} %)')
13plt.title('Value Over Time (Train)')
14plt.xlabel('Time (Day)')
15plt.ylabel('Value')
16plt.legend()
17
18plt.show()
که شکل زیر را خواهیم داشت.
به این ترتیب، مشاهده میکنیم که ربات به خوبی توانسته روندها را شناسایی کند، و مانع از ضرر شود. توجه داشته باشید که ربات در طول مدت 1500 روز، توانسته سرمایه اولیه را به حدود 100 برابر مقدار اولیه برساند!
حال میتوانیم برای نمایش مقدار سیگنال در طول این بازه، به شکل زیر عمل کنیم:
1plt.subplot(3, 1, (1, 2))
2plt.semilogy(Trader.trDF['Close'].to_numpy(), ls='-', lw=0.8, c='k', label='Close')
3plt.semilogy(Trader.trDF[f'SMA({Trader.L})'].to_numpy(), ls='-', lw=1, c='b', label=f'SMA({Trader.L})')
4plt.title('Price Over Time (Train)')
5plt.ylabel('Price ($)')
6plt.legend()
7
8plt.subplot(3, 1, 3)
9trZ = np.zeros_like(trSignals)
10plt.plot(trSignals, ls='-', lw=0.7, c='k')
11plt.fill_between(np.arange(trSignals.size), trSignals, trZ, where=(trSignals > trZ), color='lime', alpha=0.9, label='Buy Signal')
12plt.fill_between(np.arange(trSignals.size), trSignals, trZ, where=(trSignals < trZ), color='crimson', alpha=0.9, label='Sell Signal')
13plt.axhline(ls='-', lw=1, c='b')
14plt.title('Signal Over Time (Train)')
15plt.xlabel('Time (Day)')
16plt.ylabel('Value')
17plt.legend()
18
19plt.show()
با اجرای کد، نمودار زیر نمایش داده خواهد شد.
به این ترتیب، تطابق نمودار سیگنال با رفتار و روند بازار مشهود است. توجه داشته باشید که به دلیل کوتاه بودن طول میانگین متحرک (L=40)، معاملات ربات نیز بیشتر تحت تأثیر نوسانهای کوتاهمدت (Short Term) قرار میگیرد، درحالیکه برای L=60 این نوسانهای کوتاهمدت کمتر بود. تأثیرپذیری ربات از نوسانهای کوتاهمدت، نقطهضعف مدل نیست، چرا که میتواند سود بیشتری به همراه داشته باشد. به همین دلیل، تعیین طول پنجره اندیکاتورهای مورد استفاده مهم است.
برای نمایش نقاط خرید و فروش نیز، به شکل زیر عمل میکنیم:
1plt.semilogy(Trader.trDF['Close'].to_numpy(), ls='-', lw=0.8, c='k', label='Close')
2plt.semilogy(Trader.trDF[f'SMA({Trader.L})'].to_numpy(), ls='-', lw=1, c='b', label=f'SMA({Trader.L})')
3plt.scatter(trBuys['Time'], trBuys['Price'], s=20, c='g', marker='o', label='Buy')
4plt.scatter(trSells['Time'], trSells['Price'], s=20, c='r', marker='o', label='Sell')
5plt.title('Price Over Time (Train)')
6plt.xlabel('Time (Day)')
7plt.ylabel('Price ($)')
8plt.legend()
9plt.show()
و نمودار زیر به خوبی این نقاط را نشان میدهد.
در نمودار بالا، خرید و فروش ربات در مواقع برخورد و شکست خط میانگین متحرک ساده مشاهده میشود. نکته مهم دیگری که وجود دارد، این است که تعداد معاملات بالا و عکس هم در محل برخورد با میانگین متحرک ساده است. این اتفاق در دنیای واقعی میتواند مشکلساز باشد و نباید اجازه داد ربات با استناد به سیگنالهای ضعیف، اقدام به معامله کند.
آزمایش مدل روی مجموعه داده آزمایش
به این ترتیب، 3 نمودار مهم برای مجموعه داده آموزش رسم و بررسی شد. حال نمودارهای آورده شده را برای مجموعه داده آزمایش نیز رسم میکنیم تا تعمیمپذیری (Generalizability) مدل را بررسی کنیم. در صورتی که ربات روی مجموعه داده آزمایش، به نتایج مشابه با مجموعه داده آموزش نرسد، به این نتیجه میرسیم که مدل قابل تعمیم نبود و در دنیای واقعی نتایج مورد انتظار را نخواهد گرفت.
نمودار اول به شکل زیر حاصل میشود.
مشاهده میکنیم که میانگین درصد سود روزانه، برابر با 0.0801 % حاصل میشود که نسبت به مجموعه داده آموزش، کم است. البته باید به این نکته نیز توجه کرد که بخش بزرگی از این کاهش عملکرد، مربوط به شرایط نماد است. در 1500 روز مربوط به مجموعه داده آموزش، ارزش نماد 15 برابر شده است، ولی در 400 روز مربوط به مجوعه داده آزمایش، ارزش نماد 0.8 برابر شده است. به همین دلیل، میتوان عملکرد مدل را قابل قبول دانست.
به همین دلیل، تنها اتکا به میانگین درصد سود روزانه، نمیتواند مناسب باشد و باید سود ربات نسبت به سود نماد سنجیده شود. توجه داشته باشید که در ربات در بازههایی دچار ضرر نیز شده است و بخشی از سرمایه اول خود را نیز از دست داده است. در روزهایی که ارزش پرتفوی، کمتر از 1 واحد بوده، ربات ضرر داشته است. نمودار دوم به شکل زیر حاصل میشود.
مشاهده میکنیم که همانند مجموعه داده آموزش، ربات به خوبی توانسته روند و جهت قیمت را شناسایی و پیشبینی کند.
نمودار سوم به شکل زیر حاصل میشود.
به این ترتیب، مشاهده میکنیم که مدل در روندهای بلندمدت (Long Term)، سود مناسبی کسب کرده، ولی در شرایطی که بازار حالت نوسانی (Consolidation) و بدون روند (Range) داشته، اغلب معاملات دچار شکسته شدهاند. به همین دلیل، نیاز است تا وجود روند در بازار و محاسبه دقت سیگنال، انجام شود.
جمعبندی
در این مطلب، به اهمیت مجموعه داده آزمایش و روش تقسیم مجموعه داده پرداختیم. برای مطالعه بیشتر، میتوان موارد زیر را بررسی کرد:
- چگونه میتوان از انجام معاملات فراوان توسط ربات جلوگیری کرد؟
- شرایط نوسانی در بازار را چگونه میتوان شناسایی کرد؟
- برای رسم نمودارهای آوردهشده، متدهای در کلاس نوشته شده بنویسید که فرایند کدنویسی و رسم نمودار را تسهیل کنند.
- برای بررسی شرایط ربات در طول آموزش، یک مجموعه داده اعتبارسنجی (Validation Dataset) ایجاد کنید و بهینهترین مقدار Max Iteration و Swarm Size را بیابید.
دکتر بسیار عالی بود
ممنون
بسیار عالی. واقعا باید تقدیر و تشکر کرد از اقای دکتر کلامی بسیار مطالب مفید و عالی هستند. دست شما درد نکنه ممنون