Создание пользовательских индикаторов в MQL5 (Часть 9): Футпринт-график с отслеживанием объёма по ценовым уровням
Введение
Свечные графики показывают, куда двигалась цена, но не показывают, кто контролировал каждый уровень. Без данных ордер-флоу невозможно увидеть, покупатели или продавцы доминировали на конкретном ценовом уровне. Также не видно, было ли движение действительно подтверждено объёмом или происходило на низком объёме, и где внутри каждого бара фактически проявлялись поглощение и агрессия участников рынка. Эта статья предназначена для разработчиков MetaQuotes Language 5 (MQL5) и алгоритмических трейдеров, которые хотят создать индикатор футпринта, показывающий объёмную активность внутри каждой свечи.
В нашей предыдущей статье (Часть 8) мы усовершенствовали гибридный индикатор рыночного профиля Time Price Opportunity в MQL5, добавив объёмные данные для расчёта точки контроля, зоны стоимости и средневзвешенной по объёму цены с настраиваемой подсветкой. В Части 9 мы создадим индикатор футпринта для анализа ордер-флоу, который отслеживает потиковый объём на дискретизированных ценовых уровнях, разделяет покупательскую и продавательскую активность в режимах Bid vs Ask и Delta, а также отрисовывает окрашенный по объёму текст на наложении Canvas рядом со свечами, построенными на основе объектов трендовых линий; вся визуализация обновляется в реальном времени. Мы рассмотрим следующие темы:
- Расшифровка футпринт-графика: что ценовые уровни говорят об активности участников рынка
- Реализация в MQL5
- Бэктестинг
- Заключение
К концу статьи у вас будет рабочий индикатор футпринта 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 отображается следующим образом.

Когда 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.

Во время тестирования подписи футпринта корректно обновлялись на каждом тике, объём накапливался в правильных строках ценовых уровней, цветовое отображение дельты менялось от менее интенсивных к более интенсивным оттенкам по мере роста концентрации объёма на конкретных уровнях, а элементы Canvas мгновенно перестраивались в соответствии с новым положением баров при прокрутке или масштабировании графика, не ожидая прихода нового тика.
Заключение
В заключение мы создали индикатор для построения футпринт-графика в MQL5, который отслеживает потиковый объём на дискретизированных ценовых уровнях, разделяет покупательскую и продавательскую активность в режимах Bid vs Ask и Delta, а также отрисовывает окрашенные по объёму подписи на наложении Canvas рядом со свечами, построенными на основе объектов трендовых линий; вся визуализация обновляется в реальном времени.
Реализация охватила дискретизацию ценовых уровней и накопление объёма в структурированном массиве футпринтов, вычисление максимальных значений для нормализации цвета, сортировку ценовых уровней по убыванию, преобразование индекса бара и цены в пиксели Canvas, а также систему перерисовки, реагирующую на прокрутку, масштабирование, изменение размера графика и новые тики. После прочтения статьи вы сможете:
- Читать столбец дельты на каждом баре, чтобы определять, где покупательская или продавательская агрессия была сильнее всего на конкретных ценовых уровнях, используя значительную положительную дельту рядом с поддержкой как подтверждение для входа в длинную позицию, а значительную отрицательную дельту рядом с сопротивлением — как подтверждение для входа в короткую позицию
- Переключаться в режим Bid vs Ask и искать диагональные дисбалансы, когда ask-объём на одном уровне значительно превышает bid-объём на уровне непосредственно ниже, рассматривая такие многоуровневые дисбалансы как зоны направленного давления, способные поддержать дальнейшее движение цены
- Определять ценовые уровни с максимальным суммарным объёмом внутри бара как точку контроля этого бара и использовать возвраты к этим уровням на следующих барах как высоковероятные ориентиры для входов, выходов и размещения стопов
В следующей части мы усовершенствуем футпринт-график, добавив над каждой свечой информационный блок, который будет показывать чистую дельту, суммарный объём, а также доли покупок и продаж. Блок получит закруглённые углы, настраиваемую прозрачность и цветовое кодирование интенсивности дельты.
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/21825
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (кодовая книга сценариев)
Теория графов: Применение алгоритма эвристического поиска A* в торговле
Ассоциативные правила и теория вероятностей на Форекс: Фильтр сигналов без нейросетей и без магии
Создание пользовательских индикаторов в MQL5 (Часть 8): Интеграция объёма для углублённого анализа рыночного профиля (Market Profile)
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования