Чтение данных текущего стакана цен

После успешного выполнена функция MarketBookAdd, MQL-программа может запрашивать состояния стакана с помощью функции MarketBookGet по приходу событий OnBookEvent. Функция MarketBookGet заполняет передаваемый по ссылке массив структур MqlBookInfo записями стакана цен указанного символа.

bool MarketBookGet(string symbol, MqlBookInfo &book[])

Под приемный массив можно заранее распределить память для достаточного количества записей. Если динамический массив имеет нулевой или недостаточный размер, терминал сам выделит под него память.

Функция возвращает признак успеха (true) или ошибки (false).

Обычно MarketBookGet используется непосредственно в коде обработчика OnBookEvent или в функциях, вызываемых из него.

Отдельная запись о ценовом уровне стакана хранится в структуре MqlBookInfo.

struct MqlBookInfo 

   ENUM_BOOK_TYPE type;            // тип заявок 
   double         price;           // цена 
   long           volume;          // объем 
   double         volume_real;     // объем с повышенной точностью 
};

Перечисление ENUM_BOOK_TYPE содержит следующие элементы.

Идентификатор

Описание

BOOK_TYPE_SELL

Заявка на продажу

BOOK_TYPE_BUY

Заявка на покупку

BOOK_TYPE_SELL_MARKET

Заявка на продажу по рыночной цене

BOOK_TYPE_BUY_MARKET

Заявка на покупку по рыночной цене

Массив стакана отсортирован таким образом, что в верхней его половине расположены заявки на продажу, а в нижней — на покупку. Как правило, это приводит к соблюдению последовательности элементов от больших цен к малым. Иными словами, под 0-м индексом идет самая высокая цена, в последней записи — самая низкая, а между ними цены постепенно уменьшаются. При этом минимальный шаг цен между уровнями составляет SYMBOL_TRADE_TICK_SIZE, однако уровни с нулевыми объемами не транслируются, то есть соседние элементы могут отстоять и на большую величину.

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

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

В принципе, совпадение цен на покупку и продажу в середине стакана — корректно. Оно означает нулевой спред. Однако, к сожалению, задваивание уровней случается и на большей глубине стакана.

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

MQL-программа должна проверять корректность стакана (в частности, порядок сортировки цен) и быть готовой обработать потенциально возможные отклонения.

К менее серьезным нештатным ситуациям (которые, тем не менее, следует учесть в алгоритме) можно отнести:

  • Последовательные одинаковые стаканы, т.е. без изменений
  • Пустой стакан
  • Стакан с одним уровнем

Ниже показан фрагмент реального стакана цен, полученного от брокера. Буквами 'S' и 'B' помечены, соответственно, цены заявок на продажу и покупку.

Обратите внимание, что уровни покупок и продаж фактически перекрываются: визуально это не сильно заметно, потому что все записи 'S' в стакане специально вынесены вверх (начало приемного массива), а записи 'B' — вниз (конец массива). Однако приглядитесь: цены для покупок в элементах 20 и 21 равны 143.23 и 138.86, соответственно, а это больше всех предложений на продажу. И в то же время, цены для продаж в элементах 18 и 19 равны 134.62 и 133.55, а это ниже всех предложений на покупку.

...
10 S 138.48 652
11 S 138.47 754
12 S 138.45 2256
13 S 138.43 300
14 S 138.42 14
15 S 138.40 1761
16 S 138.39 670    // Дубликат
17 S 138.11 200
18 S 134.62 420    // Низкая
19 S 133.55 10627  // Низкая
 
20 B 143.23 9564   // Высокая
21 B 138.86 533    // Высокая
22 B 138.39 739    // Дубликат
23 B 138.38 106
24 B 138.31 100
25 B 138.25 29
26 B 138.24 6072
27 B 138.23 571
28 B 138.21 17
29 B 138.20 201
30 B 138.19 1
...

Кроме того, цена 138.39 встречается и в верхней половине под номером 16, и в нижней под номером 22.

Ошибки в стакане наиболее вероятны в экстремальных условиях: при сильной волатильности или недостатке ликвидности.

Проверим получение стакана с помощью индикатора MarketBookDisplay.mq5. Он будет подписываться на события стакана для заданного символа в параметре WorkSymbol (если там оставить пустую строку, подразумевается рабочий символ текущего графика).

input string WorkSymbol = ""// WorkSymbol (if empty, use current chart symbol)
   
const string _WorkSymbol = StringLen(WorkSymbol) == 0 ? _Symbol : WorkSymbol;
int digits;
   
void OnInit()
{
   PRTF(MarketBookAdd(_WorkSymbol));
   digits = (int)SymbolInfoInteger(_WorkSymbolSYMBOL_DIGITS);
   ...
}
   
void OnDeinit(const int)
{
   Comment("");
   PRTF(MarketBookRelease(_WorkSymbol));
}

Для обработки событий в коде определен обработчик OnBookEvent, в котором вызывается MarketBookGet, и все элементы полученного массива MqlBookInfo выводятся в многострочный комментарий.

void OnBookEvent(const string &symbol)
{
   if(symbol == _WorkSymbol// берем только стаканы запрошенного символа
   {
      MqlBookInfo mbi[];
      if(MarketBookGet(symbolmbi)) // получаем текущий стакан
      {
         ...
         int half = ArraySize(mbi) / 2// оценка середины стакана
         bool correct = true;
         // собираем информацию об уровнях и объемах в одну строку (с переносами)
         string s = "";
         for(int i = 0i < ArraySize(mbi); ++i)
         {
            s += StringFormat("%02d %s %s %d %g\n"i,
               (mbi[i].type == BOOK_TYPE_BUY ? "B" : 
               (mbi[i].type == BOOK_TYPE_SELL ? "S" : "?")),
               DoubleToString(mbi[i].pricedigits),
               mbi[i].volumembi[i].volume_real);
               
            if(i > 0// ищем середину стакана как смену типа заявок
            {
               if(mbi[i - 1].type == BOOK_TYPE_SELL
                  && mbi[i].type == BOOK_TYPE_BUY)
               {
                  half = i// это середина, т.к. произошла смена типов
               }
               
               if(mbi[i - 1].price <= mbi[i].price)
               {
                  correct = false// обратный порядок = проблема в данных
               }
            }
         }
         Comment(s + (!correct ? "\nINCORRECT BOOK" : ""));
         ...
      }
   }
}

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

#property indicator_separate_window
#property indicator_plots 2
#property indicator_buffers 2
   
#property indicator_type1   DRAW_HISTOGRAM
#property indicator_color1  clrDodgerBlue
#property indicator_width1  2
#property indicator_label1  "Buys"
   
#property indicator_type2   DRAW_HISTOGRAM
#property indicator_color2  clrOrangeRed
#property indicator_width2  2
#property indicator_label2  "Sells"
   
double buys[], sells[];

Предусмотрим возможность визуализировать стакан в стандартном и расширенном режиме (то есть пропускать или показывать уровни с нулевыми объемами), а также отображать сами объемы в долях лотов или единицах. Обе опции имеют аналоги во встроенном окне стакана.

input bool AdvancedMode = false;
input bool ShowVolumeInLots = false;

Настройку буферов и получение некоторых свойств символа (которые нам потребуются в дальнейшем) выполним в OnInit.

int depthdigits;
double tickcontract;
   
void OnInit()
{
   ...
   // настройка индикаторных буферов
   SetIndexBuffer(0buys);
   SetIndexBuffer(1sells);
   ArraySetAsSeries(buystrue);
   ArraySetAsSeries(sellstrue);
   // получение необходимых свойств символа
   depth = (int)PRTF(SymbolInfoInteger(_WorkSymbolSYMBOL_TICKS_BOOKDEPTH));
   tick = SymbolInfoDouble(_WorkSymbolSYMBOL_TRADE_TICK_SIZE);
   contract = SymbolInfoDouble(_WorkSymbolSYMBOL_TRADE_CONTRACT_SIZE);
}

В обработчик OnBookEvent добавим заполнение буферов.

#define VOL(V) (ShowVolumeInLots ? V / contract : V)
   
void OnBookEvent(const string &symbol)
{
   if(symbol == _WorkSymbol// берем только стаканы запрошенного символа
   {
      MqlBookInfo mbi[];
      if(MarketBookGet(symbolmbi)) // получаем текущий стакан
      {
         // чистим буфера на глубину с 10-кратным запасом от максимальной глубины,
         // потому что в расширенном режиме может появиться много пустых элементов
         for(int i = 0i <= depth * 10; ++i)
         {
            buys[i] = EMPTY_VALUE;
            sells[i] = EMPTY_VALUE;
         }
         ... // далее формируем и выводим комментарий как прежде
         if(!correctreturn;
         
         // заполняем буфера данными
         if(AdvancedMode// включен показ пропусков
         {
            for(int i = 0i < ArraySize(mbi); ++i)
            {
               if(i < half)
               {
                  int x = (int)MathRound((mbi[i].price - mbi[half - 1].price) / tick);
                  sells[x] = -VOL(mbi[i].volume_real);
               }
               else
               {
                  int x = (int)MathRound((mbi[half].price - mbi[i].price) / tick);
                  buys[x] = VOL(mbi[i].volume_real);
               }
            }
         }
         else // стандартный режим: показываем только значащие элементы
         {
            for(int i = 0i < ArraySize(mbi); ++i)
            {
               if(i < half)
               {
                  sells[half - i - 1] = -VOL(mbi[i].volume_real);
               }
               else
               {
                  buys[i - half] = VOL(mbi[i].volume_real);
               }
            }
         }
      }
   }
}

Следующее изображение демонстрирует работу индикатора с настройками AdvancedMode=true, ShowVolumeInLots=true.

Содержимое стакана в индикаторе MarketBookDisplay.mq5 на графике USDCNH
Содержимое стакана в индикаторе MarketBookDisplay.mq5 на графике USDCNH

Покупки выводятся как положительные величины (синяя гистограмма вверху), продажи — как отрицательные (красная внизу). Для наглядности справа размещено стандартное окно стакана с такими же настройками (в расширенном режиме, объемы в лотах), так что можно убедиться в совпадении значений.

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

В данном случае обновление графика неявным образом запрашивается в момент вызова функции Comment.