English
preview
Поиск торгового преимущества (Часть 3): Использование HMM и GRU в торговом советнике

Поиск торгового преимущества (Часть 3): Использование HMM и GRU в торговом советнике

MetaTrader 5 — Индикаторы |
37 0
Stephen Njuki
Stephen Njuki

Введение

Мы продолжаем серию статей, в которой рассматриваем как можно больше алгоритмов и нейронных сетей, доступных трейдерам, с основной целью — сформировать торговое преимущество. В предыдущей статье мы рассмотрели сочетание методов Divergence-Mapping и Temporal-Fusion-Transformer, а в данной статье представим ещё одну такую комбинацию. Мы по-прежнему рассматриваем пару компонентов — алгоритм и нейронную сеть, — чтобы охватить как можно больше вариантов и, как мы надеемся, найти интересные конфигурации. Однако, строго говоря, каждый из этих компонентов можно использовать отдельно в качестве торговой системы, поэтому решение об их объединении требует тщательной проверки.

В наши дни при разработке торговой системы добавить нейронную сеть в торговый алгоритм относительно просто. Гораздо сложнее доказать, что её использование действительно улучшает качество торговых решений. Сложные модели могут легко давать стабильные результаты тестирования на исторических данных, при этом почти ничего не добавляя к уже используемым индикаторам и правилам. Поэтому в данной статье мы попытаемся рассмотреть эту проблему с помощью пользовательского сигнального класса MQL5, который благодаря Мастеру MQL5 можно легко использовать в качестве советника. В этом сигнальном классе мы объединяем скрытую марковскую модель (HMM) с сетью на основе управляемого рекуррентного блока (GRU). Мы используем HMM, чтобы определить, наблюдается ли на рынке боковое движение, тренд или необычно высокая волатильность, а сеть GRU, которая будет обучена для выявленного рыночного режима, будет оценивать направление недавнего движения цены. Совместное использование моделей не заменяет торговые правила, а помогает оценить обоснованность предполагаемого входа в рынок.

Это различие позволяет сформулировать проверяемый вопрос: улучшает ли подтверждение конкретного рыночного режима нейронной сетью отбор сделок для допуска, например сделок на продолжение тренда на EURUSD H4, или же оно лишь снижает частоту торговли, повторяя информацию, уже предоставляемую ADX, DI, ATR и ценой? Мы рассмотрим этот класс по мере разбора данного вопроса. Сначала определим торговое решение, затем выберем корректный базовый вариант для сравнения, выясним, почему эти два алгоритма могут дополнять друг друга, реализуем их математический аппарат на MQL5 и проанализируем результаты тестирования на исторических данных и форвардного тестирования. Наша цель здесь — не столько доказать, что данные алгоритмы подходят друг другу, сколько найти свидетельства в пользу такого вывода, а также указать, какие данные могли бы заставить нас отказаться от их объединения.


Сначала определим торговое решение

Допустить или отклонить сделку

Прежде чем оценивать сочетание HMM и GRU, нам, вероятно, стоит сначала определить, какое именно торговое решение оно призвано улучшить. В приведенных тестах входному параметру "SignalMode" присваивается значение 0, что выбирает сценарий продолжения тренда. Алгоритм на основе правил сначала предлагает вход в длинную или короткую позицию на основе ADX и направленного движения цены. Предложенный вход допускается только в том случае, если HMM подтверждает трендовый рыночный режим с апостериорной вероятностью не менее 0,6.

Если для параметра "UseGRU" установлено значение "true", необходимо выполнить ещё одно условие. Сеть GRU для выбранного рыночного режима также должна прогнозировать то же направление и выдавать выходное значение с модулем не менее 0,05. Несовпадение направлений, низкая уверенность прогноза или нулевая оценка алгоритма могут привести к отмене сигнала. Когда порог открытия ("ThresholdOpen") равен 1, главная роль сети — не тонкая настройка силы сигнала, а определение того, будет ли допустимый сигнал входа передан на проверку торговой инфраструктуре.

С чем мы сравниваем?

Чтобы получить корректный базовый вариант для сравнения при оценке результатов тестирования, можно установить входной параметр "UseGRU" в значение false. Все остальное — символ, таймфрейм, режим сигнала, настройки HMM, управление капиталом, условия выхода и даты тестирования — останется без изменений. Это позволило бы сохранить фильтр рыночного режима, отключив подтверждение нейросетью, и тем самым определить, дает ли GRU что-либо сверх HMM и правила входа по тренду. Еще один вариант для сравнения можно получить, исключив HMM и оставив только простой сигнал продолжения тренда на основе ADX/DI. Сопоставление результатов трех прогонов — с исходным правилом, с правилом и фильтром HMM и с правилом и сочетанием HMM-GRU — позволило бы увидеть, на каком этапе обработки сигналов возникает улучшение, если оно вообще есть.

Какие результаты поставили бы нашу гипотезу под сомнение?

Предполагаемое торговое преимущество следует поставить под сомнение, если включение GRU не улучшает математическое ожидание выигрыша, профит-фактор, просадку или фактор восстановления на сопоставимых сделках. Говоря более прямо, входы, которые GRU отклоняет, должны давать худшие результаты, чем те, которые она допускает. Если отклоненные сделки столь же прибыльны или даже прибыльнее допущенных, добавленная сеть не приносит особой пользы при отборе сделок. Сеть лишь сокращала бы число сделок системы, что не равнозначно повышению точности прогнозов.


Потенциальное торговое преимущество: память с учетом рыночного режима

Сначала состояние, затем последовательность

Предполагаемое торговое преимущество нашего подхода возникает за счет разбиения одной задачи прогнозирования на более мелкие задачи. Сначала мы используем HMM для нормализации ADX и относительной волатильности, рассчитанной на основе ATR, чтобы определить, находится ли рынок в состоянии бокового движения цены, тренда или высокой волатильности. Затем мы используем одну из трех небольших сетей GRU, чтобы проанализировать недавнюю последовательность направленных движений, связанную с определенным рыночным режимом.

В итоге мы получаем схему "сначала состояние, потом последовательность". Вместо того чтобы поручать одной сети интерпретировать все состояния рынка, мы даем каждой сети GRU возможность специализироваться на поведении, наблюдаемом в конкретном состоянии. Движение цены, сигнализирующее о продолжении установившегося тренда, может иметь иной смысл в спокойном боковом диапазоне. Наша модель стремится сохранить это различие.

Что может помочь?

Мы проводим тесты на паре EURUSD на четырехчасовом таймфрейме, выбрав сценарий продолжения тренда. Для трейдеров, желающих использовать этот подход, потенциальная польза заключается в сокращении числа входов, когда видимый импульс не подкрепляется подходящим рыночным режимом или устойчивостью направления недавних движений. Наша HMM определяет рыночную обстановку, алгоритм предлагает сделку, а сеть GRU критически оценивает это предложение, используя краткосрочную память. Наш пользовательский сигнальный класс предусматривает шесть режимов входа для различных сценариев, включая возврат к среднему в диапазоне и пробои на фоне всплесков волатильности. Эти режимы могут расширить область применения системы, однако данный тест не дает оснований судить об их эффективности. Тем не менее наш основной вопрос остается в силе, хотя теперь он сформулирован более конкретно. Улучшают ли подтверждения, учитывающие конкретный рыночный режим, отбор входов по сценарию продолжения тренда?

...Или это дублирование?

Существует убедительный контраргумент. И торговое правило HMM, и сети GRU получают информацию из ADX, DI, ATR и ценовых данных. Их согласие может оказаться просто повторением, замаскированным под подтверждение. Три специализированные сети, обучаемые на данных примерно 80 баров, также могут получать слишком мало примеров, особенно если один из рыночных режимов встречается нерегулярно. Благоприятные тренды EURUSD в конце 2024 года могут создать впечатление, что связка HMM и GRU лучше распознает ситуацию на рынке, чем это есть на самом деле. Наша модель предполагает возможность торгового преимущества, однако правдоподобность — лишь входной билет. В идеале данные по базовому варианту для сравнения и результаты форвардного тестирования должны подтверждать, что система отклоняет менее перспективные сделки, а не просто сокращает их количество.


Аргументы в пользу комбинации HMM и GRU

Карта, водитель и штурман

Один из возможных способов интерпретации нашей модели — рассматривать HMM как карту. Она помогает определить характер местности: боковое движение, тренд или повышенную волатильность. Однако она не выбирает направление. Выбранный алгоритм играет роль водителя и предлагает открыть длинную или короткую позицию. Сеть GRU, специализированная для конкретного рыночного режима, играет роль штурмана: на основе недавней последовательности рыночных движений она подсказывает, насколько предложенное направление может соответствовать ближайшему участку дороги. Именно это разделение является основным аргументом в пользу сочетания HMM и GRU. Каждый компонент отвечает на свой вопрос. Тем не менее четкое теоретическое разделение функций не гарантирует, что на практике компоненты будут усваивать независимую информацию.

Можно ли использовать глобальную модель?

Одна сеть GRU или LSTM могла бы одновременно учиться распознавать рыночные режимы и определять направление. Такой подход может учитывать взаимодействия, которые не учитываются при явном разделении на HMM и GRU. Однако для этого потребуется больше данных, а это затрудняет анализ обоснования каждого решения. Хотя простая сеть прямого распространения могла бы требовать меньше вычислительных затрат, у неё не было бы рекуррентного состояния. Использование фиксированных пороговых значений ADX и DI может быть более понятным решением, а логистическая регрессия для каждого рыночного режима может обеспечить более простой слой подтверждения.

Аргумент в пользу этого сочетания заключается не в том, что оно лучше во всех случаях, а в том, что HMM обеспечивает интерпретируемую реакцию на меняющиеся рыночные условия. Небольшие сети GRU действительно обеспечивают ограниченную память о последовательности данных и могут обучаться непосредственно в MQL5. С другой стороны, контраргумент звучит довольно резко. Если линейный классификатор отклоняет те же сделки, это означает, что рекуррентная модель — всего лишь "декоративная обвязка".

Чем оправдана сложность

Для объективного сравнения, пожалуй, следует начать с исходного правила продолжения тренда, затем добавить фильтр HMM и лишь после этого включить GRU. В идеале на каждом этапе следует регистрировать все потенциальные сделки, включая те, которые отклоняет та или иная модель. Сравнение этих трёх вариантов позволит отделить улучшения за счёт распознавания рыночного режима от улучшений за счёт подтверждения нейросетью. Такой подход позволяет выявить и распространённый статистический "трюк": более избирательная система может демонстрировать меньшие просадки просто потому, что в ней совершается меньше сделок. Следовательно, эта комбинация оправдывает свою сложность, только если исключаемые ею сделки стабильно хуже остальных, а не просто если она сокращает число сделок.

Математическая основа

Тестировать модель становится проще, если описать два её компонента (алгоритм и сеть) через величины, которые они фактически вычисляют. HMM позволяет оценить текущее состояние рынка; выбранная сеть GRU оценивает направление движения цены в рамках этого состояния. Ни один из этих результатов сам по себе не приводит к совершению сделки. Для перехода от наблюдений к вероятностям рыночных режимов на баре (t) HMM получает двумерное наблюдение:

f1

где:

  • xt — двумерный вектор наблюдений HMM для бара (t);
  • ADX_t — индекс среднего направленного движения (ADX), деленный на 100 для приведения его обычного диапазона (0)–(100) к диапазону примерно (0)–(1);
  • ATR_t — средний истинный диапазон (ATR);
  • C_t — цена закрытия, причём (|C_t|) гарантирует, что знаменатель будет положительным; и
  • Показатель ATR_t/|C_t| отражает волатильность по отношению к цене инструмента.

Функция (z.) обозначает стандартизацию по окну оценки параметров HMM:

f2

где:

  • (mu_y) и (sigma_y) — среднее значение и стандартное отклонение относительного ATR в окне оценки параметров.

При реализации это стандартизированное значение волатильности ограничивается интервалом ([-4, 4]), чтобы снизить влияние экстремальных значений. Первый компонент отражает нормализованную силу тренда. Второй компонент отражает волатильность относительно цены с последующей стандартизацией по окну оценки параметров. Для каждого скрытого состояния (i) наш код вычисляет гауссову плотность эмиссии с диагональной ковариационной матрицей, определяемую следующим образом:

f3

где:

  • b_i({x}*t) — плотность вероятности наблюдения (xt) в случае, когда HMM находится в скрытом состоянии (i);
  • (i) обозначает одно из трёх исходных состояний HMM; и
  • (d) — индекс одной из двух компонент вектора наблюдений: нормализованного ADX и стандартизированного относительного ATR.

Выражение (x{td}) представляет собой значение признака (d) на баре (t), а (mu{id}) и (sigma_{id}^{2}) — соответственно среднее значение и дисперсию этого признака в состоянии (i). Константа pi принимается равной примерно 3,14159, а (exp[]) обозначает экспоненциальную функцию. В прямой рекурсии правдоподобие объединяется с вероятностью перехода из предыдущего состояния следующим образом:

f4

где:

  • (alpha_t(i)) — вероятность прямого прохода для состояния (i) в момент времени (t);
  • (b_i(x*t)) — плотность эмиссии для наблюдения (x*t) в состоянии (i);
  • (alpha * {t-1}(j)) — вероятность прямого прохода для состояния (j) на предыдущем шаге; и
  • (a_ji) — вероятность перехода из состояния (j) в состояние (i). Сумма охватывает все возможные предшествующие состояния (j).

Обратный проход позволил бы получить дополняющую ее вероятность для левой части последовательности. Вычисления в обоих проходах масштабируются, чтобы избежать арифметического антипереполнения при вычислениях с плавающей запятой. Их произведение позволяет получить сглаженную апостериорную вероятность следующим образом:

f5

где:

  • (gamma_t(i)) — сглаженная апостериорная вероятность состояния (i) в момент времени (t);
  • (alpha_t(i)) и (beta_t(i)) — вероятности прямого и обратного прохода для этого состояния соответственно; и
  • Знаменатель нормирует их произведения по всем состояниям (j), так что сумма вероятностей состояний равна единице.

Затем мы выполняем переоценку параметров по алгоритму Баума — Велша, чтобы обновить начальные вероятности, матрицу переходов, средние значения и дисперсии эмиссионных распределений. После оценки параметров код присваивает безымянным состояниям экономически осмысленные метки: состояние с наибольшей волатильностью получает метку "высокая волатильность", а из двух оставшихся состояний метку "тренд" получает то, у которого выше ADX. Оставшееся состояние мы относим к режиму "боковое движение". Такие метки делают результаты более понятными, но не гарантируют стабильности рыночных режимов. Это означает, что состояние с меткой "тренд" получает ее лишь потому, что именно этот гауссов кластер лучше всего соответствует правилам присвоения меток, заданным в классе.

Определение направления с помощью сети GRU, ориентированной на рыночный режим

Текущий рыночный режим определяет выбор одной из сетей GRU с шестью скрытыми нейронами каждая. На вход каждой сети подается последовательность векторов из пяти нормализованных рыночных признаков. Вентиль обновления определяет, в какой степени следует сохранить предыдущее скрытое состояние. Это определение звучит следующим образом:

f6

где:

  • (z_t) — вектор вентиля обновления в момент времени (t);
  • (sigma()) — это сигмоидная функция, действующая по элементам, которая возвращает значения в диапазоне от (0) до (1);
  • (x_t) — текущий входной вектор; (h_{t-1}) — предыдущее скрытое состояние;
  • (W_z) и (U_z) — матрицы входных и рекуррентных весов вентиля обновления соответственно;
  • (b_z) — его вектор смещения. Более высокие значения (z_t) в большей степени сохраняют предыдущее скрытое состояние.

Вентиль сброса определяет, какая часть предыдущего состояния используется при формировании нового кандидатного скрытого состояния:

f7

где:

  • (r_t) — вектор вентиля сброса в момент времени (t);
  • (sigma()) — это сигмоидная функция, применяемая по элементам;
  • (x_t) — текущий входной сигнал;
  • (h_{t-1}) — предыдущее скрытое состояние; (W_r), (U_r) и (b_r) — входные веса, рекуррентные веса и смещение вентиля сброса соответственно. (h_t) — кандидатное скрытое состояние;
  • (W_h), (U_h) и (b_h) — соответствующие параметры;
  • Функция (tanh()) ограничивает значения кандидатного скрытого состояния диапазоном от (-1) до (1);
  • а (o.) обозначает поэлементное умножение.

Таким образом, (r_t) определяет, в какой степени информация о предыдущем состоянии будет учитываться при формировании кандидатного скрытого состояния. Скрытое состояние, в свою очередь, вычисляется путем смешивания следующим образом:

f9

где:

  • (h_t) — обновлённый вектор скрытого состояния в момент времени (t);
  • (z_t) — вектор вентиля обновления; (~h_t) — кандидатное скрытое состояние;
  • (h_t-1) — предыдущее скрытое состояние; (1-z_t) — дополняющий вентиль; и
  • (o.) обозначает поэлементное умножение.

Здесь (z_t) регулирует степень смешивания: значения, близкие к (1), сохраняют предыдущую информацию, тогда как значения, близкие к (0), отдают предпочтение кандидатному скрытому состоянию. Линейный выход с последующим применением функции tanh преобразует это состояние в ограниченный по величине прогноз направления. Целевые значения для обучения аналогичным образом ограничиваются с помощью функции tanh на основе доходности следующего бара, масштабированной относительно (0.75, ATR). Данный класс минимизирует квадратичную ошибку прогнозирования, используя скорости обучения, скорректированные с учётом уверенности прогноза, ограничение градиента и затухание весов. При обучении используется одношаговое усечённое обратное распространение ошибки вместо полного обратного распространения ошибки через всю последовательность. Это позволяет сохранить приемлемую вычислительную нагрузку при выполнении непосредственно в MQL5, хотя и ослабляет довод о том, что GRU выявляет долгосрочные зависимости. Скрытое состояние переносит информацию о прошлом вперёд, однако градиент охватывает лишь короткий отрезок прошлого.

Согласование, отклонение и объединение

Если обозначить через (A_t) оценку выбранного алгоритма, а через (G_t) — прогноз GRU, то вход в сделку будет отклонён при следующем условии:

f10

где:

  • (A_t) — оценка торгового сигнала алгоритма в момент времени (t),
  • (G_t) — прогноз направления от GRU,
  • (|G_t|) — это его абсолютная величина, а (sign()) обозначает направление.

Вход в сделку отклоняется, если алгоритм не выдаёт сигнала, уверенность прогноза GRU ниже (0,05) или прогнозируемые ими направления не совпадают. Если направления совпадают, код объединяет оценку алгоритма с той из двух оценок (алгоритма или нейросети), которая имеет больший модуль. GRU, подтверждающая сигнал, может сохранить или повысить оценку, но не может плавно снижать её. На практике её влияние сосредоточено на границе между сохранением сигнала и его обнулением. Это важно, поскольку в тестируемой конфигурации порог открытия позиции равен всего единице. При такой конфигурации модель не столько обеспечивает высокую точность сигнала, сколько решает, допустить его или отклонить.


Реализация на MQL5

Несмотря на то что этот пользовательский класс содержит более 1500 строк кода, последовательность его выполнения на самом деле очень проста. Используемую последовательность действий легко проследить. Сначала мы регистрируем компонент в Мастере MQL5, подготавливаем данные, оцениваем рыночный режим, вычисляем голос алгоритма, запрашиваем подтверждение GRU, а затем возвращаем итоговую оценку в инфраструктуру советников. Это позволяет выявлять потенциальные точки отказа.

Метаданные Мастера MQL5, константы, рыночные режимы

Заголовок нашего пользовательского сигнального класса начинается с импорта базового класса CExpertSignal вместе с классами индикаторов ADX и ATR. Реализация с самого начала остаётся полностью в рамках стандартной инфраструктуры советников MQL5, без процессов Python, файлов ONNX и какого-либо внешнего обучения. Далее следует стандартная спецификация для Мастера MQL5. Хотя она оформлена в виде комментариев, содержащиеся в ней объявления сообщают Мастеру MQL5 имя класса и указывают, какие параметры должны стать входными. Они позволяют сделать доступными в качестве входных параметров периоды индикаторов, выбранный режим формирования сигнала, настройки HMM и параметры управления GRU. Определения констант с префиксом HMMGRU_ задают структуру модели: три состояния HMM, две компоненты наблюдения, пять входов GRU и шесть скрытых нейронов. Тонкая настройка с помощью оптимизации позволяет изменить окно или скорость обучения, но не увеличить число режимов или скрытых нейронов без редактирования или перекомпиляции класса.

Два перечисления позволяют нам не путать режимы с торговыми стратегиями. Перечисление ENUM_HMMGRU_REGIME задаёт состояния бокового движения, тренда и высокой волатильности, а ENUM_HMMGRU_SIGNAL_MODE перечисляет семь алгоритмов входа. HMM выбирает один из трёх рыночных режимов, а SignalMode — один из семи способов принятия торгового решения. В приведённых ниже результатах тестирования параметр SignalMode зафиксирован на значении 0, соответствующем продолжению тренда.

Владение объектами в классе

В прилагаемом пользовательском классе этот вопрос решается следующим образом:

class CSignalHMMGRU : public CExpertSignal

Наш класс "CSignalHMMGRU" содержит HMM, вычисления по правилам, веса GRU, процедуры обучения и итоговый голос модели. Наследование от класса "CExpertSignal" предоставляет нам доступ к ценовым рядам, взвешиванию сигналов, порогам открытия позиций, а также к стандартным методам интерфейса "LongCondition()" и "ShortCondition()". Переменные-члены распределяются по четырём рабочим группам. Прежде всего нам нужно хранить объекты ADX и ATR:

class CSignalHMMGRU : public CExpertSignal
  {
protected:
   //--- indicator objects
   CiADX             m_adx;
   CiATR             m_atr;

   //--- indicator and signal settings
   int               m_adx_period;
   int               m_atr_period;
   int               m_signal_mode;
   bool              m_use_gru;
   int               m_hmm_window;
   int               m_hmm_iterations;
   double            m_min_regime_probability;
   double            m_trend_adx_level;
   double            m_volatility_z_level;
   int               m_price_window;
   double            m_range_atr_distance;

   //--- GRU settings
   int               m_gru_train_bars;
   int               m_gru_sequence;
   int               m_gru_epochs;
   double            m_gru_learning_rate;
   double            m_gru_min_confidence;
   double            m_gru_weight;

   //--- cached live state
   bool              m_cache_valid;
   datetime          m_cached_time;
   double            m_cached_open;
   double            m_cached_high;
   double            m_cached_low;
   double            m_cached_close;
   double            m_cached_score;
   double            m_cached_algorithm_score;
   double            m_cached_gru_score;
   int               m_current_regime;
   double            m_current_regime_probability;
   double            m_regime_probability[HMMGRU_STATES];

Также нам необходимо хранить последние данные о наблюдаемой цене, рассчитанные оценки модели, текущий рыночный режим и три апостериорные вероятности. Этот кэш важен, поскольку фреймворк обычно запрашивает сначала условие открытия длинной позиции, а затем — короткой. Без кэша одно рыночное наблюдение может вызвать два полных цикла оценки параметров HMM. Мы также отслеживаем состояние жизненного цикла GRU: успешно ли завершилось обучение, какой закрытый бар инициировал обновление и каково последнее значение функции потерь при обучении. Это среднее значение функции потерь на обучающей выборке, получаемое при обновлении весов. Это не показатель форвардной проверки. Затем мы группируем наборы параметров GRU следующим образом:

//--- one independent GRU parameter set per semantic regime
   double            m_wz[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_INPUTS];
   double            m_uz[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_HIDDEN];
   double            m_bz[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_wr[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_INPUTS];
   double            m_ur[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_HIDDEN];
   double            m_br[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_wh[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_INPUTS];
   double            m_uh[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_HIDDEN];
   double            m_bh[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_wo[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_bo[HMMGRU_STATES];

Для каждого рыночного режима предусмотрены отдельные параметры вентиля обновления, вентиля сброса, кандидатного скрытого состояния и выхода. Соответственно, модель не является ансамблем, в котором три сети GRU голосуют одновременно. Наша HMM выбирает один семантический рыночный режим, и используется только соответствующая ему сеть GRU. Мы заменяем или переопределяем некоторые стандартные конструкторы Мастера MQL5 следующим образом:

//--- Wizard-adjustable parameters
   void              ADXPeriod(int value)                    { m_adx_period = value;                 }
   void              ATRPeriod(int value)                    { m_atr_period = value;                 }
   void              SignalMode(int value)                   { m_signal_mode = value;                }
   void              UseGRU(bool value)                      { m_use_gru = value;                    }
   void              HMMWindow(int value)                    { m_hmm_window = value;                 }
   void              HMMIterations(int value)                { m_hmm_iterations = value;             }
   void              MinRegimeProbability(double value)      { m_min_regime_probability = value;     }
   void              TrendADXLevel(double value)             { m_trend_adx_level = value;            }
   void              VolatilityZLevel(double value)          { m_volatility_z_level = value;         }
   void              PriceWindow(int value)                  { m_price_window = value;               }
   void              RangeATRDistance(double value)          { m_range_atr_distance = value;         }
   void              GRUTrainBars(int value)                 { m_gru_train_bars = value;             }
   void              GRUSequence(int value)                  { m_gru_sequence = value;               }
   void              GRUEpochs(int value)                    { m_gru_epochs = value;                 }
   void              GRULearningRate(double value)           { m_gru_learning_rate = value;          }
   void              GRUMinConfidence(double value)          { m_gru_min_confidence = value;         }
   void              GRUWeight(double value)                 { m_gru_weight = value;                 }

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

//--- standard CExpertSignal interface
   virtual bool      ValidationSettings(void);
   virtual bool      InitIndicators(CIndicators *indicators);
   virtual int       LongCondition(void);
   virtual int       ShortCondition(void);

protected:
   //--- initialization
   bool              InitADX(CIndicators *indicators, const int buffer_size);
   bool              InitATR(CIndicators *indicators, const int buffer_size);
   bool              ResizePriceSeries(const int buffer_size);

   //--- indicator and price helpers
   double            ADX(const int index)               { return(m_adx.Main(index));  }
   double            PlusDI(const int index)            { return(m_adx.Plus(index));  }
   double            MinusDI(const int index)           { return(m_adx.Minus(index)); }
   double            ATR(const int index)               { return(m_atr.Main(index));  }
   bool              IsUsable(const double value);
   bool              DataReady(const int first, const int count);
   double            Clamp(const double value, const double lower, const double upper);
   double            Clamp01(const double value);
   double            SignOf(const double value);
   double            Stronger(const double first, const double second);
   double            DirectionalIndex(const int index);
   double            ATRZScore(const int index, const int lookback);
   double            MeanClose(const int index, const int lookback);
   double            HighestPriorHigh(const int index, const int lookback);
   double            LowestPriorLow(const int index, const int lookback);

   //--- HMM estimation
   double            GaussianEmission(const double x0, const double x1,
                                      const double mean0, const double mean1,
                                      const double variance0, const double variance1);
   bool              EstimateRegime(const int offset, int &regime, double &probability,
                                    double &posterior[]);
   double            RegimeGate(const int expected_regime);

   //--- seven separate algorithm modes
   double            ModeTrendContinuation(const int index);
   double            ModeDICrossover(const int index);
   double            ModeRangeReversion(const int index);
   double            ModeVolatilityBreakout(const int index);
   double            ModeCompressionBreakout(const int index);
   double            ModePriceAction(const int index);
   double            ModeAdaptive(const int index);
   double            SelectAlgorithmMode(const int index);

   //--- GRU functions
   void              InitializeGRUWeights(void);
   double            InitialWeight(const int regime, const int gate,
                                   const int row, const int column);
   double            Sigmoid(const double value);
   double            ClipGradient(const double value);
   bool              BuildGRUFeatures(const int index, double &inputs[]);
   bool              GRUForward(const int regime, const double &inputs[],
                                double &hidden[], double &output);
   double            GRUTrainStep(const int regime, const double &inputs[],
                                  double &hidden[], const double target,
                                  const double learning_rate);
   bool              TrainGRUWindow(const int anchor, const int bars,
                                    const int epochs, const double learning_rate);
   bool              PredictGRU(const int index, const int active_regime, double &score);
   double            FuseWithGRU(const double algorithm_score, const double gru_score);

   //--- complete signal calculation and caching
   bool              SameCachedObservation(const int index);
   bool              CalculateSignal(void);

Создание и проверка

//+------------------------------------------------------------------+
//| Constructor.                                                     |
//+------------------------------------------------------------------+
CSignalHMMGRU::CSignalHMMGRU(void) :
   m_adx_period(14),
   m_atr_period(14),
   m_signal_mode(HMMGRU_MODE_TREND_CONTINUATION),
   m_use_gru(true),
   m_hmm_window(80),
   m_hmm_iterations(6),
   m_min_regime_probability(0.50),
   m_trend_adx_level(25.0),
   m_volatility_z_level(0.75),
   m_price_window(20),
   m_range_atr_distance(0.75),
   m_gru_train_bars(160),
   m_gru_sequence(10),
   m_gru_epochs(3),
   m_gru_learning_rate(0.01),
   m_gru_min_confidence(0.15),
   m_gru_weight(0.35),
   m_cache_valid(false),
   m_cached_time(0),
   m_cached_open(EMPTY_VALUE),
   m_cached_high(EMPTY_VALUE),
   m_cached_low(EMPTY_VALUE),
   m_cached_close(EMPTY_VALUE),
   m_cached_score(0.0),
   m_cached_algorithm_score(0.0),
   m_cached_gru_score(0.0),
   m_current_regime(HMMGRU_REGIME_RANGE),
   m_current_regime_probability(0.0),
   m_gru_ready(false),
   m_last_trained_bar(0),
   m_gru_training_loss(0.0)
  {
   m_used_series = USE_SERIES_OPEN + USE_SERIES_HIGH + USE_SERIES_LOW +
                   USE_SERIES_CLOSE + USE_SERIES_TIME;
   for(int state = 0; state < HMMGRU_STATES; state++)
      m_regime_probability[state] = 0.0;
   InitializeGRUWeights();
  }

//+------------------------------------------------------------------+
//| Destructor.                                                      |
//+------------------------------------------------------------------+
CSignalHMMGRU::~CSignalHMMGRU(void)
  {
  }

//+------------------------------------------------------------------+
//| Validate all adjustable settings.                                |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::ValidationSettings(void)
  {
   if(!CExpertSignal::ValidationSettings())
      return(false);
   if(m_adx_period < 2 || m_atr_period < 2)
     {
      Print(__FUNCTION__ + ": ADXPeriod and ATRPeriod must be at least 2");
      return(false);
     }
   if(m_signal_mode < HMMGRU_MODE_TREND_CONTINUATION ||
      m_signal_mode > HMMGRU_MODE_ADAPTIVE)
     {
      Print(__FUNCTION__ + ": SignalMode must be between 0 and 6");
      return(false);
     }
   if(m_hmm_window < 30 || m_hmm_window > 250)
     {
      Print(__FUNCTION__ + ": HMMWindow must be between 30 and 250");
      return(false);
     }
   if(m_hmm_iterations < 1 || m_hmm_iterations > 20)
     {
      Print(__FUNCTION__ + ": HMMIterations must be between 1 and 20");
      return(false);
     }
   if(m_min_regime_probability < 0.34 || m_min_regime_probability > 0.95)
     {
      Print(__FUNCTION__ + ": MinRegimeProbability must be inputs [0.34,0.95]");
      return(false);
     }
   if(m_trend_adx_level < 5.0 || m_trend_adx_level > 60.0)
     {
      Print(__FUNCTION__ + ": TrendADXLevel must be inputs [5,60]");
      return(false);
     }
   if(m_volatility_z_level < 0.0 || m_volatility_z_level > 3.0)
     {
      Print(__FUNCTION__ + ": VolatilityZLevel must be inputs [0,3]");
      return(false);
     }
   if(m_price_window < 5 || m_price_window > 100)
     {
      Print(__FUNCTION__ + ": PriceWindow must be between 5 and 100");
      return(false);
     }
   if(m_range_atr_distance < 0.10 || m_range_atr_distance > 5.0)
     {
      Print(__FUNCTION__ + ": RangeATRDistance must be inputs [0.10,5.0]");
      return(false);
     }
   if(m_gru_train_bars < 40 || m_gru_train_bars > 800)
     {
      Print(__FUNCTION__ + ": GRUTrainBars must be between 40 and 800");
      return(false);
     }
   if(m_gru_sequence < 2 || m_gru_sequence > 32)
     {
      Print(__FUNCTION__ + ": GRUSequence must be between 2 and 32");
      return(false);
     }
   if(m_gru_epochs < 1 || m_gru_epochs > 20)
     {
      Print(__FUNCTION__ + ": GRUEpochs must be between 1 and 20");
      return(false);
     }
   if(m_gru_learning_rate <= 0.0 || m_gru_learning_rate > 0.10)
     {
      Print(__FUNCTION__ + ": GRULearningRate must be inputs (0,0.10]");
      return(false);
     }
   if(m_gru_min_confidence < 0.0 || m_gru_min_confidence > 1.0)
     {
      Print(__FUNCTION__ + ": GRUMinConfidence must be inputs [0,1]");
      return(false);
     }
   if(m_gru_weight < 0.0 || m_gru_weight > 1.0)
     {
      Print(__FUNCTION__ + ": GRUWeight must be inputs [0,1]");
      return(false);
     }
   return(true);
  }

Наш конструктор задаёт пригодные для работы значения по умолчанию ещё до того, как Мастер MQL5 применит выбранные пользователем входные параметры. Он выбирает режим Mode-0, включает использование GRU и по умолчанию устанавливает окно HMM шириной 80 баров. Кроме того, для обучения GRU мы задаем 160 баров и три эпохи. Этот конструктор также запрашивает доступ к встроенным временным рядам цен открытия, максимума, минимума и закрытия для использования советником; он очищает кэш и вызывает функцию инициализации весов GRU "InitializeGRUWeights()".

Важно не путать значения по умолчанию с протестированной конфигурацией. В тестах, отчеты по которым приведены ниже, использовались периоды ADX и ATR, равные 16, восемь итераций HMM, порог вероятности рыночного режима 0,60, порог ADX 17,5, всего 80 баров для обучения GRU в течение одной эпохи и скорость обучения 0,005. Приведенные ниже отчеты о тестировании основаны на этих настроенных параметрах, а не на значениях по умолчанию, заданных в коде.

Метод "ValidationSettings()" сначала вызывает родительскую процедуру проверки, а затем проверяет каждый пользовательский входной параметр. Необходимо убедиться, что размеры окон, периоды, пороговые значения, число эпох, веса и скорости обучения находятся в заданных пределах. Проверки предотвращают некорректные конфигурации и попытки выделить массивы недопустимого размера. Они не означают, что допустимое значение параметра является статистически обоснованным. Например, значение в 80 баров для обучения может пройти проверку входных параметров, но при этом одной из трех сетей, каждая из которых предназначена для своего рыночного режима, все равно может не хватить пригодных обучающих примеров. Инициализация весов происходит не случайным образом, а детерминированно. Это повышает воспроизводимость: при одинаковых входных параметрах и исторических данных обучение начинается с одних и тех же весов. Однако это также означает, что повторные тесты не выявляют чувствительности к альтернативным начальным состояниям, если не изменить саму процедуру инициализации.

Ценовые ряды, индикаторы и вспомогательные функции

//+------------------------------------------------------------------+
//| Create the price series and both indicators.                     |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::InitIndicators(CIndicators *indicators)
  {
   if(indicators == NULL)
      return(false);
   if(!CExpertSignal::InitIndicators(indicators))
      return(false);
   int buffer_size = m_hmm_window + m_price_window + 32;
   if(m_use_gru)
      buffer_size += m_gru_train_bars + m_gru_sequence;
   if(!ResizePriceSeries(buffer_size))
      return(false);
   if(!InitADX(indicators, buffer_size))
      return(false);
   if(!InitATR(indicators, buffer_size))
      return(false);
   m_cache_valid = false;
   m_gru_ready = false;
   m_last_trained_bar = 0;
   return(true);
  }

//+------------------------------------------------------------------+
//| Enlarge the shared standard-library price buffers.               |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::ResizePriceSeries(const int buffer_size)
  {
   if(m_open == NULL || m_high == NULL || m_low == NULL ||
      m_close == NULL || m_time == NULL)
     {
      Print(__FUNCTION__ + ": required price series is not available");
      return(false);
     }
   if(m_open.BufferSize() < buffer_size && !m_open.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the open-price buffer");
      return(false);
     }
   if(m_high.BufferSize() < buffer_size && !m_high.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the high-price buffer");
      return(false);
     }
   if(m_low.BufferSize() < buffer_size && !m_low.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the low-price buffer");
      return(false);
     }
   if(m_close.BufferSize() < buffer_size && !m_close.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the close-price buffer");
      return(false);
     }
   if(m_time.BufferSize() < buffer_size && !m_time.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the time buffer");
      return(false);
     }
   return(true);
  }

//+------------------------------------------------------------------+
//| Create ADX.                                                      |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::InitADX(CIndicators *indicators, const int buffer_size)
  {
   if(!indicators.Add(GetPointer(m_adx)))
     {
      Print(__FUNCTION__ + ": cannot add ADX to the indicator collection");
      return(false);
     }
   if(!m_adx.Create(m_symbol.Name(), m_period, m_adx_period))
     {
      Print(__FUNCTION__ + ": cannot create ADX");
      return(false);
     }
   if(!m_adx.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the ADX buffer");
      return(false);
     }
   return(true);
  }

//+------------------------------------------------------------------+
//| Create ATR.                                                      |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::InitATR(CIndicators *indicators, const int buffer_size)
  {
   if(!indicators.Add(GetPointer(m_atr)))
     {
      Print(__FUNCTION__ + ": cannot add ATR to the indicator collection");
      return(false);
     }
   if(!m_atr.Create(m_symbol.Name(), m_period, m_atr_period))
     {
      Print(__FUNCTION__ + ": cannot create ATR");
      return(false);
     }
   if(!m_atr.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the ATR buffer");
      return(false);
     }
   return(true);
  }

//+------------------------------------------------------------------+
//| Numeric validity check.                                          |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::IsUsable(const double value)
  {
   return(value != EMPTY_VALUE && MathIsValidNumber(value));
  }

//+------------------------------------------------------------------+
//| Verify that a complete observation window is available.          |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::DataReady(const int first, const int count)
  {
   if(first < 0 || count < 1)
      return(false);
   int last = first + count - 1;
   if(last >= m_open.BufferSize() || last >= m_high.BufferSize() ||
      last >= m_low.BufferSize() || last >= m_close.BufferSize() ||
      last >= m_adx.BufferSize() || last >= m_atr.BufferSize())
      return(false);
   if(!IsUsable(Open(first)) || !IsUsable(High(first)) ||
      !IsUsable(Low(first)) || !IsUsable(Close(first)) ||
      !IsUsable(ADX(first)) || !IsUsable(PlusDI(first)) ||
      !IsUsable(MinusDI(first)) || !IsUsable(ATR(first)))
      return(false);
   if(!IsUsable(Close(last)) || !IsUsable(ADX(last)) ||
      !IsUsable(PlusDI(last)) || !IsUsable(MinusDI(last)) ||
      !IsUsable(ATR(last)))
      return(false);
   return(true);
  }

//+------------------------------------------------------------------+
//| Clamp a number.                                                  |
//+------------------------------------------------------------------+
double CSignalHMMGRU::Clamp(const double value, const double lower,
                            const double upper)
  {
   if(value < lower)
      return(lower);
   if(value > upper)
      return(upper);
   return(value);
  }

//+------------------------------------------------------------------+
//| Clamp to [0,1].                                                  |
//+------------------------------------------------------------------+
double CSignalHMMGRU::Clamp01(const double value)
  {
   return(Clamp(value, 0.0, 1.0));
  }

//+------------------------------------------------------------------+
//| Return the sign of a value.                                      |
//+------------------------------------------------------------------+
double CSignalHMMGRU::SignOf(const double value)
  {
   if(value > 0.0)
      return(1.0);
   if(value < 0.0)
      return(-1.0);
   return(0.0);
  }

//+------------------------------------------------------------------+
//| Return the score with the greater absolute magnitude.            |
//+------------------------------------------------------------------+
double CSignalHMMGRU::Stronger(const double first, const double second)
  {
   if(MathAbs(first) >= MathAbs(second))
      return(first);
   return(second);
  }

//+------------------------------------------------------------------+
//| Normalized +DI/-DI imbalance.                                    |
//+------------------------------------------------------------------+
double CSignalHMMGRU::DirectionalIndex(const int index)
  {
   double plus = PlusDI(index);
   double minus = MinusDI(index);
   if(!IsUsable(plus) || !IsUsable(minus))
      return(0.0);
   return(Clamp((plus - minus) / (plus + minus + HMMGRU_EPS), -1.0, 1.0));
  }

//+------------------------------------------------------------------+
//| ATR percentage z-score using only the current and older bars.    |
//+------------------------------------------------------------------+
double CSignalHMMGRU::ATRZScore(const int index, const int lookback)
  {
   if(lookback < 5 || !DataReady(index, lookback))
      return(EMPTY_VALUE);
   double mean = 0.0;
   int valid = 0;
   for(int i = 0; i < lookback; i++)
     {
      double close = Close(index + i);
      double atr  = ATR(index + i);
      if(!IsUsable(close) || !IsUsable(atr) || MathAbs(close) <= HMMGRU_EPS)
         continue;
      mean += atr / MathAbs(close);
      valid++;
     }
   if(valid < 5)
      return(EMPTY_VALUE);
   mean /= valid;
   double variance = 0.0;
   for(int i = 0; i < lookback; i++)
     {
      double close = Close(index + i);
      double atr  = ATR(index + i);
      if(!IsUsable(close) || !IsUsable(atr) || MathAbs(close) <= HMMGRU_EPS)
         continue;
      double value = atr / MathAbs(close);
      variance += (value - mean) * (value - mean);
     }
   variance /= valid;
   double deviation = MathSqrt(MathMax(variance, HMMGRU_EPS));
   double current = ATR(index) / MathAbs(Close(index));
   return(Clamp((current - mean) / deviation, -4.0, 4.0));
  }

//+------------------------------------------------------------------+
//| Simple close-price mean.                                         |
//+------------------------------------------------------------------+
double CSignalHMMGRU::MeanClose(const int index, const int lookback)
  {
   if(lookback < 1 || !DataReady(index, lookback))
      return(EMPTY_VALUE);
   double total = 0.0;
   for(int i = 0; i < lookback; i++)
     {
      double value = Close(index + i);
      if(!IsUsable(value))
         return(EMPTY_VALUE);
      total += value;
     }
   return(total / lookback);
  }

//+------------------------------------------------------------------+
//| Highest high before the analyzed bar.                            |
//+------------------------------------------------------------------+
double CSignalHMMGRU::HighestPriorHigh(const int index, const int lookback)
  {
   if(lookback < 1 || !DataReady(index + 1, lookback))
      return(EMPTY_VALUE);
   double result = High(index + 1);
   for(int i = 2; i <= lookback; i++)
      if(High(index + i) > result)
         result = High(index + i);
   return(result);
  }

//+------------------------------------------------------------------+
//| Lowest low before the analyzed bar.                              |
//+------------------------------------------------------------------+
double CSignalHMMGRU::LowestPriorLow(const int index, const int lookback)
  {
   if(lookback < 1 || !DataReady(index + 1, lookback))
      return(EMPTY_VALUE);
   double result = Low(index + 1);
   for(int i = 2; i <= lookback; i++)
      if(Low(index + i) < result)
         result = Low(index + i);
   return(result);
  }

При инициализации индикаторов сначала вызывается инициализация унаследованных ресурсов средствами "CExpertSignal", а затем определяется необходимый размер буферов. Версия с GRU запрашивает больший объем исторических данных, поскольку ей необходимо формировать целевые значения для обучения, многократно обращаясь к более ранним данным для окон HMM и ценовых окон. Функция "ResizePriceSeries()" увеличивает размер каждого общего ценового буфера, а функции "InitADX()" и "InitATR()" добавляют индикаторы в коллекцию фреймворка. Эти функции инициализации также приводят свои буферы к сопоставимому размеру. В случае сбоя при предварительном выделении памяти или создании индикаторов инициализация будет прервана, и торговля с частично настроенной моделью не будет разрешена.

Функция "DataReady()" проверяет границы массивов и корректность числовых значений основных ценовых данных и показаний индикаторов. Небольшие вспомогательные функции выполняют стандартные численные операции: ограничение значений заданными границами ("Clamp()"); определение направления по знаку оценки ("SignOf()"); выбор голоса с большим модулем ("Stronger()"). Функция "DirectionalIndex()" позволяет преобразовать показатели "+DI" и "-DI" в ограниченный показатель направленного дисбаланса. Это определяется уравнением:

f11

Функция "ATRZScore()" сначала делит ATR на абсолютное значение цены, а затем стандартизирует полученное процентное значение по окну, включающему текущий и предыдущие ценовые бары. Полученные значения ограничиваются диапазоном [-4, 4]. Функция "MeanClose()" используется для возврата к среднему в диапазоне, а в реализации функций "HighestPriorHigh()" и "LowestPriorLow()" начальный индекс устанавливается равным "+1", чтобы анализируемая свеча не учитывалась при определении границы её собственного пробоя. Этот подход к индексированию имеет решающее значение. Во временных рядах MQL5 большее значение индекса часто соответствует более старому бару. Поэтому циклы, проходящие от "index" к "index + lookback", движутся вглубь уже известной ценовой истории. Удобная функция "StartIndex()" унаследована от "CExpertBase" и возвращает значение 1, когда вычисления в советнике ограничиваются только завершенными барами, и 0, когда включена обработка каждого тика. В последнем случае наш сигнал будет реагировать на ещё формирующуюся свечу, и, хотя это не является ошибкой заглядывания в будущее, прогнозы могут стать менее стабильными.

Оценка параметров HMM и присвоение названий её состояниям

//+------------------------------------------------------------------+
//| Diagonal two-dimensional Gaussian density.                       |
//+------------------------------------------------------------------+
double CSignalHMMGRU::GaussianEmission(const double x0, const double x1,
                                       const double mean0, const double mean1,
                                       const double variance0,
                                       const double variance1)
  {
   double var0 = MathMax(variance0, 0.0025);
   double var1 = MathMax(variance1, 0.0500);
   double d0 = x0 - mean0;
   double d1 = x1 - mean1;
   double exponent = -0.5 * (d0 * d0 / var0 + d1 * d1 / var1);
   exponent = MathMax(exponent, -60.0);
   double denominator = 2.0 * HMMGRU_PI * MathSqrt(var0 * var1);
   return(MathMax(MathExp(exponent) / denominator, 1.0e-300));
  }

//+------------------------------------------------------------------+
//| Fit a three-state HMM and return the current semantic regime.    |
//|                                                                  |
//| Observations run from oldest to newest. Feature 0 is ADX/100.    |
//| Feature 1 is the rolling z-score of ATR as a percentage of price.|
//| Baum-Welch uses scaled forward/backward probabilities. After     |
//| fitting, raw states are relabeled by their emission means so the |
//| labels remain range, trend, and volatile despite label switching.|
//+------------------------------------------------------------------+
bool CSignalHMMGRU::EstimateRegime(const int offset, int &regime,
                                   double &probability, double &posterior[])
  {
   if(ArraySize(posterior) < HMMGRU_STATES)
      return(false);
   const int total = m_hmm_window;
   if(!DataReady(offset, total))
      return(false);
   double obs_adx[];
   double obs_vol_raw[];
   double obs_vol[];
   ArrayResize(obs_adx, total);
   ArrayResize(obs_vol_raw, total);
   ArrayResize(obs_vol, total);
   double vol_mean = 0.0;
   for(int t = 0; t < total; t++)
     {
      int index = offset + (total - 1 - t);
      double close = Close(index);
      double adx  = ADX(index);
      double atr  = ATR(index);
      if(!IsUsable(close) || !IsUsable(adx) || !IsUsable(atr) ||
         MathAbs(close) <= HMMGRU_EPS)
         return(false);
      obs_adx[t] = Clamp(adx / 100.0, 0.0, 1.0);
      obs_vol_raw[t] = atr / MathAbs(close);
      vol_mean += obs_vol_raw[t];
     }
   vol_mean /= total;
   double vol_variance = 0.0;
   for(int t = 0; t < total; t++)
      vol_variance += (obs_vol_raw[t] - vol_mean) * (obs_vol_raw[t] - vol_mean);
   vol_variance /= total;
   double vol_deviation = MathSqrt(MathMax(vol_variance, HMMGRU_EPS));
   for(int t = 0; t < total; t++)
      obs_vol[t] = Clamp((obs_vol_raw[t] - vol_mean) / vol_deviation, -4.0, 4.0);
//--- persistent-transition initialization
   double prior[HMMGRU_STATES];
   double transition[HMMGRU_STATES][HMMGRU_STATES];
   double mean[HMMGRU_STATES][HMMGRU_OBS];
   double variance[HMMGRU_STATES][HMMGRU_OBS];
   prior[0] = 0.40;
   prior[1] = 0.35;
   prior[2] = 0.25;
   for(int i = 0; i < HMMGRU_STATES; i++)
      for(int j = 0; j < HMMGRU_STATES; j++)
         transition[i][j] = (i == j ? 0.90 : 0.05);
   mean[0][0] = 0.18;
   mean[0][1] = -0.35;
   mean[1][0] = 0.42;
   mean[1][1] = 0.00;
   mean[2][0] = 0.30;
   mean[2][1] = 1.00;
   variance[0][0] = 0.018;
   variance[0][1] = 0.40;
   variance[1][0] = 0.025;
   variance[1][1] = 0.55;
   variance[2][0] = 0.040;
   variance[2][1] = 0.75;
   double emission[][HMMGRU_STATES];
   double alpha[][HMMGRU_STATES];
   double beta[][HMMGRU_STATES];
   double gamma[][HMMGRU_STATES];
   double scale[];
   ArrayResize(emission, total);
   ArrayResize(alpha, total);
   ArrayResize(beta, total);
   ArrayResize(gamma, total);
   ArrayResize(scale, total);
//--- an extra final pass evaluates posteriors after the last update
   for(int iteration = 0; iteration <= m_hmm_iterations; iteration++)
     {
      for(int t = 0; t < total; t++)
         for(int state = 0; state < HMMGRU_STATES; state++)
            emission[t][state] = GaussianEmission(obs_adx[t], obs_vol[t],
                                                  mean[state][0], mean[state][1],
                                                  variance[state][0],
                                                  variance[state][1]);
      //--- scaled forward pass
      scale[0] = 0.0;
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         alpha[0][state] = prior[state] * emission[0][state];
         scale[0] += alpha[0][state];
        }
      if(scale[0] <= HMMGRU_EPS)
         return(false);
      for(int state = 0; state < HMMGRU_STATES; state++)
         alpha[0][state] /= scale[0];
      for(int t = 1; t < total; t++)
        {
         scale[t] = 0.0;
         for(int state = 0; state < HMMGRU_STATES; state++)
           {
            double incoming = 0.0;
            for(int previous = 0; previous < HMMGRU_STATES; previous++)
               incoming += alpha[t - 1][previous] * transition[previous][state];
            alpha[t][state] = incoming * emission[t][state];
            scale[t] += alpha[t][state];
           }
         if(scale[t] <= HMMGRU_EPS)
            return(false);
         for(int state = 0; state < HMMGRU_STATES; state++)
            alpha[t][state] /= scale[t];
        }
      //--- scaled backward pass
      for(int state = 0; state < HMMGRU_STATES; state++)
         beta[total - 1][state] = 1.0;
      for(int t = total - 2; t >= 0; t--)
        {
         for(int state = 0; state < HMMGRU_STATES; state++)
           {
            double outgoing = 0.0;
            for(int next = 0; next < HMMGRU_STATES; next++)
               outgoing += transition[state][next] * emission[t + 1][next] *
                           beta[t + 1][next];
            beta[t][state] = outgoing / MathMax(scale[t + 1], HMMGRU_EPS);
           }
        }
      //--- smoothed state probabilities
      for(int t = 0; t < total; t++)
        {
         double denominator = 0.0;
         for(int state = 0; state < HMMGRU_STATES; state++)
           {
            gamma[t][state] = alpha[t][state] * beta[t][state];
            denominator += gamma[t][state];
           }
         if(denominator <= HMMGRU_EPS)
            return(false);
         for(int state = 0; state < HMMGRU_STATES; state++)
            gamma[t][state] /= denominator;
        }
      if(iteration == m_hmm_iterations)
         break;
      //--- transition sufficient statistics
      double xi_sum[HMMGRU_STATES][HMMGRU_STATES];
      double gamma_transition_sum[HMMGRU_STATES];
      for(int i = 0; i < HMMGRU_STATES; i++)
        {
         gamma_transition_sum[i] = 0.0;
         for(int j = 0; j < HMMGRU_STATES; j++)
            xi_sum[i][j] = 0.0;
        }
      for(int t = 0; t < total - 1; t++)
        {
         double denominator = 0.0;
         for(int i = 0; i < HMMGRU_STATES; i++)
            for(int j = 0; j < HMMGRU_STATES; j++)
               denominator += alpha[t][i] * transition[i][j] *
                              emission[t + 1][j] * beta[t + 1][j];
         if(denominator <= HMMGRU_EPS)
            continue;
         for(int i = 0; i < HMMGRU_STATES; i++)
           {
            gamma_transition_sum[i] += gamma[t][i];
            for(int j = 0; j < HMMGRU_STATES; j++)
               xi_sum[i][j] += alpha[t][i] * transition[i][j] *
                               emission[t + 1][j] * beta[t + 1][j] / denominator;
           }
        }
      //--- initial probabilities and transition matrix
      double prior_total = 0.0;
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         prior[state] = MathMax(gamma[0][state], 0.001);
         prior_total += prior[state];
        }
      for(int state = 0; state < HMMGRU_STATES; state++)
         prior[state] /= prior_total;
      for(int i = 0; i < HMMGRU_STATES; i++)
        {
         double row_total = 0.0;
         for(int j = 0; j < HMMGRU_STATES; j++)
           {
            double estimate = transition[i][j];
            if(gamma_transition_sum[i] > HMMGRU_EPS)
               estimate = xi_sum[i][j] / gamma_transition_sum[i];
            //--- a small persistence prior prevents single-bar state flicker
            double persistent = (i == j ? 0.90 : 0.05);
            transition[i][j] = MathMax(0.98 * estimate + 0.02 * persistent, 0.001);
            row_total += transition[i][j];
           }
         for(int j = 0; j < HMMGRU_STATES; j++)
            transition[i][j] /= row_total;
        }
      //--- Gaussian means
      double new_mean[HMMGRU_STATES][HMMGRU_OBS];
      double state_weight[HMMGRU_STATES];
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         state_weight[state] = 0.0;
         new_mean[state][0] = 0.0;
         new_mean[state][1] = 0.0;
         for(int t = 0; t < total; t++)
           {
            state_weight[state] += gamma[t][state];
            new_mean[state][0] += gamma[t][state] * obs_adx[t];
            new_mean[state][1] += gamma[t][state] * obs_vol[t];
           }
         if(state_weight[state] > HMMGRU_EPS)
           {
            new_mean[state][0] /= state_weight[state];
            new_mean[state][1] /= state_weight[state];
           }
         else
           {
            new_mean[state][0] = mean[state][0];
            new_mean[state][1] = mean[state][1];
           }
        }
      //--- diagonal Gaussian variances
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         double new_variance0 = 0.0;
         double new_variance1 = 0.0;
         for(int t = 0; t < total; t++)
           {
            double d0 = obs_adx[t] - new_mean[state][0];
            double d1 = obs_vol[t] - new_mean[state][1];
            new_variance0 += gamma[t][state] * d0 * d0;
            new_variance1 += gamma[t][state] * d1 * d1;
           }
         if(state_weight[state] > HMMGRU_EPS)
           {
            new_variance0 /= state_weight[state];
            new_variance1 /= state_weight[state];
           }
         variance[state][0] = MathMax(new_variance0, 0.0025);
         variance[state][1] = MathMax(new_variance1, 0.0500);
         mean[state][0] = new_mean[state][0];
         mean[state][1] = new_mean[state][1];
        }
     }
//--- semantic relabeling: highest volatility first, then highest ADX
   int volatile_raw = 0;
   for(int state = 1; state < HMMGRU_STATES; state++)
      if(mean[state][1] > mean[volatile_raw][1])
         volatile_raw = state;
   int trend_raw = -1;
   for(int state = 0; state < HMMGRU_STATES; state++)
     {
      if(state == volatile_raw)
         continue;
      if(trend_raw < 0 || mean[state][0] > mean[trend_raw][0])
         trend_raw = state;
     }
   int range_raw = 0;
   for(int state = 0; state < HMMGRU_STATES; state++)
      if(state != volatile_raw && state != trend_raw)
        {
         range_raw = state;
         break;
        }
   posterior[HMMGRU_REGIME_RANGE]   = gamma[total - 1][range_raw];
   posterior[HMMGRU_REGIME_TREND]   = gamma[total - 1][trend_raw];
   posterior[HMMGRU_REGIME_VOLATILE] = gamma[total - 1][volatile_raw];
   double posterior_total = posterior[0] + posterior[1] + posterior[2];
   if(posterior_total <= HMMGRU_EPS)
      return(false);
   for(int state = 0; state < HMMGRU_STATES; state++)
      posterior[state] /= posterior_total;
   regime = 0;
   probability = posterior[0];
   for(int state = 1; state < HMMGRU_STATES; state++)
      if(posterior[state] > probability)
        {
         regime = state;
         probability = posterior[state];
        }
   return(true);
  }

Функция "GaussianEmission()" вычисляет плотность вероятности двух наблюдений для состояния с гауссовским распределением и диагональной ковариационной матрицей. Мы задаём нижние границы дисперсии, чтобы распределение состояния не сжималось вокруг очень небольшой выборки наблюдений. Одновременно мы ограничиваем показатель экспоненты снизу значением −60, чтобы уменьшить вероятность переполнения в меньшую сторону. Допущение о диагональности позволяет сократить объём вычислений, но исключает непосредственный учёт ковариации ADX и волатильности внутри состояния. Функция "EstimateRegime()" начинает работу с параметра "offset", задающего бар, для которого нужно определить рыночный режим. Приведенное ниже выражение:

int index = offset + (total - 1 - t);

позволяет развернуть порядок индексации временных рядов MQL5, чтобы наблюдения HMM располагались от самых старых к самым новым. ADX делится на 100, а ATR — на модуль цены закрытия. Затем относительная волатильность стандартизируется по окну оценки параметров. Каждое наблюдение при этом использует данные, заканчивающиеся на соответствующем ему смещении. Начальные вероятности состояний задаются с небольшим перевесом в пользу бокового движения, а матрица переходов инициализируется значениями 0,9 на диагонали и 0,05 в остальных ячейках. Такие начальные значения отражают обоснованное предположение о том, что рыночные режимы могут сохраняться. Начальные средние значения распределений эмиссии аналогичным образом соответствуют состоянию с низким ADX и низкой волатильностью, трендовому состоянию с более высоким ADX и состоянию с высокой волатильностью.

Параметры локальны для функции "EstimateRegime()": при каждом вызове они инициализируются и оцениваются заново, а не переносятся из предыдущего вызова в составе сохраняемой HMM. Такой подход обеспечивает воспроизводимость определения рыночного режима на исторических данных и предотвращает утечку более поздних оценок параметров в прошлое. Однако он требует значительных вычислительных ресурсов. Цикл переоценки параметров по алгоритму Баума — Велша начинается с вычисления вероятностей эмиссии. При прямом проходе вероятность достижения каждого состояния объединяется с текущей вероятностью эмиссии, а на каждом временном шаге выполняется нормировка с использованием "scale[t]". При обратном проходе используются те же коэффициенты масштабирования, но в обратном порядке. Перемножив результаты двух проходов и выполнив нормировку, мы получаем "gamma" — апостериорные вероятности состояний.

На всех итерациях цикла, кроме последнего прохода вывода, мы рассчитываем ожидаемые переходы между состояниями и обновляем начальные вероятности и матрицу переходов. Кроме того, необходимо заново оценить средние значения и дисперсии гауссовских распределений. Мы используем двухпроцентное притяжение к исходным ценовым уровням, чтобы препятствовать переключению режима из-за одного бара. После последнего обновления параметров в цикле выполняется заключительный прямой и обратный проход, чтобы возвращаемое апостериорное распределение соответствовало последним оценкам параметров. За исходными метками состояний HMM не закреплен постоянный смысл. Например, при одной оценке параметров состояние 0 может соответствовать трендовому кластеру, а при другой — кластеру высокой волатильности. Поэтому после каждой оценки параметров необходимо заново присваивать состояниям метки. Исходному состоянию с наибольшей волатильностью присваивается метка "VOLATILE"; из оставшихся состояний состояние с наибольшим средним значением получает метку "TREND", а последнее — "RANGE". Функция "RegimeGate()" преобразует классификацию HMM в торговый фильтр. Этот фильтр отклоняет торговые сценарии, если ожидаемый рыночный режим отсутствует или его вероятность ниже минимального порога. Для допущенных голосов коэффициент допуска составляет 0,75–1,0.

В завершение описания кода отметим, что наш пользовательский сигнальный класс включает семь режимов: продолжение тренда, пересечение линий DI, возврат к среднему в диапазоне, пробой в условиях высокой волатильности, расширение после сжатия, свечные паттерны, зависящие от рыночного режима, и адаптивный выбор наиболее сильного из заранее определённых кандидатов. Оценка варьируется от −100 до +100: знак указывает направление, а модуль — степень уверенности.

Три сети GRU, каждая из которых предназначена для своего рыночного режима и содержит шесть скрытых нейронов, используют нормализованный ADX, дисбаланс DI, z-оценку ATR, изменение цены за один бар и размер тела свечи. Входные данные ограничены интервалом [-1,0; +1,0], и единственное, что сохраняется между прогнозами, — это обученные веса. При обучении используются завершённые бары и целевые значения для следующего бара. При обучении также используются одношаговое усеченное обратное распространение ошибки, ограничение градиента и затухание весов. HMM передаёт каждый обучающий пример одной из сетей GRU, однако минимальный порог в 20 примеров применяется ко всей совокупности данных, так что отдельные сети для рыночных режимов могут получить лишь ограниченное обучение.

При прогнозировании активная сеть GRU анализирует последние последовательные бары, которые, вероятно, относятся к одному и тому же рыночному режиму. Итоговый сигнал сохраняется только в том случае, если оценка алгоритма не равна нулю, уверенность GRU превышает порог и направления, указанные алгоритмом и GRU, совпадают. Эта сеть GRU может отклонить, сохранить или даже усилить голос, но не может плавно ослабить его. Результаты сохраняются в кэше и передаются через классы CExpertSignal, CExpert и CExpertTrade для исполнения ордеров. Если отключить GRU, оставив остальные настройки неизменными, мы получим базовый вариант для сравнения, позволяющий оценить реальный вклад сети.


Форвардное тестирование после оптимизации

После оптимизации мы провели тестирование как без GRU, так и с GRU. Ниже приведены оба отчета MetaTrader:

r1

c1

r2

c2

Как видно из двух приведённых выше отчётов, настройки форвардного тестирования различались. В обеих конфигурациях использовался адаптивный алгоритм на 4-часовом таймфрейме с режимом Mode-6, однако в версии GRU применялись другие настройки входа, выхода, индикатора и HMM. Полученные результаты могут характеризовать две независимо настроенные системы, однако выделить дополнительный вклад GRU мы не можем.

В обоих тестах торговля парой EURUSD велась с начальной маржой в 10 000 при кредитном плече 1:100, а качество исторических ценовых данных составило 100%. Тем не менее остальные различия между конфигурациями могли повлиять на количество сделок, время их совершения и структуру прибылей и убытков независимо от GRU. Сравнение результатов форвардного тестирования оптимизированных конфигураций представлено в следующей таблице:

Система GRU обеспечила более высокую абсолютную доходность: чистая прибыль составила 3 142,03 доллара США по сравнению с 1 085,99 доллара США без использования GRU. Математическое ожидание выигрыша у нее также было выше — 314,20 доллара США против 120,67 доллара США, а профит-фактор составил 1,98. В отчете по варианту без GRU профит-фактор равен 0,00 лишь потому, что валовой убыток отсутствовал. В варианте без GRU все 9 сделок были прибыльными, тогда как в конфигурации с GRU — 7 из 10.

Более высокая доходность GRU не привела к улучшению показателей доходности с поправкой на риск. Фактор восстановления системы с GRU составил 1,25, а коэффициент Шарпа — 0,78, тогда как при тестировании варианта без GRU эти показатели составили 2,45 и 1,98 соответственно. Максимальная просадка капитала также оказалась выше — 16,12% против 4,19%. В системе с GRU средняя прибыль по прибыльной сделке составила 907,37 доллара США, а средний убыток — −1 069,86 доллара США, то есть один типичный убыток поглощал прибыль примерно от 1,18 средней прибыльной сделки. При тестировании варианта без GRU средняя прибыль по прибыльной сделке составила 120,67 доллара США, а убыточных сделок не было. Таким образом, версия с использованием GRU обеспечила примерно в 2,9 раза большую чистую прибыль и в 2,6 раза большую прибыль на одну сделку, тогда как версия, основанная исключительно на алгоритме, продемонстрировала более стабильные результаты и более эффективное использование капитала. При объеме всего 10 и 9 сделок соответственно обе выборки слишком малы для однозначного вывода, а различия в настройках не позволяют нам отнести эту разницу исключительно на счет GRU.

Что подтверждают и не подтверждают полученные отчеты

Полученные результаты свидетельствуют о том, что обе выбранные конфигурации могут быть прибыльными в соответствующих форвардных тестах. Они также показывают, что кажущееся преимущество системы с GRU основано на ее избирательности и доле прибыльных сделок, тогда как главным недостатком может быть резко выраженная отрицательная асимметрия прибылей и убытков. Представленные отчеты не показывают, что включение GRU повышает эффективность одной и той же стратегии. При сравнении этих основных показателей можно смешать эффекты разных таймфреймов сети, режимов сигналов, окон оценки параметров HMM, пороговых значений и настроек управления сделками. Поэтому признание одной из архитектур лучшей на основании этих четырёх отчётов было бы удобным, но статистически необоснованным выводом.

ФОРВАРДНОЕ ТЕСТИРОВАНИЕ EURUSD — ДИАГНОСТИКА РЕЗУЛЬТАТОВ

sum_1

sum_2

Остаётся провести недостающий парный эксперимент: два запуска на четырёхчасовом таймфрейме в режиме Mode-0 — с GRU и без GRU. Для этого потребуется регистрировать каждый предполагаемый вход до и после проверки нейронной сетью; это, как можно надеяться, позволит выяснить, отклоняла ли GRU убыточные сделки, отсеивала ли прибыльные или просто снижала торговую активность. Именно этот тест предложен выше во введении; провести его читателю предлагается самостоятельно, поскольку весь исходный код приложен в конце статьи. Пока такого контрольного сравнения нет, результаты форвардного тестирования дают основания лишь для дальнейшего тестирования архитектуры HMM-GRU, но не подтверждают, что добавление GRU улучшает систему.


Заключение

Наша модель HMM-GRU логически согласована: HMM определяет рыночную обстановку, выбранный режим алгоритма предлагает сделку, а сеть GRU для соответствующего рыночного режима допускает или отклоняет её. Всю гипотезу можно реализовать и проверить в MQL5, в том числе оценить вклад отдельных компонентов путём их отключения. Это практическое преимущество при разработке советников с воспроизводимыми результатами. Тем не менее представленные в статье данные не доказывают, что GRU улучшает отбор входов в рынок. Оба протестированных варианта были прибыльными на периодах форвардного тестирования; однако у системы без GRU оказались выше чистая прибыль, математическое ожидание выигрыша, профит-фактор, коэффициент Шарпа и фактор восстановления. Система с GRU, в свою очередь, показала более высокую долю прибыльных сделок, сочетая частые небольшие прибыли с редкими убытками, в пять раз превышающими эти прибыли.

Эти результаты допускают две возможные интерпретации. Память, учитывающая рыночный режим, может помогать GRU отклонять слабые входы и достигать высокой точности. С другой стороны, такие результаты могут объясняться благоприятным трендом, слишком малой выборкой, различиями в настройках стратегии или отрицательной асимметрией распределения результатов при выходе из сделок, а не пользой от подтверждения нейронной сетью. Поскольку системы с GRU и без GRU используют разные таймфреймы, режимы сигналов, пороговые значения и настройки управления сделками, само по себе это сравнение не даёт оснований предпочесть одну из интерпретаций. Результаты форвардного тестирования действительно дают основания для дальнейших испытаний, но не для празднования победы. Поэтому ближайший следующий шаг был бы простым: повторно запустить варианты с GRU и без неё на сопоставимых таймфреймах и в сопоставимых режимах формирования сигналов, а затем сравнить допущенные и отклонённые потенциальные сделки в скользящем окне. Пока не получен этот базовый вариант для сравнения, дальнейшее усложнение лишь сделает поиск ответа на открытый вопрос более затратным.


Дополнительные тесты

Также прилагаются результаты ещё одного тестового прогона по EURUSD на часовом таймфрейме с форвардным тестированием за восемь уже прошедших месяцев 2026 года.

f3

c

имяОписание
03.mq5Советник, созданный с помощью Мастера MQL5, в заголовке которого перечислены используемые файлы с исходным кодом
SignalHMMGRU.mqhПользовательский сигнальный класс, необходимый Мастеру MQL5 при создании советника.

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/24056

Прикрепленные файлы |
03.mq5 (9.4 KB)
SignalHMMGRU.mqh (71.39 KB)
MetaTrader 5 как производитель сообщений Kafka: Рассылка сигналов нескольким торговым терминалам MetaTrader 5 как производитель сообщений Kafka: Рассылка сигналов нескольким торговым терминалам
В статье подробно описан производитель Kafka, реализованный непосредственно на MQL5 и использующий сетевой протокол Kafka через TCP-сокеты без дополнительных библиотек. Он реализует кодирование RecordBatch v2, varint и вычисление CRC32C, а также пакетную отправку, подтверждения (acks) и логику повторных попыток — и всё это без вспомогательного процесса или DLL. Используйте его для публикации торговых сигналов в формате JSON из одного терминала в Kafka, где информационные панели и другие сервисы подписываются на них независимо друг от друга.
Сертифицированный радиус устойчивости: Оцениваем уровень шума торговой модели ONNX Сертифицированный радиус устойчивости: Оцениваем уровень шума торговой модели ONNX
Мы представляем фильтр допуска для сигналов ONNX на основе сертифицированного радиуса, реализованный непосредственно на MQL5. Система оценивает устойчивость каждого прогноза с помощью рандомизированного сглаживания и односторонней границы Клоппера — Пирсона, а затем использует полученный радиус для допуска к сделкам, масштабирования объема в лотах и мониторинга. Это дает практически применимую оценку запаса устойчивости, которая отражает допустимый уровень шума во входных данных, а не величину значения softmax.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Нейросети в трейдинге: Habi — быстрое принятие решений без сложного планирования (Окончание) Нейросети в трейдинге: Habi — быстрое принятие решений без сложного планирования (Окончание)
Завершаем адаптацию фреймворка Habi для алгоритмической торговли с использованием MQL5 и OpenCL. Рассматриваем построение моделей, подготовку эталонных действий на истории и офлайн-обучение. Показываем, как критик учится оценивать денежные последствия торговых решений и как его оценки используются для обновления политики в режиме имитации онлайн-торговли. На примере EURUSD H1 за январь–апрель 2026 года разбираем проверку готовых моделей и ограничения полученных результатов.