English
preview
Создание пользовательских индикаторов в MQL5 (Часть 9): Футпринт-график с отслеживанием объёма по ценовым уровням

Создание пользовательских индикаторов в MQL5 (Часть 9): Футпринт-график с отслеживанием объёма по ценовым уровням

MetaTrader 5Трейдинг |
28 0
Allan Munene Mutiiria
Allan Munene Mutiiria

Введение

Свечные графики показывают, куда двигалась цена, но не показывают, кто контролировал каждый уровень. Без данных ордер-флоу невозможно увидеть, покупатели или продавцы доминировали на конкретном ценовом уровне. Также не видно, было ли движение действительно подтверждено объёмом или происходило на низком объёме, и где внутри каждого бара фактически проявлялись поглощение и агрессия участников рынка. Эта статья предназначена для разработчиков MetaQuotes Language 5 (MQL5) и алгоритмических трейдеров, которые хотят создать индикатор футпринта, показывающий объёмную активность внутри каждой свечи.

В нашей предыдущей статье (Часть 8) мы усовершенствовали гибридный индикатор рыночного профиля Time Price Opportunity в MQL5, добавив объёмные данные для расчёта точки контроля, зоны стоимости и средневзвешенной по объёму цены с настраиваемой подсветкой. В Части 9 мы создадим индикатор футпринта для анализа ордер-флоу, который отслеживает потиковый объём на дискретизированных ценовых уровнях, разделяет покупательскую и продавательскую активность в режимах Bid vs Ask и Delta, а также отрисовывает окрашенный по объёму текст на наложении Canvas рядом со свечами, построенными на основе объектов трендовых линий; вся визуализация обновляется в реальном времени. Мы рассмотрим следующие темы:

  1. Расшифровка футпринт-графика: что ценовые уровни говорят об активности участников рынка
  2. Реализация в MQL5
  3. Бэктестинг
  4. Заключение

К концу статьи у вас будет рабочий индикатор футпринта MQL5, который показывает распределение объёма по ценовым уровням внутри каждого бара и готов к дальнейшей настройке и модификации. Приступим!


Расшифровка футпринт-графика: что ценовые уровни говорят об активности участников рынка

Футпринт-график отображает объём, проторгованный на каждом ценовом уровне внутри бара, разделяя его на ask-объём для покупательской активности и bid-объём для продавательской активности. Поэтому вместо одного тела свечи мы видим набор ценовых строк, каждая из которых несёт собственный профиль объёма. Дельта — это разница между ask- и bid-объёмом на каждом уровне. Значительная положительная дельта указывает на давление покупателей, а выраженная отрицательная — на давление продавцов или поглощение. Если выстраивать такие строки по нескольким барам, появляются паттерны, невидимые на обычном свечном графике: уровни, где объём был сильно сконцентрирован, часто становятся важными опорными уровнями в дальнейшем, а уровни с почти нулевым объёмом представляют собой ценовые зоны, которые рынок быстро прошёл без существенного интереса.

ФУТПРИНТ ДЛЯ АНАЛИЗА ОБЪЁМА

В реальной торговле используйте столбец дельты, чтобы определить, где в рынок вошли агрессивные покупатели или продавцы: бар с высокой положительной дельтой рядом с уровнем поддержки подтверждает активность покупателей и делает вход в длинную позицию более обоснованным. Следите за дивергенцией дельты: если цена делает новый максимум, а дельта ослабевает, это сигнализирует о затухании покупательской агрессии и возможном развороте. Ценовые уровни с высоким суммарным объёмом внутри бара часто отмечают точку контроля этого бара: цена склонна возвращаться к этим уровням на откатах. В режиме Bid vs Ask ищите диагональные дисбалансы, когда ask-объём на одном уровне значительно превышает bid-объём на уровне непосредственно ниже, что указывает на многослойное покупательское давление, способное подтолкнуть цену выше. И наоборот, доминирующий bid-объём, диагонально расположенный под ask-объёмом, сигнализирует о давлении продавцов. Используйте зоны низкого объёма внутри бара, чтобы определять зоны, через которые цена может ускориться при повторном подходе, поскольку при первом прохождении рынок проявил там слабый интерес.

Мы будем создавать индикатор поэтапно: определим два режима отображения — Bid vs Ask и Delta, дискретизируем входящие тиковые цены в настраиваемые интервалы ценовых уровней и будем накапливать объём в структурированном массиве футпринтов баров. Затем мы рассчитаем максимальные значения для каждого бара, необходимые для цветового масштабирования, отсортируем ценовые уровни сверху вниз и отобразим значения объёма цветным текстом на наложении Canvas рядом со свечами, построенными на основе объектов трендовых линий. Визуализация оперативно перерисовывается при масштабировании, прокрутке и изменении размера графика. Ниже приведено визуальное представление того, что мы собираемся построить.

АРХИТЕКТУРА ФУТПРИНТ-ГРАФИКА ПОТОКА ОРДЕРОВ


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

Определение перечислений, входных параметров, структур и глобальных переменных

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

//+------------------------------------------------------------------+
//|                                   OrderFlow FootPrint PART 1.mq5 |
//|                           Copyright 2026, Allan Munene Mutiiria. |
//|                                   https://t.me/Forex_Algo_Trader |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Allan Munene Mutiiria."
#property link      "https://t.me/Forex_Algo_Trader"
#property version   "1.00"
#property indicator_chart_window

#property indicator_buffers 0
#property indicator_plots   0

#include <Canvas\Canvas.mqh>

//+------------------------------------------------------------------+
//| Enums                                                            |
//+------------------------------------------------------------------+
enum FootprintDisplayMode
  {
   BID_VS_ASK, // Bid vs Ask
   DELTA       // Delta
  };

//+------------------------------------------------------------------+
//| Inputs                                                           |
//+------------------------------------------------------------------+
input group "Settings"
input FootprintDisplayMode displayMode          = DELTA; // Display Mode
input int                  ticksPerPriceLevel   = 5;     // Ticks Per Price Level
input int                  maxBarsToRender      = 50;    // Max Bars to Display
input int                  priceLevelFontSize   = 11;    // Price Level Font Size
input bool                 useStrictPricePositions = true; // Strict Price Positions (no spreading)

input group "Colors"
input color upColor1      = 0x8d8d8d; // Up Color 1 (weakest)
input color upColor2      = 0x6b8bb6; // Up Color 2
input color upColor3      = 0x5289d3; // Up Color 3
input color upColor4      = 0x2e7de6; // Up Color 4
input color upColor5      = 0x0077ff; // Up Color 5 (strongest)
input color downColor1    = 0x8d8d8d; // Down Color 1 (weakest)
input color downColor2    = 0xb87b6c; // Down Color 2
input color downColor3    = 0xd46d53; // Down Color 3
input color downColor4    = 0xec5732; // Down Color 4
input color downColor5    = 0xfe3300; // Down Color 5 (strongest)
input color volumeColor1  = 0x636363; // Volume Color 1 (weakest)
input color volumeColor2  = 0x858585; // Volume Color 2
input color volumeColor3  = 0xa3a3a3; // Volume Color 3
input color volumeColor4  = 0xc9c9c9; // Volume Color 4
input color volumeColor5  = 0x000000; // Volume Color 5 (strongest)
input color candleWickColor = 0x666666; // Candle Wick Color

//+------------------------------------------------------------------+
//| Structures                                                       |
//+------------------------------------------------------------------+
struct FootprintPriceLevel
  {
   double price;      // Price at this level
   double upVolume;   // Accumulated buy volume
   double downVolume; // Accumulated sell volume
  };

struct BarFootprintData
  {
   datetime           time;                    // Bar open time
   FootprintPriceLevel priceLevels[];          // Array of price levels for this bar
   double             totalUpVolume;           // Cumulative buy volume for bar
   double             totalDownVolume;         // Cumulative sell volume for bar
   double             maxDeltaValue;           // Max absolute delta across levels
   double             maxTotalVolumeValue;     // Max combined volume across levels
   double             maxAskValue;             // Max ask volume across levels
   double             maxBidValue;             // Max bid volume across levels
  };

//+------------------------------------------------------------------+
//| Global Variables                                                 |
//+------------------------------------------------------------------+
BarFootprintData barFootprints[];        // Array of all stored bar footprints
string           objectPrefix          = "OrderFlowFootprint_"; // Prefix for all chart objects
double           priceLevelStep        = 0;    // Computed tick-based price level step

CCanvas          mainCanvas;                   // Main rendering canvas object
int              currentChartWidth     = 0;    // Last known chart width in pixels
int              currentChartHeight    = 0;    // Last known chart height in pixels
int              currentChartScale     = 0;    // Last known chart scale value
int              firstVisibleBarIndex  = 0;    // Index of the leftmost visible bar
int              visibleBarsCount      = 0;    // Number of bars currently visible
double           minVisiblePrice       = 0.0;  // Bottom of visible price range
double           maxVisiblePrice       = 0.0;  // Top of visible price range
int              currentFontSize       = 10;   // Active font size for rendering

datetime         lastBarTime           = 0;    // Time of the last processed bar
double           lastClosePrice        = 0.0;  // Close price from the previous tick
long             lastTickVolume        = 0;    // Tick volume from the previous tick
bool             lastTradeAtAsk        = true; // Whether the last trade hit the ask
int              currentBarIndex       = -1;   // Index into barFootprints for the current bar

static datetime  lastRedrawTime        = 0;    // Timestamp of the most recent canvas redraw

Начинаем реализацию с подключения библиотеки Canvas для поддержки растровой отрисовки непосредственно на графике. Затем определяем перечисление "FootprintDisplayMode" с двумя значениями: "BID_VS_ASK" для отображения исходных bid- и ask-объёмов рядом друг с другом на каждом ценовом уровне и "DELTA" для отображения дельты между покупательской и продавательской активностью вместе с суммарным объёмом. Так пользователь может выбрать между детальным и обобщённым представлением.

Раздел input организован в две группы. В настройках мы объявляем "displayMode" со значением по умолчанию "DELTA", "ticksPerPriceLevel" для управления шириной каждой ценовой строки в тиках, "maxBarsToRender" для ограничения использования памяти за счёт предельного количества хранимых баров, "priceLevelFontSize" для размера текста на Canvas и "useStrictPricePositions", который переключает режим: разносить ли перекрывающиеся ценовые подписи или оставлять их строго на точных ценах. В группе цветов мы объявляем пятиступенчатые градиенты для покупательского объёма, продавательского объёма и суммарного объёма — от самого слабого оттенка до самого сильного — плюс отдельный "candleWickColor" для теней свечей, построенных трендовыми линиями.

Для структур данных мы определяем "FootprintPriceLevel", который хранит данные одной ценовой строки: саму цену, накопленный покупательский объём в "upVolume" и накопленный продавательский объём в "downVolume". Затем определяем "BarFootprintData" для представления полного футпринта бара: время открытия бара, динамический массив записей "FootprintPriceLevel" под названием "priceLevels", накопленные итоги по покупкам и продажам, а также четыре предварительно вычисленных максимума — "maxDeltaValue", "maxTotalVolumeValue", "maxAskValue" и "maxBidValue". Они позже используются для нормализации объёмов в коэффициенты интенсивности цвета.

В разделе глобальных переменных мы объявляем "barFootprints" как динамический массив "BarFootprintData" для хранения истории обработанных баров, задаём строку "objectPrefix" для именования всех объектов трендовых линий на графике и инициализируем "priceLevelStep" нулём до расчёта при запуске. Объявляем объект "mainCanvas" типа "CCanvas" для пиксельной отрисовки, а затем отслеживаем геометрию графика: ширину, высоту, масштаб, индекс первого видимого бара, количество видимых баров и видимый ценовой диапазон. Наконец, объявляем переменные состояния тиков: "lastBarTime" для определения новых баров, "lastClosePrice" и "lastTickVolume" для расчёта приращений объёма между тиками, "lastTradeAtAsk" для хранения направления последней известной сделки, "currentBarIndex" для указания на активный бар в массиве футпринтов и статическую "lastRedrawTime" для записи времени последней полной перерисовки Canvas. После подготовки базовых элементов переходим к вспомогательным функциям, которые обеспечивают модульную структуру кода.

Дискретизация цен и сопоставление объёмов с цветами

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

//+------------------------------------------------------------------+
//| Quantize price to level                                          |
//+------------------------------------------------------------------+
double QuantizePriceToLevel(double price)
  {
   //--- Snap the price to the nearest discrete price level and return it
   return MathRound(price / priceLevelStep) * priceLevelStep;
  }

//+------------------------------------------------------------------+
//| Get volume color for up/down direction                           |
//+------------------------------------------------------------------+
color GetVolumeColor(bool isUp, double ratio)
  {
   //--- Select the color tier based on direction and intensity ratio
   if(isUp)
     {
      //--- Return progressively stronger up colors for higher ratios
      if(ratio >= 0.8) return upColor5;
      else if(ratio >= 0.5) return upColor4;
      else if(ratio >= 0.3) return upColor3;
      else if(ratio >= 0.1) return upColor2;
      else return upColor1;
     }
   else
     {
      //--- Return progressively stronger down colors for higher ratios
      if(ratio >= 0.8) return downColor5;
      else if(ratio >= 0.5) return downColor4;
      else if(ratio >= 0.3) return downColor3;
      else if(ratio >= 0.1) return downColor2;
      else return downColor1;
     }
  }

//+------------------------------------------------------------------+
//| Get diagonal volume color for bid/ask imbalance                  |
//+------------------------------------------------------------------+
color GetDiagonalVolumeColor(bool isUp, double ratio)
  {
   //--- Select color tier for diagonal bid/ask imbalance using wider ratio range
   if(isUp)
     {
      //--- Return up color based on ask-to-bid ratio threshold
      if(ratio >= 4)   return upColor5;
      else if(ratio >= 3)   return upColor4;
      else if(ratio >= 2)   return upColor3;
      else if(ratio >= 1.5) return upColor2;
      else return upColor1;
     }
   else
     {
      //--- Return down color based on bid-to-ask ratio threshold
      if(ratio >= 4)   return downColor5;
      else if(ratio >= 3)   return downColor4;
      else if(ratio >= 2)   return downColor3;
      else if(ratio >= 1.5) return downColor2;
      else return downColor1;
     }
  }

//+------------------------------------------------------------------+
//| Get total volume color by intensity ratio                        |
//+------------------------------------------------------------------+
color GetTotalVolumeColor(double ratio)
  {
   //--- Return the volume color tier matching the provided intensity ratio
   if(ratio >= 1)        return volumeColor5;
   else if(ratio >= 0.9) return volumeColor4;
   else if(ratio >= 0.7) return volumeColor3;
   else if(ratio >= 0.3) return volumeColor2;
   else return volumeColor1;
  }

Определяем функцию "QuantizePriceToLevel" для привязки любой входящей тиковой цены к ближайшему дискретному ценовому уровню. Она делит цену на "priceLevelStep", округляет результат до ближайшего целого с помощью MathRound, затем умножает обратно на "priceLevelStep", получая цену, точно привязанную к дискретной сетке. Это важно, потому что тиковые цены поступают как непрерывные значения с плавающей точкой, и без дискретизации каждый тик создавал бы собственный уникальный уровень, делая футпринт нечитаемым. Группируя тики в интервалы настраиваемой ширины, мы обеспечиваем накопление тикового объёма внутри одного ценового интервала в одной строке, формируя значимое суммарное значение объёма.

Далее определяем функцию "GetVolumeColor", которая сопоставляет нормализованный коэффициент объёма с одним из пяти цветовых уровней для покупательского или продавательского объёма. Передаваемый коэффициент показывает, насколько конкретное значение объёма велико относительно максимума бара, а функция возвращает всё более сильные цвета по мере прохождения порогов 0.1, 0.3, 0.5 и 0.8, достигая самого сильного оттенка при 0.8 и выше. Для покупательского объёма уровни соответствуют "upColor1"–"upColor5", а для продавательского — "downColor1"–"downColor5", что позволяет мгновенно видеть самые активные уровни только по интенсивности цвета, без необходимости анализировать каждое числовое значение.

Для окраски диагонального дисбаланса Bid vs Ask определяем функцию "GetDiagonalVolumeColor", использующую другую шкалу коэффициентов, потому что она сравнивает объёмы на соседних ценовых уровнях, а не с максимумом по всему бару. Здесь пороги равны 1.5, 2, 3 и 4: выраженный диагональный дисбаланс требует, чтобы объём одной стороны в несколько раз превышал объём другой, а не просто составлял определённую долю от него. Когда ask-объём на уровне как минимум в четыре раза больше bid-объёма на уровне непосредственно ниже, возвращается самый сильный восходящий цвет, указывая на многоуровневое давление покупателей. Та же логика в обратном направлении применяется и к доминирующему bid-объёму: возвращается наиболее интенсивный цвет продавательского объёма, подчёркивающий выраженный дисбаланс в пользу продавцов между соседними строками. Аналогичная логика используется и для выбора цвета суммарного объёма. Подготовив функции цветового отображения, переходим к вспомогательным функциям хранения ценовых уровней.

Управление ценовыми уровнями: поиск, обновление, сортировка и вычисление максимальных значений

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

//+------------------------------------------------------------------+
//| Get price level index in footprint                               |
//+------------------------------------------------------------------+
int GetPriceLevelIndex(BarFootprintData &footprint, double price)
  {
   //--- Get the total number of price levels stored
   int size = ArraySize(footprint.priceLevels);
   //--- Search linearly for a level matching the given price
   for(int i = 0; i < size; i++)
     {
      //--- Use half-point tolerance to account for floating-point imprecision
      if(MathAbs(footprint.priceLevels[i].price - price) < _Point / 2)
         return i;
     }
   //--- Return sentinel value indicating the level was not found
   return -1;
  }

//+------------------------------------------------------------------+
//| Update or insert price level volume                              |
//+------------------------------------------------------------------+
void UpdatePriceLevel(BarFootprintData &footprint, double price, double upVolumeToAdd, double downVolumeToAdd)
  {
   //--- Look up existing level index for the given price
   int index = GetPriceLevelIndex(footprint, price);

   if(index == -1)
     {
      //--- Append a new level since none exists at this price
      int size = ArraySize(footprint.priceLevels);
      ArrayResize(footprint.priceLevels, size + 1);
      //--- Populate the newly allocated level entry
      footprint.priceLevels[size].price      = price;
      footprint.priceLevels[size].upVolume   = upVolumeToAdd;
      footprint.priceLevels[size].downVolume = downVolumeToAdd;
     }
   else
     {
      //--- Accumulate volume into the existing level
      footprint.priceLevels[index].upVolume   += upVolumeToAdd;
      footprint.priceLevels[index].downVolume += downVolumeToAdd;
     }
  }

//+------------------------------------------------------------------+
//| Sort price levels in descending order                            |
//+------------------------------------------------------------------+
void SortPriceLevelsDescending(BarFootprintData &footprint)
  {
   //--- Get total levels count for iteration bounds
   int size = ArraySize(footprint.priceLevels);
   //--- Apply bubble sort descending by price
   for(int i = 0; i < size - 1; i++)
     {
      for(int j = 0; j < size - i - 1; j++)
        {
         //--- Swap adjacent levels if lower price precedes higher
         if(footprint.priceLevels[j].price < footprint.priceLevels[j + 1].price)
           {
            //--- Store current level in temporary variable
            FootprintPriceLevel temp      = footprint.priceLevels[j];
            footprint.priceLevels[j]      = footprint.priceLevels[j + 1];
            footprint.priceLevels[j + 1]  = temp;
           }
        }
     }
  }

//+------------------------------------------------------------------+
//| Compute max values across all price levels                       |
//+------------------------------------------------------------------+
void ComputeMaxValues(BarFootprintData &footprint)
  {
   //--- Reset all max tracking fields before recalculation
   footprint.maxDeltaValue        = 0.0;
   footprint.maxTotalVolumeValue  = 0.0;
   footprint.maxAskValue          = 0.0;
   footprint.maxBidValue          = 0.0;

   //--- Get total levels count for loop bounds
   int size = ArraySize(footprint.priceLevels);
   for(int i = 0; i < size; i++)
     {
      //--- Compute absolute delta and combined volume for this level
      double delta = MathAbs(footprint.priceLevels[i].upVolume - footprint.priceLevels[i].downVolume);
      double total = footprint.priceLevels[i].upVolume + footprint.priceLevels[i].downVolume;

      //--- Update each max field if the current level exceeds the stored value
      footprint.maxDeltaValue       = MathMax(footprint.maxDeltaValue, delta);
      footprint.maxTotalVolumeValue = MathMax(footprint.maxTotalVolumeValue, total);
      footprint.maxAskValue         = MathMax(footprint.maxAskValue, footprint.priceLevels[i].upVolume);
      footprint.maxBidValue         = MathMax(footprint.maxBidValue, footprint.priceLevels[i].downVolume);
     }
  }

Здесь мы определяем функцию "GetPriceLevelIndex" для поиска в массиве "priceLevels" бара записи, соответствующей заданной цене. Вместо точного сравнения чисел с плавающей точкой, которое не сработало бы из-за ошибок округления, проверяется, меньше ли абсолютная разница между сохранённой и входящей ценой половины пункта с помощью MathAbs, и если совпадение найдено, возвращается индекс, иначе возвращается -1 как служебное значение, показывающее отсутствие совпадения.

На этой основе реализуем функцию "UpdatePriceLevel", которая либо добавляет новый уровень, либо накапливает объём в уже существующем. Сначала она вызывает "GetPriceLevelIndex", и если результат равен -1, увеличивает массив "priceLevels" на один элемент с помощью ArrayResize и заполняет новую ячейку входящей ценой, покупательским объёмом и продавательским объёмом. Если соответствующий уровень уже существует, функция просто добавляет входящие объёмы к полям "upVolume" и "downVolume", благодаря чему каждый тик, попавший в ту же дискретизированную ценовую строку, увеличивает общий накопленный объём, а не создаёт дубликат записи.

Чтобы подготовить уровни к правильной отрисовке на Canvas сверху вниз, определяем функцию "SortPriceLevelsDescending", которая применяет пузырьковую сортировку к массиву "priceLevels", сравнивает соседние элементы и меняет их местами через временную переменную "FootprintPriceLevel", когда более низкая цена стоит перед более высокой. После сортировки максимальный ценовой уровень всегда находится в индексе ноль, что соответствует визуальной компоновке футпринта сверху вниз на графике.

Наконец, определяем функцию "ComputeMaxValues", которая проходит по всем уровням бара и обновляет четыре максимальных поля, сохранённых в структуре "BarFootprintData". Перед циклом она сбрасывает "maxDeltaValue", "maxTotalVolumeValue", "maxAskValue" и "maxBidValue" в ноль, затем на каждом уровне вычисляет абсолютную дельту через "MathAbs" и суммарный объём, обновляя каждый максимум с помощью MathMax если текущий уровень превышает сохранённое значение. Именно эти максимумы используются цветовыми функциями как знаменатели при нормализации объёмов отдельных уровней в коэффициенты, поэтому их пересчёт после каждого тика гарантирует, что интенсивность цвета всегда отражает полное распределение объёма внутри бара в данный момент. После завершения управления ценовыми уровнями переходим к функциям, которые находят футпринты и преобразуют координаты графика в пиксели Canvas.

Поиск футпринтов и преобразование координат графика в пиксели Canvas

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

//+------------------------------------------------------------------+
//| Get bar footprint index by time                                  |
//+------------------------------------------------------------------+
int GetBarFootprintIndex(datetime barTime)
  {
   //--- Get total footprints count for search bounds
   int size = ArraySize(barFootprints);
   //--- Scan for a footprint whose time matches the requested bar
   for(int i = 0; i < size; i++)
     {
      if(barFootprints[i].time == barTime)
         return i;
     }
   //--- Return sentinel indicating no match was found
   return -1;
  }

//+------------------------------------------------------------------+
//| Get bar width in pixels for given chart scale                    |
//+------------------------------------------------------------------+
int GetBarWidth(int chartScale)
  {
   //--- Compute pixel width as 2^scale and return it
   return (int)MathPow(2.0, chartScale);
  }

//+------------------------------------------------------------------+
//| Convert bar index to canvas X coordinate                         |
//+------------------------------------------------------------------+
int BarToXCoordinate(int barIndex)
  {
   //--- Calculate horizontal position relative to first visible bar
   return (firstVisibleBarIndex - barIndex) * GetBarWidth(currentChartScale);
  }

//+------------------------------------------------------------------+
//| Convert price to canvas Y coordinate                             |
//+------------------------------------------------------------------+
int PriceToYCoordinate(double price)
  {
   //--- Guard against zero price range to avoid division by zero
   if(maxVisiblePrice - minVisiblePrice == 0.0) return 0;
   //--- Map price linearly onto the canvas height and return the pixel row
   return (int)MathRound(currentChartHeight * (maxVisiblePrice - price) / (maxVisiblePrice - minVisiblePrice));
  }

Определяем функцию "GetBarFootprintIndex", которая просматривает массив "barFootprints" и ищет запись, поле времени которой совпадает с запрошенным временем бара. Если запись найдена, возвращается её индекс, иначе — -1. Этот поиск вызывается при каждой перерисовке Canvas, чтобы сопоставить каждый видимый бар с его сохранёнными данными футпринта перед отрисовкой.

Чтобы определить, какой ширины должен быть каждый бар на Canvas, реализуем функцию "GetBarWidth", которая вычисляет ширину в пикселях как 2 в степени текущего масштаба графика с помощью функции MathPow. Это соответствует тому, как MetaTrader 5 внутренне задаёт размер баров на каждом уровне масштабирования: масштаб 0 даёт бар шириной в один пиксель, масштаб 1 — два пикселя, масштаб 2 — четыре и так далее. Поэтому текстовые столбцы футпринта всегда точно выравниваются по базовым барам графика независимо от степени приближения или отдаления.

Функция "BarToXCoordinate" преобразует индекс бара в горизонтальную пиксельную позицию Canvas: вычисляет разницу между индексом первого видимого бара и целевым индексом, а затем умножает её на результат "GetBarWidth" при текущем масштабе. Поскольку MetaTrader 5 нумерует бары справа налево, где самый новый бар имеет индекс ноль, вычитание целевого индекса из индекса первого видимого бара правильно размещает старые бары левее, а новые — правее на Canvas.

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

Отрисовка свечей трендовыми линиями и подписей ценовых уровней футпринта

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

//+------------------------------------------------------------------+
//| Render candle body and wicks as trend line objects               |
//+------------------------------------------------------------------+
void RenderCandleWithTrendLines(int barIndex, datetime barTime)
  {
   //--- Fetch OHLC prices for the given bar
   double openPrice  = iOpen(_Symbol,  _Period, barIndex);
   double closePrice = iClose(_Symbol, _Period, barIndex);
   double highPrice  = iHigh(_Symbol,  _Period, barIndex);
   double lowPrice   = iLow(_Symbol,   _Period, barIndex);

   //--- Choose body color based on bullish or bearish close
   color bodyColor = closePrice >= openPrice ? upColor5 : downColor5;

   //--- Build unique object name for the candle body
   string bodyObjectName = objectPrefix + "Body_" + TimeToString(barTime);
   if(ObjectFind(0, bodyObjectName) < 0)
     {
      //--- Create the body trend object when it does not yet exist
      ObjectCreate(0, bodyObjectName, OBJ_TREND, 0, barTime, openPrice, barTime, closePrice);
      ObjectSetInteger(0, bodyObjectName, OBJPROP_RAY_RIGHT,  false);
      ObjectSetInteger(0, bodyObjectName, OBJPROP_SELECTABLE, false);
      ObjectSetInteger(0, bodyObjectName, OBJPROP_HIDDEN,     true);
     }
   //--- Apply body styling and updated OHLC coordinates
   ObjectSetInteger(0, bodyObjectName, OBJPROP_COLOR, bodyColor);
   ObjectSetInteger(0, bodyObjectName, OBJPROP_WIDTH, 3);
   ObjectSetDouble(0,  bodyObjectName, OBJPROP_PRICE, 0, openPrice);
   //--- Offset doji close by one point so the trend line has non-zero length
   ObjectSetDouble(0,  bodyObjectName, OBJPROP_PRICE, 1,
                   closePrice == openPrice ? closePrice + _Point : closePrice);

   //--- Build unique object name for the upper wick
   string upperWickObjectName = objectPrefix + "UpperWick_" + TimeToString(barTime);
   if(ObjectFind(0, upperWickObjectName) < 0)
     {
      //--- Create the upper wick trend object from high to top of body
      ObjectCreate(0, upperWickObjectName, OBJ_TREND, 0,
                   barTime, highPrice, barTime, MathMax(openPrice, closePrice));
      ObjectSetInteger(0, upperWickObjectName, OBJPROP_RAY_RIGHT,  false);
      ObjectSetInteger(0, upperWickObjectName, OBJPROP_SELECTABLE, false);
      ObjectSetInteger(0, upperWickObjectName, OBJPROP_HIDDEN,     true);
     }
   //--- Apply wick styling and updated price coordinates
   ObjectSetInteger(0, upperWickObjectName, OBJPROP_COLOR, candleWickColor);
   ObjectSetInteger(0, upperWickObjectName, OBJPROP_WIDTH, 1);
   ObjectSetDouble(0,  upperWickObjectName, OBJPROP_PRICE, 0, highPrice);
   ObjectSetDouble(0,  upperWickObjectName, OBJPROP_PRICE, 1, MathMax(openPrice, closePrice));

   //--- Build unique object name for the lower wick
   string lowerWickObjectName = objectPrefix + "LowerWick_" + TimeToString(barTime);
   if(ObjectFind(0, lowerWickObjectName) < 0)
     {
      //--- Create the lower wick trend object from low to bottom of body
      ObjectCreate(0, lowerWickObjectName, OBJ_TREND, 0,
                   barTime, lowPrice, barTime, MathMin(openPrice, closePrice));
      ObjectSetInteger(0, lowerWickObjectName, OBJPROP_RAY_RIGHT,  false);
      ObjectSetInteger(0, lowerWickObjectName, OBJPROP_SELECTABLE, false);
      ObjectSetInteger(0, lowerWickObjectName, OBJPROP_HIDDEN,     true);
     }
   //--- Apply wick styling and updated price coordinates
   ObjectSetInteger(0, lowerWickObjectName, OBJPROP_COLOR, candleWickColor);
   ObjectSetInteger(0, lowerWickObjectName, OBJPROP_WIDTH, 1);
   ObjectSetDouble(0,  lowerWickObjectName, OBJPROP_PRICE, 0, lowPrice);
   ObjectSetDouble(0,  lowerWickObjectName, OBJPROP_PRICE, 1, MathMin(openPrice, closePrice));
  }

//+------------------------------------------------------------------+
//| Render footprint labels for a single bar                         |
//+------------------------------------------------------------------+
void RenderFootprint(int footprintIndex, int barIndex, datetime barTime, int ratesTotal)
  {
   //--- Validate footprint index before accessing the array
   if(footprintIndex < 0 || footprintIndex >= ArraySize(barFootprints)) return;

   //--- Get total price levels for this footprint
   int size = ArraySize(barFootprints[footprintIndex].priceLevels);
   //--- Skip rendering if no levels have been collected yet
   if(size == 0) return;

   //--- Prepare display price array mirroring the sorted levels
   double displayPrices[];
   ArrayResize(displayPrices, size);

   //--- Copy raw level prices into display array
   for(int j = 0; j < size; j++)
     {
      displayPrices[j] = barFootprints[footprintIndex].priceLevels[j].price;
     }

   if(!useStrictPricePositions)
     {
      //--- Spread overlapping price labels when strict positioning is disabled
      for(int j = 1; j < size; j++)
        {
         //--- Calculate vertical distance between adjacent labels
         double priceDiff = displayPrices[j - 1] - displayPrices[j];
         //--- Push label down if it would overlap the one above
         if(priceDiff < priceLevelFontSize * _Point && priceDiff >= 0)
           {
            displayPrices[j] = displayPrices[j - 1] - priceLevelFontSize * _Point;
           }
        }
     }

   //--- Declare text and color arrays for left and right column labels
   string leftTexts[];
   color  leftColors[];
   string rightTexts[];
   color  rightColors[];
   ArrayResize(leftTexts,   size);
   ArrayResize(leftColors,  size);
   ArrayResize(rightTexts,  size);
   ArrayResize(rightColors, size);

   //--- Populate label text and color for each price level
   for(int j = 0; j < size; j++)
     {
      double upVolume   = barFootprints[footprintIndex].priceLevels[j].upVolume;
      double downVolume = barFootprints[footprintIndex].priceLevels[j].downVolume;

      if(displayMode == DELTA)
        {
         //--- Compute signed delta and total volume for delta display mode
         double deltaValue = upVolume - downVolume;
         double total      = upVolume + downVolume;

         //--- Default delta color to weakest down shade
         color deltaColor = downColor1;
         if(barFootprints[footprintIndex].maxDeltaValue > 0 && total > 0)
           {
            //--- Derive delta color from signed delta relative to bar maximum
            deltaColor = GetVolumeColor(
                           deltaValue >= 0,
                           MathAbs(deltaValue) / barFootprints[footprintIndex].maxDeltaValue);
           }

         //--- Default total volume color to weakest shade
         color totalColor = volumeColor1;
         if(barFootprints[footprintIndex].maxTotalVolumeValue > 0)
           {
            //--- Derive total volume color from ratio to bar maximum
            totalColor = GetTotalVolumeColor(total / barFootprints[footprintIndex].maxTotalVolumeValue);
           }

         //--- Format delta with explicit sign and total as plain integer
         leftTexts[j]   = StringFormat("%+.0f", deltaValue);
         leftColors[j]  = deltaColor;
         rightTexts[j]  = StringFormat("%.0f", total);
         rightColors[j] = totalColor;
        }
      else
        {
         //--- Display raw bid volume on left and ask volume on right
         leftTexts[j]   = StringFormat("%.0f", downVolume);
         leftColors[j]  = downColor1;
         rightTexts[j]  = StringFormat("%.0f", upVolume);
         rightColors[j] = upColor1;
        }
     }

   if(displayMode == BID_VS_ASK)
     {
      //--- Apply diagonal imbalance coloring across adjacent levels in bid/ask mode
      for(int j = 0; j < size - 1; j++)
        {
         //--- Get ask volume at current level and bid volume at level below
         double higherAsk = barFootprints[footprintIndex].priceLevels[j].upVolume;
         double lowerBid  = barFootprints[footprintIndex].priceLevels[j + 1].downVolume;

         //--- Highlight ask if it meets the significance threshold
         if(barFootprints[footprintIndex].maxAskValue > 0 &&
            higherAsk / barFootprints[footprintIndex].maxAskValue >= 0.3)
           {
            //--- Compute ask-to-bid ratio and color accordingly
            double ratio    = higherAsk / (lowerBid + 0.0001);
            rightColors[j]  = GetDiagonalVolumeColor(true, ratio);
           }

         //--- Highlight bid if it meets the significance threshold
         if(barFootprints[footprintIndex].maxBidValue > 0 &&
            lowerBid / barFootprints[footprintIndex].maxBidValue >= 0.3)
           {
            //--- Compute bid-to-ask ratio and color accordingly
            double ratio       = lowerBid / (higherAsk + 0.0001);
            leftColors[j + 1]  = GetDiagonalVolumeColor(false, ratio);
           }
        }
     }

   //--- Configure the canvas font before drawing labels
   mainCanvas.FontSet("Consolas", priceLevelFontSize, FW_NORMAL);

   //--- Compute horizontal layout anchors for this bar
   int centerX  = BarToXCoordinate(barIndex);
   int barWidth = GetBarWidth(currentChartScale);
   int gap      = MathMax(1, barWidth / 4);
   int xLeft    = centerX - gap;
   int xRight   = centerX + gap;

   //--- Draw left and right labels for every price level
   for(int j = 0; j < size; j++)
     {
      double displayPrice = displayPrices[j];
      //--- Convert the display price to a vertical canvas pixel position
      int yPosition = PriceToYCoordinate(displayPrice);

      //--- Draw the left column label right-aligned at the gap boundary
      mainCanvas.TextOut(xLeft,  yPosition, leftTexts[j],
                         ColorToARGB(leftColors[j],  255), TA_RIGHT | TA_VCENTER);
      //--- Draw the right column label left-aligned at the gap boundary
      mainCanvas.TextOut(xRight, yPosition, rightTexts[j],
                         ColorToARGB(rightColors[j], 255), TA_LEFT  | TA_VCENTER);
     }

   //--- Draw the candle body and wicks on top of the footprint labels
   RenderCandleWithTrendLines(barIndex, barTime);
  }

Определяем функцию "RenderCandleWithTrendLines", которая рисует структуру бара по ценам открытия, максимума, минимума и закрытия с помощью трёх объектов OBJ_TREND на графике: один объект для тела, один для верхней тени и один для нижней тени. Цены бара получаем через iOpen, "iClose",iHigh, и "iLow", затем выбираем цвет тела из "upColor5" или "downColor5" в зависимости от того, находится ли закрытие выше или ниже открытия. Для каждого объекта строим уникальное имя, объединяя "objectPrefix" с описателем и временем бара, преобразованным через TimeToString, проверяем через ObjectFind существует ли он уже, и создаём его с помощью ObjectCreate только если он отсутствует, задавая "OBJPROP_RAY_RIGHT" равным false, "OBJPROP_SELECTABLE" равным false, а OBJPROP_HIDDEN равным true, чтобы объекты оставались аккуратными и недоступными для выделения.

Затем при каждом вызове применяем свойства цвета и толщины независимо от того, был объект только что создан или уже существовал, чтобы изменения цены или цвета всегда отображались. Важный крайний случай обрабатывается для свечей доджи, где открытие равно закрытию: в такой ситуации трендовая линия имела бы нулевую длину и стала бы невидимой, поэтому мы смещаем цену закрытия на один пункт, чтобы объект получил минимальную отображаемую длину. Верхняя тень проходит от максимума вниз до большего из открытия и закрытия через "MathMax", а нижняя тень — от минимума вверх до меньшего из открытия и закрытия через "MathMin"; обе отрисовываются цветом "candleWickColor" с толщиной 1.

Функция "RenderFootprint" отвечает за отрисовку всех подписей одного бара. После проверки индекса футпринта и подтверждения, что существует хотя бы один ценовой уровень, мы копируем отсортированные цены уровней в отдельный массив "displayPrices". Если "useStrictPricePositions" равно false, проходим по соседним элементам и сдвигаем вниз любую подпись, которая визуально перекрывала бы подпись выше, на "priceLevelFontSize" умноженный на "_Point". Это предотвращает наложение текста друг на друга на больших масштабах, не изменяя исходные цены данных.

Затем подготавливаем четыре параллельных массива — "leftTexts", "leftColors", "rightTexts" и "rightColors" — и заполняем их по каждому уровню в зависимости от активного режима отображения. В режиме "DELTA" левый столбец показывает дельта со знаком, отформатированную с явным плюсом или минусом с помощью StringFormat, окрашенную функцией "GetVolumeColor" на основе абсолютного значения дельты, нормализованного относительно "maxDeltaValue" бара, а правый столбец показывает суммарный объём, окрашенный через "GetTotalVolumeColor" и нормализованный относительно "maxTotalVolumeValue". В режиме "BID_VS_ASK" левый столбец показывает исходный bid-объём, а правый — исходный ask-объём; оба изначально получают самые слабые оттенки цветов.

В режиме "BID_VS_ASK" после начального назначения цветов выполняется дополнительный проход для диагональных дисбалансов. Он проходит по соседним парам уровней и проверяет, достаточно ли значим ask-объём на верхнем уровне или bid-объём на нижнем уровне — как минимум 30 процентов от соответствующего максимума по бару, — чтобы применить окраску дисбаланса. Когда порог выполнен, вычисляется отношение одной стороны к другой с добавлением малого epsilon в знаменатель для предотвращения деления на ноль, после чего вызывается "GetDiagonalVolumeColor", переопределяющая цвет конкретной подписи и визуально выделяющая паттерн накопленного давления.

Когда все массивы текста и цветов готовы, задаём шрифт Canvas как Consolas с размером "priceLevelFontSize" через FontSet, вычисляем горизонтальный центр бара через "BarToXCoordinate", берём зазор как четверть ширины бара и симметрично располагаем якоря левого и правого столбцов вокруг этого центра. Для каждого уровня преобразуем отображаемую цену в координату по Y Canvas через "PriceToYCoordinate" и вызываем TextOut дважды: один раз с выравниванием по правому краю у левого якоря для левого столбца и один раз с выравниванием по левому краю у правого якоря для правого столбца; в обоих случаях текст вертикально центрируется по этой строке.

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

//+------------------------------------------------------------------+
//| Redraw entire canvas for all visible bars                        |
//+------------------------------------------------------------------+
void RedrawCanvas(int ratesTotal)
  {
   //--- Skip rendering if canvas dimensions are not yet valid
   if(currentChartWidth <= 0 || currentChartHeight <= 0) return;

   //--- Declare buffers for OHLC and time data needed during rendering
   double   highs[], lows[];
   datetime times[];

   //--- Abort if bar data cannot be fully retrieved
   if(CopyHigh(_Symbol, _Period, 0, ratesTotal, highs) != ratesTotal) return;
   if(CopyLow(_Symbol,  _Period, 0, ratesTotal, lows)  != ratesTotal) return;
   if(CopyTime(_Symbol, _Period, 0, ratesTotal, times)  != ratesTotal) return;

   //--- Clear the canvas to fully transparent before redrawing
   uint defaultColor = 0;
   mainCanvas.Erase(defaultColor);

   //--- Iterate over every visible bar and render its footprint
   for(int i = 0; i < visibleBarsCount; i++)
     {
      //--- Map visible slot index back to an absolute bar index
      int barIndex = firstVisibleBarIndex - i;
      if(barIndex < 0 || barIndex >= ratesTotal) continue;

      //--- Convert bar index to the chronological buffer position
      int      bufferIndex = ratesTotal - 1 - barIndex;
      datetime barTime     = times[bufferIndex];

      //--- Look up the stored footprint data for this bar
      int footprintIndex = GetBarFootprintIndex(barTime);
      if(footprintIndex < 0) continue;

      //--- Render labels and candle for this bar
      RenderFootprint(footprintIndex, barIndex, barTime, ratesTotal);
     }

   //--- Push the completed canvas frame to the screen
   mainCanvas.Update();
  }

Определяем функцию "RedrawCanvas", которая выполняет полную перерисовку Canvas с нуля при каждом событии, запускающем обновление. Сначала защищаемся от отрисовки до появления корректных размеров Canvas, затем получаем полные серии максимумов, минимумов и времени для всех баров с помощью CopyHigh, "CopyLow" и CopyTime, немедленно прекращая выполнение, если любая операция копирования возвращает меньше значений, чем ожидалось, поскольку отрисовка на неполных данных дала бы смещённый результат. Полностью очищаем Canvas до прозрачного состояния вызовом Erase с нулевым значением цвета, чтобы пиксели предыдущего кадра не попадали в новый.

Затем проходим по каждому слоту видимого бара от нуля до "visibleBarsCount", преобразуя индекс слота в абсолютный индекс бара вычитанием из "firstVisibleBarIndex". Любой индекс бара за пределами допустимого диапазона пропускается. Индекс бара преобразуется в хронологическую позицию буфера вычитанием из "ratesTotal" минус один; затем извлекаем соответствующее время бара из массива "times" и передаём его в "GetBarFootprintIndex" для поиска соответствующего сохранённого футпринта. Если футпринта для этого бара ещё нет, пропускаем его и идём дальше. Для баров с корректными данными футпринта вызываем "RenderFootprint", которая рисует объёмные подписи и объекты свечи для этого бара в правильной позиции Canvas. После обработки всех видимых баров вызываем Update у Canvas, чтобы вывести готовый кадр на экран одной операцией, сохраняя отображение цельным и без мерцания из-за частичной отрисовки. После того как конвейер отрисовки определён, связываем все части вместе, начиная с обработчика события инициализации.

Реализация обработчика инициализации

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

//+------------------------------------------------------------------+
//| Initialize custom indicator                                      |
//+------------------------------------------------------------------+
int OnInit()
  {
   //--- Set short name displayed on the chart
   IndicatorSetString(INDICATOR_SHORTNAME, "Order Flow: Footprint - Part 1");
   //--- Compute the price granularity from ticks-per-level input
   priceLevelStep = ticksPerPriceLevel * _Point;
   //--- Clear the footprints array on fresh load
   ArrayResize(barFootprints, 0);

   //--- Capture initial chart dimensions for canvas sizing
   currentChartWidth  = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
   currentChartHeight = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);

   //--- Build the bitmap canvas that overlays the chart
   string canvasName = "OrderFlowFootprint_Canvas";
   if(!mainCanvas.CreateBitmapLabel(0, 0, canvasName, 0, 0, currentChartWidth, currentChartHeight, COLOR_FORMAT_ARGB_NORMALIZE))
     {
      //--- Report canvas failure and abort initialization
      Print("Failed to create canvas");
      return(INIT_FAILED);
     }

   return(INIT_SUCCEEDED);
  }

В обработчике события OnInit задаём короткое имя индикатора с помощью IndicatorSetString, чтобы оно корректно отображалось на панели графика. Затем вычисляем "priceLevelStep", умножая "ticksPerPriceLevel" на _Point, получая минимальное ценовое расстояние между соседними строками футпринта. Это значение используется функцией "QuantizePriceToLevel" и определяет детализацию всего распределения объёма. Сбрасываем "barFootprints" в пустое состояние через ArrayResize, чтобы при перезагрузке индикатора без перезапуска терминала не оставались устаревшие данные.

Получаем текущие размеры графика, считывая CHART_WIDTH_IN_PIXELS и CHART_HEIGHT_IN_PIXELS через ChartGetInteger, и сохраняем их в "currentChartWidth" и "currentChartHeight", чтобы Canvas уже с первого кадра точно соответствовал размеру графика. Затем создаём растровое наложение вызовом CreateBitmapLabel у "mainCanvas", передавая имя Canvas, позицию в точке (0, 0) графика и полученные размеры с нормализованным форматом ARGB. Если создать Canvas не удалось, выводим сообщение об ошибке и возвращаем "INIT_FAILED" для корректного прерывания. При успехе возвращаем INIT_SUCCEEDED, оставляя индикатор готовым к первому вызову расчёта. После компиляции инициализированный Canvas отображается следующим образом.

EMPTY CANVAS

Когда Canvas готов, определяем логику обработки тиков, которая управляет всем объёмным анализом и обновлениями Canvas.

Обработка тиков, управление границами баров и перерисовка Canvas в обработчике расчёта

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

//+------------------------------------------------------------------+
//| Calculate custom indicator values on each tick                   |
//+------------------------------------------------------------------+
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[])
  {
   //--- Require at least two bars for meaningful processing
   if(rates_total < 2) return 0;

   //--- Retrieve the open time of the forming bar
   datetime currentBarTime = time[rates_total - 1];
   //--- Detect a bar boundary by comparing against the last known bar time
   bool isNewBar = (currentBarTime != lastBarTime);

   if(isNewBar)
     {
      //--- Append a fresh footprint record for the newly opened bar
      int newSize = ArraySize(barFootprints);
      ArrayResize(barFootprints, newSize + 1);
      currentBarIndex = newSize;

      //--- Initialize all fields of the new footprint entry
      barFootprints[currentBarIndex].time               = currentBarTime;
      barFootprints[currentBarIndex].totalUpVolume      = 0;
      barFootprints[currentBarIndex].totalDownVolume    = 0;
      barFootprints[currentBarIndex].maxDeltaValue      = 0;
      barFootprints[currentBarIndex].maxTotalVolumeValue = 0;
      barFootprints[currentBarIndex].maxAskValue        = 0;
      barFootprints[currentBarIndex].maxBidValue        = 0;
      ArrayResize(barFootprints[currentBarIndex].priceLevels, 0);

      if(newSize > maxBarsToRender * 2)
        {
         //--- Trim oldest entries when the array grows beyond the render budget
         int toRemove = newSize - maxBarsToRender;
         for(int j = 0; j < toRemove; j++)
           {
            //--- Build time string to find related chart objects for deletion
            string timeString = TimeToString(barFootprints[j].time);
            ObjectsDeleteAll(0, objectPrefix + "Body_"      + timeString);
            ObjectsDeleteAll(0, objectPrefix + "UpperWick_" + timeString);
            ObjectsDeleteAll(0, objectPrefix + "LowerWick_" + timeString);
           }
         //--- Remove the corresponding footprint records from the front of the array
         ArrayRemove(barFootprints, 0, toRemove);
         //--- Shift the current bar index to reflect the removal offset
         currentBarIndex -= toRemove;
        }

      //--- Commit new bar time and seed the close price from the open
      lastBarTime    = currentBarTime;
      lastClosePrice = open[rates_total - 1];
      lastTickVolume = 0;
     }

   //--- Abort processing if the current bar index is out of bounds
   if(currentBarIndex < 0 || currentBarIndex >= ArraySize(barFootprints)) return rates_total;

   //--- Compute volume increment since the last tick
   double volumeDiff = (double)(tick_volume[rates_total - 1] - lastTickVolume);
   //--- Advance the stored tick volume to the current tick
   lastTickVolume = tick_volume[rates_total - 1];

   //--- Flag whether data changed this tick, triggering a redraw
   bool dataChanged = false;

   if(volumeDiff > 0)
     {
      //--- Mark data as dirty so the canvas will be refreshed
      dataChanged = true;

      double currentClose = close[rates_total - 1];
      double upDiff       = 0.0;
      double downDiff     = 0.0;

      if(currentClose > lastClosePrice)
        {
         //--- Price moved up, attribute full volume increment to ask side
         upDiff         = volumeDiff;
         lastTradeAtAsk = true;
        }
      else if(currentClose < lastClosePrice)
        {
         //--- Price moved down, attribute full volume increment to bid side
         downDiff       = volumeDiff;
         lastTradeAtAsk = false;
        }
      else
        {
         //--- Price unchanged, carry volume to whichever side traded last
         if(lastTradeAtAsk) upDiff   = volumeDiff;
         else               downDiff = volumeDiff;
        }

      //--- Persist the close price for comparison on the next tick
      lastClosePrice = currentClose;

      //--- Snap the current close to its discrete price level
      double quantizedPrice = QuantizePriceToLevel(currentClose);
      //--- Accumulate the volume split into the matching price level
      UpdatePriceLevel(barFootprints[currentBarIndex], quantizedPrice, upDiff, downDiff);

      //--- Update the bar's cumulative up and down volume totals
      barFootprints[currentBarIndex].totalUpVolume   += upDiff;
      barFootprints[currentBarIndex].totalDownVolume += downDiff;

      //--- Recompute normalization maximums after the volume update
      ComputeMaxValues(barFootprints[currentBarIndex]);
      //--- Re-sort levels so the highest prices render at the top
      SortPriceLevelsDescending(barFootprints[currentBarIndex]);
     }

   //--- Track whether chart geometry has changed this tick
   bool hasChartChanged = false;

   //--- Sample current chart geometry for comparison
   int    newChartWidth       = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
   int    newChartHeight      = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
   int    newChartScale       = (int)ChartGetInteger(0, CHART_SCALE);
   int    newFirstVisibleBar  = (int)ChartGetInteger(0, CHART_FIRST_VISIBLE_BAR);
   int    newVisibleBars      = (int)ChartGetInteger(0, CHART_VISIBLE_BARS);
   double newMinPrice         = ChartGetDouble(0, CHART_PRICE_MIN, 0);
   double newMaxPrice         = ChartGetDouble(0, CHART_PRICE_MAX, 0);

   if(newChartWidth != currentChartWidth || newChartHeight != currentChartHeight)
     {
      //--- Resize the canvas bitmap to match the new window dimensions
      mainCanvas.Resize(newChartWidth, newChartHeight);
      currentChartWidth  = newChartWidth;
      currentChartHeight = newChartHeight;
      hasChartChanged    = true;
     }

   if(newChartScale      != currentChartScale      ||
      newFirstVisibleBar != firstVisibleBarIndex    ||
      newVisibleBars     != visibleBarsCount        ||
      newMinPrice        != minVisiblePrice         ||
      newMaxPrice        != maxVisiblePrice)
     {
      //--- Sync all viewport state variables with the current chart values
      currentChartScale    = newChartScale;
      firstVisibleBarIndex = newFirstVisibleBar;
      visibleBarsCount     = newVisibleBars;
      minVisiblePrice      = newMinPrice;
      maxVisiblePrice      = newMaxPrice;
      hasChartChanged      = true;
     }

   //--- Redraw the canvas whenever geometry, new bars, or volume data changed
   datetime currentTime = TimeCurrent();
   if(hasChartChanged || rates_total > prev_calculated || dataChanged)
     {
      RedrawCanvas(rates_total);
      //--- Record the redraw timestamp for potential throttling use
      lastRedrawTime = currentTime;
     }

   //--- Trigger a chart refresh to display the updated canvas overlay
   ChartRedraw(0);

   return rates_total;
  }

В обработчике события OnCalculate сначала защищаемся от недостатка данных, возвращая ноль, если доступно меньше двух баров. Считываем время открытия формирующегося бара из последнего элемента массива "time" и сравниваем его с "lastBarTime", чтобы определить границу бара. Когда обнаружен новый бар, увеличиваем "barFootprints" на один элемент, задаём "currentBarIndex" как новый слот и инициализируем все его поля — время, накопленные объёмы, максимумы для отслеживания и пустой массив "priceLevels" — нулевыми или пустыми значениями. Затем проверяем, не превысил ли размер массива удвоенное значение "maxBarsToRender". Если да, вычисляем количество записей для удаления с начала, удаляем связанные с ними объекты тела, верхней тени и нижней тени с помощью ObjectsDeleteAll с префиксом имени на основе времени, удаляем записи через ArrayRemove, и уменьшаем "currentBarIndex" на количество удалённых элементов, чтобы он указывал на правильную запись. Затем фиксируем новое время бара в "lastBarTime", заполняем "lastClosePrice" ценой открытия бара, чтобы первый тик нового бара имел корректную точку отсчёта, и сбрасываем "lastTickVolume" в ноль.

После блока нового бара вычисляем приращение объёма для текущего тика, вычитая "lastTickVolume" из текущего значения "tick_volume", затем обновляем "lastTickVolume" текущим значением. Если приращение положительное, устанавливаем "dataChanged" в true и определяем, как разделить объём между сторонами ask и bid: если текущее закрытие выше "lastClosePrice", всё приращение идёт в "upDiff", а "lastTradeAtAsk" принимает значение true; если ниже — всё приращение идёт в "downDiff", а "lastTradeAtAsk" принимает значение false; если цена не изменилась, переносим объём на сторону последней сделки, используя "lastTradeAtAsk" для определения стороны. Обновляем "lastClosePrice" текущим закрытием, дискретизируем его через "QuantizePriceToLevel" и передаём результат вместе с разделённым объёмом в "UpdatePriceLevel", чтобы накопить его в соответствующей ценовой строке. Затем добавляем приращения к накопленным итогам бара, вызываем "ComputeMaxValues" для обновления знаменателей нормализации и "SortPriceLevelsDescending", чтобы уровни оставались отсортированными сверху вниз для отрисовки.

Затем считываем текущую геометрию графика — ширину, высоту, масштаб, первый видимый бар, количество видимых баров, минимальную цену и максимальную цену — и сравниваем каждое значение с сохранённым. Если размеры Canvas изменились, вызываем "Resize" у "mainCanvas" и обновляем сохранённые ширину и высоту. Если изменилось любое другое значение геометрии, синхронизируем все переменные состояния области просмотра и устанавливаем "hasChartChanged" в true. Наконец, вызываем "RedrawCanvas" для перерисовки всего наложения и записываем время в "lastRedrawTime", если изменилось состояние графика, появились новые бары ("rates_total" больше "prev_calculated") или обновились объёмные данные ("dataChanged" равно true). Каждый вызов завершаем функцией ChartRedraw, чтобы вывести обновлённый Canvas на экран, и возвращаем "rates_total", подтверждая обработку всех баров. После завершения обработки тиков остаются два финальных обработчика событий: один очищает ресурсы, а другой обрабатывает навигацию по графику.

Освобождение ресурсов и обработка событий изменения графика

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

//+------------------------------------------------------------------+
//| Deinitialize custom indicator                                    |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   //--- Release the canvas bitmap resource
   mainCanvas.Destroy();
   //--- Remove all chart objects created by this indicator
   ObjectsDeleteAll(0, objectPrefix);
   //--- Force chart refresh after cleanup
   ChartRedraw(0);
  }

//+------------------------------------------------------------------+
//| Handle chart events                                              |
//+------------------------------------------------------------------+
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
  {
   if(id == CHARTEVENT_CHART_CHANGE)
     {
      //--- Redraw when the user scrolls, zooms, or resizes the chart
      RedrawCanvas(Bars(_Symbol, _Period));
     }
  }

В обработчике события OnDeinit вызываем Destroy у "mainCanvas", чтобы освободить растровую метку и связанную память, затем используем ObjectsDeleteAll с "objectPrefix" для удаления всех объектов трендовых линий, созданных индикатором для тел и теней свечей по всем отрисованным барам. Завершаем вызовом ChartRedraw, чтобы принудительно обновить график и подтвердить, что все визуальные элементы корректно очищены с экрана.

В обработчике события OnChartEvent проверяем, равен ли входящий идентификатор события значению CHARTEVENT_CHART_CHANGE. Это событие возникает, когда пользователь горизонтально прокручивает график, меняет масштаб или изменяет размер окна терминала. При обнаружении такого события вызываем "RedrawCanvas", передавая текущее количество баров, полученное через Bars, чтобы запустить полную перерисовку Canvas с учётом обновлённой геометрии области просмотра. Это гарантирует, что подписи футпринта и объекты свечей всегда остаются точно выровненными относительно графика после любой навигации без необходимости ждать прихода нового тика. На этом реализация завершена. Осталось протестировать систему, что мы и сделаем в следующем разделе.


Бэктестинг

Мы провели тестирование, и ниже представлен результат в виде анимированного изображения в формате GIF.

ORDER FLOW FOOTPRINT GIF

Во время тестирования подписи футпринта корректно обновлялись на каждом тике, объём накапливался в правильных строках ценовых уровней, цветовое отображение дельты менялось от менее интенсивных к более интенсивным оттенкам по мере роста концентрации объёма на конкретных уровнях, а элементы Canvas мгновенно перестраивались в соответствии с новым положением баров при прокрутке или масштабировании графика, не ожидая прихода нового тика.


Заключение

В заключение мы создали индикатор для построения футпринт-графика в MQL5, который отслеживает потиковый объём на дискретизированных ценовых уровнях, разделяет покупательскую и продавательскую активность в режимах Bid vs Ask и Delta, а также отрисовывает окрашенные по объёму подписи на наложении Canvas рядом со свечами, построенными на основе объектов трендовых линий; вся визуализация обновляется в реальном времени.

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

  • Читать столбец дельты на каждом баре, чтобы определять, где покупательская или продавательская агрессия была сильнее всего на конкретных ценовых уровнях, используя значительную положительную дельту рядом с поддержкой как подтверждение для входа в длинную позицию, а значительную отрицательную дельту рядом с сопротивлением — как подтверждение для входа в короткую позицию
  • Переключаться в режим Bid vs Ask и искать диагональные дисбалансы, когда ask-объём на одном уровне значительно превышает bid-объём на уровне непосредственно ниже, рассматривая такие многоуровневые дисбалансы как зоны направленного давления, способные поддержать дальнейшее движение цены
  • Определять ценовые уровни с максимальным суммарным объёмом внутри бара как точку контроля этого бара и использовать возвраты к этим уровням на следующих барах как высоковероятные ориентиры для входов, выходов и размещения стопов

В следующей части мы усовершенствуем футпринт-график, добавив над каждой свечой информационный блок, который будет показывать чистую дельту, суммарный объём, а также доли покупок и продаж. Блок получит закруглённые углы, настраиваемую прозрачность и цветовое кодирование интенсивности дельты.

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

Прикрепленные файлы |
Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (кодовая книга сценариев) Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (кодовая книга сценариев)
Статья описывает переход к многосценарному прогнозу на базе ORION: создаётся кодовая книга рыночных прототипов с EMA-памятью и слой, объединяющий генератор, роутер и оценку неопределённости. Такой модуль формирует траектории, их априорные вероятности и допустимый разброс, что позволяет учитывать альтернативные продолжения рынка при проектировании торговых решений.
Теория графов: Применение алгоритма эвристического поиска A* в торговле Теория графов: Применение алгоритма эвристического поиска A* в торговле
В статье эвристический алгоритм A* применяется к структуре рынка путем моделирования подтвержденных максимумов и минимумов свингов в качестве узлов графа, где веса ребер рассчитываются с учетом нормализованного по ATR расстояния, спреда и штрафов за шум. Модуль ищет наиболее эффективный маршрут для определения направления сделки и целевых уровней, а затем фильтрует сигналы по коэффициенту направленности, общей стоимости пути и противоположным свингам. TP привязывается к конечному узлу, а SL — к предыдущей структуре, с визуализацией на графике и настраиваемыми входными данными.
Ассоциативные правила и теория вероятностей на Форекс: Фильтр сигналов без нейросетей и без магии Ассоциативные правила и теория вероятностей на Форекс: Фильтр сигналов без нейросетей и без магии
Рассматриваем альтернативу нейросетевым фильтрам сигналов: ассоциативные правила и базовую теорию вероятностей в MQL5, без Python и внешних библиотек. Показано, как дискретизировать рыночные условия (RSI, EMA, ATR, импульс, сессия), собрать статистику по support, confidence, lift и edge с сглаживанием Лапласа и применять результат как прозрачный входной фильтр перед сделкой.
Создание пользовательских индикаторов в MQL5 (Часть 8): Интеграция объёма для углублённого анализа рыночного профиля (Market Profile) Создание пользовательских индикаторов в MQL5 (Часть 8): Интеграция объёма для углублённого анализа рыночного профиля (Market Profile)
В этой статье мы расширяем гибридный индикатор рыночного профиля Time Price Opportunity (TPO) в MQL5 за счёт интеграции данных объёма для расчёта точки контроля по объёму, области стоимости и средневзвешенной по объёму цены с настраиваемыми вариантами подсветки. Система вводит расширенные возможности, такие как определение начального баланса, линии продления ключевых уровней, режим split-профиля и альтернативные TPO-символы, например квадраты или круги, для улучшенного анализа графика на нескольких таймфреймах.