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

Оглавление

Введение

В статье "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):

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 для расчета моделей:

# Проверяем версию TensorFlow
print(tf.__version__)
# Проверяем поддержку графического процессора (GPU)
print(len(tf.config.list_physical_devices('GPU')) > 0)

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

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

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

# Импортируем библиотеки Python
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:

# Проверяем версию TensorFlow
print(tf.__version__)

Доступности GPU:

# Проверяем поддержку графического процессора (GPU)
print(len(tf.config.list_physical_devices('GPU')) > 0)

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

# Инициализируем MetaTrader5 для получения исторических данных
if not mt5.initialize():
    print("initialize() failed, error code =", mt5.last_error())
    quit()

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

# Отображаем информацию о терминале
terminal_info = mt5.terminal_info()
print(terminal_info)
# Отображаем путь к файлу
file_path = terminal_info.data_path + "\\MQL5\\Files\\"
print(file_path)

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

# Путь к данным для сохранения модели
data_path = argv[0]
last_index = data_path.rfind("\\") + 1
data_path = data_path[0:last_index]
print("Путь к данным для сохранения модели в формате ONNX:", data_path)

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

# Устанавливаем начальную и конечную даты для исторических данных
from datetime import timedelta, datetime
end_date = datetime.now()
start_date = end_date - timedelta(days=120)

# Выводим начальную и конечную даты
print("Дата начала данных =", start_date)
print("Дата окончания данных =", end_date)

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

# Получаем котировки EURUSD (H1) с начальной даты по конечную
eurusd_rates = mt5.copy_rates_range("EURUSD", mt5.TIMEFRAME_H1, start_date, end_date)

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

# Проверяем результат
print(eurusd_rates)
# Создаем объект DataFrame
df = pd.DataFrame(eurusd_rates)

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

# Показываем первые строки DataFrame
df.head()
# Показываем последние строки DataFrame
df.tail()
# Показываем форму DataFrame (количество строк и столбцов в наборе данных)
df.shape

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

# Подготавливаем только цены закрытия
data = df.filter(['close']).values

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

# Показываем цены закрытия
plt.figure(figsize=(18, 10))
plt.plot(data, 'b', label='Оригинал')
plt.xlabel("Часы")
plt.ylabel("Цена")
plt.title("EURUSD_H1")
plt.legend()

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

# Масштабируем данные с использованием MinMaxScaler
from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler(feature_range=(0, 1))
scaled_data = scaler.fit_transform(data)

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

# Размер обучающей выборки составляет 80% от данных
training_size = int(len(scaled_data) * 0.80)
print("Размер обучающей выборки:", training_size)
# Создаем обучающие данные и проверяем их размер
train_data_initial = scaled_data[0:training_size, :]
print(len(train_data_initial))
# Создаем тестовые данные и проверяем их размер
test_data_initial = scaled_data[training_size:, :1]
print(len(test_data_initial))

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

# Разбиваем одномерную последовательность на выборки
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 = 120
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)

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

# Показываем форму обучающих данных
x_train.shape
# Показываем форму тестовых данных
x_test.shape
# Импортируем библиотеки Keras для модели
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

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

# Определение модели
model = Sequential()

# Добавление слоя свертки
model.add(Conv1D(filters=256, kernel_size=2, activation='relu', padding='same', input_shape=(120, 1)))

# Добавление слоя пулинга
model.add(MaxPooling1D(pool_size=2))

# Добавление слоя LSTM
model.add(LSTM(100, return_sequences=True))

# Добавление слоя Dropout для регуляризации
model.add(Dropout(0.3))

# Добавление второго слоя LSTM
model.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_start
print("Время обучения =", fit_time_seconds, "секунд.")

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

# Показываем ключи истории обучения
history.history.keys()

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

# Показываем график потерь для обучения и валидации
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()
# Оцениваем обучающие данные
model.evaluate(x_train, y_train, batch_size=32)
# Оцениваем обучающие данные
model.evaluate(x_train, y_train, batch_size=32)

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

# Прогнозирование с использованием обучающих данных
train_predict = model.predict(x_train)
plot_y_train = y_train.reshape(-1, 1)

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

# Показываем график фактических и прогнозируемых (обучающих) данных
plt.figure(figsize=(18, 10))
plt.plot(scaler.inverse_transform(plot_y_train), color='b', label='Фактические')
plt.plot(scaler.inverse_transform(train_predict), color='red', label='Прогнозируемые')
plt.title("График прогнозирования с использованием обучающих данных")
plt.xlabel("Часы")
plt.ylabel("Цена")
plt.legend()
plt.show()

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

# Прогнозирование с использованием тестовых данных
test_predict = model.predict(x_test)
plot_y_test = y_test.reshape(-1, 1)

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

# Рассчитываем метрики
from sklearn import metrics
from sklearn.metrics import r2_score

# Преобразуем данные в реальные значения
value1 = scaler.inverse_transform(plot_y_test)
value2 = scaler.inverse_transform(test_predict)

# Рассчитываем метрики
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))
# Показываем график фактических и прогнозируемых (тестовых) данных
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-файл:

# Сохраняем модель в формате ONNX
output_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}")

# Завершаем работу с MetaTrader5
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 "Авторские права 2023, MetaQuotes Ltd."
#property link      "https://www.mql5.com"
#property version   "1.00"

#define UNDEFINED_REPLACE 1

//+------------------------------------------------------------------+
//| Функция запуска скрипта                                          |
//+------------------------------------------------------------------+
void OnStart()
  {
   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);
  }
//+------------------------------------------------------------------+
//| Функция вывода информации о типе данных                           |
//+------------------------------------------------------------------+
void PrintTypeInfo(const long num, const string layer, const OnnxTypeInfo& 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;
           }
        }
      //--- все измерения предполагаются 1
      if(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);
        }
     }
   else
      PrintFormat("для %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;

// Модель машинного обучения в формате ONNX для прогнозирования движения цены
#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;

//--- прогнозирование движения цены
#define PRICE_UP   0     // Цена вверх
#define PRICE_SAME 1     // Цена не изменилась
#define PRICE_DOWN 2     // Цена вниз

Функция OnInit

//+------------------------------------------------------------------+
//| Функция инициализации эксперта                                    |
//+------------------------------------------------------------------+
int OnInit()
{
    // Проверка, что модель должна работать с EURUSD, H1
    if(_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

//+------------------------------------------------------------------+
//| Функция обработки тика эксперта                                  |
//+------------------------------------------------------------------+
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();

    // Проверка минимума и максимума
    double close = iClose(_Symbol, _Period, 0);
    if(ExtMin > close)
        ExtMin = close;
    if(ExtMax < close)
        ExtMax = close;

    // Предсказание следующей цены
    PredictPrice();

    // Проверка на возможность торговли в соответствии с прогнозом
    if(ExtPredictedClass >= 0)
    {
        if(PositionSelect(_Symbol))
            CheckForClose();
        else
            CheckForOpen();
    }
}

Определяем начало нового дня. Это нужно для того, чтобы обновить минимум и максимум 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();
}

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

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

//+------------------------------------------------------------------+
//| Предсказание следующей цены                                      |
//+------------------------------------------------------------------+
void PredictPrice(void)
{
    // Вектор для получения результата
    static vectorf output_data(1);
    // Вектор для нормализации цен
    static vectorf x_norm(SAMPLE_SIZE);

    // Проверка возможности нормализации
    if(ExtMin >= ExtMax)
    {
        ExtPredictedClass = -1;
        return;
    }

    // Запрос последних баров
    if(!x_norm.CopyRates(_Symbol, _Period, COPY_RATES_CLOSE, 1, SAMPLE_SIZE))
    {
        ExtPredictedClass = -1;
        return;
    }

    // Получение последней цены закрытия
    float last_close = x_norm[SAMPLE_SIZE - 1];

    // Нормализация цен
    x_norm -= ExtMin;
    x_norm /= (ExtMax - ExtMin);

    // Запуск прогнозирования
    if(!OnnxRun(ExtHandle, ONNX_NO_CONVERSION, x_norm, output_data))
    {
        ExtPredictedClass = -1;
        return;
    }

    // Денормализация цены из выходного значения
    float predicted = output_data[0] * (ExtMax - ExtMin) + ExtMin;

    // Классификация предсказанного движения цены
    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.

// Масштабирование данных
// Импорт класса 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-модель.

Прикрепленные файлы

Last updated