Создание пользовательских индикаторов в MQL5 (Часть 10): Дополнение футпринт-графика информационным блоком настроений по объёму по каждому бару
Введение
Футпринт-график показывает объём на каждом ценовом уровне внутри каждого бара. Без информационного блока настроений для каждого бара приходится вручную агрегировать уровни, чтобы понять, какая сторона доминировала, оценить степень перекоса участия и сравнить настроения по объёму между барами. Это замедляет как визуальный анализ, так и алгоритмическое принятие решений. Эта статья предназначена для MetaQuotes Language 5 MQL5-разработчиков и системных трейдеров, которые хотят расширить существующий индикатор футпринта информационным блоком настроений для каждого бара. Этот блок обновляется при поступлении каждого нового тика, остаётся лёгким при отрисовке за счёт кэширования метрик бара и рисуется со скруглёнными углами и альфа-смешиванием, чтобы детализация ценовых уровней под ним оставалась хорошо различимой.
В нашей предыдущей статье (Часть 9), мы создали индикатор футпринта ордер-флоу в MQL5, который отслеживал потиковый объём на дискретизированных ценовых уровнях, разделял покупательскую и продавательскую активность и отображал её в режимах Bid vs Ask и Delta, а также выводил окрашенные по объёму текстовые метки на холсте Canvas рядом со свечами, построенными с помощью объектов трендовых линий с обновлением в реальном времени. В Части 10 мы добавляем над каждой свечой информационный блок. Он показывает чистую дельту, общий объём и процентное соотношение покупок и продаж с цветовым кодированием интенсивности дельты, использует суперсэмплинг для сглаженного вывода, скруглённые углы с помощью растеризации дуг и четырёхугольников, а также альфа-смешивание, чтобы аккуратно накладывать блок на холст. Мы рассмотрим следующие темы:
- Чтение истории бара — что с первого взгляда показывает сводка объёмных настроений по бару
- Реализация в MQL5
- Бэктестирование
- Заключение
К концу статьи у вас будет улучшенный MQL5-индикатор футпринт-графика с информационным блоком настроений для каждого бара. Он кратко показывает дельту, объём и процентное соотношение покупок и продаж над каждой свечой и готов к дальнейшей настройке. Приступим!
Чтение истории бара — что с первого взгляда показывает сводка объёмных настроений по бару
Завершённый бар содержит информацию, которую строки ценовых уровней плохо обобщают. Чистая дельта показывает, какая сторона в целом проявляла большую агрессивность, общий объём показывает, сопровождалась ли эта агрессия существенным участием или происходила в условиях низкой активности, а соотношение покупок и продаж раскрывает степень дисбаланса. Когда эти три числа видны над каждой свечой в компактном блоке с цветовым кодированием, трейдер может за несколько секунд просканировать десятки баров и сразу определить, в каких барах доминировала одна сторона, какие были сбалансированы и было ли доминирование подкреплено высоким или низким объёмом, не читая ни одной строки ценового уровня.
В реальной торговле используйте чистую дельту в информационном блоке настроений по объёму как первый фильтр — бар, закрывшийся выше при сильно отрицательной дельте, предупреждает, что продавцы абсорбировали движение вверх, поэтому такой бар хуже подходит для длинной позиции, чем бар с соответствующей положительной дельтой. Следите за последовательными барами, где направление дельты расходится с направлением цены: устойчивая дивергенция дельты часто предшествует разворотам. Используйте значение общего объёма, чтобы отделять бары с уверенным участием от шума: большая дельта на низком общем объёме имеет меньший вес, чем такая же дельта при высоком участии. Процентное соотношение особенно полезно на ключевых уровнях: бар с 80% покупок, отвергающий уровень поддержки, подтверждает сильное поглощение, тогда как почти равное соотношение 50/50 на том же уровне говорит скорее о нерешительности, чем об уверенности. Используйте интенсивность цвета самого информационного блока как визуальную тепловую карту по графику: последовательность всё более насыщенных цветов в одном направлении сигнализирует о нарастающем импульсе, а внезапная смена цвета — о потенциальной смене рыночного режима.
Мы расширяем структуру футпринта дельтой на уровне бара, процентами и кэшированными цветами, вычисляем эти значения при каждом тиковом обновлении через отдельную функцию, реализуем геометрию скруглённых углов и точные вспомогательные функции для рисования дуг, добавляем конвейер суперсэмплинга с последующей понижающей дискретизацией для сглаженного вывода и накладываем готовый блок на основной холст с помощью попиксельного альфа-смешивания. Ниже кратко показано, что мы собираемся реализовать.

Реализация в MQL5
Опираясь на основу из предыдущей части, теперь мы расширяем входные параметры и структуру "BarFootprintData", чтобы поддержать новый информационный блок. Входные параметры добавляют настраиваемые отступы, размеры блока, параметры прозрачности, размер шрифта и параметры скруглённых углов, а структура получает новые поля, в которых кэшируются вычисленные цвета, дельта и процентное соотношение покупок и продаж, чтобы их не нужно было пересчитывать при каждой перерисовке.
//+------------------------------------------------------------------+ //| OrderFlow FootPrint PART 2.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> //--- input double minPriceLevelSpacing = 1.1; // Min Spacing Between Price Levels input group "Info Box (Above Each Candle)" input bool showInfoBox = true; // Show Info Box input int infoBoxPaddingWidthPixels = 2; // Info Box Padding Width (pixels) input int infoBoxPaddingHeightPixels = 1; // Info Box Padding Height (pixels) input group "Info Box Fonts" input int infoBoxBaseFontSize = 5; // Info Box Font Size (base for all labels) input group "Info Box Style" input int backgroundTransparencyPercent = 15; // Background Transparency (0=opaque, 100=invisible) input int borderTransparencyPercent = 20; // Border Transparency (0=opaque, 100=invisible) input int infoBoxGapPixels = 10; // Gap Above Candle High (pixels) input group "Info Box Rounded Corners" input bool enableRoundedCorners = true; // Enable Rounded Corners input int cornerRadiusPixels = 4; // Corner Radius (pixels) input int borderThicknessPixels = 1; // Border Thickness (pixels) input int supersamplingFactor = 4; // Supersampling Level (1=off, 2=2x, 4=4x) struct BarFootprintData { datetime time; // Bar open time FootprintPriceLevel priceLevels[]; // Array of price levels for this bar double totalUpVolume; // Cumulative buy volume for the bar double totalDownVolume; // Cumulative sell volume for the 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 color boxColor; // Computed info box background color color textColor; // Computed info box text color double delta; // Net delta (up minus down) for the bar double upPercentage; // Buy volume as percentage of total double downPercentage; // Sell volume as percentage of total };
Сначала мы добавляем "minPriceLevelSpacing" в группу настроек, чтобы точнее управлять минимальным вертикальным расстоянием между соседними метками ценовых уровней и заменить прежний фиксированный множитель размера шрифта на настраиваемый коэффициент. Затем вводим четыре новые группы входных параметров для информационного блока. Первая группа добавляет переключатель "showInfoBox", а также "infoBoxPaddingWidthPixels" и "infoBoxPaddingHeightPixels" для управления внутренними отступами между краем блока и его текстовым содержимым. Группа шрифтов добавляет "infoBoxBaseFontSize" как базовый размер, масштабируемый вместе с графиком.
Группа стиля вводит "backgroundTransparencyPercent" и "borderTransparencyPercent" для независимого управления прозрачностью заливки и границы по шкале, где 0 означает полную непрозрачность, а 100 — невидимость, а "infoBoxGapPixels" задаёт вертикальный зазор между нижней границей блока и максимумом свечи (high). Группа скруглённых углов добавляет "enableRoundedCorners" как переключатель, "cornerRadiusPixels" для размера дуги в каждом углу, "borderThicknessPixels" для толщины линии и "supersamplingFactor" для задания множителя разрешения отрисовки, используемого при сглаженном рисовании блока.
В структуре "BarFootprintData" мы сохраняем все существующие поля из предыдущей части и добавляем пять новых полей для поддержки информационного блока. Мы добавляем "boxColor" и "textColor" как предварительно вычисленные значения цветов, чтобы цикл отрисовки мог применять их напрямую без пересчёта на каждом кадре. Мы добавляем "delta" для хранения чистой разницы между суммарным объёмом покупок и продаж по всему бару, а "upPercentage" и "downPercentage" — для хранения округлённых процентов участия покупателей и продавцов. Для наглядности они выделены. Кэширование этих значений в структуре означает, что основные вычисления выполняются при поступлении нового тика в отдельной функции, а не внутри цикла отрисовки, благодаря чему процесс перерисовки остаётся минимально затратным. Расширив структуру, перейдём к вспомогательным функциям, которые дадут больше контроля над рендерингом цветов.
Затемнение цветов и расчёт цветов и процентных значений для каждого бара
Перед отрисовкой информационного блока нужны две вспомогательные функции: одна создаёт более тёмный оттенок любого заданного цвета для границы блока, а вторая вычисляет и кэширует все показатели настроений для каждого бара — дельту, процентное соотношение покупок и продаж, цвет фона блока и цвет текста, — чтобы в цикле отрисовки уже было всё необходимое без вычислений во время рисования.
//+------------------------------------------------------------------+ //| Darken color by a given factor | //+------------------------------------------------------------------+ color DarkenColor(color baseColor, double darkenFactor = 0.5) { //--- Scale the red channel by the darkening factor uchar red = uchar((baseColor & 0xFF) * darkenFactor); //--- Scale the green channel by the darkening factor uchar green = uchar(((baseColor >> 8) & 0xFF) * darkenFactor); //--- Scale the blue channel by the darkening factor uchar blue = uchar(((baseColor >> 16) & 0xFF) * darkenFactor); //--- Reassemble and return the darkened color in BGR format return (color)((blue << 16) | (green << 8) | red); } //+------------------------------------------------------------------+ //| Calculate bar info box colors and volume percentages | //+------------------------------------------------------------------+ void CalculateBarColorsAndPercentages(BarFootprintData &footprint) { //--- Compute the net delta for the full bar footprint.delta = footprint.totalUpVolume - footprint.totalDownVolume; //--- Compute the combined volume for percentage calculations double totalVolume = footprint.totalUpVolume + footprint.totalDownVolume; if(totalVolume == 0) { //--- Default to neutral colors and zero percentages when no volume exists footprint.boxColor = upColor1; footprint.textColor = upColor1; footprint.upPercentage = 0; footprint.downPercentage = 0; return; } //--- Round percentages to whole numbers for display footprint.upPercentage = MathRound(100 * footprint.totalUpVolume / totalVolume); footprint.downPercentage = MathRound(100 * footprint.totalDownVolume / totalVolume); if(footprint.delta > 0) { //--- Derive box color from bullish delta intensity double ratio = MathAbs(footprint.delta / totalVolume); footprint.boxColor = GetVolumeColor(true, ratio); } else if(footprint.delta < 0) { //--- Derive box color from bearish delta intensity double ratio = MathAbs(footprint.delta / totalVolume); footprint.boxColor = GetVolumeColor(false, ratio); } else { //--- Assign neutral color when delta is exactly zero footprint.boxColor = upColor1; } //--- Identify the dominant side percentage for text color derivation double winningPercentage = (footprint.delta > 0) ? footprint.upPercentage : footprint.downPercentage; //--- Compute how far the dominant side exceeds 50% double percentAbove50 = winningPercentage - 50; //--- Clamp to zero so values below 50% do not produce negative ratios if(percentAbove50 < 0) percentAbove50 = 0; //--- Scale the excess to a 0–1 ratio for color intensity double textRatio = MathCeil(percentAbove50) / 50.0; //--- Assign the text color matching the dominant direction and intensity footprint.textColor = GetVolumeColor(footprint.delta > 0, textRatio); }
Мы определяем функцию "DarkenColor" для создания более тёмного варианта любого цвета путём независимого масштабирования каждого из трёх цветовых каналов. Она извлекает красный, зелёный и синий компоненты входного цвета с помощью побитовых операций, умножает каждый из них на "darkenFactor", который по умолчанию равен 0.5 и даёт уменьшение яркости вдвое, а затем собирает компоненты обратно в значение цвета в правильном порядке байтов. Эта функция используется при вычислении цвета границы информационного блока: затемнённая версия цвета заливки создаёт естественно выглядящий контур без отдельного входного параметра для цвета границы.
Функция "CalculateBarColorsAndPercentages" является ядром расчёта настроений для каждого бара и выполняется при каждом обновлении тика после обновления объёма. Сначала она вычисляет чистую дельту бара, вычитая общий объём продаж из общего объёма покупок, и сохраняет результат в поле "delta". Затем она вычисляет общий объём и, если он равен нулю, устанавливает для всех полей нейтральные значения и завершает выполнение. Если объём есть, она округляет процентное соотношение покупок и продаж до целых чисел с помощью MathRound и сохраняет их в "upPercentage" и "downPercentage".
Для цвета фона блока функция вычисляет абсолютное отношение дельты к общему объёму и передаёт его в "GetVolumeColor" с флагом направления true для положительной дельты и false для отрицательной. В результате получается цвет, интенсивность которого отражает, насколько одна сторона доминировала относительно общей активности. Нулевая дельта напрямую назначает базовый нейтральный оттенок.
Цвет текста рассчитывается по другой логике. Вместо отношения дельты к общему объёму берётся процент доминирующей стороны, из него вычитается 50, чтобы определить превышение над равным распределением, отрицательный результат ограничивается нулём, а затем превышение делится на 50, чтобы преобразовать его в коэффициент от нуля до единицы с помощью функции MathCeil. Это означает, что бар с идеально равным соотношением 50/50 получает самый слабый цвет текста, а бар с выраженным преобладанием одной стороны — самый сильный. Таким образом, сам текст несёт визуальный сигнал о степени дисбаланса. Когда цвета и проценты кэшированы в структуре, можно строить геометрические утилиты, от которых зависит отрисовка скруглённых углов.
Преобразование цветов в ARGB, понижающая дискретизация холстов высокого разрешения и нормализация углов
Цикл отрисовки информационного блока опирается на три базовые утилиты: функцию, которая преобразует цвет и значение непрозрачности в процентах в полноценное ARGB-значение для полупрозрачного рисования, функцию понижающей дискретизации, которая усредняет холст высокого разрешения до экранного разрешения для сглаженного вывода, и две функции работы с углами, на которые опирается отрисовщик дуг скруглённых углов при определении пикселей, попадающих в заданный сегмент дуги.
//+------------------------------------------------------------------+ //| Convert color to ARGB with opacity percent | //+------------------------------------------------------------------+ uint ColorToArgbWithOpacity(color clr, int opacityPercent) { //--- Extract the individual RGB channels from the color value uchar redComponent = (uchar)((clr >> 0) & 0xFF); uchar greenComponent = (uchar)((clr >> 8) & 0xFF); uchar blueComponent = (uchar)((clr >> 16) & 0xFF); //--- Map the 0–100 opacity percent to a 0–255 alpha value uchar alphaComponent = (uchar)((opacityPercent * 255) / 100); //--- Assemble and return the ARGB value with the computed alpha return ((uint)alphaComponent << 24) | ((uint)redComponent << 16) | ((uint)greenComponent << 8) | (uint)blueComponent; } //+------------------------------------------------------------------+ //| Downsample high-res canvas to target using box filter | //+------------------------------------------------------------------+ void DownsampleBicubic(CCanvas &targetCanvas, CCanvas &highResCanvas, int downsampleFactor) { //--- Get target canvas dimensions for iteration bounds int targetWidth = targetCanvas.Width(); int targetHeight = targetCanvas.Height(); //--- Iterate over every pixel in the target (low-res) canvas for(int pixelY = 0; pixelY < targetHeight; pixelY++) { for(int pixelX = 0; pixelX < targetWidth; pixelX++) { //--- Map the target pixel back to its source region in the high-res canvas double sourceX = pixelY * downsampleFactor; double sourceY = pixelX * downsampleFactor; //--- Accumulate weighted channel sums across the source region double sumAlpha = 0, sumRed = 0, sumGreen = 0, sumBlue = 0; double weightSum = 0; //--- Loop over all source pixels that map to this target pixel for(int deltaY = 0; deltaY < downsampleFactor; deltaY++) { for(int deltaX = 0; deltaX < downsampleFactor; deltaX++) { int sourcePixelX = (int)(sourceX + deltaX); int sourcePixelY = (int)(sourceY + deltaY); //--- Skip source pixels that fall outside the high-res canvas bounds if(sourcePixelX >= 0 && sourcePixelX < highResCanvas.Width() && sourcePixelY >= 0 && sourcePixelY < highResCanvas.Height()) { //--- Read the source pixel ARGB value uint pixelValue = highResCanvas.PixelGet(sourcePixelX, sourcePixelY); //--- Unpack individual channels from the ARGB pixel uchar alpha = (uchar)((pixelValue >> 24) & 0xFF); uchar red = (uchar)((pixelValue >> 16) & 0xFF); uchar green = (uchar)((pixelValue >> 8) & 0xFF); uchar blue = (uchar)(pixelValue & 0xFF); //--- Use uniform weight of 1.0 for simple box-filter averaging double weight = 1.0; sumAlpha += alpha * weight; sumRed += red * weight; sumGreen += green * weight; sumBlue += blue * weight; weightSum += weight; } } } if(weightSum > 0) { //--- Average the accumulated channel values across the sample region uchar finalAlpha = (uchar)(sumAlpha / weightSum); uchar finalRed = (uchar)(sumRed / weightSum); uchar finalGreen = (uchar)(sumGreen / weightSum); uchar finalBlue = (uchar)(sumBlue / weightSum); //--- Reassemble the averaged ARGB value and write it to the target canvas uint finalColor = ((uint)finalAlpha << 24) | ((uint)finalRed << 16) | ((uint)finalGreen << 8) | (uint)finalBlue; targetCanvas.PixelSet(pixelX, pixelY, finalColor); } } } } //+------------------------------------------------------------------+ //| Normalize angle to 0–2π range | //+------------------------------------------------------------------+ double NormalizeAngle(double angle) { //--- Define the full-circle constant double twoPi = 2.0 * M_PI; //--- Wrap the angle into the 0–2π range using modulo angle = MathMod(angle, twoPi); //--- Shift negative results back into the positive range if(angle < 0) angle += twoPi; return angle; } //+------------------------------------------------------------------+ //| Check if angle falls between start and end angles | //+------------------------------------------------------------------+ bool IsAngleBetween(double angle, double startAngle, double endAngle) { //--- Normalize all three angles to the 0–2π range angle = NormalizeAngle(angle); startAngle = NormalizeAngle(startAngle); endAngle = NormalizeAngle(endAngle); //--- Compute the arc span from start to end going clockwise double span = NormalizeAngle(endAngle - startAngle); //--- Compute how far the test angle is from the start going clockwise double relativeAngle = NormalizeAngle(angle - startAngle); //--- Return true if the test angle falls within the span return relativeAngle <= span; }
Мы определяем функцию "ColorToArgbWithOpacity", чтобы сформировать полноценное ARGB-значение из цвета и значения непрозрачности в процентах. Она извлекает красный, зелёный и синий каналы цвета с помощью побитовых операций, преобразует процент из диапазона от 0 до 100 в альфа-байт от нуля до 255, умножая на 255 и деля на 100, а затем собирает все четыре канала в одно беззнаковое целое в порядке ARGB. Функция используется при подготовке цветов заливки и границы информационного блока.
Чтобы получить гладкие сглаженные края блока, мы реализуем функцию "DownsampleBicubic", которая уменьшает холст высокого разрешения до экранного разрешения с помощью равномерного box-фильтра. Важно отметить, что, несмотря на название функции, фильтр присваивает одинаковый вес каждому исходному пикселю внутри области покрытия целевого пикселя, поэтому это обычное усреднение бокс-фильтром, а не бикубическая интерполяция. Название сохранено из исходной реализации, но фактическое поведение соответствует стандартному усреднению бокс-фильтром.
Для каждого пикселя целевого холста функция сопоставляет ему соответствующий блок исходных пикселей на холсте высокого разрешения: умножает X-координату целевого пикселя на коэффициент понижения, чтобы получить исходную X-координату, и Y-координату целевого пикселя на тот же коэффициент, чтобы получить исходную Y-координату. Затем она накапливает значения альфа-, красного, зелёного и синего каналов с одинаковыми весами по всем исходным пикселям блока с помощью PixelGet, делит сумму на общий вес и записывает усреднённый результат обратно в целевой холст методом PixelSet. Именно это усреднение создаёт гладкие края: резкие пиксельные переходы на холсте высокого разрешения превращаются в плавные смешивания в уменьшенном изображении, благодаря чему скруглённые углы и штрихи границы выглядят аккуратно при любом масштабе.
Для поддержки отрисовки дуг скруглённых углов мы определяем функцию "NormalizeAngle", которая приводит любое значение угла к диапазону от 0 до 2π. Она применяет MathMod, чтобы поместить угол в пределы окружности, а затем добавляет 2π, если результат отрицательный, чтобы результат всегда был неотрицательным. На этой основе функция "IsAngleBetween" определяет, находится ли заданный угол внутри дуги, описанной начальным и конечным углами. Она нормализует все три значения, вычисляет полный диапазон дуги как нормализованную разницу между концом и началом, таким же образом вычисляет относительное положение проверяемого угла от начала и возвращает true, если относительный угол попадает в диапазон. Это корректно обрабатывает дуги, пересекающие нулевую границу, без специальных условий, что важно для угловых дуг информационного блока, где разные углы соответствуют различным квадрантам окружности. Подготовив эти утилиты, можно построить функции заливки четырёхугольников и отрисовки скруглённых прямоугольников.
Построение конвейера геометрической отрисовки — заливка четырёхугольников, скруглённые прямоугольники, обводка дуг и альфа-смешивание
Когда утилиты работы с цветом и понижающей дискретизацией готовы, нам нужен полный набор геометрических функций, от которых зависит рисование информационного блока: сканлайновый растеризатор для заливки произвольных выпуклых четырёхугольников, заливка скруглённого прямоугольника, объединяющая прямоугольные полосы с заливкой квадрантов окружности, отрисовщик границы для толстых прямых рёбер и точных угловых дуг, а также функция попиксельного альфа-смешивания, которая смешивает готовый блок с основным холстом без перезаписи существующего содержимого.
//+------------------------------------------------------------------+ //| Fill convex quadrilateral using scanline rasterization | //+------------------------------------------------------------------+ void FillConvexQuadrilateral(CCanvas &canvas, double &verticesX[], double &verticesY[], uint fillColor) { //--- Find the vertical extents of the quadrilateral double minY = verticesY[0], maxY = verticesY[0]; for(int i = 1; i < 4; i++) { if(verticesY[i] < minY) minY = verticesY[i]; if(verticesY[i] > maxY) maxY = verticesY[i]; } //--- Determine the inclusive scanline row range int yStart = (int)MathCeil(minY); int yEnd = (int)MathFloor(maxY); //--- Process each horizontal scanline within the bounding box for(int y = yStart; y <= yEnd; y++) { //--- Sample the scanline at the pixel centre to avoid boundary artifacts double scanlineY = (double)y + 0.5; double xIntersections[8]; int intersectionCount = 0; //--- Compute edge–scanline intersections for all four edges for(int i = 0; i < 4; i++) { int nextIndex = (i + 1) % 4; double x0 = verticesX[i], y0 = verticesY[i]; double x1 = verticesX[nextIndex], y1 = verticesY[nextIndex]; //--- Determine the edge's vertical span double edgeMinY = (y0 < y1) ? y0 : y1; double edgeMaxY = (y0 > y1) ? y0 : y1; //--- Skip edges that do not cross this scanline if(scanlineY < edgeMinY || scanlineY > edgeMaxY) continue; //--- Skip degenerate horizontal edges if(MathAbs(y1 - y0) < 1e-12) continue; //--- Linearly interpolate to find the X crossing double interpolationFactor = (scanlineY - y0) / (y1 - y0); if(interpolationFactor < 0.0 || interpolationFactor > 1.0) continue; //--- Record the intersection X coordinate xIntersections[intersectionCount++] = x0 + interpolationFactor * (x1 - x0); } //--- Sort intersections left to right using bubble sort for(int a = 0; a < intersectionCount - 1; a++) for(int b = a + 1; b < intersectionCount; b++) if(xIntersections[a] > xIntersections[b]) { double temp = xIntersections[a]; xIntersections[a] = xIntersections[b]; xIntersections[b] = temp; } //--- Fill horizontal spans between each pair of intersections for(int pairIndex = 0; pairIndex + 1 < intersectionCount; pairIndex += 2) { int xLeft = (int)MathCeil(xIntersections[pairIndex]); int xRight = (int)MathFloor(xIntersections[pairIndex + 1]); //--- Paint every pixel in this horizontal span for(int x = xLeft; x <= xRight; x++) canvas.PixelSet(x, y, fillColor); } } } //+------------------------------------------------------------------+ //| Render rounded rectangle fill region | //+------------------------------------------------------------------+ void RenderRoundedRectangleFill(CCanvas &canvas, int positionX, int positionY, int width, int height, int radius, uint fillColor) { //--- Fill the horizontal center strip spanning the full width minus corners canvas.FillRectangle(positionX + radius, positionY, positionX + width - radius, positionY + height, fillColor); //--- Fill the left vertical strip between the top and bottom corner arcs canvas.FillRectangle(positionX, positionY + radius, positionX + radius, positionY + height - radius, fillColor); //--- Fill the right vertical strip between the top and bottom corner arcs canvas.FillRectangle(positionX + width - radius, positionY + radius, positionX + width, positionY + height - radius, fillColor); //--- Fill the four corner quadrants with the matching circle segment FillCircleQuadrant(canvas, positionX + radius, positionY + radius, radius, fillColor, 2); // TL FillCircleQuadrant(canvas, positionX + width - radius, positionY + radius, radius, fillColor, 1); // TR FillCircleQuadrant(canvas, positionX + radius, positionY + height - radius, radius, fillColor, 3); // BL FillCircleQuadrant(canvas, positionX + width - radius, positionY + height - radius, radius, fillColor, 4); // BR } //+------------------------------------------------------------------+ //| Fill one quadrant of a circle at given center | //+------------------------------------------------------------------+ void FillCircleQuadrant(CCanvas &canvas, int centerX, int centerY, int radius, uint fillColor, int quadrant) { //--- Cast radius to double for distance comparisons double radiusDouble = (double)radius; //--- Iterate over the bounding box of the quadrant including a 1-pixel border for(int deltaY = -radius - 1; deltaY <= radius + 1; deltaY++) { for(int deltaX = -radius - 1; deltaX <= radius + 1; deltaX++) { //--- Determine whether this pixel lies in the requested quadrant bool inQuadrant = false; if(quadrant == 1 && deltaX >= 0 && deltaY <= 0) inQuadrant = true; // TR else if(quadrant == 2 && deltaX <= 0 && deltaY <= 0) inQuadrant = true; // TL else if(quadrant == 3 && deltaX <= 0 && deltaY >= 0) inQuadrant = true; // BL else if(quadrant == 4 && deltaX >= 0 && deltaY >= 0) inQuadrant = true; // BR //--- Skip pixels outside the requested quadrant if(!inQuadrant) continue; //--- Paint the pixel only if it lies within the circle radius double distance = MathSqrt(deltaX * deltaX + deltaY * deltaY); if(distance <= radiusDouble) canvas.PixelSet(centerX + deltaX, centerY + deltaY, fillColor); } } } //+------------------------------------------------------------------+ //| Render rounded rectangle border strokes | //+------------------------------------------------------------------+ void RenderRoundedRectangleBorder(CCanvas &canvas, int positionX, int positionY, int width, int height, int radius, int thickness, uint borderColorArgb) { //--- Draw the four straight edges connecting the corner arcs RenderRectangleStraightEdge(canvas, positionX + radius, positionY, positionX + width - radius, positionY, thickness, borderColorArgb); // Top RenderRectangleStraightEdge(canvas, positionX + width - radius, positionY + height - 1, positionX + radius, positionY + height - 1, thickness, borderColorArgb); // Bottom RenderRectangleStraightEdge(canvas, positionX, positionY + height - radius, positionX, positionY + radius, thickness, borderColorArgb); // Left RenderRectangleStraightEdge(canvas, positionX + width - 1, positionY + radius, positionX + width - 1, positionY + height - radius, thickness, borderColorArgb); // Right //--- Draw the four corner arcs connecting the straight edges RenderRectangleCornerArcPrecise(canvas, positionX + radius, positionY + radius, radius, thickness, borderColorArgb, M_PI, M_PI * 1.5); // TL RenderRectangleCornerArcPrecise(canvas, positionX + width - radius, positionY + radius, radius, thickness, borderColorArgb, M_PI * 1.5, M_PI * 2.0); // TR RenderRectangleCornerArcPrecise(canvas, positionX + radius, positionY + height - radius, radius, thickness, borderColorArgb, M_PI * 0.5, M_PI); // BL RenderRectangleCornerArcPrecise(canvas, positionX + width - radius, positionY + height - radius, radius, thickness, borderColorArgb, 0.0, M_PI * 0.5); // BR } //+------------------------------------------------------------------+ //| Render a thick straight edge segment as a filled quadrilateral | //+------------------------------------------------------------------+ void RenderRectangleStraightEdge(CCanvas &canvas, double startX, double startY, double endX, double endY, int thickness, uint borderColor) { //--- Compute the direction vector of the edge double deltaX = endX - startX; double deltaY = endY - startY; double edgeLength = MathSqrt(deltaX * deltaX + deltaY * deltaY); //--- Skip degenerate zero-length edges if(edgeLength < 1e-6) return; //--- Compute the unit perpendicular vector for thickness expansion double perpendicularX = -deltaY / edgeLength; double perpendicularY = deltaX / edgeLength; //--- Compute the unit direction vector along the edge double edgeDirectionX = deltaX / edgeLength; double edgeDirectionY = deltaY / edgeLength; //--- Use half-thickness for symmetric expansion on both sides double halfThickness = (double)thickness / 2.0; //--- Extend endpoints slightly to avoid gaps at arc–edge junctions double extensionLength = 1.5; double extendedStartX = startX - edgeDirectionX * extensionLength; double extendedStartY = startY - edgeDirectionY * extensionLength; double extendedEndX = endX + edgeDirectionX * extensionLength; double extendedEndY = endY + edgeDirectionY * extensionLength; //--- Build the four corners of the thick edge rectangle double verticesX[4], verticesY[4]; verticesX[0] = extendedStartX - perpendicularX * halfThickness; verticesY[0] = extendedStartY - perpendicularY * halfThickness; verticesX[1] = extendedStartX + perpendicularX * halfThickness; verticesY[1] = extendedStartY + perpendicularY * halfThickness; verticesX[2] = extendedEndX + perpendicularX * halfThickness; verticesY[2] = extendedEndY + perpendicularY * halfThickness; verticesX[3] = extendedEndX - perpendicularX * halfThickness; verticesY[3] = extendedEndY - perpendicularY * halfThickness; //--- Rasterize the quadrilateral with the border color FillConvexQuadrilateral(canvas, verticesX, verticesY, borderColor); } //+------------------------------------------------------------------+ //| Render a precise anti-aliased corner arc segment | //+------------------------------------------------------------------+ void RenderRectangleCornerArcPrecise(CCanvas &canvas, int centerX, int centerY, int radius, int thickness, uint borderColor, double startAngle, double endAngle) { //--- Compute the half-thickness offset for the arc band int halfThickness = thickness / 2; double outerRadius = (double)radius + halfThickness; double innerRadius = (double)radius - halfThickness; //--- Clamp inner radius to zero for very thin radii if(innerRadius < 0) innerRadius = 0; //--- Determine the pixel scan range from the outer radius int pixelRange = (int)(outerRadius + 2); //--- Test each pixel in the bounding box for arc membership for(int deltaY = -pixelRange; deltaY <= pixelRange; deltaY++) { for(int deltaX = -pixelRange; deltaX <= pixelRange; deltaX++) { //--- Skip pixels outside the annular band double distance = MathSqrt(deltaX * deltaX + deltaY * deltaY); if(distance < innerRadius || distance > outerRadius) continue; //--- Compute the pixel's angle from the arc center double angle = MathArctan2((double)deltaY, (double)deltaX); //--- Paint the pixel only if its angle falls within the arc span if(IsAngleBetween(angle, startAngle, endAngle)) canvas.PixelSet(centerX + deltaX, centerY + deltaY, borderColor); } } } //+------------------------------------------------------------------+ //| Blend source pixel over existing canvas pixel (alpha composite) | //+------------------------------------------------------------------+ void BlendPixelSet(CCanvas &canvas, int x, int y, uint src) { //--- Reject pixels outside the canvas boundaries if(x < 0 || x >= canvas.Width() || y < 0 || y >= canvas.Height()) return; //--- Read the destination pixel currently on the canvas uint dst = canvas.PixelGet(x, y); //--- Unpack source ARGB channels and normalise to 0–1 range double sa = ((src >> 24) & 0xFF) / 255.0; double sr = ((src >> 16) & 0xFF) / 255.0; double sg = ((src >> 8) & 0xFF) / 255.0; double sb = (src & 0xFF) / 255.0; //--- Unpack destination ARGB channels and normalise to 0–1 range double da = ((dst >> 24) & 0xFF) / 255.0; double dr = ((dst >> 16) & 0xFF) / 255.0; double dg = ((dst >> 8) & 0xFF) / 255.0; double db = (dst & 0xFF) / 255.0; //--- Compute the composited alpha using the Porter–Duff over formula double out_a = sa + da * (1 - sa); if(out_a == 0) { //--- Write full transparency when both source and destination are clear canvas.PixelSet(x, y, 0); return; } //--- Composite each colour channel weighted by the respective alphas double out_r = (sr * sa + dr * da * (1 - sa)) / out_a; double out_g = (sg * sa + dg * da * (1 - sa)) / out_a; double out_b = (sb * sa + db * da * (1 - sa)) / out_a; //--- Convert composited channels back to 0–255 byte range with rounding uchar oa = (uchar)(out_a * 255 + 0.5); uchar or_ = (uchar)(out_r * 255 + 0.5); uchar og = (uchar)(out_g * 255 + 0.5); uchar ob = (uchar)(out_b * 255 + 0.5); //--- Reassemble and write the final composited pixel uint out_col = ((uint)oa << 24) | ((uint)or_ << 16) | ((uint)og << 8) | (uint)ob; canvas.PixelSet(x, y, out_col); }
Здесь мы определяем функцию "FillConvexQuadrilateral" для растеризации и заливки любого выпуклого четырёхугольника построчным методом. Сначала она находит вертикальные границы четырёхугольника, просматривая Y-координаты всех четырёх вершин и выбирая минимум и максимум, затем проходит по каждой целочисленной строке сканирования в этом диапазоне. Для каждой строки она берёт выборку по центру пикселя, добавляя 0.5 к индексу строки, чтобы избежать артефактов на границах, а затем проверяет все четыре ребра на пересечения с этой строкой.
Каждое ребро проверяется относительно собственного вертикального диапазона, вырожденные горизонтальные рёбра пропускаются, а X-координата пересечения вычисляется линейной интерполяцией по относительному положению строки сканирования между Y-значениями двух конечных точек ребра. Пересечения сортируются слева направо пузырьковой сортировкой, затем соседние пары задают горизонтальные интервалы заливки, а каждый пиксель между ними закрашивается с помощью метода PixelSet. Эта функция является основой для отрисовки толстых прямых рёбер, где сегменты границы выражаются как тонкие прямоугольники и заполняются этим растеризатором.
Чтобы заполнить фон скруглённого прямоугольника информационного блока, мы реализуем функцию "RenderRoundedRectangleFill", которая разлагает форму на пять прямоугольных областей и четыре круговых квадранта. Три вызова FillRectangle покрывают горизонтальную центральную полосу на всю ширину, а также левую и правую вертикальные полосы между центрами угловых дуг. Затем четыре угла заполняются вызовом "FillCircleQuadrant" в каждом центре угла с соответствующим номером квадранта. Функция "FillCircleQuadrant" проходит по ограничивающему прямоугольнику квадранта, определяет, попадает ли каждый пиксель в нужный направленный квадрант с помощью проверки знаков дельта-координат, а затем закрашивает его только в том случае, если евклидово расстояние от центра угла не превышает радиус, используя MathSqrt, что создаёт чистую круговую заливку без зазоров и наложений в переходах от прямоугольника к дуге.
Для обводки границы мы определяем функцию "RenderRoundedRectangleBorder", которая рисует четыре прямых ребра и четыре угловые дуги, обрамляющие залитую форму. Прямые рёбра обрабатывает "RenderRectangleStraightEdge": она вычисляет направление ребра и перпендикулярный единичный вектор, симметрично расширяет ребро на половину заданной толщины с каждой стороны, слегка продлевает конечные точки на 1.5 пикселя, чтобы предотвратить зазоры в местах стыка рёбер с дугами, собирает получившиеся четыре угловые точки в массивы вершин и вызывает "FillConvexQuadrilateral", чтобы закрасить толстое ребро как заполненный прямоугольник.
Угловые дуги обрабатывает функция "RenderRectangleCornerArcPrecise". Она задаёт кольцевую полосу между внутренним и внешним радиусами, рассчитанными из радиуса угла и половины толщины границы, просматривает каждый пиксель в ограничивающем прямоугольнике этой полосы, вычисляет расстояние и угол каждого пикселя с помощью "MathSqrt" и MathArctan2, а затем закрашивает только те пиксели, которые попадают одновременно в кольцевую полосу и в угловой диапазон дуги, определённый "IsAngleBetween". Каждому из четырёх углов назначаются правильные начальный и конечный углы в радианах, чтобы покрыть ровно свой квадрант окружности и получить бесшовные соединения с соседними прямыми рёбрами.
Наконец, мы определяем функцию "BlendPixelSet" для альфа-смешивания исходного пикселя поверх существующего содержимого в заданной позиции Canvas. Сначала она проверяет координаты на выход за границы, затем считывает целевой пиксель с помощью PixelGet и распаковывает все четыре канала исходного и целевого пикселей в нормализованные значения с плавающей точкой. Она применяет формулу Porter-Duff over, вычисляя выходную alpha как альфа-канал источника плюс альфа-канал назначения, умноженная на единицу минус alpha источника, а затем смешивает каждый цветовой канал как взвешенную сумму вкладов источника и назначения, делённую на выходную alpha. Результат снова преобразуется в байтовый диапазон с округлением и записывается методом PixelSet. Такой подход к композитингу позволяет полупрозрачному информационному блоку настроений по объёму аккуратно располагаться поверх меток футпринта и графических объектов свечей без жёстких краёв и без стирания нижележащего содержимого независимо от фоновых пикселей.
Если вам интересно, композитинг Porter–Duff, предложенный Томасом Портером и Томом Даффом, — это метод компьютерной графики для объединения нового изображения (источника, source) с существующим изображением (назначением, destination) на основе их значений альфа-канала, то есть прозрачности. Он задаёт набор операторов — например, распространённый оператор «source over» («источник поверх»), — которые определяют, какую часть каждого пикселя сохранить. Это позволяет реализовывать слои, маски и прозрачность за счёт математического смешивания цветов в зависимости от альфа-канала, то есть степени прозрачности каждого пикселя. Ниже приведён визуальный пример.

Когда все геометрические утилиты готовы, можно построить полную функцию перерисовки холста Canvas, которая собирает и размещает информационный блок для каждого видимого бара.
Упрощение функции RenderFootprint и построение полной перерисовки холста Canvas с информационным блоком настроений по объёму
В этой версии функция "RenderFootprint" упрощена и отвечает только за отрисовку свечей, а вся логика рисования меток ценовых уровней и информационного блока перенесена в "RedrawCanvas" для централизованного управления. Теперь функция перерисовки обрабатывает полный конвейер по каждому бару — метки ценовых уровней, окраску диагонального дисбаланса и новый информационный блок с суперсэмплингом — в одном цикле по видимым барам.
//--- Simplified and most logic moved to redraw canvas function //+------------------------------------------------------------------+ //| Render footprint candle for a single bar | //+------------------------------------------------------------------+ void RenderFootprint(int footprintIndex, int barIndex, datetime barTime, int ratesTotal) { //--- Validate the footprint index before accessing the array if(footprintIndex < 0 || footprintIndex >= ArraySize(barFootprints)) return; //--- Get the 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; //--- Pre-compute the minimum pixel spacing between adjacent price labels double minSpacing = minPriceLevelSpacing * priceLevelFontSize * _Point; //--- Draw the candle body and wicks behind the footprint labels RenderCandleWithTrendLines(barIndex, barTime); } //+------------------------------------------------------------------+ //| 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 high, low, 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); //--- Pre-compute the minimum label spacing for the current settings double minSpacing = minPriceLevelSpacing * priceLevelFontSize * _Point; //--- Iterate over every visible bar and render its footprint and info box for(int i = 0; i < visibleBarsCount; i++) { //--- Map the 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; int size = ArraySize(barFootprints[footprintIndex].priceLevels); if(size > 0) { //--- Prepare a display price array mirroring the sorted levels double displayPrices[]; ArrayResize(displayPrices, size); //--- Copy raw level prices into the 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 the label down if it would overlap the one above if(priceDiff < minSpacing && priceDiff >= 0) { displayPrices[j] = displayPrices[j - 1] - minSpacing; } } } //--- 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 the 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 the 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 a plain integer leftTexts[j] = StringFormat("%+.0f", deltaValue); leftColors[j] = deltaColor; rightTexts[j] = StringFormat("%.0f", total); rightColors[j] = totalColor; } else { //--- Display raw bid volume on the left and ask volume on the 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 for(int j = 0; j < size - 1; j++) { //--- Get ask volume at the current level and bid at the 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 level 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); } } if(showInfoBox) { //--- Retrieve the net delta and total volume for the info box content double delta = barFootprints[footprintIndex].delta; double totalVolume = barFootprints[footprintIndex].totalUpVolume + barFootprints[footprintIndex].totalDownVolume; //--- Skip info box rendering for bars with no volume if(totalVolume == 0) continue; //--- Retrieve pre-computed percentages and colors for this bar double upPercentage = barFootprints[footprintIndex].upPercentage; double downPercentage = barFootprints[footprintIndex].downPercentage; color boxColor = barFootprints[footprintIndex].boxColor; color textColor = barFootprints[footprintIndex].textColor; //--- Format the three info box label strings string line1 = StringFormat("Δ: %.0f", delta); string line2 = StringFormat("V: %.0f", totalVolume); string line3 = StringFormat("↓%.0f%% ↑%.0f%%", downPercentage, upPercentage); //--- Determine horizontal and vertical anchor for the info box int x = BarToXCoordinate(barIndex); double highPrice = highs[bufferIndex]; int yHigh = PriceToYCoordinate(highPrice); //--- Compute a scale-aware font size clamped to a readable range int fontSize = (int)(infoBoxBaseFontSize + currentChartScale * 1.5); fontSize = MathMax(8, MathMin(18, fontSize)); //--- Measure the bold delta label dimensions mainCanvas.FontSet("Arial Bold", (uint)fontSize, FW_BOLD); int textWidth1 = mainCanvas.TextWidth(line1); int textHeight1 = mainCanvas.TextHeight(line1); //--- Measure the normal volume and percentage label dimensions mainCanvas.FontSet("Arial", (uint)fontSize, FW_NORMAL); int textWidth2 = mainCanvas.TextWidth(line2); int textHeight2 = mainCanvas.TextHeight(line2); int textWidth3 = mainCanvas.TextWidth(line3); int textHeight3 = mainCanvas.TextHeight(line3); //--- Determine the box dimensions from the widest and tallest content int maxTextWidth = MathMax(textWidth1, MathMax(textWidth2, textWidth3)); int totalTextHeight = textHeight1 + textHeight2 + textHeight3 + 4; int rectWidth = maxTextWidth + infoBoxPaddingWidthPixels * 2; int rectHeight = totalTextHeight + infoBoxPaddingHeightPixels * 2; //--- Centre the box horizontally above the bar's high int xRect = x - rectWidth / 2; int yRect = yHigh - rectHeight - infoBoxGapPixels; //--- Compute ARGB fill, border, and text colors with configured transparency uint argbFill = ColorToArgbWithOpacity(boxColor, backgroundTransparencyPercent); color borderColor = DarkenColor(boxColor, 0.7); uint argbBorder = ColorToArgbWithOpacity(borderColor, borderTransparencyPercent); uint argbText = ColorToARGB(textColor, 255); //--- Determine the supersampling factor; disable when rounded corners are off int ssFactor = enableRoundedCorners ? MathMax(1, supersamplingFactor) : 1; int scaledWidth = rectWidth * ssFactor; int scaledHeight = rectHeight * ssFactor; int scaledRadius = cornerRadiusPixels * ssFactor; int scaledThickness = borderThicknessPixels * ssFactor; //--- Declare temporary canvases for supersampled rendering CCanvas tempHighRes, tempLowRes; if(ssFactor > 1) { //--- Create the high-resolution canvas for supersampled drawing if(!tempHighRes.Create("temp_hi_" + IntegerToString(i), scaledWidth, scaledHeight, COLOR_FORMAT_ARGB_NORMALIZE)) continue; tempHighRes.Erase(0); } //--- Create the final low-resolution canvas that maps to screen pixels if(!tempLowRes.Create("temp_lo_" + IntegerToString(i), rectWidth, rectHeight, COLOR_FORMAT_ARGB_NORMALIZE)) continue; tempLowRes.Erase(0); //--- Point the draw target to the appropriate canvas resolution CCanvas *drawCanvas = (ssFactor > 1) ? &tempHighRes : &tempLowRes; int drawWidth = (ssFactor > 1) ? scaledWidth : rectWidth; int drawHeight = (ssFactor > 1) ? scaledHeight : rectHeight; int drawRadius = (ssFactor > 1) ? scaledRadius : cornerRadiusPixels; int drawThickness = (ssFactor > 1) ? scaledThickness : borderThicknessPixels; if(enableRoundedCorners) { //--- Draw the filled rounded rectangle background RenderRoundedRectangleFill(*drawCanvas, 0, 0, drawWidth, drawHeight, drawRadius, argbFill); //--- Overlay the rounded border stroke if thickness is non-zero if(borderThicknessPixels > 0) RenderRoundedRectangleBorder(*drawCanvas, 0, 0, drawWidth, drawHeight, drawRadius, drawThickness, argbBorder); } else { //--- Draw a plain filled rectangle background for the sharp-corner style drawCanvas.FillRectangle(0, 0, drawWidth - 1, drawHeight - 1, argbFill); if(borderThicknessPixels > 0) { //--- Draw the four border edges as anti-aliased lines drawCanvas.LineAA(0, 0, drawWidth, 0, argbBorder); drawCanvas.LineAA(drawWidth, 0, drawWidth, drawHeight, argbBorder); drawCanvas.LineAA(drawWidth, drawHeight, 0, drawHeight, argbBorder); drawCanvas.LineAA(0, drawHeight, 0, 0, argbBorder); } } if(ssFactor > 1) { //--- Downsample the high-resolution canvas to the screen-resolution canvas DownsampleBicubic(tempLowRes, tempHighRes, ssFactor); } //--- Set the horizontal text anchor to the centre of the box int textX = rectWidth / 2; //--- Start the vertical text cursor at the top padding offset int textY = infoBoxPaddingHeightPixels; //--- Draw the bold delta label centred at the top of the box tempLowRes.FontSet("Arial Bold", (uint)fontSize, FW_BOLD); tempLowRes.TextOut(textX, textY, line1, argbText, TA_CENTER | TA_TOP); textY += textHeight1 + 2; //--- Draw the normal volume label below the delta label tempLowRes.FontSet("Arial", (uint)fontSize, FW_NORMAL); tempLowRes.TextOut(textX, textY, line2, argbText, TA_CENTER | TA_TOP); textY += textHeight2 + 2; //--- Draw the percentage label at the bottom of the text block tempLowRes.TextOut(textX, textY, line3, argbText, TA_CENTER | TA_TOP); //--- Alpha-composite the completed box onto the main canvas pixel by pixel for(int dy = 0; dy < rectHeight; dy++) { for(int dx = 0; dx < rectWidth; dx++) { //--- Read the composited box pixel uint col = tempLowRes.PixelGet(dx, dy); //--- Blend it over the existing main canvas content BlendPixelSet(mainCanvas, xRect + dx, yRect + dy, col); } } //--- Release the high-resolution temporary canvas when supersampling was used if(ssFactor > 1) tempHighRes.Destroy(); //--- Release the low-resolution composite canvas tempLowRes.Destroy(); } } //--- Push the completed frame to the screen mainCanvas.Update(); }
Здесь мы возвращаем функцию "RenderFootprint" к её основной ответственности: проверке индекса футпринта, подтверждению, что существует хотя бы один ценовой уровень, и вызову "RenderCandleWithTrendLines" для отрисовки тела свечи и фитилей. Логика подготовки и рисования меток, которая раньше находилась здесь, перенесена в "RedrawCanvas", чтобы информационный блок и метки ценовых уровней использовали один и тот же цикл по барам. Это устраняет лишние поиски футпринтов и обеспечивает согласованную последовательность отрисовки.
В "RedrawCanvas" мы сохраняем ту же внешнюю структуру из предыдущей части — получаем массивы high, low и time, очищаем холст Canvas и проходим по видимым барам, — но теперь обрабатываем два основных блока для каждого бара. В первом блоке мы строим массив "displayPrices", при необходимости раздвигаем перекрывающиеся метки с помощью нового множителя "minPriceLevelSpacing", заполняем массивы левого и правого текста и цветов по каждому уровню на основе активного режима отображения, применяем окраску диагонального дисбаланса в режиме Bid vs Ask, задаём шрифт с помощью FontSet, вычисляем горизонтальные опорные позиции и рисуем каждую пару меток с помощью TextOut, как и в предыдущей версии.
Второй блок выполняется, когда "showInfoBox" равен true и бар имеет ненулевой объём. Мы считываем заранее вычисленные поля "delta", "upPercentage", "downPercentage", "boxColor" и "textColor" напрямую из структуры футпринта, затем форматируем три строки меток: дельту со знаком и префиксом в виде греческого символа Δ, общий объём с префиксом V, а также проценты продаж и покупок со стрелками направления. Мы вычисляем размер шрифта с учётом масштаба, добавляя масштабированное кратное "currentChartScale" к "infoBoxBaseFontSize", и ограничиваем результат диапазоном от 8 до 18 пикселей, чтобы метки оставались разборчивыми при любом масштабе.
Затем мы измеряем все три метки с помощью TextWidth и TextHeight с настройками жирного и обычного шрифта, чтобы определить максимальную ширину и высоту содержимого, определяем размеры блока в пикселях, добавляя настроенные отступы с каждой стороны, и размещаем блок горизонтально по центру над максимумом бара с зазором "infoBoxGapPixels". Мы собираем ARGB-значения заливки и границы, вызывая "ColorToArgbWithOpacity" со значениями прозрачности из входных параметров, а цвет границы выводим, передавая цвет заливки блока в "DarkenColor".
Когда "enableRoundedCorners" равен true, мы применяем "supersamplingFactor": создаём временный холст высокого разрешения с размерами блока, умноженными на этот коэффициент, рисуем всю геометрию в этом масштабированном разрешении с помощью "RenderRoundedRectangleFill" и "RenderRoundedRectangleBorder" с пропорционально масштабированными значениями радиуса и толщины, затем уменьшаем результат до экранного разрешения, вызывая "DownsampleBicubic" во второй временный холст низкого разрешения. Когда скруглённые углы отключены, мы полностью пропускаем этап суперсэмплинга и рисуем блок напрямую в экранном разрешении с помощью FillRectangle и LineAA для границ.
Затем мы рисуем три текстовые метки на холсте низкого разрешения с помощью TextOut — жирным начертанием для строки дельты и обычным начертанием для объёма и процентов, центрируя каждую строку по горизонтали и располагая их вертикально с двухпиксельными промежутками. Наконец, мы накладываем готовый блок на основной холст пиксель за пикселем с помощью "BlendPixelSet", освобождаем оба временных холста через Destroy, а когда все бары обработаны, обновляем изображение на экране методом Update. Остаётся обновить обработчик события расчёта, чтобы изменения начали применяться. Вот и всё.
Обновление обработчика события расчёта для инициализации и обновления полей информационного блока
Обработчик события OnCalculate переносит всю логику из предыдущей части с двумя точечными дополнениями: новые поля информационного блока в "BarFootprintData" инициализируются при открытии нового бара, а "CalculateBarColorsAndPercentages" вызывается после каждого обновления объёма, чтобы кэшированные показатели настроений по объёму оставались актуальными перед следующей перерисовкой.
//+------------------------------------------------------------------+ //| 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 scalar fields of the new footprint entry barFootprints[currentBarIndex].time = currentBarTime; barFootprints[currentBarIndex].totalUpVolume = 0; barFootprints[currentBarIndex].totalDownVolume = 0; barFootprints[currentBarIndex].boxColor = upColor1; barFootprints[currentBarIndex].textColor = upColor1; barFootprints[currentBarIndex].delta = 0; barFootprints[currentBarIndex].upPercentage = 0; barFootprints[currentBarIndex].downPercentage = 0; //--- Clear the price levels array for the new bar ArrayResize(barFootprints[currentBarIndex].priceLevels, 0); if(newSize > maxBarsToRender * 2) { //--- Trim oldest entries when the array exceeds the render budget int toRemove = newSize - maxBarsToRender; for(int i = 0; i < toRemove; i++) { //--- Build the time string to locate related chart objects for deletion string timeString = TimeToString(barFootprints[i].time); ObjectsDeleteAll(0, objectPrefix + "Body_" + timeString); ObjectsDeleteAll(0, objectPrefix + "UpperWick_" + timeString); ObjectsDeleteAll(0, objectPrefix + "LowerWick_" + timeString); } //--- Remove the oldest footprint records from the front of the array ArrayRemove(barFootprints, 0, toRemove); //--- Shift the current bar index to reflect the removed entries currentBarIndex -= toRemove; } //--- Commit the new bar time and seed the close price from the open lastBarTime = currentBarTime; lastClosePrice = open[rates_total - 1]; lastTickVolume = 0; } //--- Abort if the current bar index is out of bounds if(currentBarIndex < 0 || currentBarIndex >= ArraySize(barFootprints)) return rates_total; //--- Compute the 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 the full increment to the ask side upDiff = volumeDiff; lastTradeAtAsk = true; } else if(currentClose < lastClosePrice) { //--- Price moved down — attribute the full increment to the 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 entry 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 normalisation maximums after the volume update ComputeMaxValues(barFootprints[currentBarIndex]); //--- Re-sort levels so the highest prices render at the top SortPriceLevelsDescending(barFootprints[currentBarIndex]); //--- Refresh the info box colors and percentage fields CalculateBarColorsAndPercentages(barFootprints[currentBarIndex]); } //--- Render footprints for the most recent bars within the render budget int firstBar = MathMax(0, rates_total - maxBarsToRender); for(int i = firstBar; i < rates_total; i++) { datetime barTime = time[i]; int footprintIndex = GetBarFootprintIndex(barTime); if(footprintIndex >= 0) { //--- Convert chronological index to reverse bar index for chart coordinates int barIndex = rates_total - 1 - i; RenderFootprint(footprintIndex, barIndex, barTime, rates_total); } } //--- Track whether chart geometry changed this tick bool hasChartChanged = false; //--- Sample the 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; }
Когда обнаружен новый бар, мы инициализируем пять новых полей, добавленных в расширенную структуру, вместе с уже существующими: задаём "boxColor" и "textColor" как базовый нейтральный оттенок и обнуляем "delta", "upPercentage" и "downPercentage". Благодаря этому информационный блок всегда имеет корректные начальные значения и никогда не показывает устаревшие данные предыдущего бара на первом тике нового. Логика обрезки старых баров, начальная установка времени и цены закрытия, а также проверка границ "currentBarIndex" остаются без изменений по сравнению с предыдущей частью.
В блоке обработки объёма логика направления тика, квантование цены, обновление уровня, накопление суммарных объёмов, вызов "ComputeMaxValues" и вызов "SortPriceLevelsDescending" остаются точно такими же, как раньше. Единственное добавление — вызов "CalculateBarColorsAndPercentages" после сортировки. Он пересчитывает и кэширует дельту на уровне бара, процентное соотношение, цвет фона блока и цвет текста в структуре, чтобы эти значения были готовы для прохода отрисовки информационного блока в "RedrawCanvas" без пересчёта во время рисования.
Мы также добавляем цикл отрисовки футпринта, который проходит по последним барам в пределах бюджета рендеринга, вычисляет индекс бара в обратном порядке из хронологической позиции и вызывает "RenderFootprint" для каждого бара, для которого сохранены данные футпринта. Это обеспечивает актуальность свечных объектов, построенных на трендовых линиях, после каждого тика. Обнаружение изменения геометрии, изменение размера холста Canvas, синхронизация состояния области просмотра, условный вызов "RedrawCanvas" и финальный ChartRedraw остаются без изменений, завершая обработчик событий минимальными добавлениями, которые полностью поддерживают новый информационный блок без изменения существующего потока обработки тиков. Осталось протестировать программу, и этим мы займёмся в следующем разделе.
Бэктестирование
Мы выполнили тестирование, и ниже показана итоговая визуализация, собранная в одном GIF-изображении (Graphics Interchange Format).

Во время тестирования информационный блок корректно обновлялся при поступлении каждого нового тика: значения дельты, объёма и процентов отражали фактическое накопление объёма по bid и ask на каждом ценовом уровне, цвет блока менялся от менее интенсивных к более интенсивным оттенкам по мере того, как одна сторона постепенно доминировала на последовательных барах, а скруглённые углы и полупрозрачный фон аккуратно отображались над фитилями свечей, не перекрывая нижележащие метки ценовых уровней.
Заключение
В заключение мы расширили индикатор футпринта от инструмента, ориентированного только на ценовые уровни, к комбинированному решению для анализа и уровней, и баров, добавив кэшируемые поля бара — "boxColor", "textColor", "delta", "upPercentage" и "downPercentage" — в структуру "BarFootprintData" и вычисляя их один раз на каждом тике через "CalculateBarColorsAndPercentages", чтобы перерисовка требовала минимальных вычислений. Мы построили полный цикл отрисовки, включающий цветовое кодирование интенсивности дельты, интенсивность текста на основе процентов, геометрию скруглённого прямоугольника с построчной заливкой четырёхугольников и точной обводкой дуг, опциональный этап суперсэмплинга и понижающей дискретизации бокс-фильтром, а также попиксельное альфа-смешивание по Porter–Duff, чтобы информационный блок накладывался на футпринт, не скрывая детализацию ценовых уровней. Вся отрисовка централизована в "RedrawCanvas", а обработчик события "OnCalculate" отвечает только за потиковые обновления и поддержание кэшированных данных в актуальном состоянии. После прочтения статьи вы сможете:
- Считывать цвет информационного блока и значение дельты над каждой свечой, чтобы мгновенно оценивать, доминировали ли в баре покупатели или продавцы в сумме, используя ярко окрашенные блоки с высокой дельтой как подтверждающие сигналы, а слабо окрашенные блоки — как маркеры нерешительности перед входом в направленную сделку
- Использовать процентное соотношение в информационном блоке настроений по объёму, чтобы отличать бары, где одна сторона лишь немного превзошла другую, от баров с сильно перекошенным участием, рассматривая высокий процент доминирования у ключевых уровней как более сильное свидетельство поглощения или агрессии, чем узкий разрыв на той же цене
- Просматривать цвета информационных блоков на последовательных барах как визуальную тепловую карту, чтобы выявлять нарастание импульса в одном направлении, входить по направлению усиливающейся цветовой интенсивности и оставаться вне рынка или разворачиваться, когда цвет внезапно смещается на противоположную сторону
Удачной торговли!
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/21829
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Особенности написания Пользовательских Индикаторов
Ассоциативные правила и теория вероятностей на Форекс: Фильтр сигналов без нейросетей и без магии
Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (кодовая книга сценариев)
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования