You are currently viewing the resource titled "Building a Hybrid Time Price Opportunity (TPO) Market Profile Indicator in MQL5". This page provides detailed information about the resource, including its content, attached files, and recent discussions. Feel free to explore, download available files if logged in, and join the community conversation below!
Banner Image

Introduction


So you've been staring at candlestick charts wondering, "where exactly did most of the trading happen today?" 🤔 That's precisely the question the Time Price Opportunity (TPO) Market Profile was built to answer — and in this article, we're going to build one from scratch in MQL5.

What we're creating here is a hybrid TPO Market Profile indicator that doesn't just work for one session type and call it a day. No, no — this one supports intraday, daily, weekly, monthly, and fixed time periods, all with timezone adjustments baked in. Think of it as the Swiss Army knife 🔪 of market profile indicators.

Here's what we'll walk through:

  • Exploring the Hybrid TPO Market Profile Concept
  • Implementation in MQL5
  • Backtesting
  • Conclusion

By the end, you'll have a fully working MQL5 indicator ready to deploy and customize. Let's get into it! 🚀



Exploring the Hybrid TPO Market Profile Concept


Imagine you're a shopkeeper 🏪 tracking which shelf customers visit most throughout the day. The shelves with the most foot traffic are your "hot zones" — and that's exactly what a TPO market profile does, but for price levels.

A TPO Market Profile is a visualization tool that maps how price is distributed over time within a defined trading session. It uses letters, rectangle markers, or dots to represent time intervals at specific price levels, painting a picture of where the most trading activity occurred. The result is a profile histogram — and the denser the stack at a price level, the more time the market spent there.

Two key concepts emerge from this:

Point of Control (POC) — the price level where the most TPOs stacked up. This is essentially where the market was most "comfortable" during the session. Think of it as the price that got the most votes 🗳️.

Value Area — the range of prices that contain a set percentage (typically 70%) of all TPOs. Price inside this zone is considered "fair value." Price outside it? That's where things get interesting — potential support, resistance, or breakout territory.

In practice, traders use this to enter positions near the edges of the value area, or watch for shifts in the POC to spot trend continuations before they become obvious on a plain candlestick chart.

Our plan for this indicator is to:

  1. Define sessions based on selected timeframes with timezone offsets
  2. Quantize prices into a grid for clean TPO assignment
  3. Track session metrics — highs, lows, opens, closes
  4. Compute the Point of Control (highest TPO count)
  5. Derive the Value Area covering a set percentage of total TPOs
  6. Visualize everything with color-coded labels, dots, and squares

Here's a visual overview of the architecture 👇

TPO MARKET PROFILE ARCHITECTURE


Implementation in MQL5


To get started, open MetaEditor, head to the Navigator panel, find the Indicators folder, hit the "New" tab, and follow the prompts to generate your file. Once inside the coding environment, we kick things off by defining our indicator properties and input settings.

Step 1 — Properties, Enums, Inputs & Structures

//+------------------------------------------------------------------+
//|                             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

Let's break down what's happening here 🔍.

We start with #property directives that tell MetaTrader this indicator lives on the main chart window — not a subwindow. We set indicator_buffers and indicator_plots both to zero because this indicator doesn't use traditional plot lines or histograms. Instead, it draws everything using custom chart objects. Think of it as painting directly on the canvas rather than using the pre-built brushes 🎨.

The MarketProfileTimeframe enum is what gives users the flexibility to choose their session type — INTRADAY, DAILY, WEEKLY, MONTHLY, or FIXED. No hardcoding a single mode and hoping for the best.

The inputs are organized into tidy groups using sinput group. Here's a quick rundown of the important ones:

  • ticksPerTpoLetter — controls the vertical resolution of your profile. More ticks per letter = coarser grid. Fewer = finer detail 🔬
  • valueAreaPercent — typically 70%, this defines how much of the total TPO activity falls within your "fair value" zone
  • dailySessionRange — formatted as "0830-1500", this tells the indicator which hours define your trading day
  • fixedTimeRangeStart / fixedTimeRangeEnd — for when you want to analyze a very specific time window

Two structures handle data storage. TpoPriceLevel is like a row in a spreadsheet 📊 — it stores the price, the TPO character string, and how many TPOs occurred there. ProfileSessionData is the whole spreadsheet — it holds everything about a session: open, high, low, close, all the price levels, and the POC index.

The global variables include our sessions[] array (think of it as a filing cabinet 🗄️ for up to 20 sessions), the tpoPriceGridStep for price snapping, and timezoneOffsetSeconds to handle those pesky timezone differences.


Step 2 — Initialization with OnInit()

//+------------------------------------------------------------------+
//| 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
}

The OnInit() function runs once when the indicator is attached to a chart — it's your setup crew before the show begins 🎬. First, we assign a display name so MetaTrader shows something sensible in the indicator list — not just a filename. Then we calculate tpoPriceGridStep by multiplying ticksPerTpoLetter by _Point (the symbol's minimum price increment). This becomes the vertical ruler for our profile grid.

Next, we populate the tpoCharacterSet array with all 52 letters — uppercase A–Z followed by lowercase a–z. Each new time period in a session gets the next letter in line, so you can literally read how the session developed left to right 📖.

The timezone logic checks whether the user specified something other than "Exchange". If they did, it extracts the numeric offset and converts it to seconds so all time comparisons work correctly regardless of where in the world the user is trading from 🌍.

Finally, we reset the sessions[] array to empty and return INIT_SUCCEEDED. Clean slate, ready to go ✅.


Step 3 — Session Management & Price Parsing

//+------------------------------------------------------------------+
//| 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() acts like a queue manager 🎟️. When the number of stored sessions hits the maxSessionHistory cap of 20, it drops the oldest session by shifting everything forward — essentially a first-in, first-out memory system. Then it tacks on a fresh, zeroed-out session at the end and returns its index.

QuantizePriceToGrid() is elegantly simple — it snaps any price to the nearest point on our grid using MathRound. Without this, two prices that are almost the same might get treated as different levels, creating noise in the profile 📉.

ParseDailySessionTimeRange() takes the "0830-1500" string the user provides, splits it at the hyphen, and extracts hours and minutes for both start and end times. If the string doesn't split into exactly two parts, it returns false — a small but important guard against badly formatted input 🛡️.


Step 4 — Bar Filtering & Price Level Management

//+------------------------------------------------------------------+
//| 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() is the bouncer at the door 🚪 — it checks whether a given bar falls within the user-defined trading hours. It converts the bar time (plus timezone offset) to an MqlDateTime struct, expresses everything in minutes, and checks if the bar falls in range. It even handles overnight sessions (like Forex where a session might span midnight) using an || condition instead of &&.

IsNewSessionStarted() is the function that decides — "has a new profile period begun?" It handles all five timeframe modes differently:

  • Daily — checks if we've crossed into a new day's session start time
  • Weekly — looks at day-of-week rollover
  • Monthly — compares the month field
  • Fixed — fires once when time crosses the fixed start
  • Intraday — uses modular arithmetic to trigger at precise intervals ⏱️

GetOrCreatePriceLevel() behaves like a smart registry. It first searches existing levels for a price match (within half a point tolerance). If it finds one, it returns that index — no duplicate entries. If not, it creates a new level and returns its index. Efficient, clean, and prevents bloat 💪.


Step 5 — TPO Assignment, Sorting & POC Calculation

//+------------------------------------------------------------------+
//| 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() is where the alphabet magic happens ✨. Every new time period gets a letter — period 0 gets 'A', period 1 gets 'B', and so on, wrapping back around after 'z' using modulo 52. That letter gets appended to the level's string and the count goes up by one.

SortPriceLevelsDescending() uses a classic bubble sort to arrange levels from highest price to lowest — essential for rendering the profile correctly on the chart (highest price at the top, naturally 📈). It swaps adjacent entries using a temporary variable whenever a lower price appears before a higher one.

CalculatePointOfControl() is straightforward but important — it scans all levels, finds the one with the highest TPO count, and saves that index to the session data. This becomes the highlighted "spine" of the profile 🦴.

GetTotalTpoCount() simply sums up all TPO counts across a session's levels. This total is what valueAreaPercent gets applied against — so if you have 100 total TPOs and want a 70% value area, the function tells us we need to account for 70 of them.


Step 6 — Visual Rendering

Now comes the part that makes it all visible 👀. We define two rendering helpers before assembling the full profile render.

//+------------------------------------------------------------------+
//| 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() is the function that puts a little arrow next to the closing TPO character — a subtle but useful visual cue that says "this is where the session wrapped up" 🏁. It peels the last character off the display string, positions a label at the correct chart coordinate using ChartTimePriceToXY, and colors it with the closeColor input.

RenderOpenCloseMarkers() draws two short horizontal lines on the chart — one in Dodger Blue for the session open and one in the closeColor for the session close. These are OBJ_TREND objects with the right-ray disabled so they don't extend endlessly across the chart 🚫➡️. Small detail, big difference for readability.


Step 7 — Full Profile Rendering

//+------------------------------------------------------------------+
//| 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() is the conductor 🎻 that brings all the instruments together. Here's its flow:

  1. Validates the session and checks it has data worth rendering
  2. Sorts levels from top to bottom and calculates the POC
  3. Calculates the Value Area by expanding outward from the POC — always grabbing the side with more TPOs first, like filling a balloon from the center out 🎈
  4. Copies TPO strings into a display array (which the close highlight function then trims slightly)
  5. Loops through every price level and assigns colors based on priority: POC gets its special color first, then value area, then single prints, then the default gray

The color priority system works from the bottom up 🎨 — default → single print → value area → POC — so the POC always wins visually if overlapping conditions exist.

Every label is positioned using real chart pixel coordinates via ChartTimePriceToXY, which is why the profile stays anchored correctly even when you scroll or zoom 🔎.


Step 8 — OnCalculate, Chart Events & Cleanup

//+------------------------------------------------------------------+
//| 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
}

//+------------------------------------------------------------------+
//| 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
   }
}

OnCalculate() is where the real-time engine lives ⚙️. Every tick, it:

  • Checks if there's a new bar by comparing timestamps
  • Detects a new session and finalizes the previous one before opening a fresh slate
  • On each new bar, updates session highs/lows, logs the period open, and — here's the key moment 🔑 — loops through the quantized price range from bar low to bar high, creating or finding price levels and stamping them with the period's TPO letter
  • Continuously updates the session close to reflect the live price on the current bar
  • Re-renders all sessions on every update so the chart stays current

OnChartEvent() catches CHARTEVENT_CHART_CHANGE — meaning whenever you resize the chart window, switch timeframes, or scroll horizontally ↔️, all profiles get re-rendered so the labels don't get left floating in wrong positions.

DeleteAllIndicatorObjects() loops backward through all chart objects (backward is safer when deleting 🔄) and removes any that start with our "HTMP_" prefix. This ensures a perfectly clean chart when you remove the indicator — no ghost labels left behind 👻. See what we have achieved so far.

TEXT MARKET PROFILE


Backtesting

With the indicator compiled and attached, here's the visual result from backtesting across sessions 👇


BACKTEST GIF


The profile renders correctly with text labels, color-coded value areas, highlighted POC levels, and open/close markers — all updating in real time as new bars form 📊.


Conclusion

And there we have it 🎉 — a fully functional hybrid TPO Market Profile indicator built from scratch in MQL5!

To recap what we've built:

  • Multi-session support — intraday, daily, weekly, monthly, and fixed, all with timezone handling 🌍
  • Price quantization — clean grid snapping for consistent level assignment
  • Session tracking — highs, lows, opens, and closes per session
  • POC calculation — automatically finds the most traded price level
  • Value area derivation — expands outward from the POC to cover your target percentage
  • Full visual rendering — color-coded TPO letters, single print highlights, open/close markers, and a close-price arrow

In the next article, we'll extend this further by adding square and dot bubble rendering to give the market profile an even richer visual style. Stay tuned — it's going to look even better! 🚀

Disclaimer: The ideas and strategies presented in this resource are solely those of the author and are intended for informational and educational purposes only. They do not constitute financial advice, and past performance is not indicative of future results. All materials, including but not limited to text, images, files, and any downloadable content, are protected by copyright and intellectual property laws and are the exclusive property of Forex Algo-Trader or its licensors. Reproduction, distribution, modification, or commercial use of these materials without prior written consent from Forex Algo-Trader is strictly prohibited and may result in legal action. Users are advised to exercise extreme caution, perform thorough independent research, and consult with qualified financial professionals before implementing any trading strategies or decisions based on this resource, as trading in financial markets involves significant risk of loss.

Recent Comments

Go to discussion to Comment or View other Comments

No comments yet. Be the first to comment!

Banner
🤖 Building a ChatGPT AI Trade Brain in MQL5
What’s the point of an AI trading assistant that talks but never acts? In this article, we transfo...
2026-05-15 16:50:14
Banner
Grid Scalper MA MT5 EA Review (2026): The Forever-Free MetaTrader 5 Robot
Grid Scalper MA MT5 EA Review (2026): The forever-free MetaTrader 5 robot that's quietly earning a 4...
2026-05-14 19:35:18
Banner
Supercharging Your TPO Market Profile With Volume Intelligence
You've got a TPO market profile that tracks where price spent its time — now imagine giving it a f...
2026-04-03 16:05:42