English Deutsch 日本語
preview
Создание пользовательских индикаторов в MQL5 (Часть 7): Гибридные рыночные профили Time Price Opportunity (TPO) для анализа сессий

Создание пользовательских индикаторов в MQL5 (Часть 7): Гибридные рыночные профили Time Price Opportunity (TPO) для анализа сессий

MetaTrader 5Торговые системы |
41 0
Allan Munene Mutiiria
Allan Munene Mutiiria

Введение

В предыдущей статье (Часть 6) мы разработали усовершенствованную систему расчёта Relative Strength Index на языке MetaQuotes Language 5 (MQL5), в которой были реализованы методы сглаживания, сдвиги оттенков для улучшения визуализации и поддержка нескольких таймфреймов для комплексного анализа рынка. В части 7 мы разрабатываем гибридный индикатор Time Price Opportunity (TPO) рыночного профиля с поддержкой разных таймфреймов сессий, включая внутридневные, дневные, недельные, месячные и фиксированные периоды с учётом часового пояса. Этот индикатор дискретизирует цены по сетке, отслеживает данные сессии, включая максимум, минимум, цены открытия и закрытия, рассчитывает POC и область стоимости по количеству TPO, а также отрисовывает профиль на графике с настраиваемыми цветами для подробного анализа сессии. Мы рассмотрим следующие темы:

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

В итоге у вас будет рабочий индикатор MQL5 для гибридных рыночных TPO-профилей, готовый к дальнейшей настройке. Приступим!


Изучение концепции гибридного рыночного профиля Time Price Opportunity

Гибридный рыночный профиль Time Price Opportunity (TPO) — это инструмент визуализации, который отображает распределение цены во времени в рамках заданных торговых сессий. Для представления временных интервалов на конкретных ценовых уровнях используются буквы, прямоугольные маркеры или просто точки. Такой профиль показывает зоны повышенной активности, например область стоимости и точку контроля, где происходила основная часть торговли. Этот подход помогает определять поддержку, сопротивление и зоны справедливой стоимости, агрегируя ценовое движение в профильную гистограмму: более плотные скопления TPO указывают на сбалансированную торговлю, а более тонкие — на потенциальные пробои или дисбалансы. Обычно мы применяем его по сессиям, чтобы оценивать рыночные настроения, открывать позиции вблизи границ области стоимости или отслеживать смещения POC для продолжения тренда.

Наш план состоит в том, чтобы определять сессии на основе выбранных таймфреймов с учётом часового пояса. Мы будем дискретизировать цены по сетке для назначения TPO и отслеживать метрики сессии, например максимумы и минимумы. Также мы рассчитаем POC как уровень с наибольшим количеством TPO. Затем определим область стоимости, покрывающую заданный процент от общего числа TPO. Наконец, мы визуализируем профиль с помощью меток, точек и квадратов с цветовым кодированием для более глубокого анализа графика. Ниже приведено визуальное представление наших целей.

АРХИТЕКТУРА РЫНОЧНОГО ПРОФИЛЯ TPO


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

Чтобы создать индикатор в MQL5, откройте MetaEditor, перейдите в Навигатор, найдите папку Indicators, нажмите вкладку "New" и следуйте подсказкам для создания файла. После его создания в среде разработки мы определим свойства и настройки индикатора, например количество буферов и построений индикатора.

//+------------------------------------------------------------------+
//|                             Hybrid TPO Market Profile 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 strict

#property indicator_chart_window
#property indicator_buffers 0
#property indicator_plots 0

//+------------------------------------------------------------------+
//| Enums                                                            |
//+------------------------------------------------------------------+
enum MarketProfileTimeframe { // Define market profile timeframe enum
   INTRADAY,                  // Intraday
   DAILY,                     // Daily
   WEEKLY,                    // Weekly
   MONTHLY,                   // Monthly
   FIXED                      // Fixed
};

//+------------------------------------------------------------------+
//| Inputs                                                           |
//+------------------------------------------------------------------+
sinput group "Settings"
input double ticksPerTpoLetter = 10;             // Ticks per letter
input int valueAreaPercent = 70;                 // Value Area Percent

sinput group "Time"
input MarketProfileTimeframe profileTimeframe = DAILY;    // Timeframe
input string timezone = "Exchange";              // Timezone
input string dailySessionRange = "0830-1500";    // Daily session
input int intradayProfileLengthMinutes = 60;     // Profile length in minutes (Intraday)
input datetime fixedTimeRangeStart = D'2026.02.01 08:30'; // From (Fixed)
input datetime fixedTimeRangeEnd = D'2026.02.02 15:00';   // Till (Fixed)

sinput group "Rendering"
input int labelFontSize = 10;                   // Font size

sinput group "Colors"
input color defaultTpoColor = clrGray;          // Default
input color singlePrintColor = 0xd56a6a;        // Single Print
input color valueAreaColor = clrBlack;          // Value Area
input color pointOfControlColor = 0x3f7cff;     // POC
input color closeColor = clrRed;                // Close

//+------------------------------------------------------------------+
//| Constants                                                        |
//+------------------------------------------------------------------+
#define MAX_BARS_BACK 5000
#define TPO_CHARACTERS_STRING "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

//+------------------------------------------------------------------+
//| Structures                                                       |
//+------------------------------------------------------------------+
struct TpoPriceLevel {       // Define TPO price level structure
   double price;             // Store price level
   string tpoString;         // Store TPO string
   int tpoCount;             // Store TPO count
};

struct ProfileSessionData {  // Define profile session data structure
   datetime startTime;       // Store start time
   datetime endTime;         // Store end time
   double sessionOpen;       // Store session open price
   double sessionClose;      // Store session close price
   double sessionHigh;       // Store session high price
   double sessionLow;        // Store session low price
   TpoPriceLevel levels[];   // Store array of price levels
   int periodCount;          // Store period count
   double periodOpens[];     // Store array of period opens
   int pointOfControlIndex;  // Store point of control index
};

//+------------------------------------------------------------------+
//| Global Variables                                                 |
//+------------------------------------------------------------------+
string objectPrefix = "HTMP_";     //--- Set object prefix
ProfileSessionData sessions[];     //--- Declare sessions array
int activeSessionIndex = -1;       //--- Initialize active session index
double tpoPriceGridStep = 0;       //--- Initialize TPO price grid step
string tpoCharacterSet[];          //--- Declare TPO character set array
datetime previousBarTime = 0;      //--- Initialize previous bar time
datetime lastCompletedBarTime = 0; //--- Initialize last completed bar time
int maxSessionHistory = 20;        //--- Set maximum session history
int timezoneOffsetSeconds = 0;     //--- Initialize timezone offset in seconds

Мы начинаем реализацию с настройки свойств индикатора с помощью директив #property, задавая отображение в основном окне графика с помощью indicator_chart_window и указывая ноль буферов и построений, поскольку этот индикатор ориентирован на пользовательскую отрисовку объектов, а не на построение линий или гистограмм. Далее мы определяем перечисление "MarketProfileTimeframe", которое позволяет выбирать периоды профиля: "INTRADAY" для краткосрочных сессий, "DAILY" для стандартных торговых дней, "WEEKLY", "MONTHLY" и "FIXED" для пользовательских диапазонов. Это обеспечивает гибкость при анализе сессий.

Затем мы объявляем пользовательские входные параметры, сгруппированные по разделам с помощью директив sinput group. Начинаем с настроек "ticksPerTpoLetter", управляющих ценовой детализацией на одну букву Time Price Opportunity и "valueAreaPercent", задающего процент от общего числа Time Price Opportunities, определяющий область стоимости. В группе времени входные параметры включают выбор таймфрейма профиля, строку часового пояса для корректировки смещения, диапазон дневной сессии в виде строки, например "0830-1500", длину внутридневного профиля в минутах, а также значения datetime для начала и конца фиксированного диапазона. Группа отрисовки добавляет "labelFontSize" для отображения текста, а группа цветов задаёт настраиваемые цвета: "defaultTpoColor" для обычных букв, "singlePrintColor" для одиночных принтов, "valueAreaColor", "pointOfControlColor" и "closeColor" для визуального разграничения элементов профиля.

Для единообразной работы мы вводим константы с помощью #define, включая "MAX_BARS_BACK" для ограничения обработки исторических баров и "TPO_CHARACTERS_STRING" как алфавитную последовательность для назначения букв периодам Time Price Opportunity. Для организации данных мы создаём две структуры: "TpoPriceLevel" для хранения сведений об отдельном ценовом уровне с полями цены, строки символов Time Price Opportunity и счётчика; и "ProfileSessionData" для управления информацией по сессии, включая время начала и окончания, цены открытия, закрытия, максимума и минимума, массив ценовых уровней, количество периодов, массив открытий периодов и индекс POC.

Наконец, мы инициализируем глобальные переменные необходимые для управления во время выполнения: "objectPrefix" для именования графических объектов, массив "sessions" для хранения данных профиля, "activeSessionIndex" со стартовым значением -1 для отслеживания текущей сессии, "tpoPriceGridStep" для квантования цены, массив "tpoCharacterSet" для назначения букв, временные метки "previousBarTime" и "lastCompletedBarTime", "maxSessionHistory" для ограничения истории 20 сессиями и "timezoneOffsetSeconds" для корректировки времени. После этого можно инициализировать индикатор.

//+------------------------------------------------------------------+
//| Initialize custom indicator                                      |
//+------------------------------------------------------------------+
int OnInit() {
   IndicatorSetString(INDICATOR_SHORTNAME, "Hybrid TPO Market Profile - Part 1"); //--- Set indicator short name
   
   tpoPriceGridStep = ticksPerTpoLetter * _Point;  //--- Calculate TPO price grid step
   
   ArrayResize(tpoCharacterSet, 52);               //--- Resize TPO character set array
   for(int i = 0; i < 52; i++) {                   //--- Loop through characters
      tpoCharacterSet[i] = StringSubstr(TPO_CHARACTERS_STRING, i, 1); //--- Assign character to array
   }
   
   if(timezone != "Exchange") {                    //--- Check if timezone is not exchange
      string tzString = StringSubstr(timezone, 3); //--- Extract timezone string
      int offset = (int)StringToInteger(tzString); //--- Convert offset to integer
      timezoneOffsetSeconds = offset * 3600;       //--- Calculate timezone offset in seconds
   }
   
   ArrayResize(sessions, 0);                       //--- Resize sessions array to zero
   
   return(INIT_SUCCEEDED);                         //--- Return initialization success
}

Продолжаем реализацию с обработчика события OnInit, который запускается при подключении индикатора к графику и задаёт основные настройки гибридного рыночного профиля Time Price Opportunity. Сначала мы присваиваем индикатору короткое имя с помощью функции IndicatorSetString с параметром INDICATOR_SHORTNAME чтобы в интерфейсе платформы отображалось имя "Hybrid TPO Market Profile - Part 1".

Далее мы рассчитываем шаг ценовой сетки, умножая пользовательский параметр "ticksPerTpoLetter" на значение пункта символа _Point, и сохраняем результат в "tpoPriceGridStep", чтобы определить вертикальный интервал между буквами Time Price Opportunity на основе ценовых приращений. Затем мы подготавливаем набор символов для маркировки Time Price Opportunities: изменяем размер массива "tpoCharacterSet" до 52 элементов и заполняем его в цикле, извлекая каждую букву из константы "TPO_CHARACTERS_STRING" с помощью StringSubstr для использования как заглавных, так и строчных букв при идентификации периодов.

Чтобы учесть корректировку времени, мы проверяем, отличается ли входной параметр "timezone" от "Exchange". Если да, извлекаем часть со смещением, начиная с четвёртого символа, с помощью "StringSubstr", преобразуем её в целое число с помощью StringToInteger, и рассчитываем "timezoneOffsetSeconds", умножая смещение на 3600 для представления часов в секундах. Мы сбрасываем массив "sessions", изменяя его размер до нуля с помощью ArrayResize, очищая предыдущие данные для нового построения сессионных профилей. Наконец, возвращаем INIT_SUCCEEDED чтобы сообщить платформе об успешной инициализации. Теперь нужно выполнять расчёты индикатора на каждом тике. Чтобы код был модульным и удобным для сопровождения, организуем логику во вспомогательных функциях. Начнём с создания сессии и разбора диапазонов сессий.

//+------------------------------------------------------------------+
//| Create new session                                               |
//+------------------------------------------------------------------+
int CreateNewSession() {
   int size = ArraySize(sessions);                //--- Get size of sessions array
   
   if(size >= maxSessionHistory) {                //--- Check if size exceeds history limit
      for(int i = 0; i < size - 1; i++) {         //--- Loop to shift sessions
         sessions[i] = sessions[i + 1];           //--- Copy next session to current
      }
      ArrayResize(sessions, size - 1);            //--- Resize sessions array
      size = size - 1;                            //--- Update size
   }
   
   ArrayResize(sessions, size + 1);               //--- Resize sessions array for new session
   int newIndex = size;                           //--- Set new index
   
   sessions[newIndex].startTime = 0;              //--- Initialize start time
   sessions[newIndex].endTime = 0;                //--- Initialize end time
   sessions[newIndex].sessionOpen = 0;            //--- Initialize session open
   sessions[newIndex].sessionClose = 0;           //--- Initialize session close
   sessions[newIndex].sessionHigh = 0;            //--- Initialize session high
   sessions[newIndex].sessionLow = 0;             //--- Initialize session low
   sessions[newIndex].periodCount = 0;            //--- Initialize period count
   sessions[newIndex].pointOfControlIndex = -1;   //--- Initialize point of control index
   ArrayResize(sessions[newIndex].levels, 0);     //--- Resize levels array
   ArrayResize(sessions[newIndex].periodOpens, 0);//--- Resize period opens array
   
   return newIndex;                               //--- Return new index
}

//+------------------------------------------------------------------+
//| Quantize price to grid                                           |
//+------------------------------------------------------------------+
double QuantizePriceToGrid(double price) {
   return MathRound(price / tpoPriceGridStep) * tpoPriceGridStep;   //--- Calculate and return quantized price
}

//+------------------------------------------------------------------+
//| Parse daily session time range                                   |
//+------------------------------------------------------------------+
bool ParseDailySessionTimeRange(int &startHour, int &startMinute, int &endHour, int &endMinute) {
   string parts[];                                                   //--- Declare parts array
   int count = StringSplit(dailySessionRange, '-', parts);           //--- Split daily session range
   if(count != 2) return false;                                      //--- Return false if invalid count
   
   startHour = (int)StringToInteger(StringSubstr(parts[0], 0, 2));   //--- Parse start hour
   startMinute = (int)StringToInteger(StringSubstr(parts[0], 2, 2)); //--- Parse start minute
   endHour = (int)StringToInteger(StringSubstr(parts[1], 0, 2));     //--- Parse end hour
   endMinute = (int)StringToInteger(StringSubstr(parts[1], 2, 2));   //--- Parse end minute
   
   return true;                                                      //--- Return true
}

Сначала мы определяем функцию "CreateNewSession", которая добавляет новую сессию профиля в массив. Для начала получаем текущий размер массива с помощью ArraySize Если размер достиг максимального лимита истории или превышает его, мы сдвигаем существующие сессии вперёд в цикле, удаляя самую старую, а затем уменьшаем размер массива с помощью ArrayResize перед обновлением размера. Затем мы расширяем массив на один элемент для новой сессии, задаём её индекс и инициализируем все поля значениями по умолчанию, например 0 или -1, включая очистку массивов уровней и открытий периодов. Чтобы цены соответствовали сетке Time Price Opportunity, мы реализуем функцию "QuantizePriceToGrid", которая делит входную цену на шаг сетки, округляет результат с помощью функции MathRound и умножает обратно, привязывая цену к ближайшей точке сетки.

Для обработки дневных сессий функция "ParseDailySessionTimeRange" разбивает входную строку диапазона с помощью функции StringSplit с разделителем-дефисом на части. Если найдены ровно две части, мы извлекаем и преобразуем часы и минуты из каждой части с помощью "StringSubstr" и StringToInteger, присваивая их параметрам, переданным по ссылке. В противном случае функция возвращает false, указывая на ошибку разбора. Далее нам понадобятся функции для фильтрации баров сессии и управления ценовыми уровнями внутри сессии.

//+------------------------------------------------------------------+
//| Check if bar is within daily session                             |
//+------------------------------------------------------------------+
bool IsBarWithinDailySession(datetime barTime) {
   if(profileTimeframe != DAILY) return true;                       //--- Return true if not daily timeframe
   
   int startHour, startMinute, endHour, endMinute;                  //--- Declare time variables
   if(!ParseDailySessionTimeRange(startHour, startMinute, endHour, endMinute)) return true; //--- Parse and return true if fail
   
   MqlDateTime dateTimeStruct;                                      //--- Declare date time struct
   TimeToStruct(barTime + timezoneOffsetSeconds, dateTimeStruct);   //--- Convert time to struct
   
   int barMinutes = dateTimeStruct.hour * 60 + dateTimeStruct.min;  //--- Calculate bar minutes
   int startMinutes = startHour * 60 + startMinute;                 //--- Calculate start minutes
   int endMinutes = endHour * 60 + endMinute;                       //--- Calculate end minutes
   
   if(endMinutes > startMinutes) {                                   //--- Check if end after start
      return barMinutes >= startMinutes && barMinutes <= endMinutes; //--- Return if within range
   } else {                                       //--- Handle overnight case
      return barMinutes >= startMinutes || barMinutes <= endMinutes; //--- Return if within range
   }
}

//+------------------------------------------------------------------+
//| Check if new session started                                     |
//+------------------------------------------------------------------+
bool IsNewSessionStarted(datetime currentTime, datetime previousTime) {
   if(previousTime == 0) return true;                                 //--- Return true if no previous time
   
   datetime adjustedCurrent = currentTime + timezoneOffsetSeconds;    //--- Adjust current time
   datetime adjustedPrevious = previousTime + timezoneOffsetSeconds;  //--- Adjust previous time
   
   MqlDateTime currentDateTime, previousDateTime;                     //--- Declare date time structs
   TimeToStruct(adjustedCurrent, currentDateTime);                    //--- Convert current to struct
   TimeToStruct(adjustedPrevious, previousDateTime);                  //--- Convert previous to struct
   
   switch(profileTimeframe) {                                         //--- Switch on profile timeframe
      case DAILY: {                                                   //--- Handle daily case
         int startHour, startMinute, endHour, endMinute;              //--- Declare time variables
         if(!ParseDailySessionTimeRange(startHour, startMinute, endHour, endMinute)) return false; //--- Parse and return false if fail
         
         datetime sessionStart = StringToTime(TimeToString(adjustedCurrent, TIME_DATE) + " " + 
                                              IntegerToString(startHour, 2, '0') + ":" + 
                                              IntegerToString(startMinute, 2, '0')); //--- Calculate session start
         datetime prevSessionStart = StringToTime(TimeToString(adjustedPrevious, TIME_DATE) + " " + 
                                                   IntegerToString(startHour, 2, '0') + ":" + 
                                                   IntegerToString(startMinute, 2, '0')); //--- Calculate previous session start
         
         return adjustedCurrent >= sessionStart && adjustedPrevious < prevSessionStart; //--- Return if new session
      }
      
      case WEEKLY:                                                     //--- Handle weekly case
         return currentDateTime.day_of_week < previousDateTime.day_of_week || 
                currentDateTime.day_of_year < previousDateTime.day_of_year; //--- Return if new week
      
      case MONTHLY:                                                    //--- Handle monthly case
         return currentDateTime.mon != previousDateTime.mon;           //--- Return if new month
      
      case FIXED:                                                      //--- Handle fixed case
         return currentTime >= fixedTimeRangeStart && previousTime < fixedTimeRangeStart; //--- Return if new fixed range
      
      case INTRADAY: {                                                 //--- Handle intraday case
         long currentMinute = (adjustedCurrent / 60) * 60;             //--- Calculate current minute
         long prevMinute = (adjustedPrevious / 60) * 60;               //--- Calculate previous minute
         return (currentMinute % (intradayProfileLengthMinutes * 60)) == 0 && 
                currentMinute != prevMinute;                           //--- Return if new intraday profile
      }
   }
   
   return false;                                                       //--- Return false
}

//+------------------------------------------------------------------+
//| Check if bar is eligible for processing                          |
//+------------------------------------------------------------------+
bool IsBarEligibleForProcessing(datetime barTime) {
   if(profileTimeframe == FIXED) {                                    //--- Check fixed timeframe
      return barTime >= fixedTimeRangeStart && barTime <= fixedTimeRangeEnd; //--- Return if within fixed range
   }
   
   if(profileTimeframe == DAILY) {                                   //--- Check daily timeframe
      return IsBarWithinDailySession(barTime);                       //--- Return if within daily session
   }
   
   return true;                                                      //--- Return true
}

//+------------------------------------------------------------------+
//| Get or create price level                                        |
//+------------------------------------------------------------------+
int GetOrCreatePriceLevel(int sessionIndex, double price) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return -1; //--- Return invalid if index out of range
   
   int size = ArraySize(sessions[sessionIndex].levels);              //--- Get levels size
   
   for(int i = 0; i < size; i++) {                                   //--- Loop through levels
      if(MathAbs(sessions[sessionIndex].levels[i].price - price) < _Point / 2) //--- Check if price matches
         return i;                                                   //--- Return index
   }
   
   ArrayResize(sessions[sessionIndex].levels, size + 1);             //--- Resize levels array
   sessions[sessionIndex].levels[size].price = price;                //--- Set new price
   sessions[sessionIndex].levels[size].tpoString = "";               //--- Initialize TPO string
   sessions[sessionIndex].levels[size].tpoCount = 0;                 //--- Initialize TPO count
   
   return size;                                                      //--- Return new index
}

Далее мы реализуем функцию "IsBarWithinDailySession", которая определяет, попадает ли заданный бар в указанные дневные торговые часы, и сразу возвращает true для недневных таймфреймов. Если разбор диапазона сессии через функцию "ParseDailySessionTimeRange" не удаётся, по умолчанию возвращается true. В противном случае мы преобразуем скорректированное время бара в структуру MqlDateTime с помощью TimeToStruct, рассчитываем общее количество минут для бара, времени начала и окончания, а затем проверяем, находится ли время бара внутри диапазона. При этом условная логика учитывает как обычные, так и ночные сессии.

Далее функция "IsNewSessionStarted" проверяет начало новой сессии профиля, сравнивая скорректированное текущее и предыдущее время; если предыдущего времени нет, она возвращает true. Мы корректируем временные метки с учётом смещения часового пояса, преобразуем их в структуры "MqlDateTime" и используем оператор switch по таймфрейму профиля: для дневного режима разбирается диапазон сессии и формируется время начала с помощью StringToTime, "TimeToString" и "IntegerToString", чтобы проверить переход к новому дню; для недельного режима сравниваются день недели и день года; месячный режим проверяет изменение месяца; фиксированный режим сравнивает время с входным временем начала; а внутридневной режим проверяет, совпадает ли текущая минута с границей длины профиля и не совпадает ли она с предыдущей.

Для фильтрации включаемых баров мы определяем функцию "IsBarEligibleForProcessing": для фиксированных таймфреймов она проверяет, находится ли время бара между входными значениями начала и окончания; для дневного режима вызывает "IsBarWithinDailySession"; в остальных случаях возвращает true для всех баров. Наконец, функция "GetOrCreatePriceLevel" управляет ценовыми уровнями внутри сессии, проверяя индекс сессии относительно размера массива sessions с помощью ArraySize, и возвращает -1, если индекс недопустим. Она перебирает существующие уровни, чтобы найти близкое совпадение с помощью MathAbs с допуском в половину пункта и возвращает индекс при успешном совпадении. Если совпадение не найдено, функция изменяет размер массива уровней, инициализирует цену нового уровня, пустую строку Time Price Opportunity и счётчик со значением ноль, а затем возвращает новый индекс. Поскольку сначала нам нужно отрисовать буквы профиля в определённом порядке, потребуется определить алгоритм пузырьковой сортировки, чтобы правильно упорядочить буквы перед отображением.

//+------------------------------------------------------------------+
//| Add TPO character to level                                       |
//+------------------------------------------------------------------+
void AddTpoCharacterToLevel(int sessionIndex, int levelIndex, int periodIndex) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return;                  //--- Return if session index invalid
   if(levelIndex < 0 || levelIndex >= ArraySize(sessions[sessionIndex].levels)) return; //--- Return if level index invalid
   
   string tpoCharacter = tpoCharacterSet[periodIndex % 52];                             //--- Get TPO character
   
   sessions[sessionIndex].levels[levelIndex].tpoString += tpoCharacter;                 //--- Append character to TPO string
   sessions[sessionIndex].levels[levelIndex].tpoCount++;                                //--- Increment TPO count
}

//+------------------------------------------------------------------+
//| Sort price levels descending                                     |
//+------------------------------------------------------------------+
void SortPriceLevelsDescending(int sessionIndex) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return;                 //--- Return if index invalid
   
   int size = ArraySize(sessions[sessionIndex].levels);                                //--- Get levels size
   
   for(int i = 0; i < size - 1; i++) {                                                 //--- Outer loop for sorting
      for(int j = 0; j < size - i - 1; j++) {                                          //--- Inner loop for comparison
         if(sessions[sessionIndex].levels[j].price < sessions[sessionIndex].levels[j + 1].price) { //--- Check if swap needed
            TpoPriceLevel temp = sessions[sessionIndex].levels[j];                     //--- Store temporary level
            sessions[sessionIndex].levels[j] = sessions[sessionIndex].levels[j + 1];   //--- Swap levels
            sessions[sessionIndex].levels[j + 1] = temp;                               //--- Assign temporary back
         }
      }
   }
}

//+------------------------------------------------------------------+
//| Calculate point of control                                       |
//+------------------------------------------------------------------+
void CalculatePointOfControl(int sessionIndex) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return;                 //--- Return if index invalid
   
   int size = ArraySize(sessions[sessionIndex].levels);                                //--- Get levels size
   if(size == 0) return;                                                               //--- Return if no levels
   
   int maxTpoCount = 0;                                                                //--- Initialize max TPO count
   int pointOfControlIndex = 0;                                                        //--- Initialize POC index
   
   for(int i = 0; i < size; i++) {                                                     //--- Loop through levels
      if(sessions[sessionIndex].levels[i].tpoCount > maxTpoCount) {                    //--- Check if higher TPO count
         maxTpoCount = sessions[sessionIndex].levels[i].tpoCount;                      //--- Update max TPO count
         pointOfControlIndex = i;                                                      //--- Update POC index
      }
   }
   
   sessions[sessionIndex].pointOfControlIndex = pointOfControlIndex;                   //--- Set POC index
}

//+------------------------------------------------------------------+
//| Get total TPO count                                              |
//+------------------------------------------------------------------+
int GetTotalTpoCount(int sessionIndex) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return 0;               //--- Return zero if index invalid
   
   int total = 0;                                                                      //--- Initialize total
   int size = ArraySize(sessions[sessionIndex].levels);                                //--- Get levels size
   
   for(int i = 0; i < size; i++) {                                                     //--- Loop through levels
      total += sessions[sessionIndex].levels[i].tpoCount;                              //--- Accumulate TPO count
   }
   
   return total;                                                                       //--- Return total
}

Сначала мы реализуем функцию "AddTpoCharacterToLevel", которая назначает буквы Time Price Opportunity конкретным ценовым уровням. Сначала она проверяет индексы сессии и уровня относительно размеров массивов с помощью "ArraySize", чтобы избежать ошибок, и досрочно завершает работу при недопустимых значениях. Затем получает нужный символ из набора с использованием остатка от деления индекса периода на 52, добавляет его к строке уровня и увеличивает счётчик для отслеживания активности на этой цене. Чтобы упорядочить уровни от самой высокой цены к самой низкой, функция "SortPriceLevelsDescending" проверяет корректность индекса сессии, получает размер массива уровней и применяет алгоритм пузырьковой сортировки во вложенных циклах, меняя соседние элементы местами, если текущая цена ниже следующей. Для обмена используется временная структура "TpoPriceLevel".

Функция "CalculatePointOfControl" определяет ценовой уровень с наибольшим количеством Time Price Opportunities: она проверяет сессию, инициализирует максимальный счётчик и индекс нулём, затем перебирает уровни и обновляет эти значения при обнаружении большего количества, после чего сохраняет индекс в данных сессии. Мы добавляем функцию "GetTotalTpoCount", которая суммирует все значения Time Price Opportunity по уровням сессии: при недопустимом индексе она возвращает ноль, иначе инициализирует общую сумму, накапливает значения в цикле и возвращает итог для расчётов области стоимости. Теперь можно перейти к логике визуальной отрисовки этих компонентов рыночного профиля. Сначала определим логику подсветки TPO закрытия и маркеров открытия/закрытия.

//+------------------------------------------------------------------+
//| Render close TPO highlight                                       |
//+------------------------------------------------------------------+
void RenderCloseTpoHighlight(int sessionIndex, int closeLevelIndex, string &displayStrings[]) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return;                              //--- Return if session invalid
   if(closeLevelIndex < 0 || closeLevelIndex >= ArraySize(sessions[sessionIndex].levels)) return;   //--- Return if level invalid
   
   string fullString = displayStrings[closeLevelIndex];                                             //--- Get full display string
   int stringLength = StringLen(fullString);                                                        //--- Get string length
   if(stringLength == 0) return;                                                                    //--- Return if empty string
   
   string closeCharacter = StringSubstr(fullString, stringLength - 1, 1);                           //--- Extract close character
   string remainingCharacters = StringSubstr(fullString, 0, stringLength - 1);                      //--- Extract remaining characters
   
   string objectName = objectPrefix + "CloseTPO_" + IntegerToString(sessions[sessionIndex].startTime); //--- Create object name
   int barIndex = iBarShift(_Symbol, _Period, sessions[sessionIndex].startTime);                    //--- Get bar index
   if(barIndex < 0) return;                                                                         //--- Return if invalid bar index
   
   datetime labelTime = iTime(_Symbol, _Period, barIndex);                                          //--- Get label time
   int x, y;                                                                                        //--- Declare coordinates
   ChartTimePriceToXY(0, 0, labelTime, sessions[sessionIndex].levels[closeLevelIndex].price, x, y); //--- Convert to XY
   
   int characterWidth = 8;                                                                          //--- Set character width
   int offsetX = (stringLength - 1) * characterWidth;                                               //--- Calculate offset X
   
   if(ObjectFind(0, objectName) < 0) {                                                              //--- Check if object not found
      ObjectCreate(0, objectName, OBJ_LABEL, 0, 0, 0);                                              //--- Create label object
   }
   
   ObjectSetInteger(0, objectName, OBJPROP_XDISTANCE, x + offsetX);                                 //--- Set X distance
   ObjectSetInteger(0, objectName, OBJPROP_YDISTANCE, y);                                           //--- Set Y distance
   ObjectSetInteger(0, objectName, OBJPROP_CORNER, CORNER_LEFT_UPPER);                              //--- Set corner
   ObjectSetInteger(0, objectName, OBJPROP_ANCHOR, ANCHOR_LEFT);                                    //--- Set anchor
   ObjectSetInteger(0, objectName, OBJPROP_COLOR, closeColor);                                      //--- Set color
   ObjectSetInteger(0, objectName, OBJPROP_FONTSIZE, labelFontSize);                                //--- Set font size
   ObjectSetString(0, objectName, OBJPROP_FONT, "Arial");                                           //--- Set font
   ObjectSetString(0, objectName, OBJPROP_TEXT, closeCharacter + "◄");                              //--- Set text
   ObjectSetInteger(0, objectName, OBJPROP_SELECTABLE, false);                                      //--- Set selectable false
   ObjectSetInteger(0, objectName, OBJPROP_HIDDEN, true);                                           //--- Set hidden true
   
   displayStrings[closeLevelIndex] = remainingCharacters;                                           //--- Update display string
}

//+------------------------------------------------------------------+
//| Render open close markers                                        |
//+------------------------------------------------------------------+
void RenderOpenCloseMarkers(int sessionIndex, int barIndex) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return;                              //--- Return if index invalid
   if(sessions[sessionIndex].sessionOpen == 0) return;                                              //--- Return if no open
   
   datetime startTime = iTime(_Symbol, _Period, barIndex);                                          //--- Get start time
   
   string openObjectName = objectPrefix + "Open_" + IntegerToString(sessions[sessionIndex].startTime); //--- Create open object name
   if(ObjectFind(0, openObjectName) < 0) {                                                          //--- Check if not found
      ObjectCreate(0, openObjectName, OBJ_TREND, 0, startTime, sessions[sessionIndex].sessionOpen, 
                   startTime, sessions[sessionIndex].sessionOpen);                                  //--- Create trend object
      ObjectSetInteger(0, openObjectName, OBJPROP_RAY_RIGHT, false);                                //--- Set ray right false
      ObjectSetInteger(0, openObjectName, OBJPROP_SELECTABLE, false);                               //--- Set selectable false
      ObjectSetInteger(0, openObjectName, OBJPROP_HIDDEN, true);                                    //--- Set hidden true
   }
   ObjectSetInteger(0, openObjectName, OBJPROP_COLOR, clrDodgerBlue);                               //--- Set color
   ObjectSetInteger(0, openObjectName, OBJPROP_WIDTH, 2);                                           //--- Set width
   
   string closeObjectName = objectPrefix + "Close_" + IntegerToString(sessions[sessionIndex].startTime); //--- Create close object name
   if(ObjectFind(0, closeObjectName) < 0) {                                                         //--- Check if not found
      ObjectCreate(0, closeObjectName, OBJ_TREND, 0, startTime, sessions[sessionIndex].sessionClose, 
                   startTime, sessions[sessionIndex].sessionClose);                                 //--- Create trend object
      ObjectSetInteger(0, closeObjectName, OBJPROP_RAY_RIGHT, false);                               //--- Set ray right false
      ObjectSetInteger(0, closeObjectName, OBJPROP_SELECTABLE, false);                              //--- Set selectable false
      ObjectSetInteger(0, closeObjectName, OBJPROP_HIDDEN, true);                                   //--- Set hidden true
   }
   ObjectSetInteger(0, closeObjectName, OBJPROP_COLOR, closeColor);                                 //--- Set color
   ObjectSetInteger(0, closeObjectName, OBJPROP_WIDTH, 2);                                          //--- Set width
}

Мы определяем функцию "RenderCloseTpoHighlight", которая выделяет символ Time Price Opportunity закрытия в отображении профиля. Она начинается с проверки индексов сессии и уровня с помощью ArraySize чтобы пропустить недопустимые случаи. Мы получаем строку отображения на уровне закрытия, вычисляем её длину с помощью StringLen, и, если строка не пуста, извлекаем последний символ с помощью StringSubstr для подсветки, сохраняя оставшуюся часть строки.

Имя объекта формируется объединением префикса со временем начала сессии, преобразованным через IntegerToString, а индекс бара мы получаем с помощью iBarShift; если индекс недопустим, функция завершается досрочно. Затем получаем время метки с помощью iTime, преобразуем цену и время в координаты графика с помощью ChartTimePriceToXY, и вычисляем смещение по X на основе ширины символа и длины строки. Если объект не найден через ObjectFind, мы создаём метку с помощью ObjectCreate с типом OBJ_LABEL, затем задаём её позицию, угол размещения и точку привязки, цвет, размер шрифта, шрифт, текст с добавленным маркером, а также свойства недоступности для выбора и скрытия с помощью ObjectSetInteger и "ObjectSetString", после чего обновляем строку отображения, исключая из неё выделенный символ.

Для визуализации границ сессии мы реализуем функцию "RenderOpenCloseMarkers", которая проверяет индекс сессии и пропускает обработку, если цена открытия не задана. Мы получаем время начала с помощью iTime на основе индекса бара, затем аналогично формируем имена объектов открытия и закрытия. Для маркера открытия, если он не найден через "ObjectFind", мы создаём объект трендовой линии через "ObjectCreate" с использованием OBJ_TREND на цене открытия, отключаем продление луча вправо, делаем объект недоступным для выбора и скрытым, а также задаём синий цвет и ширину 2. Аналогично для маркера закрытия мы создаём или обновляем трендовую линию на цене закрытия, устанавливая входной цвет закрытия и ту же ширину. В результате оба маркера отображаются как короткие горизонтальные линии на графике у бара начала сессии. Теперь можно объединить всю эту логику для отрисовки полного рыночного профиля.

//+------------------------------------------------------------------+
//| Render session profile                                           |
//+------------------------------------------------------------------+
void RenderSessionProfile(int sessionIndex) {
   if(sessionIndex < 0 || sessionIndex >= ArraySize(sessions)) return;           //--- Return if index invalid
   
   int size = ArraySize(sessions[sessionIndex].levels);                          //--- Get levels size
   if(size == 0 || sessions[sessionIndex].startTime == 0) return;                //--- Return if no levels or no start time
   
   int barIndex = iBarShift(_Symbol, _Period, sessions[sessionIndex].startTime); //--- Get bar index
   if(barIndex < 0) return;                                                      //--- Return if invalid
   
   SortPriceLevelsDescending(sessionIndex);                                      //--- Sort levels descending
   CalculatePointOfControl(sessionIndex);                                        //--- Calculate POC
   
   int totalTpoCount = GetTotalTpoCount(sessionIndex);                           //--- Get total TPO count
   int pointOfControlIndex = sessions[sessionIndex].pointOfControlIndex;         //--- Get POC index
   
   int valueAreaUpperIndex = pointOfControlIndex; //--- Initialize value area upper index
   int valueAreaLowerIndex = pointOfControlIndex; //--- Initialize value area lower index
   
   if(pointOfControlIndex >= 0) {                 //--- Check valid POC index
      int targetTpoCount = (int)(totalTpoCount * valueAreaPercent / 100.0);      //--- Calculate target TPO count
      int currentTpoCount = sessions[sessionIndex].levels[pointOfControlIndex].tpoCount; //--- Set current TPO count
      
      while(currentTpoCount < targetTpoCount && (valueAreaUpperIndex > 0 || valueAreaLowerIndex < size - 1)) { //--- Loop to expand value area
         int upperTpoCount = (valueAreaUpperIndex > 0) ? sessions[sessionIndex].levels[valueAreaUpperIndex - 1].tpoCount : 0; //--- Get upper TPO count
         int lowerTpoCount = (valueAreaLowerIndex < size - 1) ? sessions[sessionIndex].levels[valueAreaLowerIndex + 1].tpoCount : 0; //--- Get lower TPO count
         
         if(upperTpoCount >= lowerTpoCount && valueAreaUpperIndex > 0) {         //--- Check upper expansion
            valueAreaUpperIndex--;                //--- Decrement upper index
            currentTpoCount += upperTpoCount;     //--- Add upper TPO
         } else if(valueAreaLowerIndex < size - 1) { //--- Check lower expansion
            valueAreaLowerIndex++;                //--- Increment lower index
            currentTpoCount += lowerTpoCount;     //--- Add lower TPO
         } else if(valueAreaUpperIndex > 0) {     //--- Fallback upper expansion
            valueAreaUpperIndex--;                //--- Decrement upper index
            currentTpoCount += upperTpoCount;     //--- Add upper TPO
         } else {                                 //--- Break if no more
            break;                                //--- Exit loop
         }
      }
   }
   
   string displayStrings[];                       //--- Declare display strings array
   ArrayResize(displayStrings, size);             //--- Resize display strings
   for(int i = 0; i < size; i++) {                //--- Loop through levels
      displayStrings[i] = sessions[sessionIndex].levels[i].tpoString; //--- Copy TPO string
   }
   
   int closeLevelIndex = -1;                      //--- Initialize close level index
   double closePrice = sessions[sessionIndex].sessionClose; //--- Get close price
   
   for(int i = 0; i < size; i++) {                //--- Loop to find close level
      if(MathAbs(sessions[sessionIndex].levels[i].price - closePrice) < tpoPriceGridStep / 2) { //--- Check price match
         closeLevelIndex = i;                     //--- Set close level index
         break;                                   //--- Exit loop
      }
   }
   
   RenderCloseTpoHighlight(sessionIndex, closeLevelIndex, displayStrings); //--- Render close highlight
   
   for(int i = 0; i < size; i++) {                                        //--- Loop to render levels
      string objectName = objectPrefix + "TPO_" + IntegerToString(sessions[sessionIndex].startTime) + "_" + IntegerToString(i); //--- Create object name
      
      color textColor = defaultTpoColor;                                  //--- Set default color
      
      if(sessions[sessionIndex].levels[i].tpoCount == 1) {                //--- Check single print
         textColor = singlePrintColor;                                    //--- Set single print color
      }
      
      if(i >= valueAreaUpperIndex && i <= valueAreaLowerIndex) {          //--- Check value area
         textColor = valueAreaColor;                                      //--- Set value area color
      }
      
      if(i == sessions[sessionIndex].pointOfControlIndex) {               //--- Check POC
         textColor = pointOfControlColor;                                 //--- Set POC color
      }
      
      if(ObjectFind(0, objectName) < 0) {                                 //--- Check if object not found
         ObjectCreate(0, objectName, OBJ_LABEL, 0, 0, 0);                 //--- Create label
         ObjectSetInteger(0, objectName, OBJPROP_XDISTANCE, 0);           //--- Set X distance
         ObjectSetInteger(0, objectName, OBJPROP_YDISTANCE, 0);           //--- Set Y distance
      }
      
      datetime labelTime = iTime(_Symbol, _Period, barIndex);             //--- Get label time
      int x, y;                                                           //--- Declare coordinates
      ChartTimePriceToXY(0, 0, labelTime, sessions[sessionIndex].levels[i].price, x, y); //--- Convert to XY
      
      ObjectSetInteger(0, objectName, OBJPROP_XDISTANCE, x);              //--- Set X distance
      ObjectSetInteger(0, objectName, OBJPROP_YDISTANCE, y);              //--- Set Y distance
      ObjectSetInteger(0, objectName, OBJPROP_CORNER, CORNER_LEFT_UPPER); //--- Set corner
      ObjectSetInteger(0, objectName, OBJPROP_ANCHOR, ANCHOR_LEFT);       //--- Set anchor
      ObjectSetInteger(0, objectName, OBJPROP_COLOR, textColor);          //--- Set color
      ObjectSetInteger(0, objectName, OBJPROP_FONTSIZE, labelFontSize);   //--- Set font size
      ObjectSetString(0, objectName, OBJPROP_FONT, "Arial");              //--- Set font
      ObjectSetString(0, objectName, OBJPROP_TEXT, displayStrings[i]);    //--- Set text
      ObjectSetInteger(0, objectName, OBJPROP_SELECTABLE, false);         //--- Set selectable false
      ObjectSetInteger(0, objectName, OBJPROP_HIDDEN, true);              //--- Set hidden true
   }
   
   RenderOpenCloseMarkers(sessionIndex, barIndex);                        //--- Render open close markers
}

Здесь мы реализуем функцию "RenderSessionProfile", которая рисует полный рыночный профиль для заданной сессии на графике. Сначала выполняется проверка индекса относительно размера массива sessions с помощью ArraySize чтобы досрочно завершить работу при недопустимом индексе, а затем дополнительно проверяется наличие уровней и корректное время начала. Индекс стартового бара мы получаем с помощью iBarShift на основе символа, периода и времени начала сессии; при недопустимом значении функция завершается. Затем вызываем "SortPriceLevelsDescending" для сортировки ценовых уровней от высокого к низкому и "CalculatePointOfControl" для определения уровня с наибольшим количеством Time Price Opportunity.

Далее мы получаем общее количество Time Price Opportunity через "GetTotalTpoCount" и индекс POC, инициализируя им верхнюю и нижнюю границы области стоимости. Если POC корректна, мы рассчитываем целевое количество как процент от общего числа с использованием входного процента области стоимости, начиная с текущего количества на этом уровне. Затем расширяем область стоимости в цикле while, сравнивая и добавляя значения с соседних верхних или нижних уровней с приоритетом той стороны, где больше Time Price Opportunities, уменьшая или увеличивая индексы до достижения цели или границ.

Для подготовки к отрисовке мы объявляем массив строк отображения и изменяем его размер под количество уровней с помощью ArrayResize, копируя в него строку Time Price Opportunity каждого уровня в цикле. Затем мы ищем индекс уровня закрытия, перебирая уровни и используя MathAbs чтобы найти ближайшее совпадение с ценой закрытия сессии в пределах допуска, равного половине шага сетки, и прерываем цикл после нахождения.

После вызова "RenderCloseTpoHighlight" с индексом сессии, индексом уровня закрытия и строками отображения для обработки выделения закрытия мы проходим по каждому уровню, создавая или обновляя объекты-метки: формируем уникальное имя из префикса, времени начала и индекса уровня с помощью IntegerToString; задаём цвет текста по умолчанию и переопределяем его для одиночных принтов, диапазона области стоимости или POC на основе входных цветов; проверяем наличие объекта через ObjectFind и при необходимости создаём его через "ObjectCreate" с типом OBJ_LABEL с начальными нулевыми отступами; получаем время метки через "iTime" и преобразуем время-цену в координаты с помощью "ChartTimePriceToXY"; затем применяем положение, угол размещения и точку привязки, цвет, размер шрифта, шрифт, текст из строки отображения и свойства недоступности для выбора/скрытия с помощью "ObjectSetInteger" и ObjectSetString функций.

Наконец, мы вызываем "RenderOpenCloseMarkers" с индексом сессии и индексом бара, чтобы добавить на график визуальные индикаторы открытия и закрытия. Теперь эту функцию можно вызывать в обработчике расчётов на тиках, условно выполняя основную часть отрисовки.

//+------------------------------------------------------------------+
//| Calculate custom indicator                                       |
//+------------------------------------------------------------------+
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[]) {
   
   if(rates_total < 2) return 0;                                 //--- Return if insufficient rates
   
   datetime currentBarTime = time[rates_total - 1];              //--- Get current bar time
   bool isNewBar = (currentBarTime != lastCompletedBarTime);     //--- Check if new bar
   
   if(IsNewSessionStarted(currentBarTime, previousBarTime) || previousBarTime == 0) { //--- Check new session
      if(activeSessionIndex >= 0 && activeSessionIndex < ArraySize(sessions)) { //--- Check active session
         sessions[activeSessionIndex].endTime = previousBarTime; //--- Set end time
         RenderSessionProfile(activeSessionIndex);               //--- Render session profile
      }
      
      activeSessionIndex = CreateNewSession();                  //--- Create new session
      sessions[activeSessionIndex].startTime = currentBarTime;  //--- Set start time
      sessions[activeSessionIndex].sessionOpen = open[rates_total - 1]; //--- Set session open
      sessions[activeSessionIndex].sessionHigh = high[rates_total - 1]; //--- Set session high
      sessions[activeSessionIndex].sessionLow = low[rates_total - 1];   //--- Set session low
      lastCompletedBarTime = currentBarTime;                    //--- Update last completed bar time
   }
   
   previousBarTime = currentBarTime;                            //--- Update previous bar time
   
   if(isNewBar && IsBarEligibleForProcessing(currentBarTime) && activeSessionIndex >= 0) { //--- Check if process bar
      sessions[activeSessionIndex].sessionHigh = MathMax(sessions[activeSessionIndex].sessionHigh, high[rates_total - 1]); //--- Update session high
      sessions[activeSessionIndex].sessionLow = MathMin(sessions[activeSessionIndex].sessionLow, low[rates_total - 1]); //--- Update session low
      sessions[activeSessionIndex].sessionClose = close[rates_total - 1]; //--- Update session close
      
      int periodIndex = sessions[activeSessionIndex].periodCount; //--- Get period index
      ArrayResize(sessions[activeSessionIndex].periodOpens, periodIndex + 1); //--- Resize period opens
      
      sessions[activeSessionIndex].periodOpens[periodIndex] = open[rates_total - 1]; //--- Set period open
      sessions[activeSessionIndex].periodCount++;                 //--- Increment period count
      
      double quantizedHigh = QuantizePriceToGrid(high[rates_total - 1]); //--- Quantize high
      double quantizedLow = QuantizePriceToGrid(low[rates_total - 1]);   //--- Quantize low
      
      for(double price = quantizedLow; price <= quantizedHigh; price += tpoPriceGridStep) { //--- Loop through prices
         int levelIndex = GetOrCreatePriceLevel(activeSessionIndex, price); //--- Get or create level
         if(levelIndex >= 0) {                                    //--- Check valid level
            AddTpoCharacterToLevel(activeSessionIndex, levelIndex, periodIndex); //--- Add TPO character
         }
      }
      
      lastCompletedBarTime = currentBarTime;                      //--- Update last completed bar time
   }
   
   if(IsBarEligibleForProcessing(currentBarTime) && activeSessionIndex >= 0) { //--- Check if update session
      sessions[activeSessionIndex].sessionClose = close[rates_total - 1]; //--- Update close
      sessions[activeSessionIndex].sessionHigh = MathMax(sessions[activeSessionIndex].sessionHigh, high[rates_total - 1]); //--- Update high
      sessions[activeSessionIndex].sessionLow = MathMin(sessions[activeSessionIndex].sessionLow, low[rates_total - 1]); //--- Update low
   }
   
   for(int i = 0; i < ArraySize(sessions); i++) {                 //--- Loop through sessions
      RenderSessionProfile(i);                                    //--- Render profile
   }
   
   return rates_total;                                            //--- Return rates total
}

Основные вычисления выполняются в обработчике события OnCalculate, который обрабатывает массивы ценовых данных пользовательского индикатора при каждом обновлении. Сначала он возвращает ноль, если доступно меньше двух баров, чтобы обеспечить достаточный объём данных. Мы извлекаем время текущего бара из массива time по последнему индексу и определяем, является ли это новым баром, сравнивая его с последним завершённым временем. Если через "IsNewSessionStarted" обнаружена новая сессия или предыдущего времени нет, мы завершаем активную сессию, задавая её время окончания и отрисовывая её через "RenderSessionProfile", если она допустима по границам ArraySize затем создаём новую сессию с помощью "CreateNewSession", инициализируем её время начала, открытие, максимум и минимум по данным текущего бара и обновляем время последнего завершённого бара.

После обновления времени предыдущего бара, если это новый бар, подходящий для обработки через "IsBarEligibleForProcessing", и активная сессия существует, мы обновляем максимум и минимум сессии с помощью MathMax и MathMin используя значения текущего бара, задаём закрытие, увеличиваем счётчик периодов после изменения размера массива и сохранения открытия периода, квантуем максимум и минимум бара через "QuantizePriceToGrid" и проходим от минимума к максимуму с шагом сетки, получая или создавая уровни через "GetOrCreatePriceLevel" перед добавлением символов Time Price Opportunity с помощью "AddTpoCharacterToLevel". В конце обновляем время последнего завершённого бара. Кроме того, если текущий бар подходит для обработки и сессия активна, мы непрерывно обновляем закрытие, максимум и минимум, чтобы отражать изменения в реальном времени.

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

//+------------------------------------------------------------------+
//| Handle chart event                                               |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam) {
   if(id == CHARTEVENT_CHART_CHANGE) {               //--- Check chart change event
      for(int i = 0; i < ArraySize(sessions); i++) { //--- Loop through sessions
         RenderSessionProfile(i);                    //--- Render profile
      }
   }
}

//+------------------------------------------------------------------+
//| Deinitialize custom indicator                                    |
//+------------------------------------------------------------------+
void OnDeinit(const int reason) {
   DeleteAllIndicatorObjects();                      //--- Delete all indicator objects
}

//+------------------------------------------------------------------+
//| Delete all indicator objects                                     |
//+------------------------------------------------------------------+
void DeleteAllIndicatorObjects() {
   int total = ObjectsTotal(0, 0, -1);            //--- Get total number of objects
   for(int i = total - 1; i >= 0; i--) {          //--- Loop through objects in reverse
      string name = ObjectName(0, i, 0, -1);      //--- Get object name
      if(StringFind(name, objectPrefix) == 0)     //--- Check if name starts with prefix
         ObjectDelete(0, name);                   //--- Delete object
   }
}

Взаимодействие с графиком обрабатывается в обработчике события OnChartEvent который срабатывает при различных событиях графика. Он проверяет, совпадает ли идентификатор события с CHARTEVENT_CHART_CHANGE чтобы обнаружить изменения, например переключение таймфрейма или изменение размера; если да, мы проходим по всем сессиям, используя ArraySize для получения их количества, и повторно отрисовываем каждый профиль через "RenderSessionProfile", чтобы обновить визуальное отображение.

При удалении индикатора выполняется обработчик события OnDeinit который вызывает "DeleteAllIndicatorObjects" для очистки всех пользовательских элементов графика и предотвращения появления оставшихся объектов.

В "DeleteAllIndicatorObjects" мы получаем общее количество объектов графика через функцию ObjectsTotal по всем окнам и типам, затем перебираем их в обратном порядке от последнего индекса до нуля, получая имя каждого объекта с помощью ObjectName, и если оно начинается с префикса согласно StringFind возвращающей ноль, удаляем объект с помощью ObjectDelete чтобы обеспечить полную очистку. При запуске индикатора получаем следующий результат.

TEXT MARKET PROFILE

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


Бэктестирование

Мы выполнили тестирование, и ниже представлена итоговая визуализация в виде единого Graphics Interchange Format (GIF)-изображения.

BACKTEST GIF


Заключение

В заключение мы разработали пользовательский индикатор на MQL5 для гибридных рыночных профилей Time Price Opportunity (TPO), который поддерживает несколько сессионных периодов, включая внутридневные, дневные, недельные, месячные и фиксированные периоды с учётом часового пояса. Индикатор дискретизирует цены по сетке, отслеживает данные сессии, включая максимум, минимум, цены открытия и закрытия, рассчитывает POC и область стоимости на основе количества TPO и отображает профили на графике с настраиваемыми цветами для букв TPO, одиночных принтов, областей стоимости, POC и маркеров закрытия. В следующей части мы добавим отрисовку квадратных и точечных маркеров для разметки рыночного профиля всеми метками. Следите за продолжением!

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

Прикрепленные файлы |
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
Встраивание торговой дисциплины в код (Часть 4): Принудительный контроль торговых сессий и блокировка торговли на новостях в MQL5 Встраивание торговой дисциплины в код (Часть 4): Принудительный контроль торговых сессий и блокировка торговли на новостях в MQL5
Система управления на языке MQL5, которая блокирует ордера вне запланированных торговых часов и во время запланированных выпусков новостей, преобразуя временные правила в автоматически применяемые ограничения. Она сочетает в себе модуль разрешений, советник, перехватывающий торговые операции на уровне транзакций и визуальный дашборд для отображения статуса в реальном времени и предстоящих ограничений. Конфигурация осуществляется с помощью редактируемых файлов, с кэшированием и логом аудита CSV для обеспечения отслеживаемости.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Создание пользовательских индикаторов в MQL5 (Часть 6): Усовершенствование расчётов RSI — сглаживание, динамические цвета и поддержка нескольких таймфреймов Создание пользовательских индикаторов в MQL5 (Часть 6): Усовершенствование расчётов RSI — сглаживание, динамические цвета и поддержка нескольких таймфреймов
В этой статье мы создадим универсальный индикатор RSI в MQL5 с поддержкой нескольких вариантов расчёта, источников данных и методов сглаживания для более качественного анализа. Мы добавим изменение оттенка для цветовой индикации, динамические границы зон перекупленности/перепроданности и уведомления о смене тренда. Индикатор включает мультитаймфреймовую поддержку с интерполяцией и предоставляет настраиваемый инструмент RSI для разных стратегий.