Ожидание данных и управление видимостью (DRAW_NONE)

В предыдущей главе, в разделе Работа с массивами реальных тиков в структурах MqlTick был представлен скрипт SeriesTicksDeltaVolume.mq5, который позволяет рассчитывать дельту объемов на каждом баре. Тогда мы выводили результаты в журнал, однако намного более удобным и логичным способом анализа такой технической информации является индикатор. В этом разделе мы такой создадим — IndDeltaVolume.mq5.

Здесь нам придется столкнуться с двумя факторами, которые часто встречаются при разработке индикаторов, но отсутствовали в предыдущих примерах.

Первый из них заключается в том, что тиковые данные не относятся к стандартным ценовым таймсериям, которые терминал передает в индикатор в параметрах OnCalculate. Значит, индикатор должен сам их запрашивать и ждать готовности, прежде чем появится возможность что-то отобразить в окне.

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

  • Бычий бар и положительная дельта — подтверждение восходящего тренда;
  • Медвежий бар и отрицательная дельта — подтверждение нисходящего тренда;
  • Бычий бар и отрицательная дельта — возможен разворот вниз;
  • Медвежий бар и положительная дельта — возможен разворот вверх.

Чтобы увидеть гистограмму дельт потребуется предусмотреть режим отключения "больших" гистограмм (покупок и продаж), для чего мы воспользуемся типом DRAW_NONE. Он отключает прорисовку конкретной диаграммы и её участие в подборе автоматического масштаба для окна (но оставляет буфер в Окне данных). Таким образом, убрав "большие" построения из рассмотрения, мы добьемся укрупнения автомасштаба под оставшуюся диаграмму дельт. Другой способ сокрытия буферов с помощью пометки их вспомогательными (режим INDICATOR_CALCULATIONS) будет рассмотрен в следующем разделе.

Напомним, что суть дельты объемов заключается в раздельном подсчете объемов покупок и продаж в тиках, после чего можно найти разницу между этими объемами. Соответственно, получается 3 таймсерии с объемами покупок, объемами продаж и сами разницы между ними. Поскольку эта информация не укладывается в шкалу цен, индикатор должен отображаться в собственном окне, а способом отображения трех таймсерий выберем гистограммы от нуля (DRAW_HISTOGRAM).

Согласно этому опишем свойства индикатора в директивах: размещение, количество буферов и диаграмм, а также их типы.

#property indicator_separate_window
#property indicator_buffers 3
#property indicator_plots   3
#property indicator_type1   DRAW_HISTOGRAM
#property indicator_color1  clrBlue
#property indicator_width1  1
#property indicator_label1  "Buy"
#property indicator_type2   DRAW_HISTOGRAM
#property indicator_color2  clrRed
#property indicator_width2  1
#property indicator_label2  "Sell"
#property indicator_type3   DRAW_HISTOGRAM
#property indicator_color3  clrMagenta
#property indicator_width3  3
#property indicator_label3  "Delta"

Из прежнего скрипта перенесем входные переменные. В частности, так как тики представляют собой довольно массивные данные, ограничим количество баров для расчета на истории (BarCount). Кроме того, в зависимости от наличия или отсутствия реальных объемов в тиках конкретного финансового инструмента, мы умеем считать дельту двумя разными способами, для чего сохранен параметр TickType (перечисление COPY_TICKS определено в заголовочном файле TickEnum.mqh, который мы уже использовали в скрипте).

#include <MQL5Book/TickEnum.mqh>
 
input int BarCount = 100;
input COPY_TICKS TickType = INFO_TICKS;
input bool ShowBuySell = true;

В обработчике OnInit переключим режим работы первых двух гистограмм между DRAW_HISTOGRAM и DRAW_NONE, в зависимости от выбора пользователем параметра ShowBuySell (true по умолчанию означает показ всех трех гистограмм). Обратите внимание, что динамическая настройка с помощью PlotIndexSetInteger переписывает статические настройки (в данном случае, лишь некоторые из них), внедренные в исполняемый файл с помощью директив #property.

int OnInit()
{
   PlotIndexSetInteger(0PLOT_DRAW_TYPEShowBuySell ? DRAW_HISTOGRAM : DRAW_NONE);
   PlotIndexSetInteger(1PLOT_DRAW_TYPEShowBuySell ? DRAW_HISTOGRAM : DRAW_NONE);
   
   return INIT_SUCCEEDED;
}

В данный момент может возникнуть вопрос: а где же регистрация индикаторных буферов? Мы вернемся к нему через пару абзацев, а пока создадим заготовку функции OnCalculate.

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
   if(prev_calculated == 0)
   {
      // TODO(1): инициализация, заполнение нулями
   }
   
   // на каждом новом баре или множестве новых баров при первом запуске
   if(prev_calculated != rates_total)
   {
      // обработать все или новые бары
      for(int i = fmax(prev_calculatedfmax(1rates_total - BarCount));
         i < rates_total && !IsStopped(); ++i)
      {
         // TODO(2): пробуем получить данные и рассчитать i-й бар,
         // если не получится - нужно что-то предпринять! 
      }
   }
   else // тики на текущем баре
   {
      // TODO(3): обновить текущий бар
   }
   
   return rates_total;
}

Основная техническая проблема находится в блоке, помеченном TODO(2). Напомним, что алгоритм получения тиков, который использовался в скрипте и будет с минимальными изменениями перенесен в индикатор, запрашивает их функцией CopyTicksRange. Такой вызов возвращает имеющиеся в базе тиков данные, но если их для заданного исторического бара еще нет, запрос вызывает скачивание и синхронизацию тиковых данных в асинхронном режиме (в фоне), а вызывающий код получает 0 тиков. В связи с этим, получив такой "пустой" ответ, индикатор должен прервать вычисления с признаком неуспеха (но не ошибки) и через некоторое время перезапросить тики. В нормальной ситуации открытого рынка к нам регулярно поступают тики, так что функция OnCalculate наверняка должна быть скоро вызвана и пересчитана с обновленной базой тиков. Но что делать в выходные, когда тиков нет?

Для корректной обработки такой ситуации MQL5 предоставляет таймер. Мы изучим его в одной из следующих глав, а пока воспользуемся как "черным ящиком". Специальная функция EventSetTimer позволяет "попросить" ядро вызвать нашу MQL-программу через заданное количество секунд. Точкой входа для такого вызова является зарезервированный обработчик OnTimer — мы видели его в общей таблице в разделе Обзор функций обработки событий. Таким образом, при возникновении задержки в получении тиковых данных следует запустить таймер с помощью EventSetTimer (достаточно минимального периода 1 секунда) и вернуть из OnCalculate ноль.

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
      ...
      for(int i = fmax(prev_calculatedfmax(1rates_total - BarCount));
         i < rates_total && !IsStopped(); ++i)
      {
         // TODO(2): пробуем получить данные и рассчитать i-й бар,
         if(/*если данных нет*/)
         {
            Print("No data on bar "i", at "TimeToString(time[i]),
               ". Setting up timer for refresh...");
            EventSetTimer(1); // просим вызвать нас через 1 секунду
            return 0// ничего не показываем в окне пока
         }
      }
      ...
}

В обработчике OnTimer используем функцию EventKillTimer, чтобы остановить таймер (если этого не сделать, система продолжит вызывать наш обработчик каждую секунду). Но помимо этого нам нужно каким-то образом запустить пересчет индикатора. Для этой цели применим другую функцию, которую нам еще предстоит освоить в главе про графики — ChartSetSymbolPeriod (см. раздел Переключение символа и таймфрейма). Она позволяет установить для графика с заданным идентификатором (0 означает текущий график) новое сочетание символа и таймфрейма. Однако если их не менять, передав, соответственно, _Symbol и _Period (см. раздел Предопределенные переменные), то график просто будет обновлен (индикаторы при этом пересчитываются).

void OnTimer()
{
   EventKillTimer();
   ChartSetSymbolPeriod(0_Symbol_Period); // самообновление графика
}

Единственный момент, на который здесь стоит дополнительно обратить внимание, заключается в том, что на открытом рынке событие таймера и самообновление графика может оказаться лишним, если следующий тик случится до вызова OnTimer. Поэтому мы заведем глобальную переменную (calcDone) для переключения признака готовности расчетов. Вначале OnCalculate будем её сбрасывать в false, а при штатном завершении расчета — взводить в true.

bool calcDone = false;
 
int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
   calcDone = false;
   ...
         if(/*если данных нет*/)
         {
            ...
            return 0// выходим с calcDone = false
         }
   ...
   calcDone = true;
   return rates_total;
}

Тогда в OnTimer можно инициировать автообновление графика только при calcDone равном false.

void OnTimer()
{
   EventKillTimer();
   if(!calcDone)
   {
      ChartSetSymbolPeriod(0_Symbol_Period);
   }
}

Теперь обратимся к вопросу, чем заменить комментарии TODO(1,2,3), вместо которых должен проводиться, собственно, расчет и заполняться индикаторные буфера. Объединим все эти операции в одном классе CalcDeltaVolume. Таким образом, под каждое действие будет выделен отдельный метод, и мы сохраним обработчик OnCalculate таким же простым (вместо комментариев появятся вызовы методов).

В классе предусмотрим переменные-члены, которые примут настройки пользователи по количеству обрабатываемых баров истории и методу расчета дельты, а также три массива под индикаторные буфера. Их инициализацию проведем в конструкторе.

class CalcDeltaVolume
{
   const int limit;
   const COPY_TICKS tickType;
   
   double buy[];
   double sell[];
   double delta[];
   
public:
   CalcDeltaVolume(
      const int bars,
      const COPY_TICKS type)
      : limit(bars), tickType(type), lasttime(0), lastcount(0)
   {
      // регистрируем внутренние массивы как буфера индикатора
      SetIndexBuffer(0buy);
      SetIndexBuffer(1sell);
      SetIndexBuffer(2delta);
   }

Мы можем назначать массивы-члены в качестве буферов, поскольку собираемся затем создать глобальный объект данного класса. В принципе, для корректного отображения данных важно только, чтобы массивы, привязанные к диаграммам, существовали на момент отрисовки. Допустимо менять привязку буферов динамически (см. пример IndSubChartSimple.mq5 в следующем разделе).

Важно отметить, что индикаторные буфера должны иметь тип double, однако объемы имеют тип ulong, в связи с чем для очень больших значений (например, на очень крупных таймфреймах) могут гипотетически возникать потери точности.

Для инициализации буферов создан метод reset. Большая часть элементов массивов заполняется "пустым" значением EMPTY_VALUE, а последние limit баров нулем, потому что в них мы будем суммировать объемы покупок и продаж, раздельно.

   void reset()
   {
      // заполняем массив buy, а остальные скопируем из него
      // "пустое" значение во всех элементах кроме последних limit баров с 0
      ArrayInitialize(buyEMPTY_VALUE);
      ArrayFill(buyArraySize(buy) - limitlimit0);
      
      // дублируем начальное состояние в другие массивы
      ArrayCopy(sellbuy);
      ArrayCopy(deltabuy);
   }

Расчет на i-м историческом баре выполняет метод createDeltaBar. На его вход подается номер бара и ссылка на массив с временными метками баров (его мы получаем в виде параметра OnCalculate). i-е элементы массивов инициализируются нулем.

   int createDeltaBar(const int iconst datetime &time[])
   {
      delta[i] = buy[i] = sell[i] = 0;
      ...

Затем следует найти временные границы i-го бара: prev и next, причем next отсчитывается вправо от prev путем добавления значения новой для нас функции PeriodSeconds. Она возвращает количество секунд в текущем таймфрейме. Прибавляя это количество, мы находим теоретическое начало следующего бара. На истории, когда i не равно номеру последнего бара, можно было бы заменить нахождение следующей метки времени на time[i + 1]. Однако индикатор должен работать и на последнем, еще находящемся в процессе формирования баре, у которого нет следующего бара. Поэтому в общем случае использовать time[i + 1] нельзя.

      ...
      const datetime prev = time[i];
      const datetime next = prev + PeriodSeconds();

Когда мы делали аналогичный расчет в скрипте, то обошлись без функции PeriodSeconds, потому что не считали последний, текущий бар и могли позволить себе находить next и prev как iTime(WorkSymbol, TimeFrame, i) и iTime(WorkSymbol, TimeFrame, i + 1) соответственно.

Далее в методе createDeltaBar делаем запрос тиков в пределах найденных временных меток (от правой вычитаем 1 миллисекунду, чтобы не "залезть" на следующий бар). Тики поступают в массив ticks, обработка которого поручена вспомогательному методу calc — в него практически без изменений перенесен алгоритм скрипта. Выделить его в отдельный метод нас вынудило то обстоятельство, что расчет будет выполняться в двух разных ситуациях: по историческим барам (вспоминаем комментарий TODO(2)) и по тикам на текущем баре (комментарий TODO(3)). Вторую ситуацию рассмотрим чуть ниже.

      ResetLastError();
      MqlTick ticks[];
      const int n = CopyTicksRange(_SymbolticksCOPY_TICKS_ALL,
         prev * 1000next * 1000 - 1);
      if(n > -1 && _LastError == 0)
      {
         calc(iticks);
      }
      else
      {
         return -_LastError;
      }
      return n;
   }

В случае успешного запроса метод возвращает количество обработанных тиков, а в случае ошибки — код ошибки со знаком минус. Обратите внимание, что если тиков для бара пока в базе нет (что не является ошибкой, строго говоря, но не позволяет продолжать визуальную работу индикатора), метод вернет 0 (знак у 0 не меняет его значения). Поэтому в функции OnCalculate потребуется проверять результат метода на "меньше или равно" 0.

Метод calc практически состоит из рабочих строк скрипта SeriesTicksDeltaVolume.mq5, поэтому не станем его здесь приводить. Желающие могут освежить память, заглянув в IndDeltaVolume.mq5.

Для расчета дельты на постоянно обновляющемся последнем баре нам потребуется фиксировать с точностью до миллисекунды метку времени последнего обработанного тика. Тогда при следующем вызове OnCalculate мы сможем запросить все тики, после этой метки.

Важно напомнить об отсутствии гарантии, что система успеет вызвать наш обработчик OnCalculate на каждом тике в реальном режиме времени. Если мы выполняем тяжелые расчеты, или какая-то другая MQL-проограмма нагрузила терминал расчетами, или тики приходят очень быстро (например, при выходе важных новостей), события могут не попадать в очередь индикатора (в очереди хранится не более одного события каждого типа, в том числе не более одного уведомления о тике). Поэтому, если программа хочет получить все тики, она должна запрашивать их с помощью CopyTicksRange или CopyTicks.

Однако одной только временной метки последнего обработанного тика недостаточно. Дело в том, что тики могут иметь одинаковое время даже с учетом миллисекунд. Поэтому мы не имеем права прибавить к метке 1 миллисекунду, чтобы исключить "старый" тик: ведь после него могут идти "новые" тики с той же меткой.

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

Для реализации этого алгоритма в классе описаны две переменные lasttime и lastcount.

   ulong lasttime// миллисекундная метка последнего обработанного онлайн тика
   int lastcount;  // количество тиков с такой меткой в тот момент

Из полученного от системы массива тиков найдем значения для этих переменных с помощью вспомогательного метода updateLastTime.

   void updateLastTime(const int nconst MqlTick &ticks[])
   {
      lasttime = ticks[n - 1].time_msc;
      lastcount = 0;
      for(int k = n - 1k >= 0; --k)
      {
         if(ticks[k].time_msc == ticks[n - 1].time_msc) ++lastcount;
      }
   }

Теперь мы можем уточнить метод createDeltaBar: при обработке последнего бара вызовем updateLastTime в первый раз.

   int createDeltaBar(const int iconst datetime &time[])
   {
      ...
      const int size = ArraySize(time);
      const int n = CopyTicksRange(_SymbolticksCOPY_TICKS_ALL,
         prev * 1000next * 1000 - 1);
      if(n > -1 && _LastError == 0)
      {
         if(i == size - 1// последний бар
         {
            updateLastTime(nticks);
         }
         calc(iticks);
      }
      ...
   }

Имея актуальные значения lasttime и lastcount, мы можем реализовать метод для подсчета дельт на текущем баре в режиме онлайн.

   int updateLastDelta(const int total)
   {
      MqlTick ticks[];
      ResetLastError();
      const int n = CopyTicksRange(_SymbolticksCOPY_TICKS_ALLlasttime);
      if(n > -1 && _LastError == 0)
      {
         const int skip = lastcount;
         updateLastTime(nticks);
         calc(total - 1ticksskip);
         return n - skip;
      }
      return -_LastError;
   }

В методе calc для такого режима внесен дополнительный опциональный параметр skip, с помощью которого можно пропустить расчет на заданном количестве "старых" тиков.

   void calc(const int iconst MqlTick &ticks[], const int skip = 0)
   {
      const int n = ArraySize(ticks);
      for(int j = skipj < n; ++j)
      ...
   }

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

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
   if(prev_calculated == 0)
   {
      deltas.reset(); // инициализация, заполнение нулями
   }
   
   calcDone = false;
   
   // на каждом новом баре или множестве новых баров при первом запуске
   if(prev_calculated != rates_total)
   {
      // обработать все или новые бары
      for(int i = fmax(prev_calculatedfmax(1rates_total - BarCount));
         i < rates_total && !IsStopped(); ++i)
      {
         // пробуем получить данные и рассчитать i-й бар,
         if((deltas.createDeltaBar(itime)) <= 0)
         {
            Print("No data on bar "i", at "TimeToString(time[i]),
               ". Setting up timer for refresh...");
            EventSetTimer(1); // просим вызвать нас через 1 секунду
            return 0// ничего не показываем в окне пока
         }
      }
   }
   else // тики на текущем баре
   {
      if((deltas.updateLastDelta(rates_total)) <= 0)
      {
         return 0// ошибка
      }
   }
   
   calcDone = true;
   return rates_total;
}

Откомпилируем и запустим индикатор. Для начала желательно выбрать таймфрейм не старше H1 и оставить количество баров в BarCount равным по умолчанию 100. После некоторого ожидания построения индикатора результат должен выглядеть примерно так:

Индикатор дельт объемов со всеми гистограммами, включая покупки и продажи

Индикатор дельт объемов со всеми гистограммами, включая покупки и продажи

Теперь сравним с тем, что будет при установке параметра ShowBuySell в false.

Индикатор объемов с одной гистограммой дельт (раздельные покупки и продажи скрыты)

Индикатор объемов с одной гистограммой дельт (раздельные покупки и продажи скрыты)

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