В статье "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) сравнивались различные модели прогноза котировок фондового рынка.
Ценовые данные акций обладают характеристиками временных рядов.
В то же время, на основе машинного обучения с использованием долгосрочной краткосрочной памяти (LSTM), которая имеет преимущества в анализе взаимосвязей между временными рядами через свою функцию памяти, мы предлагаем метод прогнозирования цены акций на основе CNN-LSTM.
В течение этого времени мы используем модели MLP, CNN, RNN, LSTM, CNN-RNN и другие модели прогнозирования для поочередного прогнозирования цены акций. Кроме того, результаты прогнозирования этих моделей анализируются и сравниваются. Данные, использованные в этом исследовании, касаются дневных цен на акции с 1 июля 1991 года по 31 августа 2020 года, включая 7127 торговых дней.
В отношении исторических данных мы выбираем восемь характеристик, включая цену открытия, максимальную цену, минимальную цену, цену закрытия, объем, оборот, подъемы и падения, а также изменения. Во-первых, мы используем CNN для эффективного извлечения характеристик из данных, которые представляют собой данные предыдущих 10 дней. Затем мы применяем LSTM для прогнозирования цены акций с использованием извлеченных данных характеристик.
Согласно результатам экспериментов, CNN-LSTM может обеспечить надежный прогноз цен на акции с наивысшей точностью предсказания. Этот метод прогнозирования не только предоставляет новую исследовательскую идею для прогнозирования цен на акции, но и предоставляет практический опыт ученым для изучения временных рядов финансовых данных.
Таким образом, среди рассмотренных моделей наилучшие результаты показали модели типа 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):
Код для проверки установленной версии TensorFlow и возможностей использования GPU для расчета моделей:
# Проверяем версию TensorFlowprint(tf.__version__)# Проверяем поддержку графического процессора (GPU)print(len(tf.config.list_physical_devices('GPU')) >0)
2.10.0
True
Создание и обучение модели производится скриптом на Python, ниже кратко рассматриваются этапы этого процесса.
1.3. Создание и обучение модели
Скрипт начинается с импорта библиотек Python, которые будут использованы.
# Импортируем библиотеки Pythonimport matplotlib.pyplot as pltimport MetaTrader5 as mt5import tensorflow as tfimport numpy as npimport pandas as pdimport tf2onnxfrom sklearn.model_selection import train_test_splitfrom sys import argv
Проверка версии TensorFlow:
# Проверяем версию TensorFlowprint(tf.__version__)
2.10.0
Доступности GPU:
# Проверяем поддержку графического процессора (GPU)print(len(tf.config.list_physical_devices('GPU')) >0)
True
Инициализация MetaTrader 5 для работы из Python:
# Инициализируем MetaTrader5 для получения исторических данныхifnot mt5.initialize():print("initialize() failed, error code =", mt5.last_error())quit()
Информация о терминале MetaTrader 5:
# Отображаем информацию о терминалеterminal_info = mt5.terminal_info()print(terminal_info)
Выводим путь для сохранения модели (в этом примере скрипт исполняется в Jupyter Notebook):
# Путь к данным для сохранения моделиdata_path = argv[0]last_index = data_path.rfind("\\")+1data_path = data_path[0:last_index]print("Путь к данным для сохранения модели в формате ONNX:", data_path)
data path to save onnx model C:\Users\user\AppData\Roaming\Python\Python39\site-packages\
Подготавливаем даты для запроса исторических данных. В данном случае часовые бары по EURUSD за 120 дней с текущей даты:
# Устанавливаем начальную и конечную даты для исторических данныхfrom datetime import timedelta, datetimeend_date = datetime.now()start_date = end_date -timedelta(days=120)# Выводим начальную и конечную датыprint("Дата начала данных =", start_date)print("Дата окончания данных =", end_date)
data start date= 2022-11-28 12:28:39.870685
data end date= 2023-03-28 12:28:39.870685
Запрашиваем исторические данные по инструменту EURUSD:
# Получаем котировки EURUSD (H1) с начальной даты по конечнуюeurusd_rates = mt5.copy_rates_range("EURUSD", mt5.TIMEFRAME_H1, start_date, end_date)
Отображаем загруженные данные:
# Проверяем результатprint(eurusd_rates)
# Создаем объект DataFramedf = pd.DataFrame(eurusd_rates)
Выводим начало и конец датафрейма:
# Показываем первые строки DataFramedf.head()
# Показываем последние строки DataFramedf.tail()
# Показываем форму DataFrame (количество строк и столбцов в наборе данных)df.shape
(2045, 8)
Выборка только цен close:
# Подготавливаем только цены закрытияdata = df.filter(['close']).values
Приводим исходные ценовые данные к диапазону [0,1] при помощи MinMaxScaler:
# Масштабируем данные с использованием MinMaxScalerfrom sklearn.preprocessing import MinMaxScalerscaler =MinMaxScaler(feature_range=(0, 1))scaled_data = scaler.fit_transform(data)
Для обучения будут использоваться первые 80% данных.
# Размер обучающей выборки составляет 80% от данныхtraining_size =int(len(scaled_data) *0.80)print("Размер обучающей выборки:", training_size)
Размер обучающей выборки: 1636
# Создаем обучающие данные и проверяем их размерtrain_data_initial = scaled_data[0:training_size,:]print(len(train_data_initial))
1636
# Создаем тестовые данные и проверяем их размерtest_data_initial = scaled_data[training_size:,:1]print(len(test_data_initial))
409
Функция для создания обучающих последовательностей:
# Разбиваем одномерную последовательность на выборкиdefsplit_sequence(sequence,n_steps): X, y =list(),list()for i inrange(len(sequence)):# Находим конец текущего образца end_ix = i + n_steps# Проверяем, не вышли ли за пределы последовательностиif end_ix >len(sequence)-1:break# Собираем входные и выходные части образца 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)
# Изменяем форму входных данных на [образцы, временные шаги, признаки], что необходимо для LSTMx_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)
Формы тензоров для обучения и тестирования:
# Показываем форму обучающих данныхx_train.shape
(1516, 120, 1)
# Показываем форму тестовых данных
x_test.shape
(289, 120, 1)
# Импортируем библиотеки Keras для моделиimport mathfrom keras.models import Sequentialfrom keras.layers import Dense, Activation, Conv1D, MaxPooling1D, Dropoutfrom keras.layers import LSTMfrom keras.utils.vis_utils import plot_modelfrom keras.metrics import RootMeanSquaredError as rmsefrom keras import optimizers
Задаем модель:
# Определение моделиmodel =Sequential()# Добавление слоя сверткиmodel.add(Conv1D(filters=256, kernel_size=2, activation='relu', padding='same', input_shape=(120, 1)))# Добавление слоя пулингаmodel.add(MaxPooling1D(pool_size=2))# Добавление слоя LSTMmodel.add(LSTM(100, return_sequences=True))# Добавление слоя Dropout для регуляризацииmodel.add(Dropout(0.3))# Добавление второго слоя LSTMmodel.add(LSTM(100, return_sequences=False))# Добавление второго слоя Dropout для регуляризацииmodel.add(Dropout(0.3))# Добавление полносвязного слояmodel.add(Dense(units=1, activation='sigmoid'))# Компиляция моделиmodel.compile(optimizer='adam', loss='mse', metrics=[rmse()])
Выводим свойства модели:
# Показываем структуру моделиmodel.summary()
Обучение модели:
# Измеряем времяimport time# Начало измерения времениtime_calc_start = time.time()# Обучаем модель на 300 эпохахhistory = model.fit(x_train, y_train, epochs=300, validation_data=(x_test, y_test), batch_size=32, verbose=1)# Вычисляем времяfit_time_seconds = time.time()- time_calc_startprint("Время обучения =", fit_time_seconds, "секунд.")
Динамика оптимизации на обучаемом и тестовом наборах:
# Показываем график потерь для обучения и валидацииplt.figure(figsize=(18, 10))plt.plot(history.history['loss'], label='Потери на обучении', color='b')plt.plot(history.history['val_loss'], label='Потери на валидации', color='g')plt.xlabel("Итерация")plt.ylabel("Потери")plt.title("ПОТЕРИ")plt.legend()
# Показываем график RMSE для обучения и валидацииplt.figure(figsize=(18, 10))plt.plot(history.history['root_mean_squared_error'], label='RMSE на обучении', color='b')plt.plot(history.history['val_root_mean_squared_error'], label='RMSE на валидации', color='g')plt.xlabel("Итерация")plt.ylabel("RMSE")plt.title("RMSE")plt.legend()
# Показываем график фактических и прогнозируемых (тестовых) данныхplt.figure(figsize=(18, 10))plt.plot(scaler.inverse_transform(plot_y_test), color='b', label='Фактические')plt.plot(scaler.inverse_transform(test_predict), color='g', label='Прогнозируемые')plt.title("График прогнозирования с использованием тестовых данных")plt.xlabel("Часы")plt.ylabel("Цена")plt.legend()plt.show()
Экспорт модели в onnx-файл:
# Сохраняем модель в формате ONNXoutput_path = data_path +"model.eurusd.H1.120.onnx"onnx_model = tf2onnx.convert.from_keras(model, output_path=output_path)print(f"Модель сохранена в {output_path}")output_path = file_path +"model.eurusd.H1.120.onnx"onnx_model = tf2onnx.convert.from_keras(model, output_path=output_path)print(f"Сохранена модель в {output_path}")# Завершаем работу с MetaTrader5mt5.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.
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 "Авторские права 2023, MetaQuotes Ltd."#property link "https://www.mql5.com"#property version "1.00"#defineUNDEFINED_REPLACE1//+------------------------------------------------------------------+//| Функция запуска скрипта |//+------------------------------------------------------------------+voidOnStart() { string file_names[]; // Открываем диалог выбора файла ONNX модели if(FileSelectDialog("Открыть ONNX модель", NULL, "ONNX файлы (*.onnx)|*.onnx|Все файлы (*.*)|*.*", FSD_FILE_MUST_EXIST, file_names, NULL) < 1)
return;PrintFormat("Создаем модель из %s с отладочными логами",file_names[0]);long session_handle =OnnxCreate(file_names[0], ONNX_DEBUG_LOGS);if(session_handle == INVALID_HANDLE) {Print("Ошибка OnnxCreate ",GetLastError());return; } OnnxTypeInfo type_info;long input_count =OnnxGetInputCount(session_handle);Print("Модель имеет ", input_count," вход(ов)");for(long i =0; i < input_count; i++) { string input_name =OnnxGetInputName(session_handle, i);Print(i," имя входа ", input_name);if(OnnxGetInputTypeInfo(session_handle, i, type_info))PrintTypeInfo(i,"вход", type_info); }long output_count =OnnxGetOutputCount(session_handle);Print("Модель имеет ", output_count," выход(ов)");for(long i =0; i < output_count; i++) { string output_name =OnnxGetOutputName(session_handle, i);Print(i," имя выхода ", output_name);if(OnnxGetOutputTypeInfo(session_handle, i, type_info))PrintTypeInfo(i,"выход", type_info); }OnnxRelease(session_handle); }//+------------------------------------------------------------------+//| Функция вывода информации о типе данных |//+------------------------------------------------------------------+voidPrintTypeInfo(constlong num,conststring layer,constOnnxTypeInfo& type_info) {Print(" тип ",EnumToString(type_info.type));Print(" тип данных ",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(" форма [", dimensions,"]"); //--- не все измерения определеныif(!dim_defined)PrintFormat(" %I64d %s форма должна быть явно определена перед использованием модели", num, layer); //--- уменьшаем формуuint reduced =0;long dims[];for(long n =0; n <type_info.dimensions.Size(); n++) {long dimension =type_info.dimensions[n]; //--- заменяем неопределенное измерениеif(dimension <=0) dimension = UNDEFINED_REPLACE; //--- 1 можно уменьшитьif(dimension >1) {ArrayResize(dims, reduced +1);dims[reduced++] = dimension; } } //--- все измерения предполагаются 1if(reduced ==0) {ArrayResize(dims,1);dims[reduced++] =1; } //--- форма уменьшена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 =" если неопределенное измерение установлено в "+ (string)UNDEFINED_REPLACE;PrintFormat(" форма данных %s может быть уменьшена до [%s]%s", layer, dimensions, sentence); } }elsePrintFormat("для %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
Результаты выполнения скрипта:
Создание модели из model.eurusd.H1.120.onnx с отладочными журналами.
ONNX: Создание и использование потоков пула для каждой сессии, поскольку use_per_session_threads_ установлено в true.
ONNX: Базовый динамический блок установлен на 0.
ONNX: Инициализация сессии.
ONNX: Добавление по умолчанию поставщика выполнения для ЦП.
ONNX: Общее количество общих инициализаторов скаляров: 0
ONNX: Общее количество объединенных узлов изменения формы: 0
ONNX: Удаление NodeArg 'Gather_out0'. Он больше не используется ни одним узлом.
ONNX: Удаление NodeArg 'Gather_token_1_out0'. Он больше не используется ни одним узлом.
ONNX: Общее количество общих инициализаторов скаляров: 0
ONNX: Общее количество объединенных узлов изменения формы: 0
ONNX: Удаление инициализатора 'sequential/conv1d/Conv1D/ExpandDims_1:0'. Он больше не используется ни одним узлом.
ONNX: Использование DeviceBasedPartition по умолчанию.
ONNX: Сохранение инициализированных тензоров.
ONNX: Завершено сохранение инициализированных тензоров.
ONNX: Сессия успешно инициализирована.
Модель имеет 1 вход(ов):
0 имя входа - conv1d_input
тип ONNX_TYPE_TENSOR
тип данных ONNX_DATA_TYPE_FLOAT
форма [-1, 120, 1]
0 форма входа должна быть явно определена перед выводом модели
форма входных данных может быть уменьшена до [120], если неопределенное измерение установлено на 1
Модель имеет 1 выход(ов):
0 имя выхода - dense
тип ONNX_TYPE_TENSOR
тип данных ONNX_DATA_TYPE_FLOAT
форма [-1, 1]
0 форма выхода должна быть явно определена перед выводом модели
форма выходных данных может быть уменьшена до [1], если неопределенное измерение установлено на 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;// Модель машинного обучения в формате ONNX для прогнозирования движения цены#resource "Python/model.120.H1.onnx" as uchar ExtModel[]// Размер выборки#defineSAMPLE_SIZE120// Дескриптор для расширенной функциональностиlong ExtHandle=INVALID_HANDLE;// Прогнозируемый классint ExtPredictedClass=-1;// Время следующего бараdatetime ExtNextBar=0;// Время следующего дняdatetime ExtNextDay=0;// Минимальное значение ценыfloat ExtMin=0.0;// Максимальное значение ценыfloat ExtMax=0.0;// Объект торговлиCTrade ExtTrade;//--- прогнозирование движения цены#definePRICE_UP0 // Цена вверх#definePRICE_SAME1 // Цена не изменилась#definePRICE_DOWN2 // Цена вниз
Функция OnInit
//+------------------------------------------------------------------+//| Функция инициализации эксперта |//+------------------------------------------------------------------+intOnInit(){ // Проверка, что модель должна работать с EURUSD, H1if(_Symbol!="EURUSD"|| _Period!=PERIOD_H1) {Print("Модель должна работать с EURUSD, H1");return(INIT_FAILED); } // Создание модели из статического буфера ExtHandle=OnnxCreateFromBuffer(ExtModel,ONNX_DEFAULT);if(ExtHandle==INVALID_HANDLE) {Print("Ошибка OnnxCreateFromBuffer ",GetLastError());return(INIT_FAILED); } // Установка размеров входного тензора const long input_shape[] = {1,SAMPLE_SIZE,1}; // Первый индекс - размер пакета, второй - размер последовательности, третий - количество последовательностей (только Close)
if(!OnnxSetInputShape(ExtHandle,0,input_shape)) {Print("Ошибка OnnxSetInputShape ",GetLastError());return(INIT_FAILED); } // Установка размеров выходного тензора const long output_shape[] = {1,1}; // Первый индекс - размер пакета, должен соответствовать размеру пакета входного тензора, второй - количество предсказанных цен (мы предсказываем только Close)
if(!OnnxSetOutputShape(ExtHandle,0,output_shape)) {Print("Ошибка OnnxSetOutputShape ",GetLastError());return(INIT_FAILED); } // Инициализация успешно завершенаreturn(INIT_SUCCEEDED);}
Работаем только с EURUSD,H1. Просто потому, что используем данные текущего символа-периода.
Наша модель включена в эксперт в виде ресурса. Эксперт самодостаточен, и нет необходимости читать onnx-файл извне. Создаём модель сразу из ресурсного массива.
Обязательно явно определяем формы входных и выходных данных.
Функция OnTick
//+------------------------------------------------------------------+//| Функция обработки тика эксперта |//+------------------------------------------------------------------+voidOnTick(){ // Проверка нового дняif(TimeCurrent() >= ExtNextDay) {GetMinMax(); // Установка времени следующего дня ExtNextDay =TimeCurrent(); ExtNextDay -= ExtNextDay %PeriodSeconds(PERIOD_D1); ExtNextDay +=PeriodSeconds(PERIOD_D1); } // Проверка нового бараif(TimeCurrent() < ExtNextBar)return; // Установка времени следующего бара ExtNextBar =TimeCurrent(); ExtNextBar -= ExtNextBar %PeriodSeconds(); ExtNextBar +=PeriodSeconds(); // Проверка минимума и максимумаdouble close =iClose(_Symbol, _Period,0);if(ExtMin > close) ExtMin = close;if(ExtMax < close) ExtMax = close; // Предсказание следующей ценыPredictPrice(); // Проверка на возможность торговли в соответствии с прогнозомif(ExtPredictedClass >=0) {if(PositionSelect(_Symbol))CheckForClose();elseCheckForOpen(); }}
Определяем начало нового дня. Это нужно для того, чтобы обновить минимум и максимум 120-дневной последовательности для нормирования цен в 120-часовой последовательности. Мы обучали модель именно при этих условиях, поэтому должны соблюсти правила для подготовки входных данных.
//+------------------------------------------------------------------+
//| Получение минимальной и максимальной цены закрытия за последние 120 дней |
//+------------------------------------------------------------------+
void GetMinMax(void)
{
// Создание вектора для хранения цен закрытия
vectorf close;
// Копирование цен закрытия за последние 120 дней
close.CopyRates(_Symbol, PERIOD_D1, COPY_RATES_CLOSE, 0, SAMPLE_SIZE);
// Нахождение минимальной и максимальной цены
ExtMin = close.Min();
ExtMax = close.Max();
}
Потом при необходимости мы в течение дня модифицируем минимум и максимум.
Сначала проверяем, можем ли мы осуществить нормализацию. Нормализация осуществляется аналогично питоновскому MinMaxScaler.
// Масштабирование данных
// Импорт класса MinMaxScaler из библиотеки sklearn.preprocessing
from sklearn.preprocessing import MinMaxScaler;
// Создание объекта scaler класса MinMaxScaler с диапазоном значений от 0 до 1
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 не установлен.
После этого разнормализуем полученные данные в предсказанную цену и определяем класс — цена пойдёт вверх, вниз или не поменяется.
Торговая стратегия проста. В начале каждого часа проверяем прогноз цены на конец часа. Если цена по прогнозу пойдёт вверх, покупаем. И наоборот, если цена пойдёт вниз, продаём.
//+------------------------------------------------------------------+
//| Проверка условий для открытия позиции |
//+------------------------------------------------------------------+
void CheckForOpen(void)
{
// Сигнал для открытия позиции
ENUM_ORDER_TYPE signal = WRONG_VALUE;
// Проверка сигналов
if(ExtPredictedClass == PRICE_DOWN)
signal = ORDER_TYPE_SELL; // условие для продажи
else
{
if(ExtPredictedClass == PRICE_UP)
signal = ORDER_TYPE_BUY; // условие для покупки
}
// Открытие позиции, если сигнал существует и торговля разрешена
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);
}
}
//+------------------------------------------------------------------+
//| Проверка условий для закрытия позиции |
//+------------------------------------------------------------------+
void CheckForClose(void)
{
// Флаг для сигнала закрытия позиции
bool bsignal = false;
// Проверка выбранной позиции
long type = PositionGetInteger(POSITION_TYPE);
// Проверка сигналов для закрытия
if(type == POSITION_TYPE_BUY && ExtPredictedClass == PRICE_DOWN)
bsignal = true;
if(type == POSITION_TYPE_SELL && ExtPredictedClass == PRICE_UP)
bsignal = true;
// Закрытие позиции, если возможно
if(bsignal && TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
{
ExtTrade.PositionClose(_Symbol, 3);
// Открытие противоположной позиции
CheckForOpen();
}
}
Теперь необходимо проверить работоспособность эксперта. Это можно сделать в тестере. Но для того, чтобы протестировать эксперта с начала года, необходимо тренировать модель на данных до начала года. Поэтому мы немного видоизменили питоновский скрипт, убрав из него всё лишнее и заменив конечную дату.
Скрипт ONNX.eurusd.H1.120.Training.py находится в подпапке Python и запускается прямо в MetaEditor. Полученная ONNX модель будет записана рядом в этой же подпапке Python и при компиляции эксперта будет использована в качестве ресурса.
# Авторские права 2023, MetaQuotes Ltd.
# https://www.mql5.com
# Импорт библиотек Python
import MetaTrader5 as mt5
import tensorflow as tf
import numpy as np
import pandas as pd
import tf2onnx
# Входные параметры
inp_model_name = "model.eurusd.H1.120.onnx"
inp_history_size = 120
# Инициализация MetaTrader 5
if not mt5.initialize():
print("initialize() failed, error code =", mt5.last_error())
quit()
# Сохранение сгенерированной модели ONNX рядом с нашим скриптом для использования в качестве ресурса
from sys import argv
data_path = argv[0]
last_index = data_path.rfind("\\") + 1
data_path = data_path[0:last_index]
print("Путь для сохранения модели ONNX:", data_path)
# Установка начальной и конечной дат для исторических данных
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_date)
print("Дата окончания данных:", end_date)
# Получение котировок
eurusd_rates = mt5.copy_rates_range("EURUSD", mt5.TIMEFRAME_H1, start_date, end_date)
# Создание DataFrame
df = pd.DataFrame(eurusd_rates)
# Получение только цен закрытия
data = df.filter(['close']).values
# Масштабирование данных
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler(feature_range=(0,1))
scaled_data = scaler.fit_transform(data)
# Размер обучающей выборки - 80% данных
training_size = int(len(scaled_data) * 0.80)
print("Размер обучающей выборки:", training_size)
train_data_initial = scaled_data[0:training_size, :]
test_data_initial = scaled_data[training_size:, :1]
# Разделение последовательности на выборки
def split_sequence(sequence, n_steps):
X, y = list(), list()
for i in range(len(sequence)):
end_ix = i + n_steps
if end_ix > len(sequence) - 1:
break
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)
# Разделение на выборки
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)
# Изменение формы входных данных для 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)
# Определение модели
from keras.models import Sequential
from keras.layers import Dense, 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()])
# Обучение модели на 300 эпохах
history = model.fit(x_train, y_train, epochs=300, validation_data=(x_test, y_test), batch_size=32, verbose=2)
# Оценка обучающих данных
train_loss, train_rmse = model.evaluate(x_train, y_train, batch_size=32)
print(f"Потери на обучающих данных: {train_loss:.3f}")
print(f"RMSE на обучающих данных: {train_rmse:.3f}")
# Оценка тестовых данных
test_loss, test_rmse = model.evaluate(x_test, y_test, batch_size=32)
print(f"Потери на тестовых данных: {test_loss:.3f}")
print(f"RMSE на тестовых данных: {test_rmse:.3f}")
# Сохранение модели в формате ONNX
output_path = data_path + inp_model_name
onnx_model = tf2onnx.convert.from_keras(model, output_path=output_path)
print(f"Модель сохранена в {output_path}")
# Завершение работы с MetaTrader 5
mt5.shutdown()
Тестирование советника на основе ONNX-модели
Настало время проверить советника на исторических данных в тестере стратегий. Выставляем в настройках параметры, на которых тренировалась модель: символ EURUSD и таймфрейм H1.
Устанавливаем интервал за пределами периодом обучения модели — с начала года, с 01.01.2023 — и запускаем тестирование.
Так как согласно стратегии проверка сигналов производится один раз в начале каждого часа (в советнике стоит проверка на появление нового бара), то режим моделирования тиков не имеет значения — OnTick будет отрабатываться в тестере только один раз за бар.
//+------------------------------------------------------------------+
//| Функция обработки тика эксперта |
//+------------------------------------------------------------------+
void OnTick()
{
// Проверка нового дня
if(TimeCurrent() >= ExtNextDay)
{
GetMinMax();
// Установка времени следующего дня
ExtNextDay = TimeCurrent();
ExtNextDay -= ExtNextDay % PeriodSeconds(PERIOD_D1);
ExtNextDay += PeriodSeconds(PERIOD_D1);
}
// Проверка нового бара
if(TimeCurrent() < ExtNextBar)
return;
// Установка времени следующего бара
ExtNextBar = TimeCurrent();
ExtNextBar -= ExtNextBar % PeriodSeconds();
ExtNextBar += PeriodSeconds();
// Проверка минимума и максимума
float close = (float)iClose(_Symbol, _Period, 0);
if(ExtMin > close)
ExtMin = close;
if(ExtMax < close)
ExtMax = close;
// Предсказание следующей цены
PredictPrice();
// Проверка на возможность торговли в соответствии с прогнозом
if(ExtPredictedClass >= 0)
{
if(PositionSelect(_Symbol))
CheckForClose();
else
CheckForOpen();
}
}
При такой проверке тестирование за 3 месяца занимает несколько секунд. Мы сразу же получаем результаты.
Теперь изменим торговую стратегию, а именно — открытие позиции по сигналу, закрытие позиции по стоп-лоссу или тейк-профиту.
input double InpLots = 1.0; // Объем позиции для открытия
input bool InpUseStops = true; // Использовать стопы в торговле
input int InpTakeProfit = 500; // Уровень TakeProfit
input int InpStopLoss = 500; // Уровень StopLoss
//+------------------------------------------------------------------+
//| Проверка условий для открытия позиции |
//+------------------------------------------------------------------+
void CheckForOpen(void)
{
ENUM_ORDER_TYPE signal = WRONG_VALUE;
// Проверка сигналов
if(ExtPredictedClass == PRICE_DOWN)
signal = ORDER_TYPE_SELL; // условие для продажи
else
{
if(ExtPredictedClass == PRICE_UP)
signal = ORDER_TYPE_BUY; // условие для покупки
}
// Открытие позиции, если сигнал существует и торговля разрешена
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);
}
}
//+------------------------------------------------------------------+
//| Проверка условий для закрытия позиции |
//+------------------------------------------------------------------+
void CheckForClose(void)
{
// Позиция должна быть закрыта по стопам
if(InpUseStops)
return;
bool bsignal = false;
// Проверка выбранной позиции
long type = PositionGetInteger(POSITION_TYPE);
// Проверка сигналов для закрытия
if(type == POSITION_TYPE_BUY && ExtPredictedClass == PRICE_DOWN)
bsignal = true;
if(type == POSITION_TYPE_SELL && ExtPredictedClass == PRICE_UP)
bsignal = true;
// Закрытие позиции, если возможно
if(bsignal && TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
{
ExtTrade.PositionClose(_Symbol, 3);
// Открытие противоположной позиции
CheckForOpen();
}
}
Укажем параметр InpUseStops= true, это означает, что при открытии позиции выставляются уровни TP и SL.
Результаты тестирования с использованием уровней SL/TP с начала года показаны ниже.
Полный исходный код эксперта и обученная модель на начало 2023 года прикреплены к статье.
Заключение
Мы показали, что нет ничего сложного в использовании ONNX моделей в MQL5-программах. Наоборот, это очень просто. Гораздо сложнее получить адекватную ONNX-модель.