Использование ONNX-моделей в MQL5

MetaQuotes | 28 марта, 2023

Введение

В статье "A CNN-LSTM-Based Model to Forecast Stock Prices" (авторы Wenjie Lu, Jiazheng Li, Yifan Li, Aijun Sun, Jingyang Wang, журнал Complexity, vol. 2020, Article ID 6622927, 10 pages, 2020) сравнивались различные модели прогноза котировок фондового рынка.

Stock price data have the characteristics of time series.

At the same time, based on machine learning long short-term memory (LSTM) which has the advantages of analyzing relationships among time series data through its memory function, we propose a forecasting method of stock price based on CNN-LSTM.

In the meanwhile, we use MLP, CNN, RNN, LSTM, CNN-RNN, and other forecasting models to predict the stock price one by one. Moreover, the forecasting results of these models are analyzed and compared. 
The data utilized in this research concern the daily stock prices from July 1, 1991, to August 31, 2020, including 7127 trading days.

In terms of historical data, we choose eight features, including opening price, highest price, lowest price, closing price, volume, turnover, ups and downs, and change. 
Firstly, we adopt CNN to efficiently extract features from the data, which are the items of the previous 10 days.  And then, we adopt LSTM to predict the stock price with the extracted feature data.

According to the experimental results, the CNN-LSTM can provide a reliable stock price forecasting with the highest prediction accuracy.
This forecasting method not only provides a new research idea for stock price forecasting but also provides practical experience for scholars to study financial time series data.

Таким образом, среди рассмотренных моделей наилучшие результаты показали модели типа CNN-LSTM. В данной статье мы рассмотрим процесс создания такой модели для прогнозирования финансовых временных рядов и использование созданной ONNX-модели в MQL5-советнике.


1. Построение модели

Благодаря наличию специализированных библиотек язык Python обладает широкими возможностями для работы с моделями машинного обучения. Библиотеки значительно облегчают подготовку и обработку данных.

Для полноценной работы с проектами машинного обучения рекомендуется использовать возможности GPU. Многие пользователи Windows при установке текущей версии TensorFlow столкнулись с проблемами (См. комментарии к видео-инструкции и текстовый вариант), поэтому мы протестировали и рекомендуем использовать TensorFlow 2.10.0. GPU-расчеты производились на видеокарте NVIDIA GeForce RTX 2080 Ti при помощи библиотек CUDA 11.2 и CUDNN 8.1.0.7.


1.1. Установка Python и библиотек

Если язык Python не установлен, его нужно установить (мы использовали версию 3.9.16).

Далее нужно установить библиотеки (если используется Conda/Anaconda, то эти команды нужно выполнить в Anaconda Prompt):

python.exe -m pip install --upgrade pip
pip install --upgrade pandas
pip install --upgrade scikit-learn
pip install --upgrade matplotlib
pip install --upgrade tqdm
pip install --upgrade metatrader5
pip install --upgrade onnx==1.12
pip install --upgrade tf2onnx
pip install --upgrade tensorflow==2.10.0


1.2. Проверка версии TensorFlow и GPU

Код для проверки установленной версии TensorFlow и возможностей использования GPU для расчета моделей:

#check tensorflow version
print(tf.__version__)
#check GPU support
print(len(tf.config.list_physical_devices('GPU'))>0)

Если все установлено правильно, результат должен быть таким:

2.10.0
True

Создание и обучение модели производится скриптом на Python, ниже кратко рассматриваются этапы этого процесса.


1.3. Создание и обучение модели

Скрипт начинается с импорта библиотек Python, которые будут использованы.

#Python libraries
import matplotlib.pyplot as plt 
import MetaTrader5 as mt5
import tensorflow as tf
import numpy as np
import pandas as pd
import tf2onnx
from sklearn.model_selection import train_test_split
from sys import argv

Проверка версии TensorFlow и доступности GPU:

#check tensorflow version
print(tf.__version__)

2.10.0

#check GPU support
print(len(tf.config.list_physical_devices('GPU'))>0)

True

Инициализация MetaTrader 5 для работы из Python:

#initialize MetaTrader5 for history data
if not mt5.initialize():
    print("initialize() failed, error code =",mt5.last_error())
    quit()

Информация о терминале MetaTrader 5:

#show terminal info
terminal_info=mt5.terminal_info()
print(terminal_info)

TerminalInfo(community_account=True, community_connection=True, connected=True, dlls_allowed=False, trade_allowed=False, tradeapi_disabled=False, email_enabled=False, ftp_enabled=False, notifications_enabled=False, mqid=False, build=3640, maxbars=100000, codepage=0, ping_last=58768, community_balance=1.0, retransmission=0.015296317559440137, company='MetaQuotes Software Corp.', name='MetaTrader 5', language='English', path='C:\\Program Files\\MetaTrader 5', data_path='C:\\Users\\user\\AppData\\Roaming\\MetaQuotes\\Terminal\\D0E8209F77C8CF37AD8BF550E51FF075', commondata_path='C:\\Users\\user\\AppData\\Roaming\\MetaQuotes\\Terminal\\Common')

#show file path
file_path=terminal_info.data_path+"\\MQL5\\Files\\"
print(file_path)
C:\Users\user\AppData\Roaming\MetaQuotes\Terminal\D0E8209F77C8CF37AD8BF550E51FF075\MQL5\Files\

Выводим путь для сохранения модели (в этом примере скрипт исполняется в Jupyter Notebook):

#data path to save the model
data_path=argv[0]
last_index=data_path.rfind("\\")+1
data_path=data_path[0:last_index]
print("data path to save onnx model",data_path)

data path to save onnx model C:\Users\user\AppData\Roaming\Python\Python39\site-packages\

Подготавливаем даты для запроса исторических данных. В данном случае часовые бары по EURUSD за 120 дней с текущей даты:

#set start and end dates for history data
from datetime import timedelta,datetime
end_date = datetime.now()
start_date = end_date - timedelta(days=120)

#print start and end dates
print("data start date=",start_date)
print("data end date=",end_date)
data start date= 2022-11-28 12:28:39.870685
data end date= 2023-03-28 12:28:39.870685

Запрашиваем исторические данные по инструменту EURUSD:

#get EURUSD rates (H1) from start_date to end_date
eurusd_rates = mt5.copy_rates_range("EURUSD", mt5.TIMEFRAME_H1, start_date, end_date)

Отображаем загруженные данные:

#check
print(eurusd_rates)


#create dataframe
df = pd.DataFrame(eurusd_rates)

Выводим начало и конец датафрейма:

#show dataframe head
df.head()


#show dataframe tail
df.tail()


#show dataframe shape (the number of rows and columns in the data set)
df.shape

(2045, 8)

Выборка только цен close:

#prepare close prices only
data = df.filter(['close']).values

Отображение данных:

#show close prices
plt.figure(figsize = (18,10))
plt.plot(data,'b',label = 'Original')
plt.xlabel("Hours")
plt.ylabel("Price")
plt.title("EURUSD_H1")
plt.legend()

График EURUSD H1 по ценам закрытия

Приводим исходные ценовые данные к диапазону [0,1] при помощи MinMaxScaler:

#scale data using MinMaxScaler
from sklearn.preprocessing import MinMaxScaler
scaler=MinMaxScaler(feature_range=(0,1))
scaled_data = scaler.fit_transform(data)

Для обучения будут использоваться первые 80% данных.

#training size is 80% of the data
training_size = int(len(scaled_data)*0.80) 
print("training size:",training_size)

training size: 1636

#create train data and check size
train_data_initial = scaled_data[0:training_size,:]
print(len(train_data_initial))

1636

#create test data and check size
test_data_initial= scaled_data[training_size:,:1]
print(len(test_data_initial))

409

Функция для создания обучающих последовательностей:

#split a univariate sequence into samples
def split_sequence(sequence, n_steps):
    X, y = list(), list()
    for i in range(len(sequence)):
       #find the end of this pattern
       end_ix = i + n_steps
       #check if we are beyond the sequence
       if end_ix > len(sequence)-1:
          break
       #gather input and output parts of the pattern
       seq_x, seq_y = sequence[i:end_ix], sequence[end_ix]
       X.append(seq_x)
       y.append(seq_y)
    return np.array(X), np.array(y)

Производим их построение:

#split into samples
time_step = 120
x_train, y_train = split_sequence(train_data_initial, time_step)
x_test, y_test = split_sequence(test_data_initial, time_step)
#reshape input to be [samples, time steps, features] which is required for LSTM
x_train =x_train.reshape(x_train.shape[0],x_train.shape[1],1)
x_test = x_test.reshape(x_test.shape[0],x_test.shape[1],1)

Формы тензоров для обучения и тестирования:

#show shape of train data
x_train.shape

(1516, 120, 1)

#show shape of test data
x_test.shape

(289, 120, 1)

#import keras libraries for the model
import math
from keras.models import Sequential
from keras.layers import Dense,Activation,Conv1D,MaxPooling1D,Dropout
from keras.layers import LSTM
from keras.utils.vis_utils import plot_model
from keras.metrics import RootMeanSquaredError as rmse
from keras import optimizers

Задаем модель:

#define the model
model = Sequential()
model.add(Conv1D(filters=256, kernel_size=2,activation='relu',padding = 'same',input_shape=(120,1)))
model.add(MaxPooling1D(pool_size=2))
model.add(LSTM(100, return_sequences = True))
model.add(Dropout(0.3))
model.add(LSTM(100, return_sequences = False))
model.add(Dropout(0.3))
model.add(Dense(units=1, activation = 'sigmoid'))
model.compile(optimizer='adam', loss= 'mse' , metrics = [rmse()])

Выводим свойства модели:

#show model
model.summary()

Свойства модели

Обучение модели:

#measure time
import time 
time_calc_start = time.time()

#fit model with 300 epochs
history=model.fit(x_train,y_train,epochs=300,validation_data=(x_test,y_test),batch_size=32,verbose=1)

#calculate time
fit_time_seconds = time.time() - time_calc_start
print("fit time =",fit_time_seconds," seconds.")

Epoch 1/300
48/48 [==============================] - 8s 49ms/step - loss: 0.0129 - root_mean_squared_error: 0.1136 - val_loss: 0.0065 - val_root_mean_squared_error: 0.0804

...

Epoch 299/300
48/48 [==============================] - 2s 35ms/step - loss: 4.5197e-04 - root_mean_squared_error: 0.0213 - val_loss: 4.2535e-04 - val_root_mean_squared_error: 0.0206
Epoch 300/300
48/48 [==============================] - 2s 32ms/step - loss: 4.2967e-04 - root_mean_squared_error: 0.0207 - val_loss: 4.4040e-04 - val_root_mean_squared_error: 0.0210

fit time = 467.4918096065521  seconds.

В данном случае обучение заняло около 8 минут.

#show training history keys
history.history.keys()

dict_keys(['loss', 'root_mean_squared_error', 'val_loss', 'val_root_mean_squared_error'])

Динамика оптимизации на обучаемом и тестовом наборах:

#show iteration-loss graph for training and validation
plt.figure(figsize = (18,10))
plt.plot(history.history['loss'],label='Training Loss',color='b')
plt.plot(history.history['val_loss'],label='Validation-loss',color='g')
plt.xlabel("Iteration")
plt.ylabel("Loss")
plt.title("LOSS")
plt.legend()

График итераций LOSS функции

#show iteration-rmse graph for training and validation
plt.figure(figsize = (18,10))
plt.plot(history.history['root_mean_squared_error'],label='Training RMSE',color='b')
plt.plot(history.history['val_root_mean_squared_error'],label='Validation-RMSE',color='g')
plt.xlabel("Iteration")
plt.ylabel("RMSE")
plt.title("RMSE")
plt.legend()


График итераций RMSE

#evaluate training data
model.evaluate(x_train,y_train, batch_size = 32)

48/48 [==============================] - 1s 22ms/step - loss: 2.9911e-04 - root_mean_squared_error: 0.0173
[0.00029911252204328775, 0.01729486882686615]
#evaluate testing data
model.evaluate(x_test,y_test, batch_size = 32)

10/10 [==============================] - 0s 31ms/step - loss: 4.4040e-04 - root_mean_squared_error: 0.0210

[0.00044039846397936344, 0.020985672250390053]

Формирование прогноза на обучающей выборке:

#prediction using training data
train_predict = model.predict(x_train)
plot_y_train = y_train.reshape(-1,1)

48/48 [==============================] - 2s 18ms/step

Отображение графиков (реального и прогноза модели) на интервале обучения:

#show actual vs predicted (training) graph
plt.figure(figsize=(18,10))
plt.plot(scaler.inverse_transform(plot_y_train),color = 'b', label = 'Original')
plt.plot(scaler.inverse_transform(train_predict),color='red', label = 'Predicted')
plt.title("Prediction Graph Using Training Data")
plt.xlabel("Hours")
plt.ylabel("Price")
plt.legend()
plt.show()

График предсказаний на обучающей выборке

Формирование прогноза на тестовой выборке:

#prediction using testing data
test_predict = model.predict(x_test)
plot_y_test = y_test.reshape(-1,1)

11/11 [==============================] - 0s 11ms/step

Для расчета метрик требуется преобразовать данные из интервала [0,1], для этого также используем MinMaxScaler.

#calculate metrics
from sklearn import metrics
from sklearn.metrics import r2_score
#transform data to real values
value1=scaler.inverse_transform(plot_y_test)
value2=scaler.inverse_transform(test_predict)
#calc score
score = np.sqrt(metrics.mean_squared_error(value1,value2))
print("RMSE         : {}".format(score))
print("MSE          :", metrics.mean_squared_error(value1,value2))
print("R2 score     :",metrics.r2_score(value1,value2))

RMSE         : 0.0015151631684117558
MSE          : 2.295719426911551e-06
R2 score     : 0.9683533377809039

#show actual vs predicted (testing) graph
plt.figure(figsize=(18,10))
plt.plot(scaler.inverse_transform(plot_y_test),color = 'b',  label = 'Original')
plt.plot(scaler.inverse_transform(test_predict),color='g', label = 'Predicted')
plt.title("Prediction Graph Using Testing Data")
plt.xlabel("Hours")
plt.ylabel("Price")
plt.legend()
plt.show()

График предсказаний на тестовой выборке


Экспорт модели в onnx-файл:

# save model to ONNX
output_path = data_path+"model.eurusd.H1.120.onnx"
onnx_model = tf2onnx.convert.from_keras(model, output_path=output_path)
print(f"model saved to {output_path}")

output_path = file_path+"model.eurusd.H1.120.onnx"
onnx_model = tf2onnx.convert.from_keras(model, output_path=output_path)
print(f"saved model to {output_path}")

# finish
mt5.shutdown()

Полный код Python-скрипта в виде Jupyter Notebook прикреплён к статье.

В статье "A CNN-LSTM-Based Model to Forecast Stock Prices" наилучшее значение R^2=0.9646 было получено для моделей с архитектурой CNN-LSTM, в нашем примере сеть CNN-LSTM показала лучший результат R^2=0.9684. Таким образом, модели подобного типа способны эффективно решать задачи прогноза.

Представлен пример скрипта на Python для создания и обучения CNN-LSTM моделей для прогноза финансовых временных рядов.


2. Использование модели в MetaTrader 5

2.1. Перед началом использования. Что нужно знать

Существует два способа создать модель: OnnxCreate для создания модели из onnx-файла и OnnxCreateFromBuffer для создания из массива данных.

Если ONNX-модель используется в эксперте в качестве ресурса, то после каждого её изменения эксперт необходимо перекомпилировать.

Не у всех моделей полностью определены размеры входных и/или выходных тензоров. Как правило, это первая размерность, отвечающая за размер пачки. Перед запуском модели необходимо явно указать размеры, которые будут использоваться. Для этого нужны функции OnnxSetInputShape и OnnxSetOutputShape.

Входные данные модели нужно готовить таким же самым способом, что был использован при обучении модели.

Для входных и выходных данных рекомендуется использовать массивы, матрицы и/или векторы того же самого типа, что и в модели. В таком случае не придётся использовать конвертацию данных при запуске модели. Если невозможно представить данные в необходимом типе, будет применяться автоматическая конвертация.

Запуск модели (или инференс) производится функцией OnnxRun. Запуск модели можно производить многократно.

После использования модели необходимо её освободить при помощи функции OnnxRelease.

Полная документация по ONNX моделям в MQL5.


2.2. Чтение onnx-файла и получение информации о входных и выходных данных

Итак, необходимо применить полученную нами модель. Для этого необходимо знать, где брать модель, тип и размерность входных данных, тип и размерность выходных данных. Мы сами писали обучающий скрипт, поэтому знаем, что модель model.eurusd.H1.120.onnx лежит рядом с питоновским скриптом, сгенерировавшем onnx-файл. Входные данные типа float32, 120 нормализованных цен Close (если будем работать с размером пачки равным 1), выходные данные типа float32, одна нормализованная цена, предсказанная моделью.

Мы специально создали onnx-файл также и в папке MQL5\Files, чтобы можно было при помощи MQL5-скрипта получить полную информацию о входе и выходе модели.

//+------------------------------------------------------------------+
//|                                                OnnxModelInfo.mq5 |
//|                                  Copyright 2023, MetaQuotes Ltd. |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2023, MetaQuotes Ltd."
#property link      "https://www.mql5.com"
#property version   "1.00"

#define UNDEFINED_REPLACE 1

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart()
  {
   string file_names[];
   if(FileSelectDialog("Open ONNX model",NULL,"ONNX files (*.onnx)|*.onnx|All files (*.*)|*.*",FSD_FILE_MUST_EXIST,file_names,NULL)<1)
      return;

   PrintFormat("Create model from %s with debug logs",file_names[0]);

   long session_handle=OnnxCreate(file_names[0],ONNX_DEBUG_LOGS);
   if(session_handle==INVALID_HANDLE)
     {
      Print("OnnxCreate error ",GetLastError());
      return;
     }

   OnnxTypeInfo type_info;

   long input_count=OnnxGetInputCount(session_handle);
   Print("model has ",input_count," input(s)");
   for(long i=0; i<input_count; i++)
     {
      string input_name=OnnxGetInputName(session_handle,i);
      Print(i," input name is ",input_name);
      if(OnnxGetInputTypeInfo(session_handle,i,type_info))
         PrintTypeInfo(i,"input",type_info);
     }

   long output_count=OnnxGetOutputCount(session_handle);
   Print("model has ",output_count," output(s)");
   for(long i=0; i<output_count; i++)
     {
      string output_name=OnnxGetOutputName(session_handle,i);
      Print(i," output name is ",output_name);
      if(OnnxGetOutputTypeInfo(session_handle,i,type_info))
         PrintTypeInfo(i,"output",type_info);
     }

   OnnxRelease(session_handle);
  }
//+------------------------------------------------------------------+
//| PrintTypeInfo                                                    |
//+------------------------------------------------------------------+
void PrintTypeInfo(const long num,const string layer,const OnnxTypeInfo& type_info)
  {
   Print("   type ",EnumToString(type_info.type));
   Print("   data type ",EnumToString(type_info.element_type));

   if(type_info.dimensions.Size()>0)
     {
      bool   dim_defined=(type_info.dimensions[0]>0);
      string dimensions=IntegerToString(type_info.dimensions[0]);
      for(long n=1; n<type_info.dimensions.Size(); n++)
        {
         if(type_info.dimensions[n]<=0)
            dim_defined=false;
         dimensions+=", ";
         dimensions+=IntegerToString(type_info.dimensions[n]);
        }
      Print("   shape [",dimensions,"]");
      //--- not all dimensions defined
      if(!dim_defined)
         PrintFormat("   %I64d %s shape must be defined explicitly before model inference",num,layer);
      //--- reduce shape
      uint reduced=0;
      long dims[];
      for(long n=0; n<type_info.dimensions.Size(); n++)
        {
         long dimension=type_info.dimensions[n];
         //--- replace undefined dimension
         if(dimension<=0)
            dimension=UNDEFINED_REPLACE;
         //--- 1 can be reduced
         if(dimension>1)
           {
            ArrayResize(dims,reduced+1);
            dims[reduced++]=dimension;
           }
        }
      //--- all dimensions assumed 1
      if(reduced==0)
        {
         ArrayResize(dims,1);
         dims[reduced++]=1;
        }
      //--- shape was reduced
      if(reduced<type_info.dimensions.Size())
        {
         dimensions=IntegerToString(dims[0]);
         for(long n=1; n<dims.Size(); n++)
           {
            dimensions+=", ";
            dimensions+=IntegerToString(dims[n]);
           }
         string sentence="";
         if(!dim_defined)
            sentence=" if undefined dimension set to "+(string)UNDEFINED_REPLACE;
         PrintFormat("   shape of %s data can be reduced to [%s]%s",layer,dimensions,sentence);
        }
     }
   else
      PrintFormat("no dimensions defined for %I64d %s",num,layer);
  }
//+------------------------------------------------------------------+

В окне выбора файлов мы взяли наш onnx-файл, сохранённый в папке MQL5\Files, создали модель из файла (OnnxCreate) и получили вот какую информацию.

Create model from model.eurusd.H1.120.onnx with debug logs
ONNX: Creating and using per session threadpools since use_per_session_threads_ is true
ONNX: Dynamic block base set to 0
ONNX: Initializing session.
ONNX: Adding default CPU execution provider.
ONNX: Total shared scalar initializer count: 0
ONNX: Total fused reshape node count: 0
ONNX: Removing NodeArg 'Gather_out0'. It is no longer used by any node.
ONNX: Removing NodeArg 'Gather_token_1_out0'. It is no longer used by any node.
ONNX: Total shared scalar initializer count: 0
ONNX: Total fused reshape node count: 0
ONNX: Removing initializer 'sequential/conv1d/Conv1D/ExpandDims_1:0'. It is no longer used by any node.
ONNX: Use DeviceBasedPartition as default
ONNX: Saving initialized tensors.
ONNX: Done saving initialized tensors
ONNX: Session successfully initialized.
model has 1 input(s)
0 input name is conv1d_input
   type ONNX_TYPE_TENSOR
   data type ONNX_DATA_TYPE_FLOAT
   shape [-1, 120, 1]
   0 input shape must be defined explicitly before model inference
   shape of input data can be reduced to [120] if undefined dimension set to 1
model has 1 output(s)
0 output name is dense
   type ONNX_TYPE_TENSOR
   data type ONNX_DATA_TYPE_FLOAT
   shape [-1, 1]
   0 output shape must be defined explicitly before model inference
   shape of output data can be reduced to [1] if undefined dimension set to 1

Так как мы заказали отладочную информацию,

   long session_handle=OnnxCreate(file_names[0],ONNX_DEBUG_LOGS);

то получили ряд сообщений с префиксом ONNX.

Мы видим, что модель действительно имеет один вход и один выход. При этом первая размерность входного тензора и первая размерность выходного тензора не определены. Предполагается, что данные размерности отвечают за размер пачки (batch size). Поэтому перед запуском модели на исполнение (inference) необходимо явно указать, с какими размерами мы собираемся работать (OnnxSetInputShape, OnnxSetOutputShape). Как правило, мы подаём на вход только одну порцию данных. Подробный пример представлен в следующем пункте "Пример использования ONNX модели в торгующем эксперте".

Кроме этого, для подготовки входных данных совершенно необязательно использовать массив с размерностями [1, 120, 1]. На вход можно подать одномерный массив или вектор размером 120 элементов


2.3. Пример использования ONNX-модели в торгующем эксперте

Предварительные объявления и определения

#include <Trade\Trade.mqh>

input double InpLots = 1.0;    // Lots amount to open position

#resource "Python/model.120.H1.onnx" as uchar ExtModel[]

#define SAMPLE_SIZE 120

long     ExtHandle=INVALID_HANDLE;
int      ExtPredictedClass=-1;
datetime ExtNextBar=0;
datetime ExtNextDay=0;
float    ExtMin=0.0;
float    ExtMax=0.0;
CTrade   ExtTrade;

//--- price movement prediction
#define PRICE_UP   0
#define PRICE_SAME 1
#define PRICE_DOWN 2

Функция OnInit

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   if(_Symbol!="EURUSD" || _Period!=PERIOD_H1)
     {
      Print("model must work with EURUSD,H1");
      return(INIT_FAILED);
     }

//--- create a model from static buffer
   ExtHandle=OnnxCreateFromBuffer(ExtModel,ONNX_DEFAULT);
   if(ExtHandle==INVALID_HANDLE)
     {
      Print("OnnxCreateFromBuffer error ",GetLastError());
      return(INIT_FAILED);
     }

//--- since not all sizes defined in the input tensor we must set them explicitly
//--- first index - batch size, second index - series size, third index - number of series (only Close)
   const long input_shape[] = {1,SAMPLE_SIZE,1};
   if(!OnnxSetInputShape(ExtHandle,0,input_shape))
     {
      Print("OnnxSetInputShape error ",GetLastError());
      return(INIT_FAILED);
     }

//--- since not all sizes defined in the output tensor we must set them explicitly
//--- first index - batch size, must match the batch size of the input tensor
//--- second index - number of predicted prices (we only predict Close)
   const long output_shape[] = {1,1};
   if(!OnnxSetOutputShape(ExtHandle,0,output_shape))
     {
      Print("OnnxSetOutputShape error ",GetLastError());
      return(INIT_FAILED);
     }
//---
   return(INIT_SUCCEEDED);
  }

Работаем только с EURUSD,H1. Просто потому, что используем данные текущего символа-периода.

Наша модель включена в эксперт в виде ресурса. Эксперт самодостаточен, и нет необходимости читать onnx-файл извне. Создаём модель сразу из ресурсного массива.

Обязательно явно определяем формы входных и выходных данных.

Функция OnTick

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//--- check new day
   if(TimeCurrent()>=ExtNextDay)
     {
      GetMinMax();
      //--- set next day time
      ExtNextDay=TimeCurrent();
      ExtNextDay-=ExtNextDay%PeriodSeconds(PERIOD_D1);
      ExtNextDay+=PeriodSeconds(PERIOD_D1);
     }

//--- check new bar
   if(TimeCurrent()<ExtNextBar)
      return;
//--- set next bar time
   ExtNextBar=TimeCurrent();
   ExtNextBar-=ExtNextBar%PeriodSeconds();
   ExtNextBar+=PeriodSeconds();
//--- check min and max
   double close=iClose(_Symbol,_Period,0);
   if(ExtMin>close)
      ExtMin=close;
   if(ExtMax<close)
      ExtMax=close;

//--- predict next price
   PredictPrice();
//--- check trading according to prediction
   if(ExtPredictedClass>=0)
      if(PositionSelect(_Symbol))
         CheckForClose();
      else
         CheckForOpen();
  }

Определяем начало нового дня. Это нужно для того, чтобы обновить минимум и максимум 120-дневной последовательности для нормирования цен в 120-часовой последовательности. Мы обучали модель именно при этих условиях, поэтому должны соблюсти правила для подготовки входных данных.

//+------------------------------------------------------------------+
//| Get minimal and maximal Close for last 120 days                  |
//+------------------------------------------------------------------+
void GetMinMax(void)
  {
   vectorf close;
   close.CopyRates(_Symbol,PERIOD_D1,COPY_RATES_CLOSE,0,SAMPLE_SIZE);
   ExtMin=close.Min();
   ExtMax=close.Max();
  }

Потом при необходимости мы в течение дня модифицируем минимум и максимум.

Функция предсказания:

//+------------------------------------------------------------------+
//| Predict next price                                               |
//+------------------------------------------------------------------+
void PredictPrice(void)
  {
   static vectorf output_data(1);            // vector to get result
   static vectorf x_norm(SAMPLE_SIZE);       // vector for prices normalize

//--- check for normalization possibility
   if(ExtMin>=ExtMax)
     {
      ExtPredictedClass=-1;
      return;
     }
//--- request last bars
   if(!x_norm.CopyRates(_Symbol,_Period,COPY_RATES_CLOSE,1,SAMPLE_SIZE))
     {
      ExtPredictedClass=-1;
      return;
     }
   float last_close=x_norm[SAMPLE_SIZE-1];
//--- normalize prices
   x_norm-=ExtMin;
   x_norm/=(ExtMax-ExtMin);
//--- run the inference
   if(!OnnxRun(ExtHandle,ONNX_NO_CONVERSION,x_norm,output_data))
     {
      ExtPredictedClass=-1;
      return;
     }
//--- denormalize the price from the output value
   float predicted=output_data[0]*(ExtMax-ExtMin)+ExtMin;
//--- classify predicted price movement
   float delta=last_close-predicted;
   if(fabs(delta)<=0.00001)
      ExtPredictedClass=PRICE_SAME;
   else
     {
      if(delta<0)
         ExtPredictedClass=PRICE_UP;
      else
         ExtPredictedClass=PRICE_DOWN;
     }
  }

Сначала проверяем, можем ли мы осуществить нормализацию. Нормализация осуществляется аналогично питоновскому MinMaxScaler.

#scale data
from sklearn.preprocessing import MinMaxScaler
scaler=MinMaxScaler(feature_range=(0,1))
scaled_data = scaler.fit_transform(data)

Соответственно, код нормализации очень простой и очевидный.

Векторы для входных данных и для приёма результата специально организованы как статические. Это гарантирует неперемещаемый в памяти буфер, существующий всё время жизни программы. Таким образом, в ONNX модели входной и выходной тензоры не пересоздаются каждый раз при запуске модели.

Центральная функция — OnnxRun. Флаг ONNX_NO_CONVERSION означает, что данные, входные и выходные, не должны подвергаться конверсии, так как тип float в MQL5 точно соответствует типу ONNX_DATA_TYPE_FLOAT. Флаг ONNX_DEBUG не установлен.

После этого разнормализуем полученные данные в предсказанную цену и определяем класс — цена пойдёт вверх, вниз или не поменяется.


Торговая стратегия проста. В начале каждого часа проверяем прогноз цены на конец часа. Если цена по прогнозу пойдёт вверх, покупаем. И наоборот, если цена пойдёт вниз, продаём.

//+------------------------------------------------------------------+
//| Check for open position conditions                               |
//+------------------------------------------------------------------+
void CheckForOpen(void)
  {
   ENUM_ORDER_TYPE signal=WRONG_VALUE;
//--- check signals
   if(ExtPredictedClass==PRICE_DOWN)
      signal=ORDER_TYPE_SELL;    // sell condition
   else
     {
      if(ExtPredictedClass==PRICE_UP)
         signal=ORDER_TYPE_BUY;  // buy condition
     }

//--- open position if possible according to signal
   if(signal!=WRONG_VALUE && TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
     {
      double price;
      double bid=SymbolInfoDouble(_Symbol,SYMBOL_BID);
      double ask=SymbolInfoDouble(_Symbol,SYMBOL_ASK);
      if(signal==ORDER_TYPE_SELL)
         price=bid;
      else
         price=ask;
      ExtTrade.PositionOpen(_Symbol,signal,InpLots,price,0.0,0.0);
     }
  }
//+------------------------------------------------------------------+
//| Check for close position conditions                              |
//+------------------------------------------------------------------+
void CheckForClose(void)
  {
   bool bsignal=false;
//--- position already selected before
   long type=PositionGetInteger(POSITION_TYPE);
//--- check signals
   if(type==POSITION_TYPE_BUY && ExtPredictedClass==PRICE_DOWN)
      bsignal=true;
   if(type==POSITION_TYPE_SELL && ExtPredictedClass==PRICE_UP)
      bsignal=true;

//--- close position if possible
   if(bsignal && TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
     {
      ExtTrade.PositionClose(_Symbol,3);
      //--- open opposite
      CheckForOpen();
     }
  }

Теперь необходимо проверить работоспособность эксперта. Это можно сделать в тестере. Но для того, чтобы протестировать эксперта с начала года, необходимо тренировать модель на данных до начала года. Поэтому мы немного видоизменили питоновский скрипт, убрав из него всё лишнее и заменив конечную дату.

Скрипт ONNX.eurusd.H1.120.Training.py находится в подпапке Python и запускается прямо в MetaEditor. Полученная ONNX модель будет записана рядом в этой же подпапке Python и при компиляции эксперта будет использована в качестве ресурса.

# Copyright 2023, MetaQuotes Ltd.
# https://www.mql5.com

# python libraries
import MetaTrader5 as mt5
import tensorflow as tf
import numpy as np
import pandas as pd
import tf2onnx

# input parameters
inp_model_name = "model.eurusd.H1.120.onnx"
inp_history_size = 120

if not mt5.initialize():
    print("initialize() failed, error code =",mt5.last_error())
    quit()

# we will save generated onnx-file near our script to use as resource
from sys import argv
data_path=argv[0]
last_index=data_path.rfind("\\")+1
data_path=data_path[0:last_index]
print("data path to save onnx model",data_path)

# set start and end dates for history data
from datetime import timedelta, datetime
#end_date = datetime.now()
end_date = datetime(2023, 1, 1, 0)
start_date = end_date - timedelta(days=inp_history_size)

# print start and end dates
print("data start date =",start_date)
print("data end date =",end_date)

# get rates
eurusd_rates = mt5.copy_rates_range("EURUSD", mt5.TIMEFRAME_H1, start_date, end_date)

# create dataframe
df = pd.DataFrame(eurusd_rates)

# get close prices only
data = df.filter(['close']).values

# scale data
from sklearn.preprocessing import MinMaxScaler
scaler=MinMaxScaler(feature_range=(0,1))
scaled_data = scaler.fit_transform(data)

# training size is 80% of the data
training_size = int(len(scaled_data)*0.80) 
print("Training_size:",training_size)
train_data_initial = scaled_data[0:training_size,:]
test_data_initial = scaled_data[training_size:,:1]

# split a univariate sequence into samples
def split_sequence(sequence, n_steps):
    X, y = list(), list()
    for i in range(len(sequence)):
       # find the end of this pattern
       end_ix = i + n_steps
       # check if we are beyond the sequence
       if end_ix > len(sequence)-1:
          break
       # gather input and output parts of the pattern
       seq_x, seq_y = sequence[i:end_ix], sequence[end_ix]
       X.append(seq_x)
       y.append(seq_y)
    return np.array(X), np.array(y)

# split into samples
time_step = inp_history_size
x_train, y_train = split_sequence(train_data_initial, time_step)
x_test, y_test = split_sequence(test_data_initial, time_step)

# reshape input to be [samples, time steps, features] which is required for LSTM
x_train =x_train.reshape(x_train.shape[0],x_train.shape[1],1)
x_test = x_test.reshape(x_test.shape[0],x_test.shape[1],1)

# define model
from keras.models import Sequential
from keras.layers import Dense, Activation, Conv1D, MaxPooling1D, Dropout, Flatten, LSTM
from keras.metrics import RootMeanSquaredError as rmse
model = Sequential()
model.add(Conv1D(filters=256, kernel_size=2, activation='relu',padding = 'same',input_shape=(inp_history_size,1)))
model.add(MaxPooling1D(pool_size=2))
model.add(LSTM(100, return_sequences = True))
model.add(Dropout(0.3))
model.add(LSTM(100, return_sequences = False))
model.add(Dropout(0.3))
model.add(Dense(units=1, activation = 'sigmoid'))
model.compile(optimizer='adam', loss= 'mse' , metrics = [rmse()])

# model training for 300 epochs
history = model.fit(x_train, y_train, epochs = 300 , validation_data = (x_test,y_test), batch_size=32, verbose=2)

# evaluate training data
train_loss, train_rmse = model.evaluate(x_train,y_train, batch_size = 32)
print(f"train_loss={train_loss:.3f}")
print(f"train_rmse={train_rmse:.3f}")

# evaluate testing data
test_loss, test_rmse = model.evaluate(x_test,y_test, batch_size = 32)
print(f"test_loss={test_loss:.3f}")
print(f"test_rmse={test_rmse:.3f}")

# save model to ONNX
output_path = data_path+inp_model_name
onnx_model = tf2onnx.convert.from_keras(model, output_path=output_path)
print(f"saved model to {output_path}")

# finish
mt5.shutdown()


Тестирование советника на основе ONNX-модели

Настало время проверить советника на исторических данных в тестере стратегий. Выставляем в настройках параметры, на которых тренировалась модель: символ EURUSD и таймфрейм H1.

Устанавливаем  интервал за пределами периодом обучения модели — с начала года, с 01.01.2023 — и запускаем тестирование. 

Настройки тестирования советника


Так как согласно стратегии проверка сигналов производится один раз в начале каждого часа (в советнике стоит проверка на появление нового бара), то режим моделирования тиков не имеет значения — OnTick будет отрабатываться в тестере только один раз за бар.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//--- check new day
   if(TimeCurrent()>=ExtNextDay)
     {
      GetMinMax();
      //--- set next day time
      ExtNextDay=TimeCurrent();
      ExtNextDay-=ExtNextDay%PeriodSeconds(PERIOD_D1);
      ExtNextDay+=PeriodSeconds(PERIOD_D1);
     }

//--- check new bar
   if(TimeCurrent()<ExtNextBar)
      return;
//--- set next bar time
   ExtNextBar=TimeCurrent();
   ExtNextBar-=ExtNextBar%PeriodSeconds();
   ExtNextBar+=PeriodSeconds();
//--- check min and max
   float close=(float)iClose(_Symbol,_Period,0);
   if(ExtMin>close)
      ExtMin=close;
   if(ExtMax<close)
      ExtMax=close;

//--- predict next price
   PredictPrice();
//--- check trading according to prediction
   if(ExtPredictedClass>=0)
      if(PositionSelect(_Symbol))
         CheckForClose();
      else
         CheckForOpen();
  }


При такой проверке тестирование за 3 месяца занимает несколько секунд. Мы сразу же получаем результаты.

Результаты тестирования советника



Теперь изменим торговую стратегию, а именно — открытие позиции по сигналу, закрытие позиции по стоп-лоссу или тейк-профиту.

input double InpLots       = 1.0;    // Lots amount to open position
input bool   InpUseStops   = true;   // Use stops in trading
input int    InpTakeProfit = 500;    // TakeProfit level
input int    InpStopLoss   = 500;    // StopLoss level
//+------------------------------------------------------------------+
//| Check for open position conditions                               |
//+------------------------------------------------------------------+
void CheckForOpen(void)
  {
   ENUM_ORDER_TYPE signal=WRONG_VALUE;
//--- check signals
   if(ExtPredictedClass==PRICE_DOWN)
      signal=ORDER_TYPE_SELL;    // sell condition
   else
     {
      if(ExtPredictedClass==PRICE_UP)
         signal=ORDER_TYPE_BUY;  // buy condition
     }

//--- open position if possible according to signal
   if(signal!=WRONG_VALUE && TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
     {
      double price,sl=0,tp=0;
      double bid=SymbolInfoDouble(_Symbol,SYMBOL_BID);
      double ask=SymbolInfoDouble(_Symbol,SYMBOL_ASK);
      if(signal==ORDER_TYPE_SELL)
        {
         price=bid;
         if(InpUseStops)
           {
            sl=NormalizeDouble(bid+InpStopLoss*_Point,_Digits);
            tp=NormalizeDouble(ask-InpTakeProfit*_Point,_Digits);
           }
        }
      else
        {
         price=ask;
         if(InpUseStops)
           {
            sl=NormalizeDouble(ask-InpStopLoss*_Point,_Digits);
            tp=NormalizeDouble(bid+InpTakeProfit*_Point,_Digits);
           }
        }
      ExtTrade.PositionOpen(_Symbol,signal,InpLots,price,sl,tp);
     }
  }
//+------------------------------------------------------------------+
//| Check for close position conditions                              |
//+------------------------------------------------------------------+
void CheckForClose(void)
  {
//--- position should be closed by stops
   if(InpUseStops)
      return;

   bool bsignal=false;
//--- position already selected before
   long type=PositionGetInteger(POSITION_TYPE);
//--- check signals
   if(type==POSITION_TYPE_BUY && ExtPredictedClass==PRICE_DOWN)
      bsignal=true;
   if(type==POSITION_TYPE_SELL && ExtPredictedClass==PRICE_UP)
      bsignal=true;

//--- close position if possible
   if(bsignal && TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
     {
      ExtTrade.PositionClose(_Symbol,3);
      //--- open opposite
      CheckForOpen();
     }
  }

Укажем параметр InpUseStops = true, это означает, что при открытии позиции выставляются уровни TP и SL.

Входные параметры советника с использованием SL/TP


Результаты тестирования с использованием уровней  SL/TP с начала года показаны ниже.

Результаты тестирования советника с использованием уровней SL и TP


Полный исходный код эксперта и обученная модель на начало 2023 года прикреплены к статье.


Заключение

Мы показали, что нет ничего сложного в использовании ONNX моделей в MQL5-программах. Наоборот, это очень просто. Гораздо сложнее получить адекватную ONNX-модель.

Обращаем ваше внимание, что использованная в статье модель представлена только в целях демонстрации работы с ONNX-моделями средствами языка MQL5. Советник не предназначен для торговли на реальных счетах.