English
preview
Создание объектно-ориентированного механизма статистического арбитража на основе Z-показателя в MQL5

Создание объектно-ориентированного механизма статистического арбитража на основе Z-показателя в MQL5

MetaTrader 5 — Торговые системы |
54 0
Amanda Vitoria De Paula Pereira
Amanda Vitoria De Paula Pereira

Введение

Когда я создаю простую стратегию на основе возврата к среднему значению на языке MQL5, первым делом хочется использовать стандартные осцилляторы (RSI, стохастик) с фиксированными пороговыми значениями 70/30. Это работает в условиях бокового рынка, однако тестирование на разных валютных парах выявило структурный недостаток: при сильных трендах эти осцилляторы могут оставаться в экстремальной зоне в течение нескольких дней или недель, что приводит к повторяющимся ложным входам против тренда и просадкам, которых можно было бы избежать. Основная причина заключается не в самом возврате к среднему значению, а в нормализации: статические уровни не учитывают текущую волатильность инструмента. Мне требовалась нормализация, которая измеряет отклонение цены от среднего значения в единицах стандартного отклонения и которую можно было бы без изменений использовать как в визуальных индикаторах, так и в автоматизированной логике. Z-показатель выполняет эту роль: он даёт адаптивный сигнал, учитывающий волатильность. Его расчёт можно реализовать в виде единого механизма многократного использования — как для визуальной проверки на графике, так и для торговли.


Формула простыми словами

Механизм расчёта Z-показателя на удивление прост. Мы берем текущую цену, вычитаем из неё простое скользящее среднее за определённое количество исторических баров и делим полученный результат на скользящее стандартное отклонение, рассчитанное за тот же период анализа.

Формула выглядит следующим образом: Z = (Цена – Среднее значение) / Стандартное отклонение

Если полученное значение близко к нулю, цена колеблется вблизи своего локального исторического среднего. Когда Z-показатель отклоняется от нуля и достигает экстремальных значений, таких как 2,0 или -2,0, это указывает на статистически значимую аномалию. В стратегии возврата к среднему значению такие моменты сильного отклонения цены можно рассматривать как потенциальные точки входа в короткую или длинную позицию.

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


Контракт сигнала Z-показателя

Прежде чем воплотить эту математическую архитектуру в коде MQL5, нам необходимо четко определить правила работы нашей торговой логики. Этот контракт формирования сигналов точно определяет, как и когда статистическая аномалия становится основанием для торгового правила. В нашей схеме пороги входа заданы симметрично на уровнях +2,5 и −2,5 стандартного отклонения. Пересечение уровня +2,5 снизу вверх указывает на переоцененность рынка, а пересечение уровня −2,5 сверху вниз — на недооцененность актива.

Чтобы обеспечить статистическую достоверность и исключить реакцию расчетного модуля на кратковременный внутридневной шум, контракт исполнения предписывает выполнять расчеты исключительно по полностью закрытым барам. Мы обеспечиваем соблюдение этого ограничения, используя фиксированный параметр сдвига бара, равный 1, во всех наших функциях исполнения торговых операций. Это означает, что алгоритм игнорирует неподтвержденные колебания текущей свечи (индекс 0) и принимает решения о входе и выходе исключительно на основе окончательно сформированных исторических данных, обеспечивая четкую и проверяемую логику исполнения.


Архитектура: вынесение математических вычислений в отдельный класс

Чтобы не смешивать сложные статистические вычисления с логикой исполнения торговых операций, я предпочел вынести модуль расчета Z-показателя в отдельный пользовательский подключаемый файл. Создание отдельного класса позволяет разработчику решить две важнейшие задачи. Во-первых, это позволяет сохранить код скриптов развертывания индикатора и советника чистым и удобочитаемым. Во-вторых, это позволяет нам использовать одну и ту же логику расчётов как для визуального анализа графиков, так и для автоматического исполнения торговых операций, не переделывая базовый код.

Давайте начнём с определения структуры нашего расчетного модуля.

//+------------------------------------------------------------------+
//|                                                 ZScoreEngine.mqh |
//|                                  Copyright 2026, MetaQuotes Ltd. |
//+------------------------------------------------------------------+
#property copyright "Open Source"
#property version   "7.10"

//+------------------------------------------------------------------+
//| Class: CZScore                                                   |
//+------------------------------------------------------------------+
class CZScore
  {
private:
   int               m_period;            
   string            m_symbol;            
   ENUM_TIMEFRAMES   m_timeframe;         

   double            CalculateMean(const double &prices[]);
   double            CalculateStandardDeviation(const double &prices[], double mean);

public:
                     CZScore(string symbol, ENUM_TIMEFRAMES tf, int period);
                    ~CZScore(void);
                    
   double            GetZScore(int shift = 1); 
  };

Приватная секция (раздел private) класса CZScore обеспечивает строгое управление внутренними переменными состояния класса. Нам нужны период анализа, целевой символ и конкретный таймфрейм.

Далее мы реализуем конструктор. Публичный конструктор обеспечивает безопасное присваивание начальных параметров при создании экземпляра объекта. Я предусмотрел здесь автоматическую подстановку допустимых значений: например, если разработчик по ошибке задаст период анализа меньше 2, класс автоматически заменит его безопасным значением по умолчанию — 20 периодов, чтобы предотвратить ошибки инициализации.

//+------------------------------------------------------------------+
//| Constructor and Destructor                                       |
//+------------------------------------------------------------------+
CZScore::CZScore(string symbol, ENUM_TIMEFRAMES tf, int period)
  {
   m_symbol = (symbol == "") ? _Symbol : symbol;
   m_timeframe = tf;
   m_period = (period < 2) ? 20 : period; 
  }

CZScore::~CZScore(void)
  {
  }

Управление жизненным циклом объектов является одним из ключевых аспектов программирования на языке MQL5. В MQL5, в отличие от управляемых языков с автоматической сборкой мусора, динамические объекты, созданные с помощью оператора new, остаются в куче до тех пор, пока не будут явно уничтожены. Если пользователь удалит советник с рабочего графика или переключится на другой таймфрейм, в памяти терминала останется "осиротевший" указатель. Это накопление приводит к постепенной утечке памяти, которая в конечном итоге снижает производительность системы. Такая организация кода, при которой выделение и освобождение ресурсов в течение жизненного цикла выполняются в функциях OnInit и OnDeinit уровней выполнения, обеспечивает корректный возврат ресурсов операционной системе.


Реализация математических функций

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

//+------------------------------------------------------------------+
//| Calculates the Arithmetic Mean                                   |
//+------------------------------------------------------------------+
double CZScore::CalculateMean(const double &prices[])
  {
   double sum = 0.0;
   int total = ArraySize(prices);
   
   for(int i = 0; i < total; i++)
     {
      sum += prices[i];
     }
     
   return (total > 0) ? (sum / total) : 0.0;
  }
После успешного вычисления среднего значения движок должен определить дисперсию генеральной совокупности и стандартное отклонение в скользящем окне. Для этого требуется второй проход по массиву данных. В этом цикле алгоритм вычитает рассчитанное среднее значение из каждой отдельной исторической цены и возводит разницу в квадрат, чтобы исключить отрицательные значения.
//+------------------------------------------------------------------+
//| Calculates the Population Standard Deviation                     |
//+------------------------------------------------------------------+
double CZScore::CalculateStandardDeviation(const double &prices[], double mean)
  {
   double variance_sum = 0.0;
   int total = ArraySize(prices);
   
   for(int i = 0; i < total; i++)
     {
      double difference = prices[i] - mean;
      variance_sum += MathPow(difference, 2);
     }
     
   double variance = (total > 0) ? (variance_sum / total) : 0.0;
   return MathSqrt(variance);
  }

В классической статистике разработчики часто применяют поправку Бесселя при расчёте выборочной дисперсии, деля сумму квадратов отклонений на число наблюдений минус один (N - 1). Однако в данной конкретной алгоритмической реализации я намеренно делю на общее количество периодов (N). Мы имеем дело не со случайной, абстрактной статистической выборкой, а с фиксированным, жестко ограниченным окном, состоящим из последних N баров. Отказ от этой поправки позволяет получить более детерминированный и стабильный показатель нормализации, специально адаптированный для программного выполнения.


Извлечение данных: ловушка массивов в MQL5

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

Одна из самых распространённых ловушек в MQL5 связана с индексацией массивов. По умолчанию массивы заполняются слева направо. Чтобы работа с историческими данными была интуитивно понятной, я явно задаю индексацию как в таймсериях с помощью ArraySetAsSeries. Это меняет порядок индексации на обратный: индекс 0 всегда соответствует текущему бару, а индекс 1 — предыдущему закрытому бару.

//+------------------------------------------------------------------+
//| Orchestrates data extraction and returns the Z-Score             |
//+------------------------------------------------------------------+
double CZScore::GetZScore(int shift)
  {
   double prices[];
   ArraySetAsSeries(prices, true);
   
   if(CopyClose(m_symbol, m_timeframe, shift, m_period, prices) < m_period)
     {
      return 0.0; 
     }
     
   double current_close = iClose(m_symbol, m_timeframe, shift);
   double mean = CalculateMean(prices);
   double std_dev = CalculateStandardDeviation(prices, mean);
   
   if(std_dev == 0.0) return 0.0;
   
   return (current_close - mean) / std_dev;
  }

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


Индикатор: сначала визуализируем логику

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

Print

//+------------------------------------------------------------------+
//|                                                   Ind_ZScore.mq5 |
//|                                  Copyright 2026, MetaQuotes Ltd. |
//+------------------------------------------------------------------+
#property copyright "Open Source"
#property version   "9.00"
#property indicator_separate_window
#property indicator_buffers 1
#property indicator_plots   1

//--- Plotting Configuration
#property indicator_label1  "Z-Score"
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrDodgerBlue
#property indicator_style1  STYLE_SOLID
#property indicator_width1  2

#include "ZScoreEngine.mqh"

//--- Input Parameters
input int InpZScorePeriod = 50; // Lookback Period

//--- Buffers and Engine
double   ZScoreBuffer[];
CZScore *g_zscore_engine;

В блоке инициализации мы настраиваем индикаторный буфер и выводим на график горизонтальные уровни: 2,5; 0,0 и −2,5. Эти статические линии служат удобными визуальными ориентирами для определения статистических экстремумов.

//+------------------------------------------------------------------+
//| Custom indicator initialization function                         |
//+------------------------------------------------------------------+
int OnInit()
  {
   SetIndexBuffer(0, ZScoreBuffer, INDICATOR_DATA);
   IndicatorSetInteger(INDICATOR_DIGITS, 2);
   
   //--- Add horizontal levels to represent statistical extremes
   IndicatorSetInteger(INDICATOR_LEVELS, 3);
   IndicatorSetDouble(INDICATOR_LEVELVALUE, 0, 2.5);
   IndicatorSetDouble(INDICATOR_LEVELVALUE, 1, 0.0);
   IndicatorSetDouble(INDICATOR_LEVELVALUE, 2, -2.5);
   
   g_zscore_engine = new CZScore(_Symbol, PERIOD_CURRENT, InpZScorePeriod);
   return(INIT_SUCCEEDED);
  }

void OnDeinit(const int reason)
  {
   if(CheckPointer(g_zscore_engine) == POINTER_DYNAMIC)
     {
      delete g_zscore_engine;
     }
  }

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

//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
  {
   if(rates_total < InpZScorePeriod) return 0;
   
   int start = (prev_calculated > 0) ? prev_calculated - 1 : InpZScorePeriod;
   
   for(int i = start; i < rates_total && !IsStopped(); i++)
     {
      int shift = rates_total - 1 - i;
      ZScoreBuffer[i] = g_zscore_engine->GetZScore(shift);
     }
     
   return(rates_total);
  }
//+------------------------------------------------------------------+


Советник: подключение торговой логики

После того как стало ясно, что результаты расчёта корректно отображаются на всём временном диапазоне графика, следующим шагом стало подключение расчёта к автоматизированному скрипту. При написании логики исполнения у меня был выбор: создать собственную функцию маршрутизации с использованием встроенной структуры OrderSend языка MQL5 или воспользоваться стандартной библиотекой. Я решил подключить встроенный класс CTrade. Для работы со структурами MqlTradeRequest и MqlTradeResult вручную требуется написать сотни строк кода для обработки ошибок. Стандартный класс CTrade скрывает все эти детали, позволяя сосредоточить логику на наших статистических правилах, а не на низкоуровневом управлении ордерами.

Я оставил параметры стратегии максимально простыми: открывать позиции на абсолютных экстремумах и закрывать сделку при возврате Z-показателя к нулю.

//+------------------------------------------------------------------+
//|                                          EA_ZScore_Reversion.mq5 |
//|                                  Copyright 2026, MetaQuotes Ltd. |
//+------------------------------------------------------------------+
#property copyright "Open Source"
#property version   "8.90"

#include <Trade\Trade.mqh>
#include "ZScoreEngine.mqh" 

//--- Input Parameters
input int    InpZScorePeriod = 50;   // Lookback period for Mean and StdDev
input double InpEntrySigma   = 2.5;  // Z-Score threshold for trade entry
input double InpLotSize      = 0.10; // Fixed execution volume

//--- Global Objects
CZScore *g_zscore_engine;
CTrade   g_trade;

int OnInit()
  {
   g_zscore_engine = new CZScore(_Symbol, PERIOD_CURRENT, InpZScorePeriod);
   return(INIT_SUCCEEDED);
  }

void OnDeinit(const int reason)
  {
   if(CheckPointer(g_zscore_engine) == POINTER_DYNAMIC)
     {
      delete g_zscore_engine;
     }
  }
Одним из ключевых аспектов создания надежного советника является защита терминала от излишних вычислений. Выполнение ресурсоемких обходов массивов на каждом, даже самом незначительном тике минутного графика приведет к перегрузке процессора и значительному замедлению работы тестера стратегий. Поэтому я реализовал статический фильтр на основе переменной типа datetime. Это гарантирует, что логика исполнения запускается строго один раз при формировании бара и обрабатывает только окончательные данные закрытой свечи.
//+------------------------------------------------------------------+
//| Expert tick function with CPU optimization                       |
//+------------------------------------------------------------------+
void OnTick()
  {
   static datetime last_bar_time = 0;
   datetime current_bar_time = iTime(_Symbol, PERIOD_CURRENT, 0);
   
   if(current_bar_time != last_bar_time)
     {
      last_bar_time = current_bar_time;
      
      if(CheckPointer(g_zscore_engine) != POINTER_INVALID)
        {
         double current_zscore = g_zscore_engine->GetZScore();
         
         //--- Exit Logic: Close position if Z-Score reverts to the baseline mean (0.0)
         if(PositionSelect(_Symbol))
           {
            long pos_type = PositionGetInteger(POSITION_TYPE);
            
            if(pos_type == POSITION_TYPE_BUY && current_zscore >= 0.0)
              {
               g_trade.PositionClose(_Symbol);
               Print("Z-Score reverted to mean. Buy closed.");
              }
            else if(pos_type == POSITION_TYPE_SELL && current_zscore <= 0.0)
              {
               g_trade.PositionClose(_Symbol);
               Print("Z-Score reverted to mean. Sell closed.");
              }
           }
         //--- Entry Logic: Open position on extreme statistical divergence
         else
           {
            if(current_zscore >= InpEntrySigma)
              {
               g_trade.Sell(InpLotSize, _Symbol, 0, 0, 0, "Z-Score Overvalued Reversion");
              }
            else if(current_zscore <= -InpEntrySigma)
              {
               g_trade.Buy(InpLotSize, _Symbol, 0, 0, 0, "Z-Score Undervalued Reversion");
              }
           }
        }
     }
  }
//+------------------------------------------------------------------+

При развертывании кода, который анализирует открытые позиции, разработчики должны учитывать условия торгового счета. Платформа MetaTrader 5 поддерживает две модели учета позиций: неттинг и хеджирование. В неттинговой модели по каждому активу в любой момент времени может существовать только одна открытая позиция; последующие команды на покупку или продажу изменяют ее объем или закрывают ее. В режиме хеджирования терминал допускает одновременное существование нескольких независимых позиций по одному и тому же символу. Текущая реализация использует функцию PositionSelect(_Symbol). Это позволяет корректно определить наличие открытой позиции на неттинг-счёте. Однако в режиме хеджирования эта функция выберет только первую открытую позицию, поэтому для учета сложных параметров риска этот участок кода необходимо адаптировать, добавив циклы перебора тикетов.


Настройка среды бэктестинга

Чтобы проверить работу системы, я не стал просто применять её к первому попавшемуся графику. Я настроил тестер стратегий MetaTrader 5 специально с учетом этой архитектуры. Поскольку в моей функции OnTick явно используется статический фильтр на основе переменной типа datetime, позволяющий выполнять логику только в момент фактического открытия нового бара, я настроил тестер на работу в режиме моделирования «Только цены открытия». Использование режима «Все тики» для советника, который срабатывает только при открытии бара, — это огромная трата времени на тестирование и ресурсов процессора. В ходе первых тестов я сосредоточился на паре EURUSD с использованием таймфрейма H1, который, как правило, обеспечивает хороший баланс между дневной волатильностью и структурным рыночным шумом.

Print


Ограничения стратегии и пути оптимизации

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


Заключение

Следуя указаниям статьи, вы получите модульный инструментарий, позволяющий воспроизводить результаты, и чёткий контрольный список для проверки. Основные математические вычисления вынесены в файл ZScoreEngine.mqh (класс CZScore с методом GetZScore()), что позволяет избежать дублирования кода и гарантирует одинаковые вычисления в любом модуле, использующем этот класс. Индикатор IndZScore.mq5 создаёт экземпляр этого движка для визуальной проверки и отображает горизонтальные опорные уровни; советник EA ZScore_Reversion.mq5 использует тот же движок для торговли по простому правилу: вход при экстремальном значении, выход при возврате к нулю. Чтобы воспроизвести результаты, скомпилируйте три файла, добавьте индикатор на график для анализа экстремумов, а затем запустите советник в тестере стратегий в режиме "Только цены открытия" (или выполняйте расчёт один раз на бар) — например, на паре EURUSD с таймфреймом H1. Такая структура упрощает дальнейшую доработку: можно изменять пороговые значения сигналов, добавлять расчёт объёма позиции, реализовывать масштабирование на основе волатильности (например, изменять объём входов при отклонении свыше 3σ) и расширять класс для работы с массивами данных по нескольким валютным парам в целях статистического арбитража — всё это без переписывания логики нормализации.


Таблица структуры файлов

Имя файла
Описание
ZScoreEngine.mqh
Исходный код объектно-ориентированного статистического класса.
Ind_ZScore.mq5
Исходный код пользовательского индикатора визуализации.
EA_ZScore_Reversion.mq5
Исходный код советника, реализующего стратегию возврата к среднему.

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

Прикрепленные файлы |
ZScoreEngine.mqh (3.54 KB)
Ind_ZScore.mq5 (2.94 KB)
Renko внутри советника: построение по тикам без offline-графика Renko внутри советника: построение по тикам без offline-графика
Покажу, как строить Renko прямо внутри советника на MQL5 — без offline-графиков и пользовательских символов. Разберём развороты на один и два кирпича, обработку нескольких кирпичей за тик и типичные ошибки. Проверим повторяемость построения и выгрузим результат в CSV. К статье приложены класс CRenkoBuilder и демонстрационный советник.
От новичка до эксперта: Создание индикатора-оверлея CRT для нескольких таймфреймов в MQL5 От новичка до эксперта: Создание индикатора-оверлея CRT для нескольких таймфреймов в MQL5
Диапазоны CRT на старших таймфреймах информативны, однако трейдеры часто совершают сделки на младших таймфреймах без учета этого контекста. Мы реализуем индикатор на языке MQL5, который считывает данные OHLC старшего таймфрейма, накладывает полный диапазон свечи, ее тело и тени на активный график младшего таймфрейма и отмечает уровни входа, стоп-лосса и цели. Это позволяет лучше ориентироваться в ситуации и избавляет от необходимости переключаться между окнами.
Создание системы торговой аналитики (Часть 3): Сохранение данных о сделках MetaTrader 5 в SQLite Создание системы торговой аналитики (Часть 3): Сохранение данных о сделках MetaTrader 5 в SQLite
В этой статье мы расширим бэкенд на Flask, чтобы надежно принимать, проверять и сохранять данные о закрытых сделках из MetaTrader 5 с помощью SQLite и Flask-SQLAlchemy. В нем реализованы проверка обязательных полей, преобразование временных меток, сохранение данных в транзакциях с откатом при ошибке и работающие эндпоинты получения всех сделок, отдельных записей и базовой сводки. В результате получается полноценный пайплайн данных с локальным тестированием: он сохраняет записи о сделках и предоставляет доступ к ним через структурированный API для дальнейшего анализа.
Версионирование параметров советника в MQL5 с помощью файлов Версионирование параметров советника в MQL5 с помощью файлов
В этой статье рассказывается, как реализовать версионирование параметров на языке MQL5 с помощью бинарных файлов и упакованных структур. Здесь показано, как с помощью функций FileWriteStruct и FileReadStruct в режиме FILE_BIN записывать и считывать записи фиксированного размера, содержащие номера версий, временные метки и контрольную сумму. Вы также узнаете, как обнаруживать изменения с помощью контрольных сумм, безопасно добавлять записи в конец файла и загружать последнюю конфигурацию, не перезаписывая предыдущие настройки.